Skip to content

Commit 5e63cc7

Browse files
committed
feat(vscode): add "Copy Path" command for obs:// URIs
New `obsidianVFS.copyPath` command copies the active file's canonical `obs://` URI to the clipboard. Keybinding (Shift+Alt+Cmd+C) overrides the default "Copy Relative Path" for obs:// files. Assisted-by: Claude
1 parent 4383c78 commit 5e63cc7

5 files changed

Lines changed: 129 additions & 3 deletions

File tree

packages/vscode/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Browse, search, and edit your [Obsidian](https://obsidian.md) vault directly in
1515
- **Wikilink navigation**, click `[[links]]` in Markdown to jump between notes (resolves to `file://` paths for seamless navigation in workspace folders)
1616
- **Search notes** via Quick Pick across all vault Markdown files
1717
- **Open in Obsidian**, jump to the current file in the Obsidian app
18+
- **Copy path** as `obs://` URI to the clipboard (`Shift+Alt+Cmd+C` on `obs://` files)
1819
- **Auto-mount** configured folders on startup
1920
- **Status bar** showing vault name and connection mode (`full` / `degraded`)
2021
- **Workspace folder**, vault browsable in Explorer with Quick Open (`Cmd+P`) and Search (`Ctrl+Shift+F`) support
@@ -30,6 +31,7 @@ Available via the Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`):
3031
| `Obsidian VFS: Unmount Folder` | Remove a mounted vault folder from the tree view |
3132
| `Obsidian VFS: Open in Obsidian` | Open the active vault file in the Obsidian app (works from both `obs://` and `file://` documents) |
3233
| `Obsidian VFS: Search Notes` | Quick Pick search across all vault Markdown files |
34+
| `Obsidian VFS: Copy Path` | Copy the active file's `obs://` URI to the clipboard (`Shift+Alt+Cmd+C` on `obs://` files) |
3335

3436
## Settings
3537

packages/vscode/package.json

Lines changed: 12 additions & 1 deletion
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.2",
5+
"version": "0.1.3",
66
"private": true,
77
"publisher": "otaviof",
88
"engines": {
@@ -47,6 +47,17 @@
4747
{
4848
"command": "obsidianVFS.searchNotes",
4949
"title": "Obsidian VFS: Search Notes"
50+
},
51+
{
52+
"command": "obsidianVFS.copyPath",
53+
"title": "Obsidian VFS: Copy Path"
54+
}
55+
],
56+
"keybindings": [
57+
{
58+
"command": "obsidianVFS.copyPath",
59+
"key": "shift+alt+cmd+c",
60+
"when": "resourceScheme == obs"
5061
}
5162
],
5263
"views": {

packages/vscode/src/commands.test.ts

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

50-
it("registers four commands", () => {
50+
it("registers five 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(4);
57+
expect(vscode.commands.registerCommand).toHaveBeenCalledTimes(5);
5858
expect(vscode.commands.registerCommand).toHaveBeenCalledWith(
5959
"obsidianVFS.mount",
6060
expect.any(Function),
@@ -71,6 +71,10 @@ describe("registerCommands", () => {
7171
"obsidianVFS.searchNotes",
7272
expect.any(Function),
7373
);
74+
expect(vscode.commands.registerCommand).toHaveBeenCalledWith(
75+
"obsidianVFS.copyPath",
76+
expect.any(Function),
77+
);
7478
});
7579
});
7680

@@ -595,3 +599,88 @@ describe("searchNotes command", () => {
595599
expect(vscode.window.showQuickPick).not.toHaveBeenCalled();
596600
});
597601
});
602+
603+
describe("copyPath command", () => {
604+
beforeEach(() => {
605+
vi.clearAllMocks();
606+
});
607+
608+
it("copies obs:// URI when active editor has obs:// scheme", async () => {
609+
const tracker = mockTracker();
610+
611+
Object.defineProperty(vscode.window, "activeTextEditor", {
612+
value: { document: { uri: { scheme: SCHEME, path: "/notes/todo.md" } } },
613+
writable: true,
614+
configurable: true,
615+
});
616+
617+
const ctx = fakeContext();
618+
const tree = fakeTreeProvider();
619+
const channel = { appendLine: vi.fn(), dispose: vi.fn() } as never;
620+
registerCommands(ctx as never, tracker, tree as never, channel);
621+
622+
const handler = vi
623+
.mocked(vscode.commands.registerCommand)
624+
.mock.calls.find((c) => c[0] === "obsidianVFS.copyPath")![1] as () => Promise<void>;
625+
await handler();
626+
627+
// eslint-disable-next-line @typescript-eslint/unbound-method
628+
expect(vscode.env.clipboard.writeText).toHaveBeenCalledWith("obs://TestVault/notes/todo.md");
629+
630+
Object.defineProperty(vscode.window, "activeTextEditor", {
631+
value: undefined,
632+
writable: true,
633+
configurable: true,
634+
});
635+
});
636+
637+
it("copies obs:// URI when active editor has file:// scheme under vault", async () => {
638+
const tracker = mockTracker();
639+
640+
Object.defineProperty(vscode.window, "activeTextEditor", {
641+
value: { document: { uri: { scheme: "file", fsPath: "/vault/notes/todo.md" } } },
642+
writable: true,
643+
configurable: true,
644+
});
645+
646+
const ctx = fakeContext();
647+
const tree = fakeTreeProvider();
648+
const channel = { appendLine: vi.fn(), dispose: vi.fn() } as never;
649+
registerCommands(ctx as never, tracker, tree as never, channel);
650+
651+
const handler = vi
652+
.mocked(vscode.commands.registerCommand)
653+
.mock.calls.find((c) => c[0] === "obsidianVFS.copyPath")![1] as () => Promise<void>;
654+
await handler();
655+
656+
// eslint-disable-next-line @typescript-eslint/unbound-method
657+
expect(vscode.env.clipboard.writeText).toHaveBeenCalledWith("obs://TestVault/notes/todo.md");
658+
659+
Object.defineProperty(vscode.window, "activeTextEditor", {
660+
value: undefined,
661+
writable: true,
662+
configurable: true,
663+
});
664+
});
665+
666+
it("shows info message when no vault file is active", async () => {
667+
Object.defineProperty(vscode.window, "activeTextEditor", {
668+
value: undefined,
669+
writable: true,
670+
configurable: true,
671+
});
672+
673+
const tracker = mockTracker();
674+
const ctx = fakeContext();
675+
const tree = fakeTreeProvider();
676+
const channel = { appendLine: vi.fn(), dispose: vi.fn() } as never;
677+
registerCommands(ctx as never, tracker, tree as never, channel);
678+
679+
const handler = vi
680+
.mocked(vscode.commands.registerCommand)
681+
.mock.calls.find((c) => c[0] === "obsidianVFS.copyPath")![1] as () => Promise<void>;
682+
await handler();
683+
684+
expect(vscode.window.showInformationMessage).toHaveBeenCalledWith("No Obsidian VFS file active");
685+
});
686+
});

packages/vscode/src/commands.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as vscode from "vscode";
22
import type { LocalIndexTracker } from "@obsidian-vfs/core";
3+
import { buildObsUri } from "@obsidian-vfs/core";
34

45
import { SCHEME, toFileUri, toVaultPath, toVaultPathFromFile } from "./uri-adapter.js";
56
import type { VaultTreeDataProvider } from "./vault-tree-provider.js";
@@ -76,6 +77,27 @@ async function openInObsidianCommand(
7677
}
7778
}
7879

