Skip to content

Commit e5f3cff

Browse files
backnotpropclaude
andauthored
feat: Obsidian vault browser sidebar tab (#209)
* feat: add Obsidian vault browser sidebar tab (#207) Add a third sidebar tab that lets users browse their Obsidian vault's file tree, click any markdown file to open and annotate it as part of plan feedback. Includes wikilink support for navigating between vault files. - New /api/reference/obsidian/files and /api/reference/obsidian/doc endpoints - useVaultBrowser hook and VaultBrowser sidebar component - Wikilink [[filename]] and [[filename|display]] detection in Viewer - "Vault Browser" toggle in Settings > Obsidian - Bare filename search for wikilink resolution within vault Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: update docs and clear vault state on disable - Add new reference endpoints to CLAUDE.md API table - Update stale doc comments referencing "two tabs" in sidebar files - Clear vaultPath and activeFile when vault browser is disabled Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: simplify vault browser prop passing and derived state - Move VaultNode to packages/ui/types.ts (shared type location) - Pass useVaultBrowser return as single object prop through SidebarContainer - Replace showVaultTab/vaultPath useState with useMemo derivations - Extract buildVaultDocUrl helper to deduplicate URL template - Wrap onVaultFetchTree in useCallback - Fix toggle switch size to match existing toggles (h-6 w-11) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c7ae789 commit e5f3cff

13 files changed

Lines changed: 606 additions & 16 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,8 @@ Send Annotations → feedback sent to agent session
156156
| `/api/image` | GET | Serve image by path query param |
157157
| `/api/upload` | POST | Upload image, returns `{ path, originalName }` |
158158
| `/api/obsidian/vaults`| GET | Detect available Obsidian vaults |
159+
| `/api/reference/obsidian/files` | GET | List vault markdown files as nested tree (`?vaultPath=<path>`) |
160+
| `/api/reference/obsidian/doc` | GET | Read a vault markdown file (`?vaultPath=<path>&path=<file>`) |
159161
| `/api/plan/vscode-diff` | POST | Open diff in VS Code (body: baseVersion) |
160162
| `/api/doc` | GET | Serve linked .md/.mdx file (`?path=<path>`) |
161163

packages/editor/App.tsx

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ import { deriveImageName } from '@plannotator/ui/components/AttachmentsButton';
4141
import { useSidebar } from '@plannotator/ui/hooks/useSidebar';
4242
import { usePlanDiff, type VersionInfo } from '@plannotator/ui/hooks/usePlanDiff';
4343
import { useLinkedDoc } from '@plannotator/ui/hooks/useLinkedDoc';
44+
import { useVaultBrowser } from '@plannotator/ui/hooks/useVaultBrowser';
45+
import { isVaultBrowserEnabled } from '@plannotator/ui/utils/obsidian';
4446
import { SidebarTabs } from '@plannotator/ui/components/sidebar/SidebarTabs';
4547
import { SidebarContainer } from '@plannotator/ui/components/sidebar/SidebarContainer';
4648
import { PlanDiffViewer } from '@plannotator/ui/components/plan-diff/PlanDiffViewer';
@@ -435,6 +437,59 @@ const App: React.FC = () => {
435437
viewerRef, sidebar,
436438
});
437439

440+
// Obsidian vault browser
441+
const vaultBrowser = useVaultBrowser();
442+
443+
const showVaultTab = useMemo(() => isVaultBrowserEnabled(), [uiPrefs]);
444+
const vaultPath = useMemo(() => {
445+
if (!showVaultTab) return '';
446+
const settings = getObsidianSettings();
447+
return getEffectiveVaultPath(settings);
448+
}, [showVaultTab, uiPrefs]);
449+
450+
// Clear active file when vault browser is disabled
451+
useEffect(() => {
452+
if (!showVaultTab) vaultBrowser.setActiveFile(null);
453+
}, [showVaultTab]);
454+
455+
// Auto-fetch vault tree when vault tab is first opened
456+
useEffect(() => {
457+
if (sidebar.activeTab === 'vault' && showVaultTab && vaultPath && vaultBrowser.tree.length === 0 && !vaultBrowser.isLoading) {
458+
vaultBrowser.fetchTree(vaultPath);
459+
}
460+
}, [sidebar.activeTab, showVaultTab, vaultPath]);
461+
462+
const buildVaultDocUrl = React.useCallback(
463+
(vp: string) => (path: string) =>
464+
`/api/reference/obsidian/doc?vaultPath=${encodeURIComponent(vp)}&path=${encodeURIComponent(path)}`,
465+
[]
466+
);
467+
468+
// Vault file selection: open via linked doc system with vault endpoint
469+
const handleVaultFileSelect = React.useCallback((relativePath: string) => {
470+
linkedDocHook.open(relativePath, buildVaultDocUrl(vaultPath));
471+
vaultBrowser.setActiveFile(relativePath);
472+
}, [vaultPath, linkedDocHook, vaultBrowser, buildVaultDocUrl]);
473+
474+
// Route linked doc opens through vault endpoint when viewing a vault file
475+
const handleOpenLinkedDoc = React.useCallback((docPath: string) => {
476+
if (vaultBrowser.activeFile && vaultPath) {
477+
linkedDocHook.open(docPath, buildVaultDocUrl(vaultPath));
478+
} else {
479+
linkedDocHook.open(docPath);
480+
}
481+
}, [vaultBrowser.activeFile, vaultPath, linkedDocHook, buildVaultDocUrl]);
482+
483+
// Wrap linked doc back to also clear vault active file
484+
const handleLinkedDocBack = React.useCallback(() => {
485+
linkedDocHook.back();
486+
vaultBrowser.setActiveFile(null);
487+
}, [linkedDocHook, vaultBrowser]);
488+
489+
const handleVaultFetchTree = React.useCallback(() => {
490+
vaultBrowser.fetchTree(vaultPath);
491+
}, [vaultBrowser, vaultPath]);
492+
438493
// Track active section for TOC highlighting
439494
const headingCount = useMemo(() => blocks.filter(b => b.type === 'heading').length, [blocks]);
440495
const activeSection = useActiveSection(containerRef, headingCount);
@@ -1199,6 +1254,7 @@ const App: React.FC = () => {
11991254
activeTab={sidebar.activeTab}
12001255
onToggleTab={sidebar.toggleTab}
12011256
hasDiff={planDiff.hasPreviousVersion}
1257+
showVaultTab={showVaultTab}
12021258
className="hidden lg:flex"
12031259
/>
12041260
)}
@@ -1216,7 +1272,12 @@ const App: React.FC = () => {
12161272
activeSection={activeSection}
12171273
onTocNavigate={handleTocNavigate}
12181274
linkedDocFilepath={linkedDocHook.filepath}
1219-
onLinkedDocBack={linkedDocHook.isActive ? linkedDocHook.back : undefined}
1275+
onLinkedDocBack={linkedDocHook.isActive ? handleLinkedDocBack : undefined}
1276+
showVaultTab={showVaultTab}
1277+
vaultPath={vaultPath}
1278+
vaultBrowser={vaultBrowser}
1279+
onVaultSelectFile={handleVaultFileSelect}
1280+
onVaultFetchTree={handleVaultFetchTree}
12201281
versionInfo={versionInfo}
12211282
versions={planDiff.versions}
12221283
projectPlans={planDiff.projectPlans}
@@ -1280,8 +1341,8 @@ const App: React.FC = () => {
12801341
onPlanDiffToggle={() => setIsPlanDiffActive(!isPlanDiffActive)}
12811342
hasPreviousVersion={!linkedDocHook.isActive && planDiff.hasPreviousVersion}
12821343
showDemoBadge={!isApiMode && !isLoadingShared && !isSharedSession}
1283-
onOpenLinkedDoc={linkedDocHook.open}
1284-
linkedDocInfo={linkedDocHook.isActive ? { filepath: linkedDocHook.filepath!, onBack: linkedDocHook.back } : null}
1344+
onOpenLinkedDoc={handleOpenLinkedDoc}
1345+
linkedDocInfo={linkedDocHook.isActive ? { filepath: linkedDocHook.filepath!, onBack: handleLinkedDocBack, label: vaultBrowser.activeFile ? 'Vault File' : undefined } : null}
12851346
/>
12861347
)}
12871348
</div>

