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>
This commit is contained in:
Michael Ramos
2026-03-03 15:32:59 -08:00
committed by GitHub
parent c7ae789a55
commit e5f3cfffbb
13 changed files with 606 additions and 16 deletions
+2
View File
@@ -156,6 +156,8 @@ Send Annotations → feedback sent to agent session
| `/api/image` | GET | Serve image by path query param |
| `/api/upload` | POST | Upload image, returns `{ path, originalName }` |
| `/api/obsidian/vaults`| GET | Detect available Obsidian vaults |
| `/api/reference/obsidian/files` | GET | List vault markdown files as nested tree (`?vaultPath=<path>`) |
| `/api/reference/obsidian/doc` | GET | Read a vault markdown file (`?vaultPath=<path>&path=<file>`) |
| `/api/plan/vscode-diff` | POST | Open diff in VS Code (body: baseVersion) |
| `/api/doc` | GET | Serve linked .md/.mdx file (`?path=<path>`) |
+64 -3
View File
@@ -41,6 +41,8 @@ import { deriveImageName } from '@plannotator/ui/components/AttachmentsButton';
import { useSidebar } from '@plannotator/ui/hooks/useSidebar';
import { usePlanDiff, type VersionInfo } from '@plannotator/ui/hooks/usePlanDiff';
import { useLinkedDoc } from '@plannotator/ui/hooks/useLinkedDoc';
import { useVaultBrowser } from '@plannotator/ui/hooks/useVaultBrowser';
import { isVaultBrowserEnabled } from '@plannotator/ui/utils/obsidian';
import { SidebarTabs } from '@plannotator/ui/components/sidebar/SidebarTabs';
import { SidebarContainer } from '@plannotator/ui/components/sidebar/SidebarContainer';
import { PlanDiffViewer } from '@plannotator/ui/components/plan-diff/PlanDiffViewer';
@@ -435,6 +437,59 @@ const App: React.FC = () => {
viewerRef, sidebar,
});
// Obsidian vault browser
const vaultBrowser = useVaultBrowser();
const showVaultTab = useMemo(() => isVaultBrowserEnabled(), [uiPrefs]);
const vaultPath = useMemo(() => {
if (!showVaultTab) return '';
const settings = getObsidianSettings();
return getEffectiveVaultPath(settings);
}, [showVaultTab, uiPrefs]);
// Clear active file when vault browser is disabled
useEffect(() => {
if (!showVaultTab) vaultBrowser.setActiveFile(null);
}, [showVaultTab]);
// Auto-fetch vault tree when vault tab is first opened
useEffect(() => {
if (sidebar.activeTab === 'vault' && showVaultTab && vaultPath && vaultBrowser.tree.length === 0 && !vaultBrowser.isLoading) {
vaultBrowser.fetchTree(vaultPath);
}
}, [sidebar.activeTab, showVaultTab, vaultPath]);
const buildVaultDocUrl = React.useCallback(
(vp: string) => (path: string) =>
`/api/reference/obsidian/doc?vaultPath=${encodeURIComponent(vp)}&path=${encodeURIComponent(path)}`,
[]
);
// Vault file selection: open via linked doc system with vault endpoint
const handleVaultFileSelect = React.useCallback((relativePath: string) => {
linkedDocHook.open(relativePath, buildVaultDocUrl(vaultPath));
vaultBrowser.setActiveFile(relativePath);
}, [vaultPath, linkedDocHook, vaultBrowser, buildVaultDocUrl]);
// Route linked doc opens through vault endpoint when viewing a vault file
const handleOpenLinkedDoc = React.useCallback((docPath: string) => {
if (vaultBrowser.activeFile && vaultPath) {
linkedDocHook.open(docPath, buildVaultDocUrl(vaultPath));
} else {
linkedDocHook.open(docPath);
}
}, [vaultBrowser.activeFile, vaultPath, linkedDocHook, buildVaultDocUrl]);
// Wrap linked doc back to also clear vault active file
const handleLinkedDocBack = React.useCallback(() => {
linkedDocHook.back();
vaultBrowser.setActiveFile(null);
}, [linkedDocHook, vaultBrowser]);
const handleVaultFetchTree = React.useCallback(() => {
vaultBrowser.fetchTree(vaultPath);
}, [vaultBrowser, vaultPath]);
// Track active section for TOC highlighting
const headingCount = useMemo(() => blocks.filter(b => b.type === 'heading').length, [blocks]);
const activeSection = useActiveSection(containerRef, headingCount);
@@ -1199,6 +1254,7 @@ const App: React.FC = () => {
activeTab={sidebar.activeTab}
onToggleTab={sidebar.toggleTab}
hasDiff={planDiff.hasPreviousVersion}
showVaultTab={showVaultTab}
className="hidden lg:flex"
/>
)}
@@ -1216,7 +1272,12 @@ const App: React.FC = () => {
activeSection={activeSection}
onTocNavigate={handleTocNavigate}
linkedDocFilepath={linkedDocHook.filepath}
onLinkedDocBack={linkedDocHook.isActive ? linkedDocHook.back : undefined}
onLinkedDocBack={linkedDocHook.isActive ? handleLinkedDocBack : undefined}
showVaultTab={showVaultTab}
vaultPath={vaultPath}
vaultBrowser={vaultBrowser}
onVaultSelectFile={handleVaultFileSelect}
onVaultFetchTree={handleVaultFetchTree}
versionInfo={versionInfo}
versions={planDiff.versions}
projectPlans={planDiff.projectPlans}
@@ -1280,8 +1341,8 @@ const App: React.FC = () => {
onPlanDiffToggle={() => setIsPlanDiffActive(!isPlanDiffActive)}
hasPreviousVersion={!linkedDocHook.isActive && planDiff.hasPreviousVersion}
showDemoBadge={!isApiMode && !isLoadingShared && !isSharedSession}
onOpenLinkedDoc={linkedDocHook.open}
linkedDocInfo={linkedDocHook.isActive ? { filepath: linkedDocHook.filepath!, onBack: linkedDocHook.back } : null}
onOpenLinkedDoc={handleOpenLinkedDoc}
linkedDocInfo={linkedDocHook.isActive ? { filepath: linkedDocHook.filepath!, onBack: handleLinkedDocBack, label: vaultBrowser.activeFile ? 'Vault File' : undefined } : null}
/>
)}
</div>
+132 -1
View File
@@ -9,7 +9,7 @@
* PLANNOTATOR_ORIGIN - Origin identifier ("claude-code" or "opencode")
*/
import { mkdirSync } from "fs";
import { mkdirSync, existsSync, statSync } from "fs";
import { resolve } from "path";
import { isRemoteSession, getServerPort } from "./remote";
import { openBrowser } from "./browser";
@@ -350,6 +350,84 @@ export async function startPlannotatorServer(
return Response.json({ vaults });
}
// API: List Obsidian vault files as a tree
if (url.pathname === "/api/reference/obsidian/files" && req.method === "GET") {
const vaultPath = url.searchParams.get("vaultPath");
if (!vaultPath) {
return Response.json({ error: "Missing vaultPath parameter" }, { status: 400 });
}
const resolvedVault = resolve(vaultPath);
if (!existsSync(resolvedVault) || !statSync(resolvedVault).isDirectory()) {
return Response.json({ error: "Invalid vault path" }, { status: 400 });
}
try {
const glob = new Bun.Glob("**/*.md");
const files: string[] = [];
for await (const match of glob.scan({ cwd: resolvedVault, onlyFiles: true })) {
if (match.includes(".obsidian/") || match.includes(".trash/")) continue;
files.push(match);
}
files.sort();
const tree = buildFileTree(files);
return Response.json({ tree });
} catch {
return Response.json({ error: "Failed to list vault files" }, { status: 500 });
}
}
// API: Read an Obsidian vault document
if (url.pathname === "/api/reference/obsidian/doc" && req.method === "GET") {
const vaultPath = url.searchParams.get("vaultPath");
const filePath = url.searchParams.get("path");
if (!vaultPath || !filePath) {
return Response.json({ error: "Missing vaultPath or path parameter" }, { status: 400 });
}
if (!/\.mdx?$/i.test(filePath)) {
return Response.json({ error: "Only markdown files are supported" }, { status: 400 });
}
const resolvedVault = resolve(vaultPath);
let resolvedFile = resolve(resolvedVault, filePath);
// If direct path doesn't exist and it's a bare filename, search the vault
if (!existsSync(resolvedFile) && !filePath.includes("/")) {
const glob = new Bun.Glob(`**/${filePath}`);
const matches: string[] = [];
for await (const match of glob.scan({ cwd: resolvedVault, onlyFiles: true })) {
if (match.includes(".obsidian/") || match.includes(".trash/")) continue;
matches.push(resolve(resolvedVault, match));
}
if (matches.length === 1) {
resolvedFile = matches[0];
} else if (matches.length > 1) {
const relativePaths = matches.map((m) => m.replace(resolvedVault + "/", ""));
return Response.json(
{ error: `Ambiguous filename '${filePath}': found ${matches.length} matches`, matches: relativePaths },
{ status: 400 }
);
}
}
// Security: must be within vault
if (!resolvedFile.startsWith(resolvedVault + "/")) {
return Response.json({ error: "Access denied: path is outside vault" }, { status: 403 });
}
try {
const file = Bun.file(resolvedFile);
if (!(await file.exists())) {
return Response.json({ error: `File not found: ${filePath}` }, { status: 404 });
}
const markdown = await file.text();
return Response.json({ markdown, filepath: resolvedFile });
} catch {
return Response.json({ error: "Failed to read file" }, { status: 500 });
}
}
// API: Get available agents (OpenCode only)
if (url.pathname === "/api/agents") {
if (!options.opencodeClient) {
@@ -572,3 +650,56 @@ export async function handleServerReady(
await openBrowser(url);
}
}
// --- Vault file tree helpers ---
export interface VaultNode {
name: string;
path: string; // relative path within vault
type: "file" | "folder";
children?: VaultNode[];
}
/**
* Build a nested file tree from a sorted list of relative paths.
* Folders are sorted before files at each level.
*/
function buildFileTree(relativePaths: string[]): VaultNode[] {
const root: VaultNode[] = [];
for (const filePath of relativePaths) {
const parts = filePath.split("/");
let current = root;
let pathSoFar = "";
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
pathSoFar = pathSoFar ? `${pathSoFar}/${part}` : part;
const isFile = i === parts.length - 1;
let node = current.find((n) => n.name === part && n.type === (isFile ? "file" : "folder"));
if (!node) {
node = { name: part, path: pathSoFar, type: isFile ? "file" : "folder" };
if (!isFile) node.children = [];
current.push(node);
}
if (!isFile) {
current = node.children!;
}
}
}
// Sort: folders first (alphabetical), then files (alphabetical)
const sortNodes = (nodes: VaultNode[]) => {
nodes.sort((a, b) => {
if (a.type !== b.type) return a.type === "folder" ? -1 : 1;
return a.name.localeCompare(b.name);
});
for (const node of nodes) {
if (node.children) sortNodes(node.children);
}
};
sortNodes(root);
return root;
}
+23
View File
@@ -678,6 +678,29 @@ tags: [plan, ...]
---`}
</pre>
</div>
<div className="border-t border-border/30 pt-3">
<div className="flex items-center justify-between">
<div>
<div className="text-xs font-medium">Vault Browser</div>
<div className="text-[10px] text-muted-foreground">
Browse and annotate vault files from the sidebar
</div>
</div>
<button
role="switch"
aria-checked={obsidian.vaultBrowserEnabled}
onClick={() => handleObsidianChange({ vaultBrowserEnabled: !obsidian.vaultBrowserEnabled })}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${
obsidian.vaultBrowserEnabled ? 'bg-primary' : 'bg-muted'
}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
obsidian.vaultBrowserEnabled ? 'translate-x-6' : 'translate-x-1'
}`} />
</button>
</div>
</div>
</div>
)}
</div>
+36 -2
View File
@@ -27,7 +27,7 @@ interface ViewerProps {
repoInfo?: { display: string; branch?: string } | null;
stickyActions?: boolean;
onOpenLinkedDoc?: (path: string) => void;
linkedDocInfo?: { filepath: string; onBack: () => void } | null;
linkedDocInfo?: { filepath: string; onBack: () => void; label?: string } | null;
// Plan diff props
planDiffStats?: { additions: number; deletions: number; modifications: number } | null;
isPlanDiffActive?: boolean;
@@ -694,7 +694,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
plan
</button>
<span className="px-1.5 py-0.5 bg-primary/10 text-primary/80 rounded">
Linked File
{linkedDocInfo.label || 'Linked File'}
</span>
<span
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)
continue;
}
// Wikilinks: [[filename]] or [[filename|display text]]
match = remaining.match(/^\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/);
if (match) {
const target = match[1].trim();
const display = match[2]?.trim() || target;
const targetPath = /\.mdx?$/i.test(target) ? target : `${target}.md`;
if (onOpenLinkedDoc) {
parts.push(
<a
key={key++}
href={targetPath}
onClick={(e) => {
e.preventDefault();
onOpenLinkedDoc(targetPath);
}}
className="text-primary underline underline-offset-2 hover:text-primary/80 inline-flex items-center gap-1 cursor-pointer"
title={`Open: ${target}`}
>
{display}
<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">
<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" />
</svg>
</a>
);
} else {
parts.push(
<span key={key++} className="text-primary">{display}</span>
);
}
remaining = remaining.slice(match[0].length);
continue;
}
// Links: [text](url)
match = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
if (match) {
@@ -1,7 +1,7 @@
/**
* SidebarContainer — Shared sidebar shell
*
* Houses both the Table of Contents and Version Browser views.
* Houses the Table of Contents, Version Browser, and Vault Browser views.
* Tab bar at top switches between them.
*/
@@ -9,8 +9,10 @@ import React from "react";
import type { SidebarTab } from "../../hooks/useSidebar";
import type { Block, Annotation } from "../../types";
import type { VersionInfo, VersionEntry, ProjectPlan } from "../../hooks/usePlanDiff";
import type { UseVaultBrowserReturn } from "../../hooks/useVaultBrowser";
import { TableOfContents } from "../TableOfContents";
import { VersionBrowser } from "./VersionBrowser";
import { VaultBrowser } from "./VaultBrowser";
interface SidebarContainerProps {
activeTab: SidebarTab;
@@ -24,6 +26,12 @@ interface SidebarContainerProps {
onTocNavigate: (blockId: string) => void;
linkedDocFilepath?: string | null;
onLinkedDocBack?: () => void;
// Vault Browser props
showVaultTab?: boolean;
vaultPath?: string;
vaultBrowser?: UseVaultBrowserReturn;
onVaultSelectFile?: (relativePath: string) => void;
onVaultFetchTree?: () => void;
// Version Browser props
versionInfo: VersionInfo | null;
versions: VersionEntry[];
@@ -51,6 +59,11 @@ export const SidebarContainer: React.FC<SidebarContainerProps> = ({
onTocNavigate,
linkedDocFilepath,
onLinkedDocBack,
showVaultTab,
vaultPath,
vaultBrowser,
onVaultSelectFile,
onVaultFetchTree,
versionInfo,
versions,
projectPlans,
@@ -112,6 +125,28 @@ export const SidebarContainer: React.FC<SidebarContainerProps> = ({
}
label="Versions"
/>
{showVaultTab && (
<TabButton
active={activeTab === "vault"}
onClick={() => onTabChange("vault")}
icon={
<svg
className="w-3 h-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
/>
</svg>
}
label="Vault"
/>
)}
<div className="flex-1" />
<button
onClick={onClose}
@@ -164,6 +199,19 @@ export const SidebarContainer: React.FC<SidebarContainerProps> = ({
onFetchProjectPlans={onFetchProjectPlans}
/>
)}
{activeTab === "vault" && showVaultTab && vaultPath && vaultBrowser && (
<VaultBrowser
vaultPath={vaultPath}
tree={vaultBrowser.tree}
isLoading={vaultBrowser.isLoading}
error={vaultBrowser.error}
expandedFolders={vaultBrowser.expandedFolders}
onToggleFolder={vaultBrowser.toggleFolder}
onSelectFile={onVaultSelectFile ?? (() => {})}
activeFile={vaultBrowser.activeFile}
onFetchTree={onVaultFetchTree ?? (() => {})}
/>
)}
</div>
</aside>
);
+26 -1
View File
@@ -1,7 +1,7 @@
/**
* SidebarTabs — Collapsed tab flags
*
* When the sidebar is closed, two small vertical tabs protrude from the left edge.
* When the sidebar is closed, small vertical tabs protrude from the left edge.
* Clicking a tab opens the sidebar in that mode.
*/
@@ -12,6 +12,7 @@ interface SidebarTabsProps {
activeTab: SidebarTab;
onToggleTab: (tab: SidebarTab) => void;
hasDiff: boolean;
showVaultTab?: boolean;
className?: string;
}
@@ -19,6 +20,7 @@ export const SidebarTabs: React.FC<SidebarTabsProps> = ({
activeTab,
onToggleTab,
hasDiff,
showVaultTab,
className,
}) => {
return (
@@ -70,6 +72,29 @@ export const SidebarTabs: React.FC<SidebarTabsProps> = ({
<span className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-primary" />
)}
</button>
{/* Vault tab */}
{showVaultTab && (
<button
onClick={() => onToggleTab("vault")}
className="sidebar-tab-flag group flex items-center justify-center w-7 h-9 rounded-r-md border border-l-0 border-border/50 bg-card/80 backdrop-blur-sm text-muted-foreground hover:text-foreground hover:bg-card transition-colors"
title="Vault Browser"
>
<svg
className="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
/>
</svg>
</button>
)}
</div>
);
};
@@ -0,0 +1,159 @@
/**
* VaultBrowser — Obsidian vault file tree for the sidebar
*
* Displays a collapsible tree of markdown files from the user's Obsidian vault.
* Clicking a file opens it in the main viewer for annotation.
*/
import React from "react";
import type { VaultNode } from "../../types";
interface VaultBrowserProps {
vaultPath: string;
tree: VaultNode[];
isLoading: boolean;
error: string | null;
expandedFolders: Set<string>;
onToggleFolder: (path: string) => void;
onSelectFile: (relativePath: string) => void;
activeFile: string | null;
onFetchTree: () => void;
}
const TreeNode: React.FC<{
node: VaultNode;
depth: number;
expandedFolders: Set<string>;
onToggleFolder: (path: string) => void;
onSelectFile: (path: string) => void;
activeFile: string | null;
}> = ({ node, depth, expandedFolders, onToggleFolder, onSelectFile, activeFile }) => {
const isExpanded = expandedFolders.has(node.path);
const isActive = node.type === "file" && node.path === activeFile;
const paddingLeft = 8 + depth * 14;
if (node.type === "folder") {
return (
<>
<button
onClick={() => onToggleFolder(node.path)}
className="w-full flex items-center gap-1.5 py-1 text-[11px] text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors rounded-sm"
style={{ paddingLeft }}
>
<svg
className={`w-3 h-3 flex-shrink-0 transition-transform ${isExpanded ? "rotate-90" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
<svg className="w-3 h-3 flex-shrink-0 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span className="truncate">{node.name}</span>
</button>
{isExpanded && node.children?.map((child) => (
<TreeNode
key={child.path}
node={child}
depth={depth + 1}
expandedFolders={expandedFolders}
onToggleFolder={onToggleFolder}
onSelectFile={onSelectFile}
activeFile={activeFile}
/>
))}
</>
);
}
// File node
const displayName = node.name.replace(/\.md$/i, "");
return (
<button
onClick={() => onSelectFile(node.path)}
className={`w-full flex items-center gap-1.5 py-1 text-[11px] transition-colors rounded-sm ${
isActive
? "bg-primary/10 text-primary font-medium"
: "text-foreground/80 hover:text-foreground hover:bg-muted/50"
}`}
style={{ paddingLeft: paddingLeft + 15 }}
title={node.path}
>
<svg className="w-3 h-3 flex-shrink-0 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span className="truncate">{displayName}</span>
</button>
);
};
export const VaultBrowser: React.FC<VaultBrowserProps> = ({
vaultPath,
tree,
isLoading,
error,
expandedFolders,
onToggleFolder,
onSelectFile,
activeFile,
onFetchTree,
}) => {
const vaultName = vaultPath.split("/").pop() || "Vault";
if (isLoading) {
return (
<div className="p-3 text-[11px] text-muted-foreground">
Loading vault...
</div>
);
}
if (error) {
return (
<div className="p-3 space-y-2">
<div className="text-[11px] text-destructive">{error}</div>
<button
onClick={onFetchTree}
className="text-[10px] text-primary hover:underline"
>
Retry
</button>
</div>
);
}
return (
<div className="flex flex-col">
{/* Header */}
<div className="px-3 py-2 border-b border-border/30">
<div className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
{vaultName}
</div>
</div>
{/* Tree */}
<div className="py-1 px-1">
{tree.length === 0 ? (
<div className="px-3 py-2 text-[11px] text-muted-foreground">
No markdown files found
</div>
) : (
tree.map((node) => (
<TreeNode
key={node.path}
node={node}
depth={0}
expandedFolders={expandedFolders}
onToggleFolder={onToggleFolder}
onSelectFile={onSelectFile}
activeFile={activeFile}
/>
))
)}
</div>
</div>
);
};
+9 -5
View File
@@ -45,7 +45,7 @@ export interface UseLinkedDocReturn {
/** Whether a fetch is in progress */
isLoading: boolean;
/** Open a linked document by path (saves plan state, fetches doc, swaps) */
open: (docPath: string) => Promise<void>;
open: (docPath: string, buildUrl?: (path: string) => string) => Promise<void>;
/** Return to the plan (caches doc annotations, restores plan state) */
back: () => void;
/** Dismiss the current error */
@@ -80,15 +80,19 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
// Cache linked doc annotations keyed by filepath (persists across back/forth within session)
const docCache = useRef<Map<string, CachedDocState>>(new Map());
const defaultBuildUrl = useCallback(
(path: string) => `/api/doc?path=${encodeURIComponent(path)}`,
[]
);
const open = useCallback(
async (docPath: string) => {
async (docPath: string, buildUrl?: (path: string) => string) => {
setIsLoading(true);
setError(null);
try {
const res = await fetch(
`/api/doc?path=${encodeURIComponent(docPath)}`
);
const url = (buildUrl ?? defaultBuildUrl)(docPath);
const res = await fetch(url);
const data = (await res.json()) as {
markdown?: string;
filepath?: string;
+3 -3
View File
@@ -1,13 +1,13 @@
/**
* Sidebar Hook
*
* Manages the shared left sidebar state: open/close and active tab (TOC or Versions).
* The sidebar is shared between the Table of Contents and Version Browser views.
* Manages the shared left sidebar state: open/close and active tab (TOC, Versions, or Vault).
* The sidebar is shared between the Table of Contents, Version Browser, and Vault Browser views.
*/
import { useState, useCallback } from "react";
export type SidebarTab = "toc" | "versions";
export type SidebarTab = "toc" | "versions" | "vault";
export interface UseSidebarReturn {
isOpen: boolean;
+83
View File
@@ -0,0 +1,83 @@
/**
* Vault Browser Hook
*
* Manages Obsidian vault file tree state for the sidebar vault tab.
* Fetches the full tree from /api/reference/obsidian/files, tracks
* expanded folders and the currently active file.
*/
import { useState, useCallback } from "react";
import type { VaultNode } from "../types";
export type { VaultNode };
export interface UseVaultBrowserReturn {
tree: VaultNode[];
isLoading: boolean;
error: string | null;
expandedFolders: Set<string>;
toggleFolder: (path: string) => void;
fetchTree: (vaultPath: string) => void;
activeFile: string | null;
setActiveFile: (path: string | null) => void;
}
export function useVaultBrowser(): UseVaultBrowserReturn {
const [tree, setTree] = useState<VaultNode[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [activeFile, setActiveFile] = useState<string | null>(null);
const fetchTree = useCallback(async (vaultPath: string) => {
setIsLoading(true);
setError(null);
try {
const res = await fetch(
`/api/reference/obsidian/files?vaultPath=${encodeURIComponent(vaultPath)}`
);
const data = await res.json();
if (!res.ok || data.error) {
setError(data.error || "Failed to load vault");
return;
}
setTree(data.tree);
// Auto-expand root-level folders
const rootFolders = (data.tree as VaultNode[])
.filter((n) => n.type === "folder")
.map((n) => n.path);
setExpandedFolders(new Set(rootFolders));
} catch {
setError("Failed to connect to server");
} finally {
setIsLoading(false);
}
}, []);
const toggleFolder = useCallback((path: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
}, []);
return {
tree,
isLoading,
error,
expandedFolders,
toggleFolder,
fetchTree,
activeFile,
setActiveFile,
};
}
+7
View File
@@ -87,3 +87,10 @@ export interface SelectedLineRange {
side: 'deletions' | 'additions';
endSide?: 'deletions' | 'additions';
}
export interface VaultNode {
name: string;
path: string; // relative path within vault
type: "file" | "folder";
children?: VaultNode[];
}
+13
View File
@@ -14,6 +14,7 @@ const STORAGE_KEY_VAULT = 'plannotator-obsidian-vault';
const STORAGE_KEY_FOLDER = 'plannotator-obsidian-folder';
const STORAGE_KEY_CUSTOM_PATH = 'plannotator-obsidian-custom-path';
const STORAGE_KEY_FILENAME_FORMAT = 'plannotator-obsidian-filename-format';
const STORAGE_KEY_VAULT_BROWSER = 'plannotator-obsidian-vault-browser';
// Sentinel value for custom path selection
export const CUSTOM_PATH_SENTINEL = '__custom__';
@@ -33,6 +34,7 @@ export interface ObsidianSettings {
folder: string;
customPath?: string; // User-entered path when vaultPath === '__custom__'
filenameFormat?: string; // Custom filename format (e.g. '{YYYY}-{MM}-{DD} - {title}')
vaultBrowserEnabled: boolean; // Show vault file browser in sidebar
}
/**
@@ -45,6 +47,7 @@ export function getObsidianSettings(): ObsidianSettings {
folder: storage.getItem(STORAGE_KEY_FOLDER) || DEFAULT_FOLDER,
customPath: storage.getItem(STORAGE_KEY_CUSTOM_PATH) || undefined,
filenameFormat: storage.getItem(STORAGE_KEY_FILENAME_FORMAT) || undefined,
vaultBrowserEnabled: storage.getItem(STORAGE_KEY_VAULT_BROWSER) === 'true',
};
}
@@ -57,6 +60,7 @@ export function saveObsidianSettings(settings: ObsidianSettings): void {
storage.setItem(STORAGE_KEY_FOLDER, settings.folder);
storage.setItem(STORAGE_KEY_CUSTOM_PATH, settings.customPath || '');
storage.setItem(STORAGE_KEY_FILENAME_FORMAT, settings.filenameFormat || '');
storage.setItem(STORAGE_KEY_VAULT_BROWSER, String(settings.vaultBrowserEnabled));
}
/**
@@ -78,6 +82,15 @@ export function isObsidianConfigured(): boolean {
return settings.enabled && effectivePath.trim().length > 0;
}
/**
* Check if the vault browser sidebar tab should be shown
*/
export function isVaultBrowserEnabled(): boolean {
const settings = getObsidianSettings();
const effectivePath = getEffectiveVaultPath(settings);
return settings.enabled && settings.vaultBrowserEnabled && effectivePath.trim().length > 0;
}
/**
* Extract tags from markdown content using simple heuristics
*