Skip to content

Commit ef69987

Browse files
Fix placeholder filtering to respect exclude patterns (#1726)
1 parent f6a3f33 commit ef69987

5 files changed

Lines changed: 205 additions & 20 deletions

File tree

‎packages/foam-core/src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export {
2222
AlwaysIncludeMatcher,
2323
SubstringExcludeMatcher,
2424
} from './services/datastore';
25-
export { GlobMatcher } from './services/glob-matcher';
25+
export { GlobMatcher, PlaceholderMatcher } from './services/glob-matcher';
2626
export type { GlobMatcherRoot } from './services/glob-matcher';
2727
export { createMarkdownParser, getLinkDefinitions, getBlockFor } from './services/markdown-parser';
2828
export type {

‎packages/foam-core/src/services/glob-matcher.test.ts‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { GlobMatcher } from './glob-matcher';
1+
import { GlobMatcher, PlaceholderMatcher } from './glob-matcher';
22
import { URI } from '../model/uri';
33

44
const root = URI.file('/workspace');
@@ -142,3 +142,47 @@ describe('GlobMatcher', () => {
142142
expect(matcher.isMatch(root.joinPath('brand/new/note.md'))).toBeTruthy();
143143
});
144144
});
145+
146+
describe('PlaceholderMatcher', () => {
147+
it('keeps a wikilink placeholder, which lies under no root', () => {
148+
const matcher = new PlaceholderMatcher([{ uri: root, exclude: [] }]);
149+
150+
expect(matcher.isMatch(URI.placeholder('missing-note'))).toBeTruthy();
151+
});
152+
153+
it('hides a placeholder under an excluded folder, relative to its root', () => {
154+
const matcher = new PlaceholderMatcher([
155+
{ uri: root, exclude: ['journal/**'] },
156+
]);
157+
158+
expect(
159+
matcher.isMatch(URI.placeholder('/workspace/journal/2024-01-01.md'))
160+
).toBeFalsy();
161+
expect(
162+
matcher.isMatch(URI.placeholder('/workspace/Journal/2024-01-01.md'))
163+
).toBeFalsy();
164+
expect(
165+
matcher.isMatch(URI.placeholder('/workspace/notes/missing.md'))
166+
).toBeTruthy();
167+
});
168+
169+
it('matches a wikilink placeholder on its own path', () => {
170+
const matcher = new PlaceholderMatcher([
171+
{ uri: root, exclude: ['journal/**'] },
172+
]);
173+
174+
expect(matcher.isMatch(URI.placeholder('journal/2024-01-01'))).toBeFalsy();
175+
// A leading slash in a wikilink means the workspace root
176+
expect(matcher.isMatch(URI.placeholder('/journal/2024-01-01'))).toBeFalsy();
177+
expect(matcher.isMatch(URI.placeholder('notes/missing'))).toBeTruthy();
178+
});
179+
180+
it("applies every root's excludes to a wikilink placeholder", () => {
181+
const matcher = new PlaceholderMatcher([
182+
{ uri: URI.file('/notes-root'), exclude: [] },
183+
{ uri: URI.file('/journal-root'), exclude: ['journal/**'] },
184+
]);
185+
186+
expect(matcher.isMatch(URI.placeholder('journal/2024-01-01'))).toBeFalsy();
187+
});
188+
});

‎packages/foam-core/src/services/glob-matcher.ts‎

Lines changed: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,23 @@ const MATCH_OPTIONS: micromatch.Options = { dot: true, nocase: true };
2626

2727
const asPrefix = (path: string) => (path.endsWith('/') ? path : path + '/');
2828

