Compare commits

...

15 Commits

Author SHA1 Message Date
Riccardo Ferretti
f4eaf5c5ff v0.22.1 2023-04-15 13:39:00 -07:00
Riccardo Ferretti
b4830eaf30 Prepare for next release 2023-04-15 13:38:28 -07:00
Jim Graham
0cda6aed50 Allow for # alone to trigger tag completion (#1192)
* Allow for `#` alone to trigger tag completion

In #1183, I reused [HASHTAG_REGEX](83a90177b9/packages/foam-vscode/src/core/utils/hashtags.ts (L2-L3))
to validate the tag line when the `CompletionProvider` was triggered.

I wanted to prevent this:

```markdown
 # This is a Markdown header
```

but using the `HASHTAG_REGEX` had the side effect of requiring an
_additional_ character to trigger the completion provider.

```markdown

1. #p <-- triggers completion
2. #  <-- does not trigger
3. #_ (space) <-- does not trigger
```
both 1. and 2. should have triggered.

To fix, I use a slightly different regex that uses a negative lookahead
to ensure that the `#` is not followed by a space. I also added spec
cases to cover this situation.

* Update regex for more robust detection of tags

Update the regex used for more robust detection of tags. Replace the
negative lookahead assertion `\s` with `[ \t]` (allow for `\n`), and
add `#` to the class so that `##` is ignored.

Attempted to add the negation `^[0-9p{L}p{Emoji}p{N}-/]` to the
negative look ahead. This was to exclude items like `#$`, `#&` that
can't be tags. However my regex-fu was insufficient.

Instead, if the regex match is to a single `#`, ensure it is the
character to the left of the cursor. Example

  `this is text #%|`

where the `|` represents the cursor. The `TAG_REGEX`
will match the `#` at index 13. However since the cursor is at 15, the
Completion provider will not run.

Update the tests to cover these situations and add them all to a sub-
`describe` block labeled by the bug issue number #1189

* Use regex groups to determine match position

For the case like `here is #my-tag and now # |`, where `|` is the cursor
position after a trailing space, the match on `#my-tag` would allow tag
completion at the cursor position.

Ensure that the last regexp match group covers up to the the cursor
position. This also handles the case of `#$` because the match will only
be `#`.
2023-04-15 22:34:55 +02:00
Riccardo Ferretti
89c9bb5a7f v0.22.0 2023-04-15 10:47:20 -07:00
Riccardo Ferretti
941e870a65 Prepare for 0.22.0 2023-04-15 10:47:06 -07:00
Riccardo
c6655c33ff Fixed #1193 and added tests (#1197) 2023-04-15 19:31:48 +02:00
Riccardo
c94fb18f8a Resource tree items improvements (#1196)
* Consolidated common tree view code and migrated placeholder panel
* Migrated backlink panel to new pattern
* Tweaked code and fixed tests
2023-04-15 19:21:24 +02:00
Riccardo
cbd55bac74 Fix #1134 - added support for deep tag hierarchy (#1194) 2023-04-15 02:22:12 +02:00
Riccardo Ferretti
83a90177b9 v0.21.4 2023-04-14 10:39:05 -07:00
Riccardo Ferretti
37aec28af6 Prepare for next release 2023-04-14 10:38:46 -07:00
Riccardo Ferretti
447f7fc068 Fix for #1188 and #1190 - escape backslash in YAML property of generated daily note template 2023-04-14 10:37:37 -07:00
Riccardo Ferretti
ad1243665a Removed unnecessary log message 2023-04-13 17:23:38 -07:00
Riccardo Ferretti
f07de73bc4 v0.21.3 2023-04-12 17:05:49 -07:00
Riccardo Ferretti
c431ccfb62 Preparation for next release 2023-04-12 17:05:25 -07:00
Riccardo Ferretti
f31ef897cc Fix #1188 - Fixed path relative to workspace root 2023-04-12 17:04:23 -07:00
20 changed files with 604 additions and 279 deletions

View File

@@ -4,5 +4,5 @@
],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.21.2"
"version": "0.22.1"
}

View File

