Skip to content

Commit 2fa4cbb

Browse files
committed
feat(vscode): replace per-folder workspace entries with single obs://
Replace N file:// workspace folders (one per autoMount entry) with a single obs://<vault>/ workspace folder using the registered FileSystemProvider. Mounted entries appear as children under the vault root, filtered by the provider's readDirectory() at root level. - Add #autoMount field and setAutoMount() to ObsidianFileSystemProvider - Filter root readDirectory() to show only autoMount entries - Fire Created/Deleted events per entry so Explorer refreshes without window reload - Simplify addVaultWorkspaceFolder() to create one obs:// folder - Avoid removing/re-adding workspace folder on autoMount config changes - Preserve backward-compat detection of old file:// managed folders Assisted-by: Claude
1 parent ddeb56f commit 2fa4cbb

8 files changed

Lines changed: 380 additions & 219 deletions

File tree

packages/vscode/README.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Browse, search, and edit your [Obsidian](https://obsidian.md) vault directly in
1212
- **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
15-
- **Wikilink navigation**, click `[[links]]` in Markdown to jump between notes (resolves to `file://` paths for seamless navigation in workspace folders)
15+
- **Wikilink navigation**, click `[[links]]` in Markdown to jump between notes
1616
- **Search notes** via Quick Pick across all vault Markdown files
1717
- **Open in Obsidian**, jump to the current file in the Obsidian app
1818
- **Copy path** as `obs://` URI to the clipboard (`Shift+Alt+Cmd+C` on `obs://` files)
@@ -52,19 +52,19 @@ All three toggle settings (`explorer`, `statusBar`, `workspace`) take effect imm
5252

5353
### Workspace Folder
5454

55-
When `obsidianVFS.workspace` is enabled, the extension adds each `autoMount` folder as a `file://` workspace folder pointing to the actual directory on disk, named **obs://\<folder\>**. Because these are native file-system paths, VS Code's built-in file indexer (`ripgrep`) can index them — so vault files appear in **Quick Open** (`Cmd+P`) and **Search** (`Ctrl+Shift+F`).
55+
When `obsidianVFS.workspace` is enabled, the extension adds a single **obs://\<vault\>** workspace folder using the `obs://` virtual file system. Mounted `autoMount` entries appear as children under this root — the Explorer shows one vault entry instead of one per folder. VS Code's **Search** (`Ctrl+Shift+F`) and **Quick Open** (`Cmd+P`) work across mounted content through the `FileSystemProvider`.
5656

57-
The `obs://` FileSystemProvider remains registered for internal operations (stat, read, write, file watching). All user-facing navigationwikilinks, search results, tree view items — resolves to `file://` URIs so they work seamlessly with workspace folders.
57+
All file operations stat, read, write, directory listing, and file watchinggo through the `obs://` provider, which enforces `allowed`/`blocked` security rules from [`.obsidian/obsidian-vfs.json`](../../README.md#vault-configuration) at every level.
5858

5959
**Requirements:**
6060

61-
- At least one local folder must be open — vault folders are appended to the workspace folder list to avoid triggering an extension host restart.
61+
- At least one local folder must be open — the vault workspace folder is appended to the list to avoid triggering an extension host restart.
6262

6363
**Notes:**
6464

65-
- `autoMount` entries outside `allowed` or inside `blocked` (from [`.obsidian/obsidian-vfs.json`](../../README.md#vault-configuration)) are silently skipped. However, content *within* a mounted workspace folder is not filtered — because these are native `file://` folders, VS Code browses them directly from disk. Use the **Explorer tree view** for security-enforced browsing; workspace folders are the search and Quick Open surface.
66-
- The Explorer tree view and the workspace folders both appear in the sidebar. This duplication is an accepted trade-off — the tree view provides custom UI (welcome view, context menus) with `allowed`/`blocked` enforcement, while the workspace folders enable Quick Open and cross-extension visibility.
67-
- The vault's `.git` repository is automatically added to `git.ignoredRepositories` (user-level setting) when workspace folders are mounted, preventing VS Code's Git extension from listing it in Source Control. The entry is removed when `obsidianVFS.workspace` is disabled.
65+
- The Explorer tree view and the workspace folder both appear in the sidebar. The tree view provides custom UI (welcome view, context menus), while the workspace folder enables Quick Open and cross-extension visibility. The tree view uses `file://` URIs for opening files, which enables native features like Git integration.
66+
- `autoMount` entries outside `allowed` or inside `blocked` are filtered from the workspace folder root listing. The core security layer remains as defense-in-depth for navigation into subdirectories.
67+
- The vault's `.git` repository is automatically added to `git.ignoredRepositories` (user-level setting) when the workspace folder is mounted, preventing VS Code's Git extension from listing it in Source Control. The entry is removed when `obsidianVFS.workspace` is disabled.
6868

6969
## Related Tools
7070

packages/vscode/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@
127127
"obsidianVFS.workspace": {
128128
"type": "boolean",
129129
"default": true,
130-
"description": "Add the vault as a workspace folder for Explorer browsing. Requires at least one local folder open."
130+
"description": "Add the vault as a single obs:// workspace folder for Explorer browsing. Mounted entries appear as children under the vault root. Requires at least one local folder open."
131131
}
132132
}
133133
}

packages/vscode/src/extension.test.ts

Lines changed: 100 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ vi.mock("./file-system-provider.js", () => ({
3131
ObsidianFileSystemProvider: vi.fn().mockImplementation(function (this: Record<string, unknown>) {
3232
this.watch = vi.fn(() => ({ dispose: vi.fn() }));
3333
this.dispose = vi.fn();
34+
this.setAutoMount = vi.fn();
3435
}),
3536
}));
3637

@@ -84,6 +85,7 @@ import { WikilinkDocumentLinkProvider } from "./wikilink-provider.js";
8485
import {
8586
addVaultWorkspaceFolder,
8687
excludeVaultFromGitDetection,
88+
hasVaultWorkspaceFolder,
8789
includeVaultInGitDetection,
8890
removeVaultWorkspaceFolders,
8991
} from "./workspace-folder.js";
@@ -92,6 +94,7 @@ const mockBootstrap = vi.mocked(bootstrapFromConfig);
9294
const mockReadConfig = vi.mocked(readConfig);
9395
const mockAddWF = vi.mocked(addVaultWorkspaceFolder);
9496
const mockRemoveWF = vi.mocked(removeVaultWorkspaceFolders);
97+
const mockHasWF = vi.mocked(hasVaultWorkspaceFolder);
9598
const mockExcludeGit = vi.mocked(excludeVaultFromGitDetection);
9699
const mockIncludeGit = vi.mocked(includeVaultInGitDetection);
97100

@@ -151,7 +154,7 @@ describe("activate", () => {
151154
const ctx = fakeContext();
152155
await activate(ctx as never);
153156

154-
expect(ObsidianFileSystemProvider).toHaveBeenCalledWith(fakeTracker);
157+
expect(ObsidianFileSystemProvider).toHaveBeenCalledWith(fakeTracker, []);
155158
expect(vscode.workspace.registerFileSystemProvider).toHaveBeenCalledWith(
156159
SCHEME,
157160
expect.anything(),
@@ -210,11 +213,7 @@ describe("activate", () => {
210213

211214
await activate(fakeContext() as never);
212215

213-
expect(mockAddWF).toHaveBeenCalledWith("/vault", ["Notes"], {
214-
vaultRoot: "/vault",
215-
allowed: [],
216-
blocked: [],
217-
});
216+
expect(mockAddWF).toHaveBeenCalledWith("/vault", "MyVault", 1);
218217
expect(mockExcludeGit).toHaveBeenCalledWith("/vault");
219218
});
220219

@@ -669,11 +668,7 @@ describe("configuration change listener", () => {
669668
configChangeListener!({ affectsConfiguration: (key) => key === "obsidianVFS.workspace" });
670669

671670
expect(mockRemoveWF).toHaveBeenCalledWith("/vault");
672-
expect(mockAddWF).toHaveBeenCalledWith("/vault", ["Notes"], {
673-
vaultRoot: "/vault",
674-
allowed: [],
675-
blocked: [],
676-
});
671+
expect(mockAddWF).toHaveBeenCalledWith("/vault", "MyVault", 1);
677672
expect(mockExcludeGit).toHaveBeenCalledWith("/vault");
678673
});
679674

@@ -720,7 +715,7 @@ describe("configuration change listener", () => {
720715
expect(mockIncludeGit).toHaveBeenCalledWith("/vault");
721716
});
722717

723-
it("refreshes workspace folders when autoMount config changes", async () => {
718+
it("updates provider without removing workspace folder when autoMount changes", async () => {
724719
const fakeTracker = {
725720
context: {
726721
name: "MyVault",
@@ -745,8 +740,13 @@ describe("configuration change listener", () => {
745740

746741
await activate(fakeContext() as never);
747742

743+
const providerInstance = vi.mocked(ObsidianFileSystemProvider).mock.results[0].value as {
744+
setAutoMount: ReturnType<typeof vi.fn>;
745+
};
746+
748747
vi.clearAllMocks();
749748

749+
mockHasWF.mockReturnValueOnce(true);
750750
mockReadConfig.mockReturnValueOnce({
751751
cliPath: "obsidian",
752752
timeoutMs: 10_000,
@@ -755,16 +755,99 @@ describe("configuration change listener", () => {
755755
statusBar: true,
756756
workspace: true,
757757
});
758+
759+
configChangeListener!({ affectsConfiguration: (key) => key === "obsidianVFS.autoMount" });
760+
761+
expect(providerInstance.setAutoMount).toHaveBeenCalledWith(["Notes", "Projects"]);
762+
expect(mockRemoveWF).not.toHaveBeenCalled();
763+
expect(mockAddWF).not.toHaveBeenCalled();
764+
});
765+
766+
it("adds workspace folder when autoMount goes from empty to non-empty", async () => {
767+
const fakeTracker = {
768+
context: {
769+
name: "MyVault",
770+
physicalPath: "/vault",
771+
mode: "full",
772+
vfsConfig: { agents: [], skills: [], allowed: [], blocked: [] },
773+
},
774+
} as unknown as LocalIndexTracker;
775+
776+
mockBootstrap.mockResolvedValueOnce({
777+
ok: true,
778+
value: { tracker: fakeTracker, initMs: 42 },
779+
});
780+
781+
let configChangeListener:
782+
| ((e: { affectsConfiguration: (key: string) => boolean }) => void)
783+
| null = null;
784+
vi.mocked(vscode.workspace.onDidChangeConfiguration).mockImplementation((callback) => {
785+
configChangeListener = callback as typeof configChangeListener;
786+
return { dispose: vi.fn() };
787+
});
788+
789+
await activate(fakeContext() as never);
790+
791+
vi.clearAllMocks();
792+
793+
mockHasWF.mockReturnValueOnce(false);
758794
mockAddWF.mockReturnValueOnce({ status: "added" });
795+
mockReadConfig.mockReturnValueOnce({
796+
cliPath: "obsidian",
797+
timeoutMs: 10_000,
798+
autoMount: ["Notes"],
799+
explorer: true,
800+
statusBar: true,
801+
workspace: true,
802+
});
759803

760804
configChangeListener!({ affectsConfiguration: (key) => key === "obsidianVFS.autoMount" });
761805

762-
expect(mockRemoveWF).toHaveBeenCalledWith("/vault");
763-
expect(mockAddWF).toHaveBeenCalledWith("/vault", ["Notes", "Projects"], {
764-
vaultRoot: "/vault",
765-
allowed: [],
766-
blocked: [],
806+
expect(mockAddWF).toHaveBeenCalledWith("/vault", "MyVault", 1);
807+
expect(mockExcludeGit).toHaveBeenCalledWith("/vault");
808+
expect(mockRemoveWF).not.toHaveBeenCalled();
809+
});
810+
811+
it("removes workspace folder when autoMount becomes empty", async () => {
812+
const fakeTracker = {
813+
context: {
814+
name: "MyVault",
815+
physicalPath: "/vault",
816+
mode: "full",
817+
vfsConfig: { agents: [], skills: [], allowed: [], blocked: [] },
818+
},
819+
} as unknown as LocalIndexTracker;
820+
821+
mockBootstrap.mockResolvedValueOnce({
822+
ok: true,
823+
value: { tracker: fakeTracker, initMs: 42 },
767824
});
825+
826+
let configChangeListener:
827+
| ((e: { affectsConfiguration: (key: string) => boolean }) => void)
828+
| null = null;
829+
vi.mocked(vscode.workspace.onDidChangeConfiguration).mockImplementation((callback) => {
830+
configChangeListener = callback as typeof configChangeListener;
831+
return { dispose: vi.fn() };
832+
});
833+
834+
await activate(fakeContext() as never);
835+
836+
vi.clearAllMocks();
837+
838+
mockReadConfig.mockReturnValueOnce({
839+
cliPath: "obsidian",
840+
timeoutMs: 10_000,
841+
autoMount: [],
842+
explorer: true,
843+
statusBar: true,
844+
workspace: true,
845+
});
846+
847+
configChangeListener!({ affectsConfiguration: (key) => key === "obsidianVFS.autoMount" });
848+
849+
expect(mockRemoveWF).toHaveBeenCalledWith("/vault");
850+
expect(mockAddWF).not.toHaveBeenCalled();
768851
});
769852
});
770853

packages/vscode/src/extension.ts

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import * as vscode from "vscode";
22

3-
import type { PathSecurityOptions } from "@obsidian-vfs/core";
4-
53
import { bootstrapFromConfig, readConfig } from "./bootstrap.js";
64
import { SCHEME } from "./uri-adapter.js";
75
import { registerCommands } from "./commands.js";
@@ -13,6 +11,7 @@ import {
1311
FOLDER_NAME_PREFIX,
1412
addVaultWorkspaceFolder,
1513
excludeVaultFromGitDetection,
14+
hasVaultWorkspaceFolder,
1615
includeVaultInGitDetection,
1716
removeVaultWorkspaceFolders,
1817
} from "./workspace-folder.js";
@@ -34,7 +33,9 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
3433
`Obsidian VFS: vault "${tracker.context.name}" loaded in ${initMs.toFixed(0)}ms`,
3534
);
3635

37-
const provider = new ObsidianFileSystemProvider(tracker);
36+
const config = readConfig();
37+
38+
const provider = new ObsidianFileSystemProvider(tracker, config.autoMount);
3839
context.subscriptions.push(provider);
3940

4041
context.subscriptions.push(
@@ -73,23 +74,16 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
7374
),
7475
);
7576

76-
const config = readConfig();
77-
7877
await vscode.commands.executeCommand("setContext", "obsidianVFS.explorerEnabled", config.explorer);
7978
if (config.statusBar) {
8079
statusBar.show();
8180
}
82-
const securityOptions: PathSecurityOptions = {
83-
vaultRoot: tracker.context.physicalPath,
84-
allowed: tracker.context.vfsConfig.allowed,
85-
blocked: tracker.context.vfsConfig.blocked,
86-
};
8781

8882
if (config.workspace) {
8983
const wfResult = addVaultWorkspaceFolder(
9084
tracker.context.physicalPath,
91-
config.autoMount,
92-
securityOptions,
85+
tracker.context.name,
86+
config.autoMount.length,
9387
);
9488
const detail = "reason" in wfResult ? ` — ${wfResult.reason}` : "";
9589
outputChannel.appendLine(`Workspace folder: ${wfResult.status}${detail}`);
@@ -113,17 +107,32 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
113107
statusBar.hide();
114108
}
115109
}
116-
if (
117-
e.affectsConfiguration("obsidianVFS.workspace") ||
118-
e.affectsConfiguration("obsidianVFS.autoMount")
119-
) {
110+
if (e.affectsConfiguration("obsidianVFS.autoMount")) {
111+
provider.setAutoMount(updated.autoMount);
112+
}
113+
if (e.affectsConfiguration("obsidianVFS.workspace")) {
120114
removeVaultWorkspaceFolders(tracker.context.physicalPath);
121115
if (updated.workspace) {
122-
addVaultWorkspaceFolder(tracker.context.physicalPath, updated.autoMount, securityOptions);
116+
addVaultWorkspaceFolder(
117+
tracker.context.physicalPath,
118+
tracker.context.name,
119+
updated.autoMount.length,
120+
);
123121
void excludeVaultFromGitDetection(tracker.context.physicalPath);
124122
} else {
125123
void includeVaultInGitDetection(tracker.context.physicalPath);
126124
}
125+
} else if (e.affectsConfiguration("obsidianVFS.autoMount") && updated.workspace) {
126+
if (updated.autoMount.length === 0) {
127+
removeVaultWorkspaceFolders(tracker.context.physicalPath);
128+
} else if (!hasVaultWorkspaceFolder(tracker.context.physicalPath)) {
129+
addVaultWorkspaceFolder(
130+
tracker.context.physicalPath,
131+
tracker.context.name,
132+
updated.autoMount.length,
133+
);
134+
void excludeVaultFromGitDetection(tracker.context.physicalPath);
135+
}
127136
}
128137
}),
129138
);

0 commit comments

Comments
 (0)