Compare commits

...

9 Commits

Author SHA1 Message Date
Riccardo Ferretti
5de69ff3c3 v0.23.0 2023-05-06 15:33:46 +02:00
Riccardo Ferretti
8aefcfd515 Preparation for 0.23.0 release 2023-05-06 15:33:22 +02:00
Riccardo
e0e08a2a0f Notes explorer (#1223)
* Added notes explorer

* Fixed line reference in range tree items

Thanks to Wikilens extensions for the high level inspiration (and the choice for the backlink tree item icon, as I find it just perfect)
2023-05-06 14:49:19 +02:00
Riccardo
93c5d2f80c Improvements in tree views (#1220) 2023-05-02 10:36:50 +02:00
Jim Graham
1c294d84c5 Enable tag completion in front matter (#1191)
* Addresses #1184

Currently tag completion only works in the front matter if you type a `#`
character. Adding the suggested tag will then mark the tag as a comment

```markdown
---
tags: #foo #bar
---
```

Update the tag completion provider to recognize if we are in the
front-matter, by using adding two functions to utils.ts. Because the
tag completion intellisense must be summoned with either the `#`
character or the keybinding (typically `ctrl+space`), allow
for 2 outcomes

1. if the tag is prefixed in the front matter with a `#`, remove it when
   substituting the tag.
2. If `ctrl-space` is used, recognize we are on the `tags: ` line and
   allow for non-`#` prefaced words.

The tag provider only works on the `tags: ` within the `tags: ` key of
the frontmatter. For example

```markdown
---
title: A title
tags:
 - foo
 - bar
 - |
```
(where `|` is the cursor) will provide suggestions for tags.

Outside the `tags:` element, suggestions will not be provided.
```markdown
---
title: A title
tags:
 - foo
 - bar
dates:
 - 2023-01-1
 - |
```

* Refactor into functions for front matter & content

Refactor the main provider method into two
sub-functions, one for front matter, one for
regular content. Add helper functions to generate
the `CompletionItems` and to find the start & end
indices of the last match to `#{tag}`.
2023-05-02 06:11:06 +02:00
Riccardo Ferretti
f1b15eceed v0.22.2 2023-04-20 18:25:37 -07:00
Riccardo Ferretti
96f410a453 Prepare for next release 2023-04-20 18:25:06 -07:00
Riccardo Ferretti
95a14d5dd6 Create blocks markdown parser only once 2023-04-20 18:25:00 -07:00
Riccardo
10905fd703 Various improvements in tree view panels (#1201)
* Show note block in panels on hover preview
* Show tag references within tag explorer
* Improved structure of view related commands
* Refactored grouped resources tree data provider and added support for placeholders filter
  - Consolidated the naming of the accessory commands
  - Consolidated the management of the state/context related to grouping
  - Removed group-by config, simply restore the last used setting
  - Added filter to only show the placeholders related to the open file
* Refreshing placeholders when changing editor and filtering by active document
2023-04-21 03:20:22 +02:00
24 changed files with 1257 additions and 298 deletions

View File

@@ -15,6 +15,10 @@ There are two ways of creating a tag:
Tags can also be hierarchical, so you can have `#parent/child` such as #my-tag3/info.
### Tag completion
Typing the `#` character will launch VS Code's "Intellisense." This provider will show a list of possible tags that match the character. If you are editing in the frontmatter [[note-properties|note property]], you can invoke tag completion on the `tags:` line by either typing the `#` character, or using the ["trigger suggest"](https://code.visualstudio.com/docs/editor/intellisense) keybinding (usually `ctrl+space`). If the `#` is used in the frontmatter, it will be removed when the tag is inserted.
## Using *Tag Explorer*
It's possible to navigate tags via the Tag Explorer panel. Expand the Tag Explorer view in the left side bar which will list all the tags found in current Foam environment. Then, each level of tags can be expanded until the options to search by tag and a list of all files containing a particular tag are shown.
@@ -49,7 +53,5 @@ The end result will be a CSS file that looks similiar to the content below. Now
Given the power of backlinks, some people prefer to use them as tags.
For example you can tag your notes about books with [[book]].
[//begin]: # "Autogenerated link references for markdown compatibility"
[note-properties|note property]: note-properties.md "Note Properties"
[graph-visualization]: graph-visualization.md "Graph Visualization"
[//end]: # "Autogenerated link references"
[graph-visualization]: graph-visualization.md "Graph Visualization"

View File

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

View File

@@ -4,6 +4,27 @@ 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.23.0] - 2023-05-06
Features:
- Added notes explorer (#1223)
Fixes and Improvements:
- Enabled tag completion in front matter (#1191 - thanks @jimgraham)
- Various improvements to tree views (#1220)
## [0.22.2] - 2023-04-20
Fixes and Improvements:
- Support to show placeholders only for open file in panel (#1201, #988)
- Show note block in panels on hover preview (#1201, #800)
- Show tag references within tag explorer (#1201)
- Improved structure of view related commands (#1201)
- Ignore `.foam` directory
## [0.22.1] - 2023-04-15
Fixes and Improvements:

View File

@@ -8,7 +8,7 @@
"type": "git"
},
"homepage": "https://github.com/foambubble/foam",
"version": "0.22.1",
"version": "0.23.0",
"license": "MIT",
"publisher": "foam",
"engines": {
@@ -67,6 +67,12 @@
"icon": "$(tag)",
"contextualTitle": "Tags Explorer"
},
{
"id": "foam-vscode.notes-explorer",
"name": "Notes",
"icon": "$(notebook)",
"contextualTitle": "Notes Explorer"
},
{
"id": "foam-vscode.orphans",
"name": "Orphans",
@@ -96,29 +102,49 @@
},
{
"view": "foam-vscode.placeholders",
"contents": "No placeholders found. Pending links and notes without content will show up here."
"contents": "No placeholders found for selected resource or workspace."
}
],
"menus": {
"view/title": [
{
"command": "foam-vscode.group-orphans-by-folder",
"when": "view == foam-vscode.orphans && foam-vscode.orphans-grouped-by-folder == false",
"command": "foam-vscode.views.orphans.group-by:folder",
"when": "view == foam-vscode.orphans && foam-vscode.views.orphans.group-by == 'off'",
"group": "navigation"
},
{
"command": "foam-vscode.group-orphans-off",
"when": "view == foam-vscode.orphans && foam-vscode.orphans-grouped-by-folder == true",
"command": "foam-vscode.views.orphans.group-by:off",
"when": "view == foam-vscode.orphans && foam-vscode.views.orphans.group-by == 'folder'",
"group": "navigation"
},
{
"command": "foam-vscode.group-placeholders-by-folder",
"when": "view == foam-vscode.placeholders && foam-vscode.placeholders-grouped-by-folder == false",
"command": "foam-vscode.views.placeholders.show:for-current-file",
"when": "view == foam-vscode.placeholders && foam-vscode.views.placeholders.show == 'all'",
"group": "navigation"
},
{
"command": "foam-vscode.group-placeholders-off",
"when": "view == foam-vscode.placeholders && foam-vscode.placeholders-grouped-by-folder == true",
"command": "foam-vscode.views.placeholders.show:all",
"when": "view == foam-vscode.placeholders && foam-vscode.views.placeholders.show == 'for-current-file'",
"group": "navigation"
},
{
"command": "foam-vscode.views.placeholders.group-by:folder",
"when": "view == foam-vscode.placeholders && foam-vscode.views.placeholders.group-by == 'off'",
"group": "navigation"
},
{
"command": "foam-vscode.views.placeholders.group-by:off",
"when": "view == foam-vscode.placeholders && foam-vscode.views.placeholders.group-by == 'folder'",
"group": "navigation"
},
{
"command": "foam-vscode.views.notes-explorer.show:notes",
"when": "view == foam-vscode.notes-explorer && foam-vscode.views.notes-explorer.show == 'all'",
"group": "navigation"
},
{
"command": "foam-vscode.views.notes-explorer.show:all",
"when": "view == foam-vscode.notes-explorer && foam-vscode.views.notes-explorer.show == 'notes-only'",
"group": "navigation"
}
],
@@ -132,19 +158,35 @@
"when": "false"
},
{
"command": "foam-vscode.group-orphans-by-folder",
"command": "foam-vscode.views.orphans.group-by:folder",
"when": "false"
},
{
"command": "foam-vscode.group-orphans-off",
"command": "foam-vscode.views.orphans.group-by:off",
"when": "false"
},
{
"command": "foam-vscode.group-placeholders-by-folder",
"command": "foam-vscode.views.placeholders.show:for-current-file",
"when": "false"
},
{
"command": "foam-vscode.group-placeholders-off",
"command": "foam-vscode.views.placeholders.show:all",
"when": "false"
},
{
"command": "foam-vscode.views.placeholders.group-by:folder",
"when": "false"
},
{
"command": "foam-vscode.views.placeholders.group-by:off",
"when": "false"
},
{
"command": "foam-vscode.views.notes-explorer.show:all",
"when": "false"
},
{
"command": "foam-vscode.views.notes-explorer.show:notes",
"when": "false"
},
{
@@ -215,25 +257,45 @@
"title": "Foam: Open Resource"
},
{
"command": "foam-vscode.group-orphans-by-folder",
"title": "Foam: Group Orphans By Folder",
"command": "foam-vscode.views.orphans.group-by:folder",
"title": "Group By Folder",
"icon": "$(list-tree)"
},
{
"command": "foam-vscode.group-orphans-off",
"title": "Foam: Don't Group Orphans",
"command": "foam-vscode.views.orphans.group-by:off",
"title": "Flat list",
"icon": "$(list-flat)"
},
{
"command": "foam-vscode.group-placeholders-by-folder",
"title": "Foam: Group Placeholders By Folder",
"command": "foam-vscode.views.placeholders.show:for-current-file",
"title": "Show placeholders in current file",
"icon": "$(file)"
},
{
"command": "foam-vscode.views.placeholders.show:all",
"title": "Show placeholders in workspace",
"icon": "$(files)"
},
{
"command": "foam-vscode.views.placeholders.group-by:folder",
"title": "Group By Folder",
"icon": "$(list-tree)"
},
{
"command": "foam-vscode.group-placeholders-off",
"title": "Foam: Don't Group Placeholders",
"command": "foam-vscode.views.placeholders.group-by:off",
"title": "Flat list",
"icon": "$(list-flat)"
},
{
"command": "foam-vscode.views.notes-explorer.show:all",
"title": "Show all resources",
"icon": "$(files)"
},
{
"command": "foam-vscode.views.notes-explorer.show:notes",
"title": "Show only notes",
"icon": "$(file)"
},
{
"command": "foam-vscode.create-new-template",
"title": "Foam: Create New Template"
@@ -375,21 +437,6 @@
"default": [],
"markdownDescription": "Specifies the list of glob patterns that will be excluded from the orphans report. To ignore the all the content of a given folder, use `**<folderName>/**/*`"
},
"foam.orphans.groupBy": {
"type": [
"string"
],
"enum": [
"off",
"folder"
],
"enumDescriptions": [
"Disable grouping",
"Group by folder"
],
"default": "folder",
"markdownDescription": "Group orphans report entries by."
},
"foam.placeholders.exclude": {
"type": [
"array"
@@ -397,21 +444,6 @@
"default": [],
"markdownDescription": "Specifies the list of glob patterns that will be excluded from the placeholders report. To ignore the all the content of a given folder, use `**<folderName>/**/*`"
},
"foam.placeholders.groupBy": {
"type": [
"string"
],
"enum": [
"off",
"folder"
],
"enumDescriptions": [
"Disable grouping",
"Group by folder"
],
"default": "folder",
"markdownDescription": "Group blank note report entries by."
},
"foam.dateSnippets.afterCompletion": {
"type": "string",
"default": "createNote",

View File

@@ -1,4 +1,8 @@
import { createMarkdownParser, ParserPlugin } from './markdown-parser';
import {
createMarkdownParser,
getBlockFor,
ParserPlugin,
} from './markdown-parser';
import { Logger } from '../utils/log';
import { URI } from '../model/uri';
import { Range } from '../model/range';
@@ -459,3 +463,48 @@ But with some content.
]);
});
});
describe('Block detection', () => {
const md = `
- this is block 1
- this is [[block]] 2
- this is block 2.1
- this is block 3
- this is block 3.1
- this is block 3.1.1
- this is block 3.2
- this is block 4
this is a simple line
this is another simple line
`;
it('can detect block', () => {
const { block } = getBlockFor(md, 1);
expect(block).toEqual('- this is block 1');
});
it('supports nested blocks 1', () => {
const { block } = getBlockFor(md, 2);
expect(block).toEqual(`- this is [[block]] 2
- this is block 2.1`);
});
it('supports nested blocks 2', () => {
const { block } = getBlockFor(md, 5);
expect(block).toEqual(` - this is block 3.1
- this is block 3.1.1`);
});
it('returns the line if no block is detected', () => {
const { block } = getBlockFor(md, 9);
expect(block).toEqual(`this is a simple line`);
});
it('is compatible with Range object', () => {
const note = parser.parse(URI.file('/path/to/a'), md);
const { start } = note.links[0].range;
const { block } = getBlockFor(md, start);
expect(block).toEqual(`- this is [[block]] 2
- this is block 2.1`);
});
});

View File

@@ -424,3 +424,28 @@ const astPositionToFoamRange = (pos: AstPosition): Range =>
pos.end.line - 1,
pos.end.column - 1
);
const blockParser = unified().use(markdownParse, { gfm: true });
export const getBlockFor = (
markdown: string,
line: number | Position
): { block: string; nLines: number } => {
const searchLine = typeof line === 'number' ? line : line.line;
const tree = blockParser.parse(markdown);
const lines = markdown.split('\n');
let block = null;
let nLines = 0;
visit(tree, ['listItem'], (node: any) => {
if (node.position.start.line === searchLine + 1) {
block = lines
.slice(node.position.start.line - 1, node.position.end.line)
.join('\n');
nLines = node.position.end.line - node.position.start.line;
return visit.EXIT;
}
});
if (block == null) {
block = lines[searchLine];
}
return { block, nLines };
};

View File

@@ -10,6 +10,7 @@ import { fromVsCodeUri } from '../../utils/vsc-utils';
import {
ResourceRangeTreeItem,
ResourceTreeItem,
createBacklinkItemsForResource,
groupRangesByResource,
} from '../../utils/tree-view-utils';
@@ -65,17 +66,12 @@ export class BacklinksTreeDataProvider
return Promise.resolve([]);
}
const connections = this.graph
.getConnections(uri)
.filter(c => c.target.asPlain().isEqual(uri));
const backlinkItems = connections.map(c =>
ResourceRangeTreeItem.createStandardItem(
this.workspace,
this.workspace.get(c.source),
c.link.range
)
const backlinkItems = createBacklinkItemsForResource(
this.workspace,
this.graph,
uri
);
return groupRangesByResource(
this.workspace,
backlinkItems,

View File

@@ -3,3 +3,4 @@ export { default as dataviz } from './dataviz';
export { default as orphans } from './orphans';
export { default as placeholders } from './placeholders';
export { default as tags } from './tags-explorer';
export { default as notes } from './notes-explorer';

View File

@@ -0,0 +1,166 @@
import * as vscode from 'vscode';
import { FoamFeature } from '../../types';
import { Foam } from '../../core/model/foam';
import { FoamWorkspace } from '../../core/model/workspace';
import {
ResourceRangeTreeItem,
ResourceTreeItem,
createBacklinkItemsForResource as createBacklinkTreeItemsForResource,
} from '../../utils/tree-view-utils';
import { Resource } from '../../core/model/note';
import { FoamGraph } from '../../core/model/graph';
import { ContextMemento } from '../../utils/vsc-utils';
import {
FolderTreeItem,
FolderTreeProvider,
} from './utils/folder-tree-provider';
const feature: FoamFeature = {
activate: async (
context: vscode.ExtensionContext,
foamPromise: Promise<Foam>
) => {
const foam = await foamPromise;
const provider = new NotesProvider(
foam.workspace,
foam.graph,
context.globalState
);
provider.refresh();
const treeView = vscode.window.createTreeView<NotesTreeItems>(
'foam-vscode.notes-explorer',
{
treeDataProvider: provider,
showCollapseAll: true,
canSelectMany: true,
}
);
const revealTextEditorItem = async () => {
const target = vscode.window.activeTextEditor?.document.uri;
if (treeView.visible) {
if (target) {
const item = await findTreeItemByUri(provider, target);
// Check if the item is already selected.
// This check is needed because always calling reveal() will
// cause the tree view to take the focus from the item when
// browsing the notes explorer
if (
!treeView.selection.find(
i => i.resourceUri?.path === item.resourceUri.path
)
) {
treeView.reveal(item);
}
}
}
};
context.subscriptions.push(
treeView,
provider,
foam.graph.onDidUpdate(() => {
provider.refresh();
}),
vscode.window.onDidChangeActiveTextEditor(revealTextEditorItem),
treeView.onDidChangeVisibility(revealTextEditorItem)
);
},
};
export default feature;
export function findTreeItemByUri<I, T>(
provider: FolderTreeProvider<I, T>,
target: vscode.Uri
) {
const path = vscode.workspace.asRelativePath(
target,
vscode.workspace.workspaceFolders.length > 1
);
return provider.findTreeItemByPath(path.split('/'));
}
export type NotesTreeItems =
| ResourceTreeItem
| FolderTreeItem<Resource>
| ResourceRangeTreeItem;
export class NotesProvider extends FolderTreeProvider<
NotesTreeItems,
Resource
> {
public show = new ContextMemento<'all' | 'notes-only'>(
this.state,
`foam-vscode.views.notes-explorer.show`,
'all'
);
constructor(
private workspace: FoamWorkspace,
private graph: FoamGraph,
private state: vscode.Memento
) {
super();
this.disposables.push(
vscode.commands.registerCommand(
`foam-vscode.views.notes-explorer.show:all`,
() => {
this.show.update('all');
this.refresh();
}
),
vscode.commands.registerCommand(
`foam-vscode.views.notes-explorer.show:notes`,
() => {
this.show.update('notes-only');
this.refresh();
}
)
);
}
getValues() {
return this.workspace.list();
}
getFilterFn() {
return this.show.get() === 'notes-only'
? res => res.type !== 'image' && res.type !== 'attachment'
: () => true;
}
valueToPath(value: Resource) {
const path = vscode.workspace.asRelativePath(
value.uri.path,
vscode.workspace.workspaceFolders.length > 1
);
const parts = path.split('/');
return parts;
}
isValueType(value: Resource): value is Resource {
return value.uri != null;
}
createValueTreeItem(
value: Resource,
parent: FolderTreeItem<Resource>
): NotesTreeItems {
const res = new ResourceTreeItem(value, this.workspace, {
parent,
collapsibleState:
this.graph.getBacklinks(value.uri).length > 0
? vscode.TreeItemCollapsibleState.Collapsed
: vscode.TreeItemCollapsibleState.None,
});
res.getChildren = async () => {
const backlinks = await createBacklinkTreeItemsForResource(
this.workspace,
this.graph,
res.uri
);
return backlinks;
};
return res;
}
}

View File

@@ -5,6 +5,9 @@ import { getOrphansConfig } from '../../settings';
import { FoamFeature } from '../../types';
import { GroupedResourcesTreeDataProvider } from '../../utils/grouped-resources-tree-data-provider';
import { ResourceTreeItem, UriTreeItem } from '../../utils/tree-view-utils';
import { IMatcher } from '../../core/services/datastore';
import { FoamWorkspace } from '../../core/model/workspace';
import { FoamGraph } from '../../core/model/graph';
const EXCLUDE_TYPES = ['image', 'attachment'];
const feature: FoamFeature = {
@@ -17,36 +20,24 @@ const feature: FoamFeature = {
const { matcher } = await createMatcherAndDataStore(
getOrphansConfig().exclude
);
const provider = new GroupedResourcesTreeDataProvider(
'orphans',
'orphan',
() =>
foam.graph
.getAllNodes()
.filter(
uri =>
!EXCLUDE_TYPES.includes(foam.workspace.find(uri)?.type) &&
foam.graph.getConnections(uri).length === 0
),
uri => {
return uri.isPlaceholder()
? new UriTreeItem(uri)
: new ResourceTreeItem(foam.workspace.find(uri), foam.workspace);
},
const provider = new OrphanTreeView(
context.globalState,
foam.workspace,
foam.graph,
matcher
);
provider.setGroupBy(getOrphansConfig().groupBy);
const treeView = vscode.window.createTreeView('foam-vscode.orphans', {
treeDataProvider: provider,
showCollapseAll: true,
});
provider.refresh();
const baseTitle = treeView.title;
treeView.title = baseTitle + ` (${provider.numElements})`;
context.subscriptions.push(
vscode.window.registerTreeDataProvider('foam-vscode.orphans', provider),
...provider.commands,
provider,
foam.graph.onDidUpdate(() => {
provider.refresh();
treeView.title = baseTitle + ` (${provider.numElements})`;
@@ -55,4 +46,30 @@ const feature: FoamFeature = {
},
};
export class OrphanTreeView extends GroupedResourcesTreeDataProvider {
constructor(
state: vscode.Memento,
private workspace: FoamWorkspace,
private graph: FoamGraph,
matcher: IMatcher
) {
super('orphans', 'orphan', state, matcher);
}
createTreeItem = uri => {
return uri.isPlaceholder()
? new UriTreeItem(uri)
: new ResourceTreeItem(this.workspace.find(uri), this.workspace);
};
computeResources = () =>
this.graph
.getAllNodes()
.filter(
uri =>
!EXCLUDE_TYPES.includes(this.workspace.find(uri)?.type) &&
this.graph.getConnections(uri).length === 0
);
}
export default feature;

View File

@@ -5,10 +5,15 @@ import { getPlaceholdersConfig } from '../../settings';
import { FoamFeature } from '../../types';
import { GroupedResourcesTreeDataProvider } from '../../utils/grouped-resources-tree-data-provider';
import {
ResourceRangeTreeItem,
UriTreeItem,
createBacklinkItemsForResource,
groupRangesByResource,
} from '../../utils/tree-view-utils';
import { IMatcher } from '../../core/services/datastore';
import { ContextMemento, fromVsCodeUri } from '../../utils/vsc-utils';
import { FoamGraph } from '../../core/model/graph';
import { URI } from '../../core/model/uri';
import { FoamWorkspace } from '../../core/model/workspace';
const feature: FoamFeature = {
activate: async (
@@ -19,30 +24,12 @@ const feature: FoamFeature = {
const { matcher } = await createMatcherAndDataStore(
getPlaceholdersConfig().exclude
);
const provider = new GroupedResourcesTreeDataProvider(
'placeholders',
'placeholder',
() => foam.graph.getAllNodes().filter(uri => uri.isPlaceholder()),
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
);
})
);
},
});
},
const provider = new PlaceholderTreeView(
context.globalState,
foam.workspace,
foam.graph,
matcher
);
provider.setGroupBy(getPlaceholdersConfig().groupBy);
const treeView = vscode.window.createTreeView('foam-vscode.placeholders', {
treeDataProvider: provider,
@@ -50,16 +37,84 @@ const feature: FoamFeature = {
});
const baseTitle = treeView.title;
treeView.title = baseTitle + ` (${provider.numElements})`;
provider.refresh();
context.subscriptions.push(
treeView,
...provider.commands,
provider,
foam.graph.onDidUpdate(() => {
provider.refresh();
}),
provider.onDidChangeTreeData(() => {
treeView.title = baseTitle + ` (${provider.numElements})`;
}),
vscode.window.onDidChangeActiveTextEditor(() => {
if (provider.show.get() === 'for-current-file') {
provider.refresh();
}
})
);
},
};
export class PlaceholderTreeView extends GroupedResourcesTreeDataProvider {
public show = new ContextMemento<'all' | 'for-current-file'>(
this.state,
`foam-vscode.views.${this.providerId}.show`,
'all'
);
public constructor(
state: vscode.Memento,
private workspace: FoamWorkspace,
private graph: FoamGraph,
matcher: IMatcher
) {
super('placeholders', 'placeholder', state, matcher);
this.disposables.push(
vscode.commands.registerCommand(
`foam-vscode.views.${this.providerId}.show:all`,
() => {
this.show.update('all');
this.refresh();
}
),
vscode.commands.registerCommand(
`foam-vscode.views.${this.providerId}.show:for-current-file`,
() => {
this.show.update('for-current-file');
this.refresh();
}
)
);
}
createTreeItem = uri => {
const item = new UriTreeItem(uri, {
collapsibleState: vscode.TreeItemCollapsibleState.Collapsed,
});
item.getChildren = async () => {
return groupRangesByResource(
this.workspace,
createBacklinkItemsForResource(this.workspace, this.graph, uri)
);
};
item.iconPath = new vscode.ThemeIcon('link');
return item;
};
computeResources = (): URI[] => {
if (this.show.get() === 'for-current-file') {
const currentFile = vscode.window.activeTextEditor?.document.uri;
return currentFile
? this.graph
.getLinks(fromVsCodeUri(currentFile))
.map(link => link.target)
.filter(uri => uri.isPlaceholder())
: [];
}
return this.graph.getAllNodes().filter(uri => uri.isPlaceholder());
};
}
export default feature;

View File

@@ -1,8 +1,9 @@
import { createTestNote } from '../../test/test-utils';
import { cleanWorkspace, closeEditors } from '../../test/test-utils-vscode';
import { TagItem, TagReference, TagsProvider } from './tags-explorer';
import { TagItem, TagsProvider } from './tags-explorer';
import { FoamTags } from '../../core/model/tags';
import { FoamWorkspace } from '../../core/model/workspace';
import { ResourceTreeItem } from '../../utils/tree-view-utils';
describe('Tags tree panel', () => {
beforeAll(async () => {
@@ -122,9 +123,9 @@ describe('Tags tree panel', () => {
)) as TagItem[];
childTreeItems
.filter(item => item instanceof TagReference)
.filter(item => item instanceof ResourceTreeItem)
.forEach(item => {
expect(item.title).toEqual('Test note');
expect(item.label).toEqual('Test note');
});
childTreeItems

View File

@@ -7,6 +7,11 @@ 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';
import {
ResourceRangeTreeItem,
ResourceTreeItem,
groupRangesByResource,
} from '../../utils/tree-view-utils';
const TAG_SEPARATOR = '/';
const feature: FoamFeature = {
@@ -71,7 +76,11 @@ export class TagsProvider implements vscode.TreeDataProvider<TagTreeItem> {
return element;
}
getChildren(element?: TagItem): Promise<TagTreeItem[]> {
async getChildren(element?: TagItem): Promise<TagTreeItem[]> {
if ((element as any)?.getChildren) {
const children = await (element as any).getChildren();
return children;
}
const parentTag = element ? element.tag : '';
const parentPrefix = element ? parentTag + TAG_SEPARATOR : '';
@@ -96,45 +105,53 @@ export class TagsProvider implements vscode.TreeDataProvider<TagTreeItem> {
return acc;
}, new Map() as Map<string, { label: string; tagId: string; nResources: number }>);
const tagChildren = Array.from(tagsAtThisLevel.values())
const subtags = 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));
const referenceChildren: TagTreeItem[] = (element?.notes ?? [])
const resourceTags: ResourceRangeTreeItem[] = (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));
const items = tags.map(t =>
ResourceRangeTreeItem.createStandardItem(
this.workspace,
note,
t.range,
'tag'
)
);
return [...acc, ...items];
}, []);
const resources = await groupRangesByResource(this.workspace, resourceTags);
return Promise.resolve(
[
element && new TagSearch(element.tag),
...tagChildren,
...referenceChildren,
].filter(Boolean)
[element && new TagSearch(element.tag), ...subtags, ...resources].filter(
Boolean
)
);
}
async resolveTreeItem(item: TagTreeItem): Promise<TagTreeItem> {
if (item instanceof TagReference) {
const content = await this.workspace.readAsMarkdown(item.note.uri);
if (isSome(content)) {
item.tooltip = getNoteTooltip(content);
}
if (
item instanceof ResourceTreeItem ||
item instanceof ResourceRangeTreeItem
) {
return item.resolveTreeItem();
}
return item;
return Promise.resolve(item);
}
}
type TagTreeItem = TagItem | TagReference | TagSearch;
type TagTreeItem =
| TagItem
| TagSearch
| ResourceTreeItem
| ResourceRangeTreeItem;
export class TagItem extends vscode.TreeItem {
constructor(
@@ -176,28 +193,3 @@ export class TagSearch extends vscode.TreeItem {
iconPath = new vscode.ThemeIcon('search');
contextValue = 'tag-search';
}
export class TagReference extends vscode.TreeItem {
public readonly title: string;
constructor(public readonly tag: Tag, public readonly note: Resource) {
super(note.title, vscode.TreeItemCollapsibleState.None);
const uri = toVsCodeUri(note.uri);
this.title = note.title;
this.description = vscode.workspace.asRelativePath(uri);
this.tooltip = undefined;
this.command = {
command: 'vscode.open',
arguments: [
uri,
{
preview: true,
selection: toVsCodeRange(tag.range),
},
],
title: 'Open File',
};
}
iconPath = new vscode.ThemeIcon('note');
contextValue = 'reference';
}

View File

@@ -0,0 +1,34 @@
import * as vscode from 'vscode';
import { IDisposable } from 'packages/foam-vscode/src/core/common/lifecycle';
export abstract class BaseTreeProvider<T>
implements vscode.TreeDataProvider<T>, IDisposable
{
protected disposables: vscode.Disposable[] = [];
// prettier-ignore
private _onDidChangeTreeData: vscode.EventEmitter<T | undefined | void> = new vscode.EventEmitter<T | undefined | void>();
// prettier-ignore
readonly onDidChangeTreeData: vscode.Event<T | undefined | void> = this._onDidChangeTreeData.event;
abstract getChildren(element?: T): vscode.ProviderResult<T[]>;
getTreeItem(element: T) {
return element;
}
async resolveTreeItem(item: T): Promise<T> {
if ((item as any)?.resolveTreeItem) {
return (item as any).resolveTreeItem();
}
return Promise.resolve(item);
}
refresh(): void {
this._onDidChangeTreeData.fire();
}
dispose(): void {
this.disposables.forEach(d => d.dispose());
}
}

View File

@@ -0,0 +1,159 @@
import * as vscode from 'vscode';
import { BaseTreeProvider } from './base-tree-provider';
import { BaseTreeItem, ResourceTreeItem } from '../../../utils/tree-view-utils';
export interface Folder<T> {
[basename: string]: Folder<T> | T;
}
export class FolderTreeItem<T> extends vscode.TreeItem {
collapsibleState = vscode.TreeItemCollapsibleState.Collapsed;
contextValue = 'folder';
iconPath = new vscode.ThemeIcon('folder');
constructor(
public parent: Folder<T>,
public name: string,
public parentElement?: FolderTreeItem<T>
) {
super(name, vscode.TreeItemCollapsibleState.Collapsed);
}
}
export abstract class FolderTreeProvider<I, T> extends BaseTreeProvider<I> {
private root: Folder<T>;
refresh(): void {
this.createTree(this.getValues(), this.getFilterFn());
super.refresh();
}
getParent(element: I | FolderTreeItem<T>): vscode.ProviderResult<I> {
if (element instanceof ResourceTreeItem) {
return Promise.resolve(element.parent as I);
}
if (element instanceof FolderTreeItem) {
return Promise.resolve(element.parentElement as any);
}
}
async getChildren(item?: I): Promise<I[]> {
if (item instanceof BaseTreeItem) {
return item.getChildren() as Promise<I[]>;
}
const parent = (item as any)?.parent ?? this.root;
const children: vscode.TreeItem[] = Object.keys(parent).map(name => {
const value = parent[name];
if (this.isValueType(value)) {
return this.createValueTreeItem(value, undefined);
} else {
return new FolderTreeItem<T>(
value as Folder<T>,
name,
item as unknown as FolderTreeItem<T>
);
}
});
return children.sort((a, b) => sortFolderTreeItems(a, b)) as any;
}
createTree(values: T[], filterFn: (value: T) => boolean): Folder<T> {
const root: Folder<T> = {};
for (const r of values) {
const parts = this.valueToPath(r);
let currentNode: Folder<T> = root;
parts.forEach((part, index) => {
if (!currentNode[part]) {
if (index < parts.length - 1) {
currentNode[part] = {};
} else {
if (filterFn(r)) {
currentNode[part] = r;
}
}
}
currentNode = currentNode[part] as Folder<T>;
});
}
this.root = root;
return root;
}
getTreeItemsHierarchy(path: string[]): vscode.TreeItem[] {
const treeItemsHierarchy: vscode.TreeItem[] = [];
let currentNode: Folder<T> | T = this.root;
for (const part of path) {
if (currentNode[part] !== undefined) {
currentNode = currentNode[part] as Folder<T> | T;
if (this.isValueType(currentNode as T)) {
treeItemsHierarchy.push(
this.createValueTreeItem(
currentNode as T,
treeItemsHierarchy[
treeItemsHierarchy.length - 1
] as FolderTreeItem<T>
)
);
} else {
treeItemsHierarchy.push(
new FolderTreeItem(
currentNode as Folder<T>,
part,
treeItemsHierarchy[
treeItemsHierarchy.length - 1
] as FolderTreeItem<T>
)
);
}
} else {
// If a part is not found in the tree structure, the given URI is not valid.
return [];
}
}
return treeItemsHierarchy;
}
findTreeItemByPath(path: string[]): Promise<I> {
const hierarchy = this.getTreeItemsHierarchy(path);
return hierarchy.length > 0
? Promise.resolve(hierarchy.pop())
: Promise.resolve(null);
}
abstract valueToPath(value: T);
abstract getValues(): T[];
abstract getFilterFn(): (value: T) => boolean;
abstract isValueType(value: T): value is T;
abstract createValueTreeItem(value: T, parent: FolderTreeItem<T>): I;
}
function sortFolderTreeItems(a: vscode.TreeItem, b: vscode.TreeItem): number {
// Both a and b are FolderTreeItem instances
if (a instanceof FolderTreeItem && b instanceof FolderTreeItem) {
return a.label.toString().localeCompare(b.label.toString());
}
// Only a is a FolderTreeItem instance
if (a instanceof FolderTreeItem) {
return -1;
}
// Only b is a FolderTreeItem instance
if (b instanceof FolderTreeItem) {
return 1;
}
return a.label.toString().localeCompare(b.label.toString());
}

View File

@@ -213,4 +213,154 @@ more text
expect(tags).toBeNull();
});
});
describe('works inside front-matter #1184', () => {
it('should provide suggestions when on `tags:` in the front-matter', async () => {
const { uri } = await createFile(`---
created: 2023-01-01
tags: prim`);
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(2, 10)
);
expect(foamTags.tags.get('primary')).toBeTruthy();
expect(tags.items.length).toEqual(3);
});
it('should provide suggestions when on `tags:` in the front-matter with leading `[`', async () => {
const { uri } = await createFile('---\ntags: [');
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(1, 7)
);
expect(foamTags.tags.get('primary')).toBeTruthy();
expect(tags.items.length).toEqual(3);
});
it('should provide suggestions when on `tags:` in the front-matter with `#`', async () => {
const { uri } = await createFile('---\ntags: #');
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(1, 7)
);
expect(foamTags.tags.get('primary')).toBeTruthy();
expect(tags.items.length).toEqual(3);
});
it('should provide suggestions when on `tags:` in the front-matter when tags are comma separated', async () => {
const { uri } = await createFile(
'---\ncreated: 2023-01-01\ntags: secondary, prim'
);
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(2, 21)
);
expect(foamTags.tags.get('primary')).toBeTruthy();
expect(tags.items.length).toEqual(3);
});
it('should provide suggestions when on `tags:` in the front-matter in middle of comma separated', async () => {
const { uri } = await createFile(
'---\ncreated: 2023-01-01\ntags: second, prim'
);
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(2, 12)
);
expect(foamTags.tags.get('secondary')).toBeTruthy();
expect(tags.items.length).toEqual(3);
});
it('should provide suggestions in `tags:` on separate line with leading space', async () => {
const { uri } = await createFile('---\ntags: second, prim\n ');
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(2, 1)
);
expect(foamTags.tags.get('secondary')).toBeTruthy();
expect(tags.items.length).toEqual(3);
});
it('should provide suggestions in `tags:` on separate line with leading ` - `', async () => {
const { uri } = await createFile('---\ntags:\n - ');
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(2, 3)
);
expect(foamTags.tags.get('secondary')).toBeTruthy();
expect(tags.items.length).toEqual(3);
});
it('should not provide suggestions when on non-`tags:` in the front-matter', async () => {
const { uri } = await createFile('---\ntags: prim\ntitle: prim');
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(2, 11)
);
expect(foamTags.tags.get('primary')).toBeTruthy();
expect(tags).toBeNull();
});
it('should not provide suggestions when outside the front-matter without `#` key', async () => {
const { uri } = await createFile(
'---\ncreated: 2023-01-01\ntags: prim\n---\ncontent\ntags: prim'
);
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(5, 10)
);
expect(foamTags.tags.get('primary')).toBeTruthy();
expect(tags).toBeNull();
});
it('should not provide suggestions in `tags:` on separate line with leading ` -`', async () => {
const { uri } = await createFile('---\ntags:\n -');
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('secondary')).toBeTruthy();
expect(tags).toBeNull();
});
});
});