@@ -4,6 +4,32 @@ All notable changes to the "foam-vscode" extension will be documented in this fi
Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file.
## [0.22.1] - 2023-04-15
Fixes and Improvements:
- Allow the `#` char to trigger tag autocompletion (#1192, #1189 - thanks @jimgraham)
## [0.22.0] - 2023-04-15
Fixes and Improvements:
- Added support for deep tag hierarchy in Tag Explorer panel (#1134, #1194)
- Consolidated and improved Backlinks, Placeholders and Orphans panels (#1196)
- Fixed note resolution when using template without defined path (#1197)
## [0.21.4] - 2023-04-14
Fixes and Improvements:
- Fixed issue with generated daily note template due to path escape (#1188, #1190)
## [0.21.3] - 2023-04-12
Fixes and Improvements:
- Fixed relative path from workspace root in templates (#1188)
## [0.21.2] - 2023-04-11
Fixes and Improvements:

View File

@@ -0,0 +1,33 @@
The MIT Licence (MIT)
Copyright 2020 - present Jani Eväkallio <jani.evakallio@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicence, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Where noted, some code uses the following license:
MIT License
Copyright (c) 2015 - present Microsoft Corporation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

View File

@@ -8,7 +8,7 @@
"type": "git"
},
"homepage": "https://github.com/foambubble/foam",
"version": "0.21.2",
"version": "0.22.1",
"license": "MIT",
"publisher": "foam",
"engines": {

View File

@@ -69,4 +69,8 @@ export abstract class Range {
static isBefore(a: Range, b: Range): number {
return a.start.line - b.start.line || a.start.character - b.start.character;
}
static toString(range: Range): string {
return `${range.start.line}:${range.start.character} - ${range.end.line}:${range.end.character}`;
}
}

View File

@@ -86,7 +86,7 @@ export async function createDailyNoteIfNotExists(targetDate: Date) {
const templateFallbackText = `---
foam_template:
filepath: "${pathFromLegacyConfiguration.toFsPath()}"
filepath: "${pathFromLegacyConfiguration.toFsPath().replace(/\\/g, '\\\\')}"
---
# ${dateFormat(targetDate, titleFormat, false)}
`;

View File

@@ -79,14 +79,6 @@ async function openResource(workspace: FoamWorkspace, args?: OpenResourceArgs) {
: toVsCodeUri(item.uri.asPlain());
return vscode.commands.executeCommand('vscode.open', targetUri);
}
Logger.info(
`${OPEN_COMMAND.command}: No resource matches given args`,
JSON.stringify(args)
);
vscode.window.showInformationMessage(
`${OPEN_COMMAND.command}: No resource matches given args`
);
}
const feature: FoamFeature = {

View File

@@ -6,12 +6,15 @@ import {
createNote,
getUriInWorkspace,
} from '../../test/test-utils-vscode';
import { BacklinksTreeDataProvider, BacklinkTreeItem } from './backlinks';
import { ResourceTreeItem } from '../../utils/grouped-resources-tree-data-provider';
import { BacklinksTreeDataProvider } from './backlinks';
import { OPEN_COMMAND } from '../commands/open-resource';
import { toVsCodeUri } from '../../utils/vsc-utils';
import { FoamGraph } from '../../core/model/graph';
import { URI } from '../../core/model/uri';
import {
ResourceRangeTreeItem,
ResourceTreeItem,
} from '../../utils/tree-view-utils';
describe('Backlinks panel', () => {
beforeAll(async () => {
@@ -84,19 +87,19 @@ describe('Backlinks panel', () => {
const notes = (await provider.getChildren()) as ResourceTreeItem[];
const linksFromB = (await provider.getChildren(
notes[0]
)) as BacklinkTreeItem[];
expect(linksFromB.map(l => l.link)).toEqual(
noteB.links.sort(
(a, b) => a.range.start.character - b.range.start.character
)
)) as ResourceRangeTreeItem[];
expect(linksFromB.map(l => l.range)).toEqual(
noteB.links
.map(l => l.range)
.sort((a, b) => a.start.character - b.start.character)
);
});
it('navigates to the document if clicking on note', async () => {
provider.target = noteA.uri;
const notes = (await provider.getChildren()) as ResourceTreeItem[];
expect(notes[0].command).toMatchObject({
command: OPEN_COMMAND.command,
arguments: [expect.objectContaining({ uri: noteB.uri })],
command: 'vscode.open',
arguments: [expect.objectContaining({ path: noteB.uri.path })],
});
});
it('navigates to document with link selection if clicking on backlink', async () => {
@@ -104,11 +107,11 @@ describe('Backlinks panel', () => {
const notes = (await provider.getChildren()) as ResourceTreeItem[];
const linksFromB = (await provider.getChildren(
notes[0]
)) as BacklinkTreeItem[];
)) as ResourceRangeTreeItem[];
expect(linksFromB[0].command).toMatchObject({
command: 'vscode.open',
arguments: [
noteB.uri,
expect.objectContaining({ path: noteB.uri.path }),
{
selection: expect.arrayContaining([]),
},

View File

@@ -1,16 +1,17 @@
import * as vscode from 'vscode';
import { groupBy } from 'lodash';
import { URI } from '../../core/model/uri';
import { getNoteTooltip, isNone } from '../../utils';
import { isNone } from '../../utils';
import { FoamFeature } from '../../types';
import { ResourceTreeItem } from '../../utils/grouped-resources-tree-data-provider';
import { Foam } from '../../core/model/foam';
import { FoamWorkspace } from '../../core/model/workspace';
import { FoamGraph } from '../../core/model/graph';
import { Resource, ResourceLink } from '../../core/model/note';
import { Range } from '../../core/model/range';
import { fromVsCodeUri, toVsCodeUri } from '../../utils/vsc-utils';
import { fromVsCodeUri } from '../../utils/vsc-utils';
import {
ResourceRangeTreeItem,
ResourceTreeItem,
groupRangesByResource,
} from '../../utils/tree-view-utils';
const feature: FoamFeature = {
activate: async (
@@ -37,7 +38,7 @@ const feature: FoamFeature = {
export default feature;
export class BacklinksTreeDataProvider
implements vscode.TreeDataProvider<BacklinkPanelTreeItem>
implements vscode.TreeDataProvider<vscode.TreeItem>
{
public target?: URI = undefined;
// prettier-ignore
@@ -54,67 +55,32 @@ export class BacklinksTreeDataProvider
return item;
}
getChildren(item?: ResourceTreeItem): Promise<BacklinkPanelTreeItem[]> {
getChildren(item?: BacklinkPanelTreeItem): Promise<vscode.TreeItem[]> {
const uri = this.target;
if (item) {
const resource = item.resource;
const backlinkRefs = Promise.all(
resource.links
.filter(link =>
this.workspace.resolveLink(resource, link).asPlain().isEqual(uri)
)
.map(async link => {
const item = new BacklinkTreeItem(resource, link);
const lines = (
(await this.workspace.readAsMarkdown(resource.uri)) ?? ''
).split('\n');
if (link.range.start.line < lines.length) {
const line = lines[link.range.start.line];
const start = Math.max(0, link.range.start.character - 15);
const ellipsis = start === 0 ? '' : '...';
item.label = `${link.range.start.line}: ${ellipsis}${line.substr(
start,
300
)}`;
item.tooltip = getNoteTooltip(line);
}
return item;
})
);
return backlinkRefs;
if (item && item instanceof ResourceTreeItem) {
return item.getChildren();
}
if (isNone(uri) || isNone(this.workspace.find(uri))) {
return Promise.resolve([]);
}
const backlinksByResourcePath = groupBy(
this.graph
.getConnections(uri)
.filter(c => c.target.asPlain().isEqual(uri)),
b => b.source.path
);
const connections = this.graph
.getConnections(uri)
.filter(c => c.target.asPlain().isEqual(uri));
const resources = Object.keys(backlinksByResourcePath)
.map(res => backlinksByResourcePath[res][0].source)
.map(uri => this.workspace.get(uri))
.sort(Resource.sortByTitle)
.map(note => {
const connections = backlinksByResourcePath[note.uri.path].sort(
(a, b) => Range.isBefore(a.link.range, b.link.range)
);
const item = new ResourceTreeItem(
note,
this.workspace,
vscode.TreeItemCollapsibleState.Expanded
);
item.description = `(${connections.length}) ${item.description}`;
return item;
});
return Promise.resolve(resources);
const backlinkItems = connections.map(c =>
ResourceRangeTreeItem.createStandardItem(
this.workspace,
this.workspace.get(c.source),
c.link.range
)
);
return groupRangesByResource(
this.workspace,
backlinkItems,
vscode.TreeItemCollapsibleState.Expanded
);
}
resolveTreeItem(item: BacklinkPanelTreeItem): Promise<BacklinkPanelTreeItem> {
@@ -122,23 +88,4 @@ export class BacklinksTreeDataProvider
}
}
export class BacklinkTreeItem extends vscode.TreeItem {
constructor(
public readonly resource: Resource,
public readonly link: ResourceLink
) {
super(link.rawText, vscode.TreeItemCollapsibleState.None);
this.label = `${link.range.start.line}: ${this.label}`;
this.command = {
command: 'vscode.open',
arguments: [toVsCodeUri(resource.uri), { selection: link.range }],
title: 'Go to link',
};
}
resolveTreeItem(): Promise<BacklinkTreeItem> {
return Promise.resolve(this);
}
}
type BacklinkPanelTreeItem = ResourceTreeItem | BacklinkTreeItem;
type BacklinkPanelTreeItem = ResourceTreeItem | ResourceRangeTreeItem;

View File

@@ -3,11 +3,8 @@ import { Foam } from '../../core/model/foam';
import { createMatcherAndDataStore } from '../../services/editor';
import { getOrphansConfig } from '../../settings';
import { FoamFeature } from '../../types';
import {
GroupedResourcesTreeDataProvider,
ResourceTreeItem,
UriTreeItem,
} from '../../utils/grouped-resources-tree-data-provider';
import { GroupedResourcesTreeDataProvider } from '../../utils/grouped-resources-tree-data-provider';
import { ResourceTreeItem, UriTreeItem } from '../../utils/tree-view-utils';
const EXCLUDE_TYPES = ['image', 'attachment'];
const feature: FoamFeature = {

View File

@@ -3,10 +3,12 @@ import { Foam } from '../../core/model/foam';
import { createMatcherAndDataStore } from '../../services/editor';
import { getPlaceholdersConfig } from '../../settings';
import { FoamFeature } from '../../types';
import { GroupedResourcesTreeDataProvider } from '../../utils/grouped-resources-tree-data-provider';
import {
GroupedResourcesTreeDataProvider,
ResourceRangeTreeItem,
UriTreeItem,
} from '../../utils/grouped-resources-tree-data-provider';
groupRangesByResource,
} from '../../utils/tree-view-utils';
const feature: FoamFeature = {
activate: async (
@@ -22,7 +24,21 @@ const feature: FoamFeature = {
'placeholder',
() => foam.graph.getAllNodes().filter(uri => uri.isPlaceholder()),
uri => {
return new UriTreeItem(uri);
return new UriTreeItem(uri, {
icon: 'link',
getChildren: async () => {
return groupRangesByResource(
foam.workspace,
foam.graph.getBacklinks(uri).map(link => {
return ResourceRangeTreeItem.createStandardItem(
foam.workspace,
foam.workspace.get(link.source),
link.link.range
);
})
);
},
});
},
matcher
);

View File

@@ -1,65 +1,48 @@
import {
createTestNote,
readFileFromFs,
TEST_DATA_DIR,
} from '../../test/test-utils';
import { createTestNote } from '../../test/test-utils';
import { cleanWorkspace, closeEditors } from '../../test/test-utils-vscode';
import { TagItem, TagReference, TagsProvider } from './tags-explorer';
import { bootstrap, Foam } from '../../core/model/foam';
import { MarkdownResourceProvider } from '../../core/services/markdown-provider';
import { createMarkdownParser } from '../../core/services/markdown-parser';
import { URI } from '../../core/model/uri';
import { FileDataStore, Matcher } from '../../test/test-datastore';
import { FoamTags } from '../../core/model/tags';
import { FoamWorkspace } from '../../core/model/workspace';
describe('Tags tree panel', () => {
let _foam: Foam;
let provider: TagsProvider;
const dataStore = new FileDataStore(readFileFromFs, TEST_DATA_DIR.toFsPath());
const matcher = new Matcher([URI.file(TEST_DATA_DIR.toFsPath())]);
const parser = createMarkdownParser();
const mdProvider = new MarkdownResourceProvider(dataStore, parser);
beforeAll(async () => {
await cleanWorkspace();
});
afterAll(async () => {
_foam.dispose();
await cleanWorkspace();
});
beforeEach(async () => {
_foam = await bootstrap(matcher, undefined, dataStore, parser, [
mdProvider,
]);
provider = new TagsProvider(_foam, _foam.workspace);
await closeEditors();
});
afterEach(() => {
_foam.dispose();
});
it('correctly provides a tag from a set of notes', async () => {
it('provides a tag from a set of notes', async () => {
const noteA = createTestNote({
tags: ['test'],
uri: './note-a.md',
});
_foam.workspace.set(noteA);
const workspace = new FoamWorkspace().set(noteA);
const foamTags = FoamTags.fromWorkspace(workspace);
const provider = new TagsProvider(foamTags, workspace);
provider.refresh();
const treeItems = (await provider.getChildren()) as TagItem[];
treeItems.forEach(item => expect(item.tag).toContain('test'));
expect(treeItems).toHaveLength(1);
expect(treeItems[0].label).toEqual('test');
expect(treeItems[0].tag).toEqual('test');
expect(treeItems[0].nResourcesInSubtree).toEqual(1);
});
it('correctly handles a parent and child tag', async () => {
it('handles a simple parent and child tag', async () => {
const noteA = createTestNote({
tags: ['parent/child'],
uri: './note-a.md',
});
_foam.workspace.set(noteA);
const workspace = new FoamWorkspace().set(noteA);
const foamTags = FoamTags.fromWorkspace(workspace);
const provider = new TagsProvider(foamTags, workspace);
provider.refresh();
const parentTreeItems = (await provider.getChildren()) as TagItem[];
@@ -78,17 +61,18 @@ describe('Tags tree panel', () => {
});
});
it('correctly handles a single parent and multiple child tag', async () => {
it('handles a single parent and multiple child tag', async () => {
const noteA = createTestNote({
tags: ['parent/child'],
uri: './note-a.md',
});
_foam.workspace.set(noteA);
const noteB = createTestNote({
tags: ['parent/subchild'],
uri: './note-b.md',
});
_foam.workspace.set(noteB);
const workspace = new FoamWorkspace().set(noteA).set(noteB);
const foamTags = FoamTags.fromWorkspace(workspace);
const provider = new TagsProvider(foamTags, workspace);
provider.refresh();
const parentTreeItems = (await provider.getChildren()) as TagItem[];
@@ -114,14 +98,15 @@ describe('Tags tree panel', () => {
expect(childTreeItems).toHaveLength(3);
});
it('correctly handles a single parent and child tag in the same note', async () => {
it('handles a parent and child tag in the same note', async () => {
const noteC = createTestNote({
tags: ['main', 'main/subtopic'],
title: 'Test note',
uri: './note-c.md',
});
_foam.workspace.set(noteC);
const workspace = new FoamWorkspace().set(noteC);
const foamTags = FoamTags.fromWorkspace(workspace);
const provider = new TagsProvider(foamTags, workspace);
provider.refresh();
@@ -151,4 +136,36 @@ describe('Tags tree panel', () => {
expect(childTreeItems).toHaveLength(3);
});
it('handles a tag with multiple levels of hierarchy - #1134', async () => {
const noteA = createTestNote({
tags: ['parent/child/second'],
uri: './note-a.md',
});
const workspace = new FoamWorkspace().set(noteA);
const foamTags = FoamTags.fromWorkspace(workspace);
const provider = new TagsProvider(foamTags, workspace);
provider.refresh();
const parentTreeItems = (await provider.getChildren()) as TagItem[];
const parentTagItem = parentTreeItems.pop();
expect(parentTagItem.title).toEqual('parent');
const childTreeItems = (await provider.getChildren(
parentTagItem
)) as TagItem[];
expect(childTreeItems).toHaveLength(2);
expect(childTreeItems[0].label).toMatch(/^Search.*/);
expect(childTreeItems[1].label).toEqual('child');
const grandchildTreeItems = (await provider.getChildren(
childTreeItems[1]
)) as TagItem[];
expect(grandchildTreeItems).toHaveLength(2);
expect(grandchildTreeItems[0].label).toMatch(/^Search.*/);
expect(grandchildTreeItems[1].label).toEqual('second');
});
});

View File

@@ -6,6 +6,7 @@ import { toVsCodeRange, toVsCodeUri } from '../../utils/vsc-utils';
import { Foam } from '../../core/model/foam';
import { FoamWorkspace } from '../../core/model/workspace';
import { Resource, Tag } from '../../core/model/note';
import { FoamTags } from '../../core/model/tags';
const TAG_SEPARATOR = '/';
const feature: FoamFeature = {
@@ -14,7 +15,7 @@ const feature: FoamFeature = {
foamPromise: Promise<Foam>
) => {
const foam = await foamPromise;
const provider = new TagsProvider(foam, foam.workspace);
const provider = new TagsProvider(foam.tags, foam.workspace);
const treeView = vscode.window.createTreeView('foam-vscode.tags-explorer', {
treeDataProvider: provider,
showCollapseAll: true,
@@ -48,7 +49,10 @@ export class TagsProvider implements vscode.TreeDataProvider<TagTreeItem> {
notes: URI[];
}[];
constructor(private foam: Foam, private workspace: FoamWorkspace) {
private foamTags: FoamTags;
constructor(tags: FoamTags, private workspace: FoamWorkspace) {
this.foamTags = tags;
this.computeTags();
}
@@ -58,7 +62,7 @@ export class TagsProvider implements vscode.TreeDataProvider<TagTreeItem> {
}
private computeTags() {
this.tags = [...this.foam.tags.tags]
this.tags = [...this.foamTags.tags]
.map(([tag, notes]) => ({ tag, notes }))
.sort((a, b) => a.tag.localeCompare(b.tag));
}
@@ -68,53 +72,55 @@ export class TagsProvider implements vscode.TreeDataProvider<TagTreeItem> {
}
getChildren(element?: TagItem): Promise<TagTreeItem[]> {
if (element) {
const nestedTagItems: TagTreeItem[] = this.tags
.filter(item => item.tag.indexOf(element.title + TAG_SEPARATOR) > -1)
.map(
item =>
new TagItem(
item.tag,
item.tag.substring(item.tag.indexOf(TAG_SEPARATOR) + 1),
item.notes
)
)
.sort((a, b) => a.title.localeCompare(b.title));
const parentTag = element ? element.tag : '';
const parentPrefix = element ? parentTag + TAG_SEPARATOR : '';
const references: TagTreeItem[] = element.notes
.map(uri => this.foam.workspace.get(uri))
.reduce((acc, note) => {
const tags = note.tags.filter(t => t.label === element.tag);
return [
...acc,
...tags.slice(0, 1).map(t => new TagReference(t, note)),
];
}, [])
.sort((a, b) => a.title.localeCompare(b.title));
const tagsAtThisLevel = this.tags
.filter(({ tag }) => tag.startsWith(parentPrefix))
.map(({ tag }) => {
const nextSeparator = tag.indexOf(TAG_SEPARATOR, parentPrefix.length);
const label =
nextSeparator > -1
? tag.substring(parentPrefix.length, nextSeparator)
: tag.substring(parentPrefix.length);
const tagId = parentPrefix + label;
return { label, tagId, tag };
})
.reduce((acc, { label, tagId, tag }) => {
const existing = acc.has(label);
const nResources = this.foamTags.tags.get(tag).length ?? 0;
if (!existing) {
acc.set(label, { label, tagId, nResources: 0 });
}
acc.get(label).nResources += nResources;
return acc;
}, new Map() as Map<string, { label: string; tagId: string; nResources: number }>);
return Promise.resolve([
new TagSearch(element.tag),
...nestedTagItems,
...references,
]);
}
if (!element) {
const tags: TagItem[] = this.tags
.map(({ tag, notes }) => {
const parentTag =
tag.indexOf(TAG_SEPARATOR) > 0
? tag.substring(0, tag.indexOf(TAG_SEPARATOR))
: tag;
const tagChildren = Array.from(tagsAtThisLevel.values())
.map(({ label, tagId, nResources }) => {
const resources = this.foamTags.tags.get(tagId) ?? [];
return new TagItem(tagId, label, nResources, resources);
})
.sort((a, b) => a.title.localeCompare(b.title));
return new TagItem(parentTag, parentTag, notes);
})
.filter(
(value, index, array) =>
array.findIndex(tag => tag.title === value.title) === index
);
const referenceChildren: TagTreeItem[] = (element?.notes ?? [])
.map(uri => this.workspace.get(uri))
.reduce((acc, note) => {
const tags = note.tags.filter(t => t.label === element.tag);
return [
...acc,
...tags.slice(0, 1).map(t => new TagReference(t, note)),
];
}, [])
.sort((a, b) => a.title.localeCompare(b.title));
return Promise.resolve(tags.sort((a, b) => a.tag.localeCompare(b.tag)));
}
return Promise.resolve(
[
element && new TagSearch(element.tag),
...tagChildren,
...referenceChildren,
].filter(Boolean)
);
}
async resolveTreeItem(item: TagTreeItem): Promise<TagTreeItem> {
@@ -134,11 +140,12 @@ export class TagItem extends vscode.TreeItem {
constructor(
public readonly tag: string,
public readonly title: string,
public readonly nResourcesInSubtree: number,
public readonly notes: URI[]
) {
super(title, vscode.TreeItemCollapsibleState.Collapsed);
this.description = `${this.notes.length} reference${
this.notes.length !== 1 ? 's' : ''
this.description = `${nResourcesInSubtree} reference${
nResourcesInSubtree !== 1 ? 's' : ''
}`;
}

View File

@@ -95,7 +95,7 @@ describe('Tag Completion', () => {
});
it('should not provide suggestions when inside a markdown heading #1182', async () => {
const { uri } = await createFile('# primary heading 1');
const { uri } = await createFile('# primary');
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
@@ -107,4 +107,110 @@ describe('Tag Completion', () => {
expect(foamTags.tags.get('primary')).toBeTruthy();
expect(tags).toBeNull();
});
describe('has robust triggering #1189', () => {
it('should provide multiple suggestions when typing #', async () => {
const { uri } = await createFile(`# Title
#`);
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(2, 1)
);
expect(tags.items.length).toEqual(3);
});
it('should provide multiple suggestions when typing # on line with match', async () => {
const { uri } = await createFile('Here is #my-tag and #');
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(0, 21)
);
expect(tags.items.length).toEqual(3);
});
it('should provide multiple suggestions when typing # at EOL', async () => {
const { uri } = await createFile(`# Title
#
more text
`);
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(2, 1)
);
expect(tags.items.length).toEqual(3);
});
it('should not provide a suggestion when typing `# `', async () => {
const { uri } = await createFile(`# Title
# `);
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(2, 2)
);
expect(foamTags.tags.get('primary')).toBeTruthy();
expect(tags).toBeNull();
});
it('should not provide a suggestion when typing `#{non-match}`', async () => {
const { uri } = await createFile(`# Title
#$`);
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(2, 2)
);
expect(foamTags.tags.get('primary')).toBeTruthy();
expect(tags).toBeNull();
});
it('should not provide a suggestion when typing `##`', async () => {
const { uri } = await createFile(`# Title
##`);
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(2, 2)
);
expect(foamTags.tags.get('primary')).toBeTruthy();
expect(tags).toBeNull();
});
it('should not provide a suggestion when typing `# ` in a line that already matched', async () => {
const { uri } = await createFile('here is #primary and now # ');
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(0, 29)
);
expect(foamTags.tags.get('primary')).toBeTruthy();
expect(tags).toBeNull();
});
});
});

View File

@@ -1,10 +1,14 @@
import * as vscode from 'vscode';
import { Foam } from '../core/model/foam';
import { FoamTags } from '../core/model/tags';
import { HASHTAG_REGEX } from '../core/utils/hashtags';
import { FoamFeature } from '../types';
import { mdDocSelector } from '../utils';
// this regex is different from HASHTAG_REGEX in that it does not look for a
// #+character. It uses a negative look-ahead for `# `
const TAG_REGEX =
/(?<=^|\s)#(?![ \t#])([0-9]*[\p{L}\p{Emoji_Presentation}\p{N}/_-]*)/dgu;
const feature: FoamFeature = {
activate: async (
context: vscode.ExtensionContext,
@@ -34,12 +38,23 @@ export class TagCompletionProvider
.lineAt(position)
.text.substr(0, position.character);
const requiresAutocomplete = cursorPrefix.match(HASHTAG_REGEX);
const requiresAutocomplete = cursorPrefix.match(TAG_REGEX);
if (!requiresAutocomplete) {
return null;
}
// check the match group length.
// find the last match group, and ensure the end of that group is
// at the cursor position.
// This excludes both `#%` and also `here is #my-app1 and now # ` with
// trailing space
const matches = Array.from(cursorPrefix.matchAll(TAG_REGEX));
const lastMatch = matches[matches.length - 1];
const lastMatchEndIndex = lastMatch[0].length + lastMatch.index;
if (lastMatchEndIndex !== position.character) {
return null;
}
const completionTags = [];
[...this.foamTags.tags].forEach(([tag]) => {
const item = new vscode.CompletionItem(

View File

@@ -1,4 +1,4 @@
import { Selection, ViewColumn, window } from 'vscode';
import { Selection, Uri, ViewColumn, window, workspace } from 'vscode';
import { fromVsCodeUri } from '../utils/vsc-utils';
import { NoteFactory } from '../services/templates';
import {
@@ -7,6 +7,7 @@ import {
deleteFile,
getUriInWorkspace,
showInEditor,
withModifiedFoamConfiguration,
} from '../test/test-utils-vscode';
import { Resolver } from './variable-resolver';
import { fileExists } from './editor';
@@ -19,6 +20,56 @@ describe('Create note from template', () => {
});
describe('User flow', () => {
it('should resolve the path using the config when path is derived from note title', async () => {
const templateA = await createFile('Template A', [
'.foam',
'templates',
'template-a.md',
]);
jest
.spyOn(window, 'showInputBox')
.mockImplementation(jest.fn(() => Promise.resolve('Title of note')));
const noteA = await createFile('Note A', [
'path',
'of-new-note',
'note-a.md',
]);
await showInEditor(noteA.uri);
await withModifiedFoamConfiguration(
'files.newNotePath',
'currentDir',
async () => {
const result = await NoteFactory.createFromTemplate(
templateA.uri,
new Resolver(new Map(), new Date())
);
expect(result.uri.path).toEqual(
noteA.uri.getDirectory().joinPath('Title of note.md').path
);
await deleteFile(result.uri);
}
);
await withModifiedFoamConfiguration(
'files.newNotePath',
'root',
async () => {
const result = await NoteFactory.createFromTemplate(
templateA.uri,
new Resolver(new Map(), new Date())
);
expect(result.uri.path).toEqual(
Uri.joinPath(workspace.workspaceFolders[0].uri, 'Title of note.md')
.path
);
await deleteFile(result.uri);
}
);
await deleteFile(noteA);
await deleteFile(templateA);
});
it('should ask a user to confirm the path if note already exists', async () => {
const templateA = await createFile('Template A', [
'.foam',

View File

@@ -349,11 +349,15 @@ export const NoteFactory = {
resolver
);
const newFilePath = template.metadata.has('filepath')
let newFilePath = template.metadata.has('filepath')
? URI.file(template.metadata.get('filepath'))
: isSome(filepathFallbackURI)
? filepathFallbackURI
: await getPathFromTitle(resolver);
: filepathFallbackURI;
if (isNone(newFilePath)) {
newFilePath = await getPathFromTitle(resolver);
} else if (!newFilePath.path.startsWith('./')) {
newFilePath = asAbsoluteWorkspaceUri(newFilePath);
}
return NoteFactory.createNote(
newFilePath,

View File

@@ -9,8 +9,8 @@ import { createTestNote } from '../test/test-utils';
import {
DirectoryTreeItem,
GroupedResourcesTreeDataProvider,
UriTreeItem,
} from './grouped-resources-tree-data-provider';
import { ResourceTreeItem, UriTreeItem } from './tree-view-utils';
const testMatcher = new SubstringExcludeMatcher('path-exclude');
@@ -74,14 +74,14 @@ describe('GroupedResourcesTreeDataProvider', () => {
.list()
.filter(r => r.title.length === 3)
.map(r => r.uri),
uri => new UriTreeItem(uri),
uri => new ResourceTreeItem(workspace.get(uri), workspace),
testMatcher
);
provider.setGroupBy(GroupedResoucesConfigGroupBy.Folder);
const directory = new DirectoryTreeItem(
'/path',
[new UriTreeItem(matchingNote1.uri)],
[new ResourceTreeItem(matchingNote1, workspace)],
'note'
);
const result = await provider.getChildren(directory);
@@ -90,7 +90,7 @@ describe('GroupedResourcesTreeDataProvider', () => {
collapsibleState: 0,
label: 'ABC',
description: '/path/ABC.md',
command: { command: OPEN_COMMAND.command },
command: { command: 'vscode.open' },
},
]);
});
@@ -104,7 +104,7 @@ describe('GroupedResourcesTreeDataProvider', () => {
.list()
.filter(r => r.title.length === 3)
.map(r => r.uri),
uri => new UriTreeItem(uri),
uri => new ResourceTreeItem(workspace.get(uri), workspace),
testMatcher
);
provider.setGroupBy(GroupedResoucesConfigGroupBy.Off);
@@ -115,13 +115,13 @@ describe('GroupedResourcesTreeDataProvider', () => {
collapsibleState: 0,
label: matchingNote1.title,
description: '/path/ABC.md',
command: { command: OPEN_COMMAND.command },
command: { command: 'vscode.open' },
},
{
collapsibleState: 0,
label: matchingNote2.title,
description: '/path-bis/XYZ.md',
command: { command: OPEN_COMMAND.command },
command: { command: 'vscode.open' },
},
]);
});

View File

@@ -1,13 +1,10 @@
import * as path from 'path';
import * as vscode from 'vscode';
import { GroupedResoucesConfigGroupBy } from '../settings';
import { getContainsTooltip, getNoteTooltip, isSome } from '../utils';
import { OPEN_COMMAND } from '../features/commands/open-resource';
import { toVsCodeUri } from './vsc-utils';
import { getContainsTooltip, isSome } from '../utils';
import { URI } from '../core/model/uri';
import { Resource } from '../core/model/note';
import { FoamWorkspace } from '../core/model/workspace';
import { IMatcher } from '../core/services/datastore';
import { UriTreeItem } from './tree-view-utils';
/**
* Provides the ability to expose a TreeDataExplorerView in VSCode. This class will
@@ -128,13 +125,14 @@ export class GroupedResourcesTreeDataProvider
return item;
}
getChildren(
directory?: DirectoryTreeItem
async getChildren(
item?: GroupedResourceTreeItem
): Promise<GroupedResourceTreeItem[]> {
if ((item as any)?.getChildren) {
const children = await (item as any).getChildren();
return children.sort(sortByTreeItemLabel);
}
if (this.groupBy === GroupedResoucesConfigGroupBy.Folder) {
if (isSome(directory)) {
return Promise.resolve(directory.children.sort(sortByTreeItemLabel));
}
const directories = Object.entries(this.getUrisByDirectory())
.sort(([dir1], [dir2]) => sortByString(dir1, dir2))
.map(
@@ -186,63 +184,6 @@ type UrisByDirectory = { [key: string]: Array<URI> };
type GroupedResourceTreeItem = UriTreeItem | DirectoryTreeItem;
export class UriTreeItem extends vscode.TreeItem {
constructor(
public readonly uri: URI,
options: {
collapsibleState?: vscode.TreeItemCollapsibleState;
icon?: string;
title?: string;
} = {}
) {
super(options?.title ?? uri.getName(), options.collapsibleState);
this.description = uri.path.replace(
vscode.workspace.getWorkspaceFolder(toVsCodeUri(uri))?.uri.path,
''
);
this.tooltip = undefined;
this.command = {
command: OPEN_COMMAND.command,
title: OPEN_COMMAND.title,
arguments: [
{
uri: uri,
},
],
};
this.iconPath = new vscode.ThemeIcon(options.icon ?? 'new-file');
}
resolveTreeItem(): Promise<GroupedResourceTreeItem> {
return Promise.resolve(this);
}
}
export class ResourceTreeItem extends UriTreeItem {
constructor(
public readonly resource: Resource,
private readonly workspace: FoamWorkspace,
collapsibleState = vscode.TreeItemCollapsibleState.None
) {
super(resource.uri, {
title: resource.title,
icon: 'note',
collapsibleState,
});
this.contextValue = 'resource';
}
async resolveTreeItem(): Promise<ResourceTreeItem> {
if (this instanceof ResourceTreeItem) {
const content = await this.workspace.readAsMarkdown(this.resource.uri);
this.tooltip = isSome(content)
? getNoteTooltip(content)
: this.resource.title;
}
return this;
}
}
export class DirectoryTreeItem extends vscode.TreeItem {
constructor(
public readonly dir: string,
@@ -264,6 +205,10 @@ export class DirectoryTreeItem extends vscode.TreeItem {
this.tooltip = getContainsTooltip(titles);
return Promise.resolve(this);
}
getChildren(): Promise<GroupedResourceTreeItem[]> {
return Promise.resolve(this.children);
}
}
const sortByTreeItemLabel = (a: vscode.TreeItem, b: vscode.TreeItem) =>

View File

@@ -0,0 +1,162 @@
import * as vscode from 'vscode';
import { Resource } from '../core/model/note';
import { toVsCodeUri } from './vsc-utils';
import { Range } from '../core/model/range';
import { OPEN_COMMAND } from '../features/commands/open-resource';
import { URI } from '../core/model/uri';
import { FoamWorkspace } from '../core/model/workspace';
import { getNoteTooltip } from '../utils';
import { isSome } from '../core/utils';
import { groupBy } from 'lodash';
export class UriTreeItem extends vscode.TreeItem {
private doGetChildren: () => Promise<vscode.TreeItem[]>;
constructor(
public readonly uri: URI,
options: {
collapsibleState?: vscode.TreeItemCollapsibleState;
icon?: string;
title?: string;
getChildren?: () => Promise<vscode.TreeItem[]>;
} = {}
) {
super(
options?.title ?? uri.getName(),
options.collapsibleState
? options.collapsibleState
: options.getChildren
? vscode.TreeItemCollapsibleState.Collapsed
: vscode.TreeItemCollapsibleState.None
);
this.doGetChildren = options.getChildren;
this.description = uri.path.replace(
vscode.workspace.getWorkspaceFolder(toVsCodeUri(uri))?.uri.path,
''
);
this.tooltip = undefined;
this.iconPath = new vscode.ThemeIcon(options.icon ?? 'new-file');
}
resolveTreeItem(): Promise<UriTreeItem> {
return Promise.resolve(this);
}
getChildren(): Promise<vscode.TreeItem[]> {
return isSome(this.doGetChildren)
? this.doGetChildren()
: Promise.resolve([]);
}
}
export class ResourceTreeItem extends UriTreeItem {
constructor(
public readonly resource: Resource,
private readonly workspace: FoamWorkspace,
options: {
collapsibleState?: vscode.TreeItemCollapsibleState;
getChildren?: () => Promise<vscode.TreeItem[]>;
} = {}
) {
super(resource.uri, {
title: resource.title,
icon: 'note',
collapsibleState: options.collapsibleState,
getChildren: options.getChildren,
});
this.command = {
command: 'vscode.open',
arguments: [toVsCodeUri(resource.uri)],
title: 'Go to location',
};
this.contextValue = 'resource';
}
async resolveTreeItem(): Promise<ResourceTreeItem> {
if (this instanceof ResourceTreeItem) {
const content = await this.workspace.readAsMarkdown(this.resource.uri);
this.tooltip = isSome(content)
? getNoteTooltip(content)
: this.resource.title;
}
return this;
}
}
export class ResourceRangeTreeItem extends vscode.TreeItem {
constructor(
public label: string,
public readonly resource: Resource,
public readonly range: Range
) {
super(label, vscode.TreeItemCollapsibleState.None);
this.label = `${range.start.line}: ${this.label}`;
this.command = {
command: 'vscode.open',
arguments: [toVsCodeUri(resource.uri), { selection: range }],
title: 'Go to location',
};
}
resolveTreeItem(): Promise<ResourceRangeTreeItem> {
return Promise.resolve(this);
}
static async createStandardItem(
workspace: FoamWorkspace,
resource: Resource,
range: Range
): Promise<ResourceRangeTreeItem> {
const lines = ((await workspace.readAsMarkdown(resource.uri)) ?? '').split(
'\n'
);
const line = lines[range.start.line];
const start = Math.max(0, range.start.character - 15);
const ellipsis = start === 0 ? '' : '...';
const label = line
? `${range.start.line}: ${ellipsis}${line.slice(start, start + 300)}`
: Range.toString(range);
const tooltip = line && getNoteTooltip(line);
const item = new ResourceRangeTreeItem(label, resource, range);
item.tooltip = tooltip;
return item;
}
}
export const groupRangesByResource = async (
workspace: FoamWorkspace,
items:
| ResourceRangeTreeItem[]
| Promise<ResourceRangeTreeItem[]>
| Promise<ResourceRangeTreeItem>[],
collapsibleState = vscode.TreeItemCollapsibleState.Collapsed
) => {
let itemsArray = [] as ResourceRangeTreeItem[];
if (items instanceof Promise) {
itemsArray = await items;
}
if (items instanceof Array && items[0] instanceof Promise) {
itemsArray = await Promise.all(items);
}
if (items instanceof Array && items[0] instanceof ResourceRangeTreeItem) {
itemsArray = items as any;
}
const byResource = groupBy(itemsArray, item => item.resource.uri.path);
const resourceItems = Object.values(byResource).map(items => {
const resourceItem = new ResourceTreeItem(items[0].resource, workspace, {
collapsibleState,
getChildren: () => {
return Promise.resolve(
items.sort((a, b) => Range.isBefore(a.range, b.range))
);
},
});
resourceItem.description = `(${items.length}) ${resourceItem.description}`;
return resourceItem;
});
resourceItems.sort((a, b) => Resource.sortByTitle(a.resource, b.resource));
return resourceItems;
};