packages/server/index.ts

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* PLANNOTATOR_ORIGIN - Origin identifier ("claude-code" or "opencode")
1010
*/
1111

12-
import { mkdirSync } from "fs";
12+
import { mkdirSync, existsSync, statSync } from "fs";
1313
import { resolve } from "path";
1414
import { isRemoteSession, getServerPort } from "./remote";
1515
import { openBrowser } from "./browser";
@@ -350,6 +350,84 @@ export async function startPlannotatorServer(
350350
return Response.json({ vaults });
351351
}
352352

353+
// API: List Obsidian vault files as a tree
354+
if (url.pathname === "/api/reference/obsidian/files" && req.method === "GET") {
355+
const vaultPath = url.searchParams.get("vaultPath");
356+
if (!vaultPath) {
357+
return Response.json({ error: "Missing vaultPath parameter" }, { status: 400 });
358+
}
359+
360+
const resolvedVault = resolve(vaultPath);
361+
if (!existsSync(resolvedVault) || !statSync(resolvedVault).isDirectory()) {
362+
return Response.json({ error: "Invalid vault path" }, { status: 400 });
363+
}
364+
365+
try {
366+
const glob = new Bun.Glob("**/*.md");
367+
const files: string[] = [];
368+
for await (const match of glob.scan({ cwd: resolvedVault, onlyFiles: true })) {
369+
if (match.includes(".obsidian/") || match.includes(".trash/")) continue;
370+
files.push(match);
371+
}
372+
files.sort();
373+
374+
const tree = buildFileTree(files);
375+
return Response.json({ tree });
376+
} catch {
377+
return Response.json({ error: "Failed to list vault files" }, { status: 500 });
378+
}
379+
}
380+
381+
// API: Read an Obsidian vault document
382+
if (url.pathname === "/api/reference/obsidian/doc" && req.method === "GET") {
383+
const vaultPath = url.searchParams.get("vaultPath");
384+
const filePath = url.searchParams.get("path");
385+
if (!vaultPath || !filePath) {
386+
return Response.json({ error: "Missing vaultPath or path parameter" }, { status: 400 });
387+
}
388+
if (!/\.mdx?$/i.test(filePath)) {
389+
return Response.json({ error: "Only markdown files are supported" }, { status: 400 });
390+
}
391+
392+
const resolvedVault = resolve(vaultPath);
393+
let resolvedFile = resolve(resolvedVault, filePath);
394+
395+
// If direct path doesn't exist and it's a bare filename, search the vault
396+
if (!existsSync(resolvedFile) && !filePath.includes("/")) {
397+
const glob = new Bun.Glob(`**/${filePath}`);
398+
const matches: string[] = [];
399+
for await (const match of glob.scan({ cwd: resolvedVault, onlyFiles: true })) {
400+
if (match.includes(".obsidian/") || match.includes(".trash/")) continue;
401+
matches.push(resolve(resolvedVault, match));
402+
}
403+
if (matches.length === 1) {
404+
resolvedFile = matches[0];
405+
} else if (matches.length > 1) {
406+
const relativePaths = matches.map((m) => m.replace(resolvedVault + "/", ""));
407+
return Response.json(
408+
{ error: `Ambiguous filename '${filePath}': found ${matches.length} matches`, matches: relativePaths },
409+
{ status: 400 }
410+
);
411+
}
412+
}
413+
414+
// Security: must be within vault
415+
if (!resolvedFile.startsWith(resolvedVault + "/")) {
416+
return Response.json({ error: "Access denied: path is outside vault" }, { status: 403 });
417+
}
418+
419+
try {
420+
const file = Bun.file(resolvedFile);
421+
if (!(await file.exists())) {
422+
return Response.json({ error: `File not found: ${filePath}` }, { status: 404 });
423+
}
424+
const markdown = await file.text();
425+
return Response.json({ markdown, filepath: resolvedFile });
426+
} catch {
427+
return Response.json({ error: "Failed to read file" }, { status: 500 });
428+
}
429+
}
430+
353431
// API: Get available agents (OpenCode only)
354432
if (url.pathname === "/api/agents") {
355433
if (!options.opencodeClient) {
@@ -572,3 +650,56 @@ export async function handleServerReady(
572650
await openBrowser(url);
573651
}
574652
}
653+
654+
// --- Vault file tree helpers ---
655+
656+
export interface VaultNode {
657+
name: string;
658+
path: string; // relative path within vault
659+
type: "file" | "folder";
660+
children?: VaultNode[];
661+
}
662+
663+
/**
664+
* Build a nested file tree from a sorted list of relative paths.
665+
* Folders are sorted before files at each level.
666+
*/
667+
function buildFileTree(relativePaths: string[]): VaultNode[] {
668+
const root: VaultNode[] = [];
669+
670+
for (const filePath of relativePaths) {
671+
const parts = filePath.split("/");
672+
let current = root;
673+
let pathSoFar = "";
674+
675+
for (let i = 0; i < parts.length; i++) {
676+
const part = parts[i];
677+
pathSoFar = pathSoFar ? `${pathSoFar}/${part}` : part;
678+
const isFile = i === parts.length - 1;
679+
680+
let node = current.find((n) => n.name === part && n.type === (isFile ? "file" : "folder"));
681+
if (!node) {
682+
node = { name: part, path: pathSoFar, type: isFile ? "file" : "folder" };
683+
if (!isFile) node.children = [];
684+
current.push(node);
685+
}
686+
if (!isFile) {
687+
current = node.children!;
688+
}
689+
}
690+
}
691+
692+
// Sort: folders first (alphabetical), then files (alphabetical)
693+
const sortNodes = (nodes: VaultNode[]) => {
694+
nodes.sort((a, b) => {
695+
if (a.type !== b.type) return a.type === "folder" ? -1 : 1;
696+
return a.name.localeCompare(b.name);
697+
});
698+
for (const node of nodes) {
699+
if (node.children) sortNodes(node.children);
700+
}
701+
};
702+
sortNodes(root);
703+
704+
return root;
705+
}

packages/ui/components/Settings.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,29 @@ tags: [plan, ...]
678678
---`}
679679
</pre>
680680
</div>
681+
682+
<div className="border-t border-border/30 pt-3">
683+
<div className="flex items-center justify-between">
684+
<div>
685+
<div className="text-xs font-medium">Vault Browser</div>
686+
<div className="text-[10px] text-muted-foreground">
687+
Browse and annotate vault files from the sidebar
688+
</div>
689+
</div>
690+
<button
691+
role="switch"
692+
aria-checked={obsidian.vaultBrowserEnabled}
693+
onClick={() => handleObsidianChange({ vaultBrowserEnabled: !obsidian.vaultBrowserEnabled })}
694+
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${
695+
obsidian.vaultBrowserEnabled ? 'bg-primary' : 'bg-muted'
696+
}`}
697+
>
698+
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
699+
obsidian.vaultBrowserEnabled ? 'translate-x-6' : 'translate-x-1'
700+
}`} />
701+
</button>
702+
</div>
703+
</div>
681704
</div>
682705
)}
683706
</div>

packages/ui/components/Viewer.tsx

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ interface ViewerProps {
2727
repoInfo?: { display: string; branch?: string } | null;
2828
stickyActions?: boolean;
2929
onOpenLinkedDoc?: (path: string) => void;
30-
linkedDocInfo?: { filepath: string; onBack: () => void } | null;
30+
linkedDocInfo?: { filepath: string; onBack: () => void; label?: string } | null;
3131
// Plan diff props
3232
planDiffStats?: { additions: number; deletions: number; modifications: number } | null;
3333
isPlanDiffActive?: boolean;
@@ -694,7 +694,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
694694
plan
695695
</button>
696696
<span className="px-1.5 py-0.5 bg-primary/10 text-primary/80 rounded">
697-
Linked File
697+
{linkedDocInfo.label || 'Linked File'}
698698
</span>
699699
<span
700700
className="px-1.5 py-0.5 bg-muted/50 text-muted-foreground rounded truncate max-w-[200px]"
@@ -933,6 +933,40 @@ const InlineMarkdown: React.FC<{ text: string; onOpenLinkedDoc?: (path: string)
933933
continue;
934934
}
935935

936+
// Wikilinks: [[filename]] or [[filename|display text]]
937+
match = remaining.match(/^\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/);
938+
if (match) {
939+
const target = match[1].trim();
940+
const display = match[2]?.trim() || target;
941+
const targetPath = /\.mdx?$/i.test(target) ? target : `${target}.md`;
942+
943+
if (onOpenLinkedDoc) {
944+
parts.push(
945+
<a
946+
key={key++}
947+
href={targetPath}
948+
onClick={(e) => {
949+
e.preventDefault();
950+
onOpenLinkedDoc(targetPath);
951+
}}
952+
className="text-primary underline underline-offset-2 hover:text-primary/80 inline-flex items-center gap-1 cursor-pointer"
953+
title={`Open: ${target}`}
954+
>
955+
{display}
956+
<svg className="w-3 h-3 opacity-50 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
957+
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
958+
</svg>
959+
</a>
960+
);
961+
} else {
962+
parts.push(
963+
<span key={key++} className="text-primary">{display}</span>
964+
);
965+
}
966+
remaining = remaining.slice(match[0].length);
967+
continue;
968+
}
969+
936970
// Links: [text](url)
937971
match = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
938972
if (match) {

0 commit comments

Comments
 (0)