Skip to content

Commit 83a3f31

Browse files
committed
feat(vscode): switch workspace file:// with files.exclude filtering
Replace the obs:// workspace folder with a single file:// folder at the vault root, enabling Quick Open (Cmd+P) and full-text search support. Non-autoMount entries and blocked paths are hidden via managed files.exclude patterns tracked in context.workspaceState. - addVaultWorkspaceFolder now creates file:// URI at vault root - syncFilesExclude scans vault root, excludes non-autoMount entries and blocked paths from vfsConfig, preserves user-set patterns - clearManagedExcludes removes managed patterns on workspace disable - Serialized via promise chain to prevent concurrent read-modify-write - Graceful degradation when readdir fails (returns previous state) - Extract CONFIG_SECTION/CONFIG_KEY constants to types.ts - Version bump 0.2.3 → 0.3.0 Assisted-by: Claude
1 parent bd7538e commit 83a3f31

10 files changed

Lines changed: 505 additions & 84 deletions

File tree

AGENTS.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ Style, file layout, patterns: [CONTRIBUTING.md#conventions](CONTRIBUTING.md#conv
2828
- **Claude plugin**: `.claude-plugin/marketplace.json`, `packages/claude-plugin/package.json`, `.claude-plugin/plugin.json` (all three must match)
2929
- **npm packages**: `packages/core/package.json` + `packages/cli/package.json` (must match)
3030

31+
## VSCode: Workspace Folder Architecture
32+
33+
- **Single `file://` workspace folder at the vault root** for Quick Open (`Cmd+P`) and `Ctrl+Shift+F` search. VSCode's file discovery and ripgrep indexer only operate on `file://` workspace folders — `obs://` workspace folders provide zero discoverability (confirmed by spike, 2026-05-15).
34+
- **`files.exclude` patterns** hide non-autoMount vault content from Explorer and Quick Open. Patterns are managed via `ConfigurationTarget.Workspace` (routes to `.vscode/settings.json` or `.code-workspace` file automatically). Extension-managed patterns are tracked in `context.workspaceState` and cleaned up on autoMount change or workspace disable.
35+
- **`obs://` FileSystemProvider** remains registered for TreeView sidebar, wikilinks, drag-and-drop, and watch events — it does not back a workspace folder.
36+
- **`FileSearchProvider`/`TextSearchProvider` are proposed (unstable) APIs** as of `@types/vscode@1.118.0`. When stabilized, the extension can switch to a single `obs://` workspace folder with native search, eliminating the `files.exclude` workaround. Check `@types/vscode` for stable availability — do not use while proposed.
37+
3138
## Documentation
3239

33-
Update the package's README and CONTRIBUTING.md when changes affect user-facing behavior or API surface. Link, don't duplicate.
40+
Update the package's README and CONTRIBUTING.md when changes affect user-facing behavior or API surface. Link, don't duplicate.

packages/vscode/README.md

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,26 +46,39 @@ Configure via **Settings UI** or `settings.json`:
4646
| `obsidianVFS.autoMount` | `string[]` | `[]` | Vault-relative paths (folders or notes) to display in the Explorer tree view on activation |
4747
| `obsidianVFS.explorer` | `boolean` | `true` | Show the Obsidian VFS tree view in the Explorer sidebar |
4848
| `obsidianVFS.statusBar` | `boolean` | `true` | Show vault name and mode in the status bar |
49-
| `obsidianVFS.workspace` | `boolean` | `true` | Add the vault as a workspace folder for Explorer browsing (see below) |
49+
| `obsidianVFS.workspace` | `boolean` | `true` | Add the vault as a workspace folder for Quick Open and Search (see below) |
5050

5151
All three toggle settings (`explorer`, `statusBar`, `workspace`) take effect immediately — no reload required.
5252

5353
### Workspace Folder
5454

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`.
55+
When `obsidianVFS.workspace` is enabled and at least one `autoMount` entry is configured, the extension adds a single `file://` workspace folder at the vault root (named **obs://\<vault\>** in the sidebar). VS Code's **Quick Open** (`Cmd+P`) and **Search** (`Ctrl+Shift+F`) discover vault files through this folder.
5656

57-
All file operations — stat, read, write, directory listing, and file watching — go through the `obs://` provider, which enforces `allowed`/`blocked` security rules from [`.obsidian/obsidian-vfs.json`](../../README.md#vault-configuration) at every level.
57+
Non-autoMount vault content (`.obsidian/`, `.trash/`, and any directories not in `autoMount`) is hidden from Explorer and Quick Open via `files.exclude` patterns managed by the extension. Your own `files.exclude` patterns are never modified or removed.
5858

5959
**Requirements:**
6060

6161
- At least one local folder must be open — the vault workspace folder is appended to the list to avoid triggering an extension host restart.
62+
- At least one `autoMount` entry must be configured — the workspace folder is not added when `autoMount` is empty.
6263

63-
**Notes:**
64+
**How it works:**
6465

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.
66+
- The extension scans the vault root and adds `files.exclude` patterns for entries not in `autoMount`. Patterns are written to workspace settings (`ConfigurationTarget.Workspace`) and tracked internally for cleanup.
67+
- When `autoMount` entries change, patterns are re-synced automatically — stale patterns are removed and new ones added.
68+
- When `obsidianVFS.workspace` is disabled, all managed patterns are removed and the workspace folder is deleted.
6769
- 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.
6870

71+
**Known limitations:**
72+
73+
- **Pattern scope:** `files.exclude` patterns apply to all workspace folders. If a non-autoMount vault directory shares a name with a directory in your project (e.g., both have a `docs/` folder), the project directory will also be hidden. Fix: add the vault directory to `autoMount`, or rename it in your vault.
74+
- **Not a security boundary:** `files.exclude` hides content from Explorer and Quick Open but does not enforce access restrictions. The `obs://` FileSystemProvider's path security (`allowed`/`blocked` lists in `.obsidian-vfs.json`) applies to TreeView, wikilink, and drag-and-drop operations.
75+
- **Title bar:** Adding the vault as a workspace folder creates a multi-root workspace. VS Code may show "UNTITLED (WORKSPACE)" in the title bar.
76+
77+
**Notes:**
78+
79+
- The Explorer tree view and the workspace folder both appear in the sidebar. The tree view provides custom UI (welcome view, context menus, drag-and-drop), while the workspace folder enables Quick Open and full-text search.
80+
- The `obs://` FileSystemProvider remains registered for the TreeView sidebar, wikilink navigation, and drag-and-drop — it does not back a workspace folder.
81+
6982
## Related Tools
7083

7184
This VSCode extension provides file-system access and UI integration for Obsidian vaults. If you use **Claude Code**, the companion [`@obsidian-vfs/claude-plugin`](https://github.com/otaviof/obsidian-vfs/tree/main/packages/claude-plugin) enables Claude to read and search your vault via `@obs:` mentions and automatically resolves wikilinks in agent definitions and skills.

packages/vscode/package.json

Lines changed: 2 additions & 2 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.2.3",
5+
"version": "0.3.0",
66
"private": true,
77
"publisher": "otaviof",
88
"engines": {
@@ -127,7 +127,7 @@
127127
"obsidianVFS.workspace": {
128128
"type": "boolean",
129129
"default": true,
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."
130+
"description": "Add the vault as a file:// workspace folder for Quick Open (Cmd+P) and Search (Ctrl+Shift+F). Non-autoMount content is hidden via files.exclude patterns managed by the extension. Requires at least one local folder open and at least one autoMount entry."
131131
}
132132
}
133133
}

packages/vscode/src/bootstrap.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ import * as vscode from "vscode";
22
import { DEFAULT_TIMEOUT_MS, bootstrapTracker, resolveCliPath } from "@obsidian-vfs/core";
33
import type { BootstrapResult, VFSResult } from "@obsidian-vfs/core";
44

5+
import { CONFIG_SECTION } from "./types.js";
56
import type { ExtensionConfig } from "./types.js";
67

78
/** Read extension configuration from VSCode settings. */
89
export function readConfig(): ExtensionConfig {
9-
const cfg = vscode.workspace.getConfiguration("obsidianVFS");
10+
const cfg = vscode.workspace.getConfiguration(CONFIG_SECTION);
1011
return {
1112
cliPath: resolveCliPath({ userPath: cfg.get<string>("cliPath", "") }),
1213
timeoutMs: cfg.get<number>("timeoutMs", DEFAULT_TIMEOUT_MS),

packages/vscode/src/extension.test.ts

Lines changed: 72 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ vi.mock("./workspace-folder.js", async (importOriginal) => {
7171
hasVaultWorkspaceFolder: vi.fn(),
7272
excludeVaultFromGitDetection: vi.fn().mockResolvedValue(undefined),
7373
includeVaultInGitDetection: vi.fn().mockResolvedValue(undefined),
74+
syncFilesExclude: vi.fn().mockResolvedValue([]),
75+
clearManagedExcludes: vi.fn().mockResolvedValue(undefined),
7476
};
7577
});
7678

@@ -80,6 +82,7 @@ import type { LocalIndexTracker } from "@obsidian-vfs/core";
8082
import { bootstrapFromConfig, readConfig } from "./bootstrap.js";
8183
import { registerCommands } from "./commands.js";
8284
import { activate, deactivate } from "./extension.js";
85+
import { CONFIG_KEY } from "./types.js";
8386
import { SCHEME } from "./uri-adapter.js";
8487
import { FOLDER_NAME_PREFIX } from "./workspace-folder.js";
8588
import { ObsidianFileSystemProvider } from "./file-system-provider.js";
@@ -89,10 +92,12 @@ import { VaultTreeDragAndDropController } from "./tree-drag-drop.js";
8992
import { WikilinkDocumentLinkProvider } from "./wikilink-provider.js";
9093
import {
9194
addVaultWorkspaceFolder,
95+
clearManagedExcludes,
9296
excludeVaultFromGitDetection,
9397
hasVaultWorkspaceFolder,
9498
includeVaultInGitDetection,
9599
removeVaultWorkspaceFolders,
100+
syncFilesExclude,
96101
} from "./workspace-folder.js";
97102

98103
const mockBootstrap = vi.mocked(bootstrapFromConfig);
@@ -102,6 +107,8 @@ const mockRemoveWF = vi.mocked(removeVaultWorkspaceFolders);
102107
const mockHasWF = vi.mocked(hasVaultWorkspaceFolder);
103108
const mockExcludeGit = vi.mocked(excludeVaultFromGitDetection);
104109
const mockIncludeGit = vi.mocked(includeVaultInGitDetection);
110+
const mockSyncExclude = vi.mocked(syncFilesExclude);
111+
const mockClearExclude = vi.mocked(clearManagedExcludes);
105112

106113
describe("activate", () => {
107114
beforeEach(() => {
@@ -192,7 +199,7 @@ describe("activate", () => {
192199
);
193200
});
194201

195-
it("adds workspace folder when workspace is true", async () => {
202+
it("adds workspace folder and syncs excludes when workspace is true with autoMount", async () => {
196203
const fakeTracker = {
197204
context: {
198205
name: "MyVault",
@@ -214,11 +221,15 @@ describe("activate", () => {
214221
statusBar: true,
215222
workspace: true,
216223
});
224+
mockSyncExclude.mockResolvedValueOnce([".obsidian", ".trash"]);
217225

218-
await activate(fakeContext() as never);
226+
const ctx = fakeContext();
227+
await activate(ctx as never);
219228

220-
expect(mockAddWF).toHaveBeenCalledWith("/vault", "MyVault", 1);
229+
expect(mockAddWF).toHaveBeenCalledWith("/vault", "MyVault");
221230
expect(mockExcludeGit).toHaveBeenCalledWith("/vault");
231+
expect(mockSyncExclude).toHaveBeenCalledWith("/vault", ["Notes"], [], []);
232+
expect(ctx.workspaceState.get("managedFilesExclude")).toEqual([".obsidian", ".trash"]);
222233
});
223234

224235
it("skips workspace folder when workspace is false", async () => {
@@ -247,6 +258,36 @@ describe("activate", () => {
247258
await activate(fakeContext() as never);
248259

249260
expect(mockAddWF).not.toHaveBeenCalled();
261+
expect(mockSyncExclude).not.toHaveBeenCalled();
262+
});
263+
264+
it("skips workspace folder when autoMount is empty", async () => {
265+
const fakeTracker = {
266+
context: {
267+
name: "MyVault",
268+
physicalPath: "/vault",
269+
mode: "full",
270+
vfsConfig: { agents: [], skills: [], allowed: [], blocked: [] },
271+
},
272+
} as unknown as LocalIndexTracker;
273+
274+
mockBootstrap.mockResolvedValueOnce({
275+
ok: true,
276+
value: { tracker: fakeTracker, initMs: 42 },
277+
});
278+
mockReadConfig.mockReturnValueOnce({
279+
cliPath: "obsidian",
280+
timeoutMs: 10_000,
281+
autoMount: [],
282+
explorer: true,
283+
statusBar: true,
284+
workspace: true,
285+
});
286+
287+
await activate(fakeContext() as never);
288+
289+
expect(mockAddWF).not.toHaveBeenCalled();
290+
expect(mockSyncExclude).not.toHaveBeenCalled();
250291
});
251292

252293
it("logs workspace folder result when workspace is true and added", async () => {
@@ -540,7 +581,7 @@ describe("configuration change listener", () => {
540581
workspace: false,
541582
});
542583

543-
configChangeListener!({ affectsConfiguration: (key) => key === "obsidianVFS.explorer" });
584+
configChangeListener!({ affectsConfiguration: (key) => key === CONFIG_KEY.explorer });
544585

545586
const treeProviderInstance = vi.mocked(VaultTreeDataProvider).mock.results[0].value as {
546587
enabled: boolean;
@@ -582,7 +623,7 @@ describe("configuration change listener", () => {
582623
workspace: false,
583624
});
584625

585-
configChangeListener!({ affectsConfiguration: (key) => key === "obsidianVFS.explorer" });
626+
configChangeListener!({ affectsConfiguration: (key) => key === CONFIG_KEY.explorer });
586627

587628
const treeProviderInstance = vi.mocked(VaultTreeDataProvider).mock.results[0].value as {
588629
enabled: boolean;
@@ -624,7 +665,7 @@ describe("configuration change listener", () => {
624665
workspace: false,
625666
});
626667

627-
configChangeListener!({ affectsConfiguration: (key) => key === "obsidianVFS.statusBar" });
668+
configChangeListener!({ affectsConfiguration: (key) => key === CONFIG_KEY.statusBar });
628669

629670
const statusBarInstance = vi.mocked(StatusBarManager).mock.results[0].value as {
630671
show: ReturnType<typeof vi.fn>;
@@ -667,7 +708,7 @@ describe("configuration change listener", () => {
667708
workspace: false,
668709
});
669710

670-
configChangeListener!({ affectsConfiguration: (key) => key === "obsidianVFS.statusBar" });
711+
configChangeListener!({ affectsConfiguration: (key) => key === CONFIG_KEY.statusBar });
671712

672713
const statusBarInstance = vi.mocked(StatusBarManager).mock.results[0].value as {
673714
show: ReturnType<typeof vi.fn>;
@@ -676,7 +717,7 @@ describe("configuration change listener", () => {
676717
expect(statusBarInstance.hide).toHaveBeenCalled();
677718
});
678719

679-
it("adds workspace folder when workspace config changes to true", async () => {
720+
it("adds workspace folder and syncs excludes when workspace config changes to true", async () => {
680721
const fakeTracker = {
681722
context: {
682723
name: "MyVault",
@@ -712,15 +753,18 @@ describe("configuration change listener", () => {
712753
workspace: true,
713754
});
714755
mockAddWF.mockReturnValueOnce({ status: "added" });
756+
mockSyncExclude.mockResolvedValueOnce([".obsidian"]);
715757

716-
configChangeListener!({ affectsConfiguration: (key) => key === "obsidianVFS.workspace" });
758+
configChangeListener!({ affectsConfiguration: (key) => key === CONFIG_KEY.workspace });
759+
await new Promise((r) => setTimeout(r, 0));
717760

718761
expect(mockRemoveWF).toHaveBeenCalledWith("/vault");
719-
expect(mockAddWF).toHaveBeenCalledWith("/vault", "MyVault", 1);
762+
expect(mockAddWF).toHaveBeenCalledWith("/vault", "MyVault");
720763
expect(mockExcludeGit).toHaveBeenCalledWith("/vault");
764+
expect(mockSyncExclude).toHaveBeenCalledWith("/vault", ["Notes"], [], []);
721765
});
722766

723-
it("removes workspace folders when workspace config changes to false", async () => {
767+
it("clears excludes and removes workspace folders when workspace config changes to false", async () => {
724768
const fakeTracker = {
725769
context: {
726770
name: "MyVault",
@@ -756,14 +800,16 @@ describe("configuration change listener", () => {
756800
workspace: false,
757801
});
758802

759-
configChangeListener!({ affectsConfiguration: (key) => key === "obsidianVFS.workspace" });
803+
configChangeListener!({ affectsConfiguration: (key) => key === CONFIG_KEY.workspace });
804+
await new Promise((r) => setTimeout(r, 0));
760805

761806
expect(mockRemoveWF).toHaveBeenCalledWith("/vault");
762807
expect(mockAddWF).not.toHaveBeenCalled();
808+
expect(mockClearExclude).toHaveBeenCalledWith([]);
763809
expect(mockIncludeGit).toHaveBeenCalledWith("/vault");
764810
});
765811

766-
it("updates provider without removing workspace folder when autoMount changes", async () => {
812+
it("syncs excludes when autoMount changes without workspace toggle", async () => {
767813
const fakeTracker = {
768814
context: {
769815
name: "MyVault",
@@ -803,12 +849,15 @@ describe("configuration change listener", () => {
803849
statusBar: true,
804850
workspace: true,
805851
});
852+
mockSyncExclude.mockResolvedValueOnce([".obsidian"]);
806853

807-
configChangeListener!({ affectsConfiguration: (key) => key === "obsidianVFS.autoMount" });
854+
configChangeListener!({ affectsConfiguration: (key) => key === CONFIG_KEY.autoMount });
855+
await new Promise((r) => setTimeout(r, 0));
808856

809857
expect(providerInstance.setAutoMount).toHaveBeenCalledWith(["Notes", "Projects"]);
810858
expect(mockRemoveWF).not.toHaveBeenCalled();
811859
expect(mockAddWF).not.toHaveBeenCalled();
860+
expect(mockSyncExclude).toHaveBeenCalledWith("/vault", ["Notes", "Projects"], [], []);
812861
});
813862

814863
it("adds workspace folder when autoMount goes from empty to non-empty", async () => {
@@ -840,6 +889,7 @@ describe("configuration change listener", () => {
840889

841890
mockHasWF.mockReturnValueOnce(false);
842891
mockAddWF.mockReturnValueOnce({ status: "added" });
892+
mockSyncExclude.mockResolvedValueOnce([".obsidian"]);
843893
mockReadConfig.mockReturnValueOnce({
844894
cliPath: "obsidian",
845895
timeoutMs: 10_000,
@@ -849,14 +899,16 @@ describe("configuration change listener", () => {
849899
workspace: true,
850900
});
851901

852-
configChangeListener!({ affectsConfiguration: (key) => key === "obsidianVFS.autoMount" });
902+
configChangeListener!({ affectsConfiguration: (key) => key === CONFIG_KEY.autoMount });
903+
await new Promise((r) => setTimeout(r, 0));
853904

854-
expect(mockAddWF).toHaveBeenCalledWith("/vault", "MyVault", 1);
905+
expect(mockAddWF).toHaveBeenCalledWith("/vault", "MyVault");
855906
expect(mockExcludeGit).toHaveBeenCalledWith("/vault");
907+
expect(mockSyncExclude).toHaveBeenCalledWith("/vault", ["Notes"], [], []);
856908
expect(mockRemoveWF).not.toHaveBeenCalled();
857909
});
858910

859-
it("removes workspace folder when autoMount becomes empty", async () => {
911+
it("removes workspace folder and clears excludes when autoMount becomes empty", async () => {
860912
const fakeTracker = {
861913
context: {
862914
name: "MyVault",
@@ -892,9 +944,11 @@ describe("configuration change listener", () => {
892944
workspace: true,
893945
});
894946

895-
configChangeListener!({ affectsConfiguration: (key) => key === "obsidianVFS.autoMount" });
947+
configChangeListener!({ affectsConfiguration: (key) => key === CONFIG_KEY.autoMount });
948+
await new Promise((r) => setTimeout(r, 0));
896949

897950
expect(mockRemoveWF).toHaveBeenCalledWith("/vault");
951+
expect(mockClearExclude).toHaveBeenCalledWith([]);
898952
expect(mockAddWF).not.toHaveBeenCalled();
899953
});
900954
});

0 commit comments

Comments
 (0)