Skip to content

Commit 05d22fd

Browse files
Index notes in folders created, moved or renamed outside VS Code (#1724)
VS Code reports a folder created, deleted, moved or renamed as one event for the folder, and on Linux often doesn't report the files written into a new folder before it is watched. Foam's watcher only matched note and attachment extensions, so it dropped the folder event and never saw those notes; a renamed folder also left its old notes in the index. Watch folders too: a created folder is expanded into the files it contains, and a deleted folder removes the resources under it. Claude-Session: https://claude.ai/code/session_01EX3EnAE7eqkcteJi29pqJr Co-authored-by: Claude <noreply@anthropic.com>
1 parent ac56662 commit 05d22fd

6 files changed

Lines changed: 151 additions & 6 deletions

File tree

‎packages/foam-core/src/model/foam.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,34 @@ describe('bootstrap file-watching', () => {
139139
}
140140
});
141141

142+
it('removes the notes under a deleted folder', async () => {
143+
// VS Code reports a folder deleted, or moved or renamed away, as one event
144+
// for the folder, with no events for the files it contained
145+
const { watcher, foam } = await bootstrapWithWatcher(new CountingMatcher());
146+
147+
try {
148+
for (const path of [
149+
'/workspace/folder/a.md',
150+
'/workspace/folder/nested/b.md',
151+
'/workspace/folder.md',
152+
'/workspace/folder-sibling/c.md',
153+
]) {
154+
watcher.fireCreate(URI.file(path));
155+
}
156+
await flush(DEBOUNCE_SETTLE_MS);
157+
158+
watcher.fireDelete(URI.file('/workspace/folder'));
159+
160+
const remaining = foam.workspace.list().map(r => r.uri.path);
161+
expect(remaining.sort()).toEqual([
162+
'/workspace/folder-sibling/c.md',
163+
'/workspace/folder.md',
164+
]);
165+
} finally {
166+
foam.dispose();
167+
}
168+
});
169+
142170
it('does not refresh the matcher for creates after dispose', async () => {
143171
const matcher = new CountingMatcher();
144172
const { watcher, foam } = await bootstrapWithWatcher(matcher);

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,17 @@ export const bootstrap = async (
106106

107107
subscriptions.push(
108108
watcher.onDidDelete(uri => {
109-
workspace.delete(uri);
109+
if (workspace.delete(uri)) {
110+
return;
111+
}
112+
// Not a resource, so possibly a folder: a watcher may report a folder
113+
// deleted, moved or renamed as one event, without its files
114+
const folderPrefix = uri.path.endsWith('/') ? uri.path : uri.path + '/';
115+
for (const resource of workspace.list()) {
116+
if (resource.uri.path.startsWith(folderPrefix)) {
117+
workspace.delete(resource.uri);
118+
}
119+
}
110120
})
111121
);
112122
}

‎packages/foam-core/src/services/datastore.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ export interface IDataStore {
5050

5151
export interface IWatcher {
5252
onDidChange: Event<URI>;
53+
/** Fires for each created file, including those inside a created folder */
5354
onDidCreate: Event<URI>;
55+
/** Fires for a deleted file, or for a deleted folder without its files */
5456
onDidDelete: Event<URI>;
5557
}
5658

‎packages/foam-vscode/src/extension.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
commands,
88
TextEditor,
99
RelativePattern,
10+
FileType,
11+
Uri,
1012
} from 'vscode';
1113
import {
1214
MarkdownResourceProvider,
@@ -99,7 +101,28 @@ export async function activate(context: ExtensionContext) {
99101
new RelativePattern(folder.uri, watchGlob)
100102
)
101103
),
102-
workspace.onDidSaveTextDocument
104+
workspace.onDidSaveTextDocument,
105+
{
106+
watchers: workspace.workspaceFolders.map(folder =>
107+
workspace.createFileSystemWatcher(
108+
new RelativePattern(folder.uri, '**/*'),
109+
false,
110+
true,
111+
false
112+
)
113+
),
114+
listFilesInFolder: async (uri: Uri) => {
115+
try {
116+
const stat = await workspace.fs.stat(uri);
117+
return stat.type & FileType.Directory
118+
? await workspace.findFiles(new RelativePattern(uri, watchGlob))
119+
: [];
120+
} catch {
121+
// Already gone again
122+
return [];
123+
}
124+
},
125+
}
103126
);
104127
context.subscriptions.push(watcher);
105128
// Attributes the workspace load time to reading vs parsing vs neither, so
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
import {
4+
getUriInWorkspace,
5+
waitForNoteInFoamWorkspace,
6+
waitForNoteRemovedFromFoamWorkspace,
7+
} from '../../test/test-utils-vscode';
8+
import { randomString, wait } from '../../test/test-utils';
9+
10+
/**
11+
* VS Code reports a folder moved or renamed as a single event for the folder,
12+
* without events for the files inside it. The folders here are changed with
13+
* Node's fs, as a terminal, a file manager or git would do, rather than
14+
* through VS Code.
15+
*/
16+
describe('Watching folders changed outside VS Code', () => {
17+
it('indexes the notes in a folder moved into the workspace', async () => {
18+
const name = `moved-in-${randomString()}`;
19+
const folder = getUriInWorkspace(name);
20+
// Outside the workspace folder, on the same file system so rename works
21+
const outside = path.join(folder.toFsPath(), '..', '..', `.${name}`);
22+
const note = folder.joinPath('note.md');
23+
try {
24+
fs.mkdirSync(outside);
25+
fs.writeFileSync(path.join(outside, 'note.md'), '# Note');
26+
fs.renameSync(outside, folder.toFsPath());
27+
28+
await waitForNoteInFoamWorkspace(note);
29+
} finally {
30+
fs.rmSync(outside, { recursive: true, force: true });
31+
fs.rmSync(folder.toFsPath(), { recursive: true, force: true });
32+
}
33+
});
34+
35+
it('re-indexes the notes in a renamed folder', async () => {
36+
const before = getUriInWorkspace(`renamed-${randomString()}`);
37+
const after = getUriInWorkspace(`renamed-${randomString()}`);
38+
const noteBefore = before.joinPath('note.md');
39+
const noteAfter = after.joinPath('note.md');
40+
try {
41+
fs.mkdirSync(before.toFsPath());
42+
// Let the watcher pick up the folder, so the note's own event is seen
43+
await wait(1000);
44+
fs.writeFileSync(noteBefore.toFsPath(), '# Note');
45+
await waitForNoteInFoamWorkspace(noteBefore);
46+
47+
fs.renameSync(before.toFsPath(), after.toFsPath());
48+
49+
await waitForNoteInFoamWorkspace(noteAfter);
50+
await waitForNoteRemovedFromFoamWorkspace(noteBefore);
51+
} finally {
52+
fs.rmSync(before.toFsPath(), { recursive: true, force: true });
53+
fs.rmSync(after.toFsPath(), { recursive: true, force: true });
54+
}
55+
});
56+
});

‎packages/foam-vscode/src/vscode/services/watcher.ts‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,24 @@ import { IDisposable } from '@foam/core';
22
import { Emitter } from '@foam/core';
33
import { IWatcher } from '@foam/core';
44
import { URI } from '@foam/core';
5-
import { Event, FileSystemWatcher, TextDocument } from 'vscode';
5+
import { Event, FileSystemWatcher, TextDocument, Uri } from 'vscode';
66
import { fromVsCodeUri } from '../utils/vsc-utils';
77

88
const DEBOUNCE_MS = 100;
99

10+
/**
11+
* VS Code reports a folder created, deleted, moved or renamed as one event for
12+
* the folder, and may not report the files inside it at all (e.g. on Linux,
13+
* files written before the new folder is watched). Watchers matching folders
14+
* make up for it: a created folder is expanded into its files, and a deleted
15+
* one is forwarded for the consumer to expand.
16+
*/
17+
export interface FolderWatch {
18+
watchers: FileSystemWatcher[];
19+
/** The files to report as created under `uri`, or none if it isn't a folder */
20+
listFilesInFolder: (uri: Uri) => Promise<Uri[]>;
21+
}
22+
1023
export class VsCodeWatcher implements IWatcher, IDisposable {
1124
public onDidCreateEmitter = new Emitter<URI>();
1225
public onDidChangeEmitter = new Emitter<URI>();
@@ -20,15 +33,17 @@ export class VsCodeWatcher implements IWatcher, IDisposable {
2033

2134
constructor(
2235
vsCodeWatcher: FileSystemWatcher | FileSystemWatcher[],
23-
onDidSaveTextDocument?: Event<TextDocument>
36+
onDidSaveTextDocument?: Event<TextDocument>,
37+
folderWatch?: FolderWatch
2438
) {
2539
// Multiple watchers support multi-root workspaces, where each folder gets
2640
// its own scoped RelativePattern watcher.
27-
this.vsCodeWatchers = Array.isArray(vsCodeWatcher)
41+
const fileWatchers = Array.isArray(vsCodeWatcher)
2842
? vsCodeWatcher
2943
: [vsCodeWatcher];
44+
this.vsCodeWatchers = [...fileWatchers, ...(folderWatch?.watchers ?? [])];
3045

31-
for (const w of this.vsCodeWatchers) {
46+
for (const w of fileWatchers) {
3247
w.onDidCreate(uri =>
3348
this.onDidCreateEmitter.fire(fromVsCodeUri(uri))
3449
);
@@ -38,6 +53,17 @@ export class VsCodeWatcher implements IWatcher, IDisposable {
3853
);
3954
}
4055
onDidSaveTextDocument?.(doc => this.fireChange(fromVsCodeUri(doc.uri)));
56+
57+
for (const w of folderWatch?.watchers ?? []) {
58+
w.onDidCreate(async uri => {
59+
for (const file of await folderWatch.listFilesInFolder(uri)) {
60+
this.onDidCreateEmitter.fire(fromVsCodeUri(file));
61+
}
62+
});
63+
w.onDidDelete(uri =>
64+
this.onDidDeleteEmitter.fire(fromVsCodeUri(uri))
65+
);
66+
}
4167
}
4268

4369
private fireChange(uri: URI): void {

0 commit comments

Comments
 (0)