View File

@@ -2,12 +2,13 @@ import * as vscode from 'vscode';
import { Foam } from '../core/model/foam';
import { FoamTags } from '../core/model/tags';
import { FoamFeature } from '../types';
import { mdDocSelector } from '../utils';
import { isInFrontMatter, isOnYAMLKeywordLine, 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 =
const HASH_REGEX =
/(?<=^|\s)#(?![ \t#])([0-9]*[\p{L}\p{Emoji_Presentation}\p{N}/_-]*)/dgu;
const MAX_LINES_FOR_FRONT_MATTER = 50;
const feature: FoamFeature = {
activate: async (
@@ -38,23 +39,96 @@ export class TagCompletionProvider
.lineAt(position)
.text.substr(0, position.character);
const requiresAutocomplete = cursorPrefix.match(TAG_REGEX);
if (!requiresAutocomplete) {
const beginningOfFileText = document.getText(
new vscode.Range(
new vscode.Position(0, 0),
new vscode.Position(
position.line < MAX_LINES_FOR_FRONT_MATTER
? position.line
: MAX_LINES_FOR_FRONT_MATTER,
position.character
)
)
);
const isHashMatch = cursorPrefix.match(HASH_REGEX) !== null;
const inFrontMatter = isInFrontMatter(beginningOfFileText, position.line);
if (!isHashMatch && !inFrontMatter) {
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;
return inFrontMatter
? this.createTagsForFrontMatter(beginningOfFileText, position)
: this.createTagsForContent(cursorPrefix, position);
}
private createTagsForFrontMatter(
content: string,
position: vscode.Position
): vscode.ProviderResult<vscode.CompletionList<vscode.CompletionItem>> {
const FRONT_MATTER_PREVIOUS_CHARACTER = /[#[\s\w]/g;
const lines = content.split('\n');
if (position.line >= lines.length) {
return null;
}
const cursorPrefix = lines[position.line].substring(0, position.character);
const isTagsMatch =
isOnYAMLKeywordLine(content, 'tags') &&
cursorPrefix
.charAt(position.character - 1)
.match(FRONT_MATTER_PREVIOUS_CHARACTER);
if (!isTagsMatch) {
return null;
}
const [lastMatchStartIndex, lastMatchEndIndex] = this.tagMatchIndices(
cursorPrefix,
HASH_REGEX
);
const isHashMatch = cursorPrefix.match(HASH_REGEX) !== null;
if (isHashMatch && lastMatchEndIndex !== position.character) {
return null;
}
const completionTags = this.createCompletionTagItems();
// We are in the front matter and we typed #, remove the `#`
if (isHashMatch) {
completionTags.forEach(item => {
item.additionalTextEdits = [
vscode.TextEdit.delete(
new vscode.Range(
position.line,
lastMatchStartIndex,
position.line,
lastMatchStartIndex + 1
)
),
];
});
}
return new vscode.CompletionList(completionTags);
}
private createTagsForContent(
content: string,
position: vscode.Position
): vscode.ProviderResult<vscode.CompletionList<vscode.CompletionItem>> {
const [_, lastMatchEndIndex] = this.tagMatchIndices(content, HASH_REGEX);
if (lastMatchEndIndex !== position.character) {
return null;
}
return new vscode.CompletionList(this.createCompletionTagItems());
}
private createCompletionTagItems(): vscode.CompletionItem[] {
const completionTags = [];
[...this.foamTags.tags].forEach(([tag]) => {
const item = new vscode.CompletionItem(
@@ -67,8 +141,25 @@ export class TagCompletionProvider
completionTags.push(item);
});
return completionTags;
}
return new vscode.CompletionList(completionTags);
private tagMatchIndices(content: string, match: RegExp): number[] {
// 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(content.matchAll(match));
if (matches.length === 0) {
return [-1, -1];
}
const lastMatch = matches[matches.length - 1];
const lastMatchStartIndex = lastMatch.index;
const lastMatchEndIndex = lastMatch[0].length + lastMatchStartIndex;
return [lastMatchStartIndex, lastMatchEndIndex];
}
}

View File

@@ -19,6 +19,7 @@ export function getWikilinkDefinitionSetting(): LinkReferenceDefinitionsSetting
/** Retrieve the list of file ignoring globs. */
export function getIgnoredFilesSetting(): GlobPattern[] {
return [
'**/.foam/**',
...workspace.getConfiguration().get('foam.files.ignore', []),
...Object.keys(workspace.getConfiguration().get('files.exclude', {})),
];
@@ -42,24 +43,16 @@ export function getFoamLoggerLevel(): LogLevel {
export function getOrphansConfig(): GroupedResourcesConfig {
const orphansConfig = workspace.getConfiguration('foam.orphans');
const exclude: string[] = orphansConfig.get('exclude');
const groupBy: GroupedResoucesConfigGroupBy = orphansConfig.get('groupBy');
return { exclude, groupBy };
return { exclude };
}
/** Retrieve the placeholders configuration */
export function getPlaceholdersConfig(): GroupedResourcesConfig {
const placeholderCfg = workspace.getConfiguration('foam.placeholders');
const exclude: string[] = placeholderCfg.get('exclude');
const groupBy: GroupedResoucesConfigGroupBy = placeholderCfg.get('groupBy');
return { exclude, groupBy };
return { exclude };
}
export interface GroupedResourcesConfig {
exclude: string[];
groupBy: GroupedResoucesConfigGroupBy;
}
export enum GroupedResoucesConfigGroupBy {
Folder = 'folder',
Off = 'off',
}

View File

@@ -1,4 +1,10 @@
import { removeBrackets, toTitleCase } from './utils';
import exp from 'constants';
import {
isInFrontMatter,
isOnYAMLKeywordLine,
removeBrackets,
toTitleCase,
} from './utils';
describe('removeBrackets', () => {
it('removes the brackets', () => {
@@ -57,3 +63,66 @@ describe('toTitleCase', () => {
expect(actual).toEqual(expected);
});
});
describe('isInFrontMatter', () => {
it('is true for started front matter', () => {
const content = `---
`;
const actual = isInFrontMatter(content, 1);
expect(actual).toBeTruthy();
});
it('is true for inside completed front matter', () => {
const content = '---\ntitle: A title\n---\n';
const actual = isInFrontMatter(content, 1);
expect(actual).toBeTruthy();
});
it('is true for inside completed front matter with "..." end delimiter', () => {
const content = '---\ntitle: A title\n...\n';
const actual = isInFrontMatter(content, 1);
expect(actual).toBeTruthy();
});
it('is false for outside completed front matter', () => {
const content = '---\ntitle: A title\n---\ncontent\nmore content\n';
const actual = isInFrontMatter(content, 3);
expect(actual).toBeFalsy();
});
it('is false for outside completed front matter with "..." end delimiter', () => {
const content = '---\ntitle: A title\n...\ncontent\nmore content\n';
const actual = isInFrontMatter(content, 3);
expect(actual).toBeFalsy();
});
it('is false for position on initial front matter delimiter', () => {
const content = '---\ntitle: A title\n---\ncontent\nmore content\n';
const actual = isInFrontMatter(content, 0);
expect(actual).toBeFalsy();
});
it('is false for position on final front matter delimiter', () => {
const content = '---\ntitle: A title\n---\ncontent\nmore content\n';
const actual = isInFrontMatter(content, 2);
expect(actual).toBeFalsy();
});
describe('isOnYAMLKeywordLine', () => {
it('is true if line starts with keyword', () => {
const content = 'tags: foo, bar\n';
const actual = isOnYAMLKeywordLine(content, 'tags');
expect(actual).toBeTruthy();
});
it('is true if previous line starts with keyword', () => {
const content = 'tags: foo\n - bar\n';
const actual = isOnYAMLKeywordLine(content, 'tags');
expect(actual).toBeTruthy();
});
it('is false if line starts with wrong keyword', () => {
const content = 'tags: foo, bar\n';
const actual = isOnYAMLKeywordLine(content, 'title');
expect(actual).toBeFalsy();
});
it('is false if previous line starts with wrong keyword', () => {
const content = 'dates:\n - 2023-01-1\n - 2023-01-02\n';
const actual = isOnYAMLKeywordLine(content, 'tags');
expect(actual).toBeFalsy();
});
});
});

View File

@@ -221,3 +221,37 @@ export function stripImages(markdown: string): string {
'$1'.length ? '[Image: $1]' : ''
);
}
export function isInFrontMatter(content: string, lineNumber: number): Boolean {
const FIRST_DELIMITER_MATCH = /^---\s*?$/gm;
const LAST_DELIMITER_MATCH = /^[-.]{3}\s*?$/g;
// if we're on the first line, we're not _yet_ in the front matter
if (lineNumber == 0) {
return false;
}
// look for --- at start, and a second --- or ... to end
if (content.match(FIRST_DELIMITER_MATCH) === null) {
return false;
}
const lines = content.split('\n');
lines.shift();
const endLineMatches = (l: string) => l.match(LAST_DELIMITER_MATCH);
const endLineNumber = lines.findIndex(endLineMatches);
return endLineNumber == -1 || endLineNumber >= lineNumber;
}
export function isOnYAMLKeywordLine(content: string, keyword: string): Boolean {
const keywordMatch = /^\s*(\w+):/gm;
if (content.match(keywordMatch) === null) {
return false;
}
const matches = Array.from(content.matchAll(keywordMatch));
const lastMatch = matches[matches.length - 1];
return lastMatch[1] === keyword;
}

View File

@@ -3,14 +3,14 @@ import {
AlwaysIncludeMatcher,
SubstringExcludeMatcher,
} from '../core/services/datastore';
import { OPEN_COMMAND } from '../features/commands/open-resource';
import { GroupedResoucesConfigGroupBy } from '../settings';
import { createTestNote } from '../test/test-utils';
import {
DirectoryTreeItem,
GroupedResourcesTreeDataProvider,
} from './grouped-resources-tree-data-provider';
import { ResourceTreeItem, UriTreeItem } from './tree-view-utils';
import { randomString } from '../test/test-utils';
import { MapBasedMemento } from './vsc-utils';
const testMatcher = new SubstringExcludeMatcher('path-exclude');
@@ -37,17 +37,19 @@ describe('GroupedResourcesTreeDataProvider', () => {
it('should return the grouped resources as a folder tree', async () => {
const provider = new GroupedResourcesTreeDataProvider(
'length3',
randomString(),
'note',
new MapBasedMemento(),
testMatcher,
() =>
workspace
.list()
.filter(r => r.title.length === 3)
.map(r => r.uri),
uri => new UriTreeItem(uri),
testMatcher
uri => new UriTreeItem(uri)
);
provider.setGroupBy(GroupedResoucesConfigGroupBy.Folder);
provider.groupBy.update('folder');
provider.refresh();
const result = await provider.getChildren();
expect(result).toMatchObject([
{
@@ -67,17 +69,19 @@ describe('GroupedResourcesTreeDataProvider', () => {
it('should return the grouped resources in a directory', async () => {
const provider = new GroupedResourcesTreeDataProvider(
'length3',
randomString(),
'note',
new MapBasedMemento(),
testMatcher,
() =>
workspace
.list()
.filter(r => r.title.length === 3)
.map(r => r.uri),
uri => new ResourceTreeItem(workspace.get(uri), workspace),
testMatcher
uri => new ResourceTreeItem(workspace.get(uri), workspace)
);
provider.setGroupBy(GroupedResoucesConfigGroupBy.Folder);
provider.groupBy.update('folder');
provider.refresh();
const directory = new DirectoryTreeItem(
'/path',
@@ -97,17 +101,19 @@ describe('GroupedResourcesTreeDataProvider', () => {
it('should return the flattened resources', async () => {
const provider = new GroupedResourcesTreeDataProvider(
'length3',
randomString(),
'note',
new MapBasedMemento(),
testMatcher,
() =>
workspace
.list()
.filter(r => r.title.length === 3)
.map(r => r.uri),
uri => new ResourceTreeItem(workspace.get(uri), workspace),
testMatcher
uri => new ResourceTreeItem(workspace.get(uri), workspace)
);
provider.setGroupBy(GroupedResoucesConfigGroupBy.Off);
provider.groupBy.update('off');
provider.refresh();
const result = await provider.getChildren();
expect(result).toMatchObject([
@@ -128,17 +134,19 @@ describe('GroupedResourcesTreeDataProvider', () => {
it('should return the grouped resources without exclusion', async () => {
const provider = new GroupedResourcesTreeDataProvider(
'length3',
randomString(),
'note',
new MapBasedMemento(),
new AlwaysIncludeMatcher(),
() =>
workspace
.list()
.filter(r => r.title.length === 3)
.map(r => r.uri),
uri => new UriTreeItem(uri),
new AlwaysIncludeMatcher()
uri => new UriTreeItem(uri)
);
provider.setGroupBy(GroupedResoucesConfigGroupBy.Folder);
provider.groupBy.update('folder');
provider.refresh();
const result = await provider.getChildren();
expect(result).toMatchObject([
@@ -156,17 +164,19 @@ describe('GroupedResourcesTreeDataProvider', () => {
it('should dynamically set the description', async () => {
const description = 'test description';
const provider = new GroupedResourcesTreeDataProvider(
'length3',
randomString(),
description,
new MapBasedMemento(),
testMatcher,
() =>
workspace
.list()
.filter(r => r.title.length === 3)
.map(r => r.uri),
uri => new UriTreeItem(uri),
testMatcher
uri => new UriTreeItem(uri)
);
provider.setGroupBy(GroupedResoucesConfigGroupBy.Folder);
provider.groupBy.update('folder');
provider.refresh();
const result = await provider.getChildren();
expect(result).toMatchObject([
{

View File

@@ -1,10 +1,10 @@
import * as path from 'path';
import * as vscode from 'vscode';
import { GroupedResoucesConfigGroupBy } from '../settings';
import { getContainsTooltip, isSome } from '../utils';
import { URI } from '../core/model/uri';
import { IMatcher } from '../core/services/datastore';
import { UriTreeItem } from './tree-view-utils';
import { ContextMemento } from './vsc-utils';
/**
* Provides the ability to expose a TreeDataExplorerView in VSCode. This class will
@@ -13,8 +13,8 @@ import { UriTreeItem } from './tree-view-utils';
*
* **NOTE**: In order for this provider to correctly function, you must define the following command in the package.json file:
* ```
* foam-vscode.group-${providerId}-by-folder
* foam-vscode.group-${providerId}-off
* foam-vscode.views.${providerId}.group-by-folder
* foam-vscode.views.${providerId}.group-off
* ```
* Where `providerId` is the same string provided to the constructor. You must also register the commands in your context subscriptions as follows:
* ```
@@ -34,24 +34,29 @@ import { UriTreeItem } from './tree-view-utils';
* @implements {vscode.TreeDataProvider<GroupedResourceTreeItem>}
*/
export class GroupedResourcesTreeDataProvider
implements vscode.TreeDataProvider<GroupedResourceTreeItem>
implements
vscode.TreeDataProvider<GroupedResourceTreeItem>,
vscode.Disposable
{
// prettier-ignore
private _onDidChangeTreeData: vscode.EventEmitter<GroupedResourceTreeItem | undefined | void> = new vscode.EventEmitter<GroupedResourceTreeItem | undefined | void>();
// prettier-ignore
readonly onDidChangeTreeData: vscode.Event<GroupedResourceTreeItem | undefined | void> = this._onDidChangeTreeData.event;
// prettier-ignore
private groupBy: GroupedResoucesConfigGroupBy = GroupedResoucesConfigGroupBy.Folder;
private exclude: string[] = [];
private flatUris: Array<URI> = [];
private root = vscode.workspace.workspaceFolders[0].uri.path;
public groupBy = new ContextMemento<'off' | 'folder'>(
this.state,
`foam-vscode.views.${this.providerId}.group-by`,
'folder'
);
protected disposables: vscode.Disposable[] = [];
/**
* Creates an instance of GroupedResourcesTreeDataProvider.
* **NOTE**: In order for this provider to correctly function, you must define the following command in the package.json file:
* ```
* foam-vscode.group-${providerId}-by-folder
* foam-vscode.group-${providerId}-off
* foam-vscode.views.${this.providerId}.group-by-folder
* foam-vscode.views.${this.providerId}.group-by-off
* ```
* Where `providerId` is the same string provided to this constructor. You must also register the commands in your context subscriptions as follows:
* ```
@@ -75,47 +80,43 @@ export class GroupedResourcesTreeDataProvider
* @memberof GroupedResourcesTreeDataProvider
*/
constructor(
private providerId: string,
protected providerId: string,
private resourceName: string,
private computeResources: () => Array<URI>,
private createTreeItem: (item: URI) => GroupedResourceTreeItem,
private matcher: IMatcher
protected state: vscode.Memento,
private matcher: IMatcher,
protected computeResources: () => Array<URI> = () => {
throw new Error('Not implemented');
},
protected createTreeItem: (item: URI) => GroupedResourceTreeItem = () => {
throw new Error('Not implemented');
}
) {
this.setContext();
this.doComputeResources();
this.disposables.push(
vscode.commands.registerCommand(
`foam-vscode.views.${this.providerId}.group-by:folder`,
() => {
this.groupBy.update('folder');
this.refresh();
}
),
vscode.commands.registerCommand(
`foam-vscode.views.${this.providerId}.group-by:off`,
() => {
this.groupBy.update('off');
this.refresh();
}
)
);
}
public get commands() {
return [
vscode.commands.registerCommand(
`foam-vscode.group-${this.providerId}-by-folder`,
() => this.setGroupBy(GroupedResoucesConfigGroupBy.Folder)
),
vscode.commands.registerCommand(
`foam-vscode.group-${this.providerId}-off`,
() => this.setGroupBy(GroupedResoucesConfigGroupBy.Off)
),
];
dispose() {
this.disposables.forEach(d => d.dispose());
}
public get numElements() {
return this.flatUris.length;
}
setGroupBy(groupBy: GroupedResoucesConfigGroupBy): void {
this.groupBy = groupBy;
this.setContext();
this.refresh();
}
private setContext(): void {
vscode.commands.executeCommand(
'setContext',
`foam-vscode.${this.providerId}-grouped-by-folder`,
this.groupBy === GroupedResoucesConfigGroupBy.Folder
);
}
refresh(): void {
this.doComputeResources();
this._onDidChangeTreeData.fire();
@@ -129,10 +130,9 @@ export class GroupedResourcesTreeDataProvider
item?: GroupedResourceTreeItem
): Promise<GroupedResourceTreeItem[]> {
if ((item as any)?.getChildren) {
const children = await (item as any).getChildren();
return children.sort(sortByTreeItemLabel);
return (item as any).getChildren();
}
if (this.groupBy === GroupedResoucesConfigGroupBy.Folder) {
if (this.groupBy.get() === 'folder') {
const directories = Object.entries(this.getUrisByDirectory())
.sort(([dir1], [dir2]) => sortByString(dir1, dir2))
.map(
@@ -155,7 +155,7 @@ export class GroupedResourcesTreeDataProvider
resolveTreeItem(
item: GroupedResourceTreeItem
): Promise<GroupedResourceTreeItem> {
return item.resolveTreeItem();
return item.resolveTreeItem() as Promise<GroupedResourceTreeItem>;
}
private doComputeResources(): void {

View File

@@ -1,51 +1,43 @@
import * as vscode from 'vscode';
import { groupBy } from 'lodash';
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';
import { getBlockFor } from '../core/services/markdown-parser';
import { FoamGraph } from '../core/model/graph';
export class UriTreeItem extends vscode.TreeItem {
private doGetChildren: () => Promise<vscode.TreeItem[]>;
export class BaseTreeItem extends vscode.TreeItem {
resolveTreeItem(): Promise<vscode.TreeItem> {
return Promise.resolve(this);
}
getChildren(): Promise<vscode.TreeItem[]> {
return Promise.resolve([]);
}
}
export class UriTreeItem extends BaseTreeItem {
public parent?: vscode.TreeItem;
constructor(
public readonly uri: URI,
options: {
collapsibleState?: vscode.TreeItemCollapsibleState;
icon?: string;
title?: string;
getChildren?: () => Promise<vscode.TreeItem[]>;
parent?: vscode.TreeItem;
} = {}
) {
super(
options?.title ?? uri.getName(),
options.collapsibleState
? options.collapsibleState
: options.getChildren
? vscode.TreeItemCollapsibleState.Collapsed
: vscode.TreeItemCollapsibleState.None
);
this.doGetChildren = options.getChildren;
super(options?.title ?? uri.getName(), options.collapsibleState);
this.parent = options.parent;
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([]);
this.iconPath = new vscode.ThemeIcon('new-file');
}
}
@@ -55,22 +47,22 @@ export class ResourceTreeItem extends UriTreeItem {
private readonly workspace: FoamWorkspace,
options: {
collapsibleState?: vscode.TreeItemCollapsibleState;
getChildren?: () => Promise<vscode.TreeItem[]>;
parent?: vscode.TreeItem;
} = {}
) {
super(resource.uri, {
title: resource.title,
icon: 'note',
collapsibleState: options.collapsibleState,
getChildren: options.getChildren,
parent: options.parent,
});
this.command = {
command: 'vscode.open',
arguments: [toVsCodeUri(resource.uri)],
title: 'Go to location',
};
this.contextValue = 'resource';
this.resourceUri = toVsCodeUri(resource.uri);
this.iconPath = vscode.ThemeIcon.File;
this.contextValue = 'foam.resource';
}
async resolveTreeItem(): Promise<ResourceTreeItem> {
@@ -84,14 +76,14 @@ export class ResourceTreeItem extends UriTreeItem {
}
}
export class ResourceRangeTreeItem extends vscode.TreeItem {
export class ResourceRangeTreeItem extends BaseTreeItem {
constructor(
public label: string,
public readonly resource: Resource,
public readonly range: Range
public readonly range: Range,
public readonly workspace: FoamWorkspace
) {
super(label, vscode.TreeItemCollapsibleState.None);
this.label = `${range.start.line}: ${this.label}`;
this.command = {
command: 'vscode.open',
arguments: [toVsCodeUri(resource.uri), { selection: range }],
@@ -99,29 +91,45 @@ export class ResourceRangeTreeItem extends vscode.TreeItem {
};
}
resolveTreeItem(): Promise<ResourceRangeTreeItem> {
async resolveTreeItem(): Promise<ResourceRangeTreeItem> {
const markdown =
(await this.workspace.readAsMarkdown(this.resource.uri)) ?? '';
let { block, nLines } = getBlockFor(markdown, this.range.start);
// Long blocks need to be interrupted or they won't display in hover preview
// We keep the extra lines so that the count in the preview is correct
if (nLines > 15) {
let tmp = block.split('\n');
tmp.splice(15, 1, '\n'); // replace a line with a blank line to interrupt the block
block = tmp.join('\n');
}
const tooltip = getNoteTooltip(block ?? this.label ?? '');
this.tooltip = tooltip;
return Promise.resolve(this);
}
static async createStandardItem(
workspace: FoamWorkspace,
resource: Resource,
range: Range
range: Range,
type?: 'backlink' | 'tag'
): Promise<ResourceRangeTreeItem> {
const lines = ((await workspace.readAsMarkdown(resource.uri)) ?? '').split(
'\n'
);
const markdown = (await workspace.readAsMarkdown(resource.uri)) ?? '';
const lines = markdown.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.start.line + 1}: ${ellipsis}${line.slice(start, start + 300)}`
: Range.toString(range);
const tooltip = line && getNoteTooltip(line);
const item = new ResourceRangeTreeItem(label, resource, range);
item.tooltip = tooltip;
const item = new ResourceRangeTreeItem(label, resource, range, workspace);
item.iconPath = new vscode.ThemeIcon(
type === 'backlink' ? 'arrow-left' : 'symbol-number',
new vscode.ThemeColor('charts.purple')
);
return item;
}
}
@@ -148,15 +156,35 @@ export const groupRangesByResource = async (
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.getChildren = () =>
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;
};
export function createBacklinkItemsForResource(
workspace: FoamWorkspace,
graph: FoamGraph,
uri: URI
) {
const connections = graph
.getConnections(uri)
.filter(c => c.target.asPlain().isEqual(uri));
const backlinkItems = connections.map(async c => {
const item = await ResourceRangeTreeItem.createStandardItem(
workspace,
workspace.get(c.source),
c.link.range,
'backlink'
);
item.description = item.label;
item.label = workspace.get(c.source).title;
return item;
});
return Promise.all(backlinkItems);
}

View File

@@ -1,4 +1,4 @@
import { Position, Range, Uri } from 'vscode';
import { Memento, Position, Range, Uri, commands } from 'vscode';
import { Position as FoamPosition } from '../core/model/position';
import { Range as FoamRange } from '../core/model/range';
import { URI as FoamURI } from '../core/model/uri';
@@ -12,3 +12,37 @@ export const toVsCodeRange = (r: FoamRange): Range =>
export const toVsCodeUri = (u: FoamURI): Uri => Uri.parse(u.toString());
export const fromVsCodeUri = (u: Uri): FoamURI => FoamURI.parse(u.toString());
/**
* A class that wraps context value, syncs it via setContext, and provides a typed interface to it.
*/
export class ContextMemento<T> {
constructor(private data: Memento, private key: string, defaultValue: T) {
const value = data.get(key) ?? defaultValue;
commands.executeCommand('setContext', this.key, value);
}
public get(): T {
return this.data.get(this.key);
}
public async update(value: T): Promise<void> {
this.data.update(this.key, value);
await commands.executeCommand('setContext', this.key, value);
}
}
/**
* Implementation of the Memento interface that uses a Map as backend
*/
export class MapBasedMemento implements Memento {
get<T>(key: unknown, defaultValue?: unknown | T): T | T {
return (this.map.get(key as string) as T) || (defaultValue as T);
}
private map: Map<string, string> = new Map();
keys(): readonly string[] {
return Array.from(this.map.keys());
}
update(key: string, value: any): Promise<void> {
this.map.set(key, value);
return Promise.resolve();
}
}