29+
/** With nested roots, the innermost one owns the path. */
30+
function findOwner<R extends { prefix: string }>(
31+
roots: R[],
32+
path: string
33+
): R | undefined {
34+
let owner: R | undefined;
35+
for (const root of roots) {
36+
if (
37+
path.startsWith(root.prefix) &&
38+
(!owner || root.prefix.length > owner.prefix.length)
39+
) {
40+
owner = root;
41+
}
42+
}
43+
return owner;
44+
}
45+
2946
/**
3047
* An {@link IMatcher} that answers from the include/exclude globs directly.
3148
*
@@ -55,16 +72,7 @@ export class GlobMatcher implements IMatcher {
5572
}
5673

5774
isMatch(uri: URI): boolean {
58-
// With nested roots, the innermost one owns the file.
59-
let owner: (typeof this.roots)[number] | undefined;
60-
for (const root of this.roots) {
61-
if (
62-
uri.path.startsWith(root.prefix) &&
63-
(!owner || root.prefix.length > owner.prefix.length)
64-
) {
65-
owner = root;
66-
}
67-
}
75+
const owner = findOwner(this.roots, uri.path);
6876
if (!owner || owner.include.length === 0) {
6977
return false;
7078
}
@@ -79,3 +87,53 @@ export class GlobMatcher implements IMatcher {
7987
return Promise.resolve();
8088
}
8189
}
90+
91+
/**
92+
* An {@link IMatcher} for placeholders, which are link targets rather than
93+
* files.
94+
*
95+
* Only exclude globs apply: the include globs already selected the notes the
96+
* links come from, and say nothing about where a missing note would live. A
97+
* placeholder from a path link lies under a workspace root and is tested
98+
* relative to it, like a file. One from a wikilink keeps the link text as its
99+
* path (`missing-note`, `journal/2024-01-01`) and lies under no root, so that
100+
* path is tested as is — a leading slash meaning the workspace root — against
101+
* every root's excludes.
102+
*/
103+
export class PlaceholderMatcher implements IMatcher {
104+
public readonly include = ['**/*'];
105+
public readonly exclude: string[];
106+
107+
private readonly roots: { prefix: string; exclude: string[] }[];
108+
109+
constructor(roots: Omit<GlobMatcherRoot, 'include'>[]) {
110+
this.roots = roots.map(r => ({
111+
prefix: asPrefix(r.uri.path),
112+
exclude: r.exclude,
113+
}));
114+
this.exclude = roots.flatMap(r => r.exclude);
115+
}
116+
117+
match(uris: URI[]): URI[] {
118+
return uris.filter(u => this.isMatch(u));
119+
}
120+
121+
isMatch(uri: URI): boolean {
122+
const owner = findOwner(this.roots, uri.path);
123+
return owner
124+
? !micromatch.isMatch(
125+
uri.path.slice(owner.prefix.length),
126+
owner.exclude,
127+
MATCH_OPTIONS
128+
)
129+
: !micromatch.isMatch(
130+
uri.path.replace(/^\//, ''),
131+
this.exclude,
132+
MATCH_OPTIONS
133+
);
134+
}
135+
136+
refresh(): Promise<void> {
137+
return Promise.resolve();
138+
}
139+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/* @unit-ready */
2+
import * as vscode from 'vscode';
3+
import { FoamGraph, listPlaceholders } from '@foam/core';
4+
import {
5+
createNoteFromMarkdown,
6+
createTestWorkspace,
7+
} from '../../../test/test-utils';
8+
import { withModifiedFoamConfiguration } from '../../../test/test-utils-vscode';
9+
import { fromVsCodeUri } from '../../utils/vsc-utils';
10+
import { createPlaceholderMatcher } from './placeholders';
11+
12+
/**
13+
* Runs the panel's matcher over the placeholders of a note in `docs/` that
14+
* links to three missing notes: one by wikilink, one next to it, and one
15+
* outside `docs/`. Returns the paths of the placeholders it hides.
16+
*/
17+
const hiddenPlaceholders = async () => {
18+
const root = fromVsCodeUri(vscode.workspace.workspaceFolders[0].uri);
19+
const workspace = createTestWorkspace([root]).set(
20+
createNoteFromMarkdown(
21+
root.joinPath('docs', 'note.md').path,
22+
[
23+
'[[missing-note]]',
24+
'[missing](missing.md)',
25+
'[elsewhere](../elsewhere/missing.md)',
26+
].join('\n\n')
27+
)
28+
);
29+
const graph = FoamGraph.fromWorkspace(workspace);
30+
try {
31+
const placeholders = listPlaceholders(workspace, graph).map(p => p.uri);
32+
expect(placeholders).toHaveLength(3);
33+
const matcher = await createPlaceholderMatcher();
34+
return {
35+
root,
36+
hidden: placeholders
37+
.filter(uri => !matcher.isMatch(uri))
38+
.map(uri => uri.path),
39+
};
40+
} finally {
41+
graph.dispose();
42+
workspace.dispose();
43+
}
44+
};
45+
46+
describe('Placeholders panel filter', () => {
47+
it('shows every placeholder when foam.files.include is restricted to a subfolder (#1702)', async () => {
48+
await withModifiedFoamConfiguration(
49+
'files.include',
50+
['docs/**/*.md'],
51+
async () => {
52+
const { hidden } = await hiddenPlaceholders();
53+
54+
expect(hidden).toEqual([]);
55+
}
56+
);
57+
});
58+
59+
it('hides placeholders matching foam.placeholders.exclude', async () => {
60+
await withModifiedFoamConfiguration(
61+
'placeholders.exclude',
62+
['elsewhere/**'],
63+
async () => {
64+
const { root, hidden } = await hiddenPlaceholders();
65+
66+
expect(hidden).toEqual([root.joinPath('elsewhere', 'missing.md').path]);
67+
}
68+
);
69+
});
70+
});

‎packages/foam-vscode/src/vscode/features/notes/placeholders.ts‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as vscode from 'vscode';
2-
import { Foam, listPlaceholders } from '@foam/core';
2+
import { Foam, listPlaceholders, PlaceholderMatcher } from '@foam/core';
33
import {
44
createMatcherAndDataStore,
55
getActiveTabUri,
@@ -16,12 +16,11 @@ import {
1616
groupRangesByResource,
1717
} from '../../utils/tree-views/tree-view-utils';
1818
import { IMatcher } from '@foam/core';
19-
import { ContextMemento } from '../../utils/vsc-utils';
19+
import { ContextMemento, fromVsCodeUri } from '../../utils/vsc-utils';
2020
import { FoamGraph } from '@foam/core';
2121
import { URI } from '@foam/core';
2222
import { FoamWorkspace } from '@foam/core';
2323
import { FolderTreeItem } from '../../utils/tree-views/folder-tree-provider';
24-
import { Config } from '@foam/core';
2524
import { instrumentTreeView } from '../../services/telemetry';
2625

2726
/** Retrieve the placeholders configuration */
@@ -31,20 +30,34 @@ export function getPlaceholdersConfig(): GroupedResourcesConfig {
3130
return { exclude };
3231
}
3332

33+
/**
34+
* Decides which placeholders the panel shows: `foam.placeholders.exclude`
35+
* applies, `foam.files.include` doesn't — see {@link PlaceholderMatcher}.
36+
*/
37+
export async function createPlaceholderMatcher(): Promise<IMatcher> {
38+
// Only used to split the excludes by workspace folder
39+
const { excludePatterns } = await createMatcherAndDataStore(
40+
[],
41+
getPlaceholdersConfig().exclude
42+
);
43+
return new PlaceholderMatcher(
44+
vscode.workspace.workspaceFolders.map(folder => ({
45+
uri: fromVsCodeUri(folder.uri),
46+
exclude: excludePatterns.get(folder.name),
47+
}))
48+
);
49+
}
50+
3451
export default async function activate(
3552
context: vscode.ExtensionContext,
3653
foamPromise: Promise<Foam>
3754
) {
3855
const foam = await foamPromise;
39-
const { matcher } = await createMatcherAndDataStore(
40-
Config.getFilesInclude(),
41-
getPlaceholdersConfig().exclude
42-
);
4356
const provider = new PlaceholderTreeView(
4457
context.globalState,
4558
foam.workspace,
4659
foam.graph,
47-
matcher
60+
await createPlaceholderMatcher()
4861
);
4962

5063
const treeView = vscode.window.createTreeView('foam-vscode.placeholders', {

0 commit comments

Comments
 (0)