80+
/** Copy the active file's `obs://` URI to the clipboard. */
81+
async function copyPathCommand(tracker: LocalIndexTracker): Promise<void> {
82+
const editor = vscode.window.activeTextEditor;
83+
if (editor?.document.uri.scheme !== SCHEME && editor?.document.uri.scheme !== "file") {
84+
await vscode.window.showInformationMessage("No Obsidian VFS file active");
85+
return;
86+
}
87+
88+
const vaultPath =
89+
editor.document.uri.scheme === SCHEME
90+
? toVaultPath(editor.document.uri)
91+
: toVaultPathFromFile(editor.document.uri, tracker.context.physicalPath);
92+
93+
const obsUri = buildObsUri({
94+
vaultName: tracker.context.name,
95+
path: vaultPath,
96+
section: undefined,
97+
});
98+
await vscode.env.clipboard.writeText(obsUri);
99+
}
100+
79101
/** Search vault notes via Quick Pick and open the selected file. */
80102
async function searchNotesCommand(tracker: LocalIndexTracker): Promise<void> {
81103
const result = await tracker.listFiles();
@@ -111,5 +133,6 @@ export function registerCommands(
111133
openInObsidianCommand(tracker, outputChannel),
112134
),
113135
vscode.commands.registerCommand("obsidianVFS.searchNotes", () => searchNotesCommand(tracker)),
136+
vscode.commands.registerCommand("obsidianVFS.copyPath", () => copyPathCommand(tracker)),
114137
);
115138
}

packages/vscode/src/test-mocks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export function createVscodeMock(
9696
mock.Uri = createMockUri();
9797
}
9898
if (parts.window) {
99+
mock.env = { clipboard: { writeText: vi.fn() } };
99100
const outputChannel = { appendLine: vi.fn(), dispose: vi.fn() };
100101
mock.window = {
101102
createOutputChannel: vi.fn(() => outputChannel),

0 commit comments

Comments
 (0)