Skip to content

Commit 274e142

Browse files
committed
feat(vscode): add "Mount Note" and fix tree-view for file entries
Split mounting into two commands — "Mount Folder" (directory-only) and "Mount Note" (file search via `listFiles`) — for a cleaner Quick Pick UX. Fix `#getRootChildren` to use `tracker.stat()` instead of hardcoding all root entries as directories, with fallback to directory on stat failure. Filter file entries from workspace folder creation since VSCode requires directory URIs. Bump version to 0.1.4. Assisted-by: Claude
1 parent c7adc8d commit 274e142

8 files changed

Lines changed: 365 additions & 22 deletions

File tree

packages/vscode/README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Browse, search, and edit your [Obsidian](https://obsidian.md) vault directly in
99

1010
## Features
1111

12-
- **Mount vault folders** into the Explorer tree view
12+
- **Mount vault folders or individual notes** into the Explorer tree view
1313
- **Browse and read** Markdown files through the `obs://` virtual file system
1414
- **Edit existing files** with writes going directly to the vault on disk
1515
- **Wikilink navigation**, click `[[links]]` in Markdown to jump between notes (resolves to `file://` paths for seamless navigation in workspace folders)
@@ -28,7 +28,8 @@ Available via the Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`):
2828
| Command | Description |
2929
|---------|-------------|
3030
| `Obsidian VFS: Mount Folder` | Pick a top-level vault folder and add it to the Explorer tree view |
31-
| `Obsidian VFS: Unmount Folder` | Remove a mounted vault folder from the tree view |
31+
| `Obsidian VFS: Mount Note` | Search vault notes and add one to the Explorer tree view |
32+
| `Obsidian VFS: Unmount Entry` | Remove a mounted vault entry from the tree view |
3233
| `Obsidian VFS: Open in Obsidian` | Open the active vault file in the Obsidian app (works from both `obs://` and `file://` documents) |
3334
| `Obsidian VFS: Search Notes` | Quick Pick search across all vault Markdown files |
3435
| `Obsidian VFS: Copy Path` | Copy the active file's `obs://` URI to the clipboard (`Shift+Alt+Cmd+C` on `obs://` files) |
@@ -42,7 +43,7 @@ Configure via **Settings UI** or `settings.json`:
4243
| `obsidianVFS.cliPath` | `string` | `"obsidian"` | Path to the Obsidian CLI binary |
4344
| `obsidianVFS.timeoutMs` | `number` | `10000` | CLI operation timeout in milliseconds |
4445
| `obsidianVFS.treeViewTitle` | `string` | `""` | Custom title for the Explorer tree view (defaults to `obs://<vault>`) |
45-
| `obsidianVFS.autoMount` | `string[]` | `[]` | Vault-relative folders to display in the Explorer tree view on activation |
46+
| `obsidianVFS.autoMount` | `string[]` | `[]` | Vault-relative paths (folders or notes) to display in the Explorer tree view on activation |
4647
| `obsidianVFS.explorer` | `boolean` | `true` | Show the Obsidian VFS tree view in the Explorer sidebar |
4748
| `obsidianVFS.statusBar` | `boolean` | `true` | Show vault name and mode in the status bar |
4849
| `obsidianVFS.workspace` | `boolean` | `true` | Add the vault as a workspace folder for Explorer browsing (see below) |

packages/vscode/package.json

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "obsidian-vfs",
33
"displayName": "Obsidian VFS",
44
"description": "Browse, search, and edit your Obsidian vault directly in VSCode via a virtual file system (obs://)",
5-
"version": "0.1.3",
5+
"version": "0.1.4",
66
"private": true,
77
"publisher": "otaviof",
88
"engines": {
@@ -36,9 +36,13 @@
3636
"command": "obsidianVFS.mount",
3737
"title": "Obsidian VFS: Mount Folder"
3838
},
39+
{
40+
"command": "obsidianVFS.mountNote",
41+
"title": "Obsidian VFS: Mount Note"
42+
},
3943
{
4044
"command": "obsidianVFS.unmount",
41-
"title": "Obsidian VFS: Unmount Folder"
45+
"title": "Obsidian VFS: Unmount Entry"
4246
},
4347
{
4448
"command": "obsidianVFS.openInObsidian",
@@ -72,7 +76,7 @@
7276
"viewsWelcome": [
7377
{
7478
"view": "obsidianVFS",
75-
"contents": "No vault folders mounted.\n[Mount Folder](command:obsidianVFS.mount)"
79+
"contents": "No vault entries mounted.\n[Mount Folder](command:obsidianVFS.mount)\n[Mount Note](command:obsidianVFS.mountNote)"
7680
}
7781
],
7882
"menus": {
@@ -108,7 +112,7 @@
108112
"type": "string"
109113
},
110114
"default": [],
111-
"description": "Vault-relative folders to display in the Explorer tree view and workspace folders"
115+
"description": "Vault-relative paths (folders or files) to display in the Explorer tree view and workspace folders"
112116
},
113117
"obsidianVFS.explorer": {
114118
"type": "boolean",

packages/vscode/src/commands.test.ts

Lines changed: 174 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,22 @@ describe("registerCommands", () => {
4747
vi.clearAllMocks();
4848
});
4949

50-
it("registers five commands", () => {
50+
it("registers six commands", () => {
5151
const ctx = fakeContext();
5252
const tracker = mockTracker();
5353
const tree = fakeTreeProvider();
5454
const channel = { appendLine: vi.fn(), dispose: vi.fn() } as never;
5555
registerCommands(ctx as never, tracker, tree as never, channel);
5656

57-
expect(vscode.commands.registerCommand).toHaveBeenCalledTimes(5);
57+
expect(vscode.commands.registerCommand).toHaveBeenCalledTimes(6);
5858
expect(vscode.commands.registerCommand).toHaveBeenCalledWith(
5959
"obsidianVFS.mount",
6060
expect.any(Function),
6161
);
62+
expect(vscode.commands.registerCommand).toHaveBeenCalledWith(
63+
"obsidianVFS.mountNote",
64+
expect.any(Function),
65+
);
6266
expect(vscode.commands.registerCommand).toHaveBeenCalledWith(
6367
"obsidianVFS.unmount",
6468
expect.any(Function),
@@ -277,6 +281,173 @@ describe("mount command", () => {
277281
});
278282
});
279283

284+
describe("mountNote command", () => {
285+
beforeEach(() => {
286+
vi.clearAllMocks();
287+
});
288+
289+
it("shows Quick Pick with vault notes and updates config", async () => {
290+
mockReadAutoMount.mockReturnValue([]);
291+
const { update } = setupConfigMock();
292+
293+
const tracker = mockTracker({
294+
listFiles: vi.fn().mockResolvedValue({
295+
ok: true,
296+
value: ["docs/overview.md", "notes/todo.md"],
297+
}),
298+
});
299+
300+
const ctx = fakeContext();
301+
const tree = fakeTreeProvider();
302+
const channel = { appendLine: vi.fn(), dispose: vi.fn() } as never;
303+
registerCommands(ctx as never, tracker, tree as never, channel);
304+
305+
vi.mocked(vscode.window.showQuickPick).mockResolvedValueOnce({
306+
label: "overview",
307+
description: "docs/overview.md",
308+
});
309+
310+
const handler = vi
311+
.mocked(vscode.commands.registerCommand)
312+
.mock.calls.find((c) => c[0] === "obsidianVFS.mountNote")![1] as () => Promise<void>;
313+
await handler();
314+
315+
expect(vscode.window.showQuickPick).toHaveBeenCalledWith(
316+
[
317+
{ label: "overview", description: "docs/overview.md" },
318+
{ label: "todo", description: "notes/todo.md" },
319+
],
320+
expect.objectContaining({ matchOnDescription: true }),
321+
);
322+
expect(update).toHaveBeenCalledWith(
323+
"autoMount",
324+
["docs/overview.md"],
325+
vscode.ConfigurationTarget.Workspace,
326+
);
327+
expect(tree.refresh).toHaveBeenCalled();
328+
});
329+
330+
it("excludes already-mounted notes from Quick Pick", async () => {
331+
mockReadAutoMount.mockReturnValue(["docs/overview.md"]);
332+
setupConfigMock();
333+
334+
const tracker = mockTracker({
335+
listFiles: vi.fn().mockResolvedValue({
336+
ok: true,
337+
value: ["docs/overview.md", "notes/todo.md"],
338+
}),
339+
});
340+
341+
const ctx = fakeContext();
342+
const tree = fakeTreeProvider();
343+
const channel = { appendLine: vi.fn(), dispose: vi.fn() } as never;
344+
registerCommands(ctx as never, tracker, tree as never, channel);
345+
346+
vi.mocked(vscode.window.showQuickPick).mockResolvedValueOnce({
347+
label: "todo",
348+
description: "notes/todo.md",
349+
});
350+
351+
const handler = vi
352+
.mocked(vscode.commands.registerCommand)
353+
.mock.calls.find((c) => c[0] === "obsidianVFS.mountNote")![1] as () => Promise<void>;
354+
await handler();
355+
356+
expect(vscode.window.showQuickPick).toHaveBeenCalledWith(
357+
[{ label: "todo", description: "notes/todo.md" }],
358+
expect.objectContaining({ matchOnDescription: true }),
359+
);
360+
});
361+
362+
it("does nothing when user cancels Quick Pick", async () => {
363+
mockReadAutoMount.mockReturnValue([]);
364+
const { update } = setupConfigMock();
365+
366+
const tracker = mockTracker({
367+
listFiles: vi.fn().mockResolvedValue({
368+
ok: true,
369+
value: ["notes/todo.md"],
370+
}),
371+
});
372+
373+
const ctx = fakeContext();
374+
const tree = fakeTreeProvider();
375+
const channel = { appendLine: vi.fn(), dispose: vi.fn() } as never;
376+
registerCommands(ctx as never, tracker, tree as never, channel);
377+
378+
vi.mocked(vscode.window.showQuickPick).mockResolvedValueOnce(undefined);
379+
380+
const handler = vi
381+
.mocked(vscode.commands.registerCommand)
382+
.mock.calls.find((c) => c[0] === "obsidianVFS.mountNote")![1] as () => Promise<void>;
383+
await handler();
384+
385+
expect(update).not.toHaveBeenCalled();
386+
});
387+
388+
it("does nothing when listFiles returns empty", async () => {
389+
const tracker = mockTracker({
390+
listFiles: vi.fn().mockResolvedValue({ ok: true, value: [] }),
391+
});
392+
393+
const ctx = fakeContext();
394+
const tree = fakeTreeProvider();
395+
const channel = { appendLine: vi.fn(), dispose: vi.fn() } as never;
396+
registerCommands(ctx as never, tracker, tree as never, channel);
397+
398+
const handler = vi
399+
.mocked(vscode.commands.registerCommand)
400+
.mock.calls.find((c) => c[0] === "obsidianVFS.mountNote")![1] as () => Promise<void>;
401+
await handler();
402+
403+
expect(vscode.window.showQuickPick).not.toHaveBeenCalled();
404+
});
405+
406+
it("does nothing when all notes already mounted", async () => {
407+
mockReadAutoMount.mockReturnValue(["docs/overview.md", "notes/todo.md"]);
408+
409+
const tracker = mockTracker({
410+
listFiles: vi.fn().mockResolvedValue({
411+
ok: true,
412+
value: ["docs/overview.md", "notes/todo.md"],
413+
}),
414+
});
415+
416+
const ctx = fakeContext();
417+
const tree = fakeTreeProvider();
418+
const channel = { appendLine: vi.fn(), dispose: vi.fn() } as never;
419+
registerCommands(ctx as never, tracker, tree as never, channel);
420+
421+
const handler = vi
422+
.mocked(vscode.commands.registerCommand)
423+
.mock.calls.find((c) => c[0] === "obsidianVFS.mountNote")![1] as () => Promise<void>;
424+
await handler();
425+
426+
expect(vscode.window.showQuickPick).not.toHaveBeenCalled();
427+
});
428+
429+
it("does nothing when listFiles returns error", async () => {
430+
const tracker = mockTracker({
431+
listFiles: vi.fn().mockResolvedValue({
432+
ok: false,
433+
error: { code: "CLI_ERROR", message: "failed" },
434+
}),
435+
});
436+
437+
const ctx = fakeContext();
438+
const tree = fakeTreeProvider();
439+
const channel = { appendLine: vi.fn(), dispose: vi.fn() } as never;
440+
registerCommands(ctx as never, tracker, tree as never, channel);
441+
442+
const handler = vi
443+
.mocked(vscode.commands.registerCommand)
444+
.mock.calls.find((c) => c[0] === "obsidianVFS.mountNote")![1] as () => Promise<void>;
445+
await handler();
446+
447+
expect(vscode.window.showQuickPick).not.toHaveBeenCalled();
448+
});
449+
});
450+
280451
describe("unmount command", () => {
281452
beforeEach(() => {
282453
vi.clearAllMocks();
@@ -322,7 +493,7 @@ describe("unmount command", () => {
322493
await unmountHandler();
323494

324495
expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
325-
"No Obsidian VFS folders mounted",
496+
"No Obsidian VFS entries mounted",
326497
);
327498
});
328499

packages/vscode/src/commands.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,48 @@ async function mountCommand(
3434
treeProvider.refresh();
3535
}
3636

37-
/** Remove a folder from `obsidianVFS.autoMount` and refresh the tree. */
37+
/** Add a single note to `obsidianVFS.autoMount` and refresh the tree. */
38+
async function mountNoteCommand(
39+
tracker: LocalIndexTracker,
40+
treeProvider: VaultTreeDataProvider,
41+
): Promise<void> {
42+
const result = await tracker.listFiles();
43+
if (!result.ok || result.value.length === 0) return;
44+
45+
const mounted = new Set(readAutoMount());
46+
const available = result.value.filter((f) => !mounted.has(f));
47+
if (available.length === 0) return;
48+
49+
const items = available.map((filePath) => ({
50+
// Safe: split("/") always returns at least one element
51+
label: filePath.replace(/\.md$/i, "").split("/").pop()!,
52+
description: filePath,
53+
}));
54+
55+
const picked = await vscode.window.showQuickPick(items, {
56+
placeHolder: "Select a vault note to mount",
57+
matchOnDescription: true,
58+
});
59+
if (!picked?.description) return;
60+
61+
const updated = [...mounted, picked.description];
62+
await vscode.workspace
63+
.getConfiguration("obsidianVFS")
64+
.update("autoMount", updated, vscode.ConfigurationTarget.Workspace);
65+
66+
treeProvider.refresh();
67+
}
68+
69+
/** Remove an entry from `obsidianVFS.autoMount` and refresh the tree. */
3870
async function unmountCommand(treeProvider: VaultTreeDataProvider): Promise<void> {
3971
const mounted = readAutoMount();
4072
if (mounted.length === 0) {
41-
await vscode.window.showInformationMessage("No Obsidian VFS folders mounted");
73+
await vscode.window.showInformationMessage("No Obsidian VFS entries mounted");
4274
return;
4375
}
4476

4577
const picked = await vscode.window.showQuickPick(mounted, {
46-
placeHolder: "Select a folder to unmount",
78+
placeHolder: "Select an entry to unmount",
4779
});
4880
if (!picked) return;
4981

@@ -128,6 +160,9 @@ export function registerCommands(
128160
): void {
129161
context.subscriptions.push(
130162
vscode.commands.registerCommand("obsidianVFS.mount", () => mountCommand(tracker, treeProvider)),
163+
vscode.commands.registerCommand("obsidianVFS.mountNote", () =>
164+
mountNoteCommand(tracker, treeProvider),
165+
),
131166
vscode.commands.registerCommand("obsidianVFS.unmount", () => unmountCommand(treeProvider)),
132167
vscode.commands.registerCommand("obsidianVFS.openInObsidian", () =>
133168
openInObsidianCommand(tracker, outputChannel),

0 commit comments

Comments
 (0)