From e5f3cfffbb0e8bd6ff46faa3cedb656d10c0e9b6 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 3 Mar 2026 15:32:59 -0800 Subject: [PATCH] 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 * 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 * 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 --------- Co-authored-by: Claude Opus 4.6 --- CLAUDE.md | 2 + packages/editor/App.tsx | 67 +++++++- packages/server/index.ts | 133 ++++++++++++++- packages/ui/components/Settings.tsx | 23 +++ packages/ui/components/Viewer.tsx | 38 ++++- .../components/sidebar/SidebarContainer.tsx | 50 +++++- .../ui/components/sidebar/SidebarTabs.tsx | 27 ++- .../ui/components/sidebar/VaultBrowser.tsx | 159 ++++++++++++++++++ packages/ui/hooks/useLinkedDoc.ts | 14 +- packages/ui/hooks/useSidebar.ts | 6 +- packages/ui/hooks/useVaultBrowser.ts | 83 +++++++++ packages/ui/types.ts | 7 + packages/ui/utils/obsidian.ts | 13 ++ 13 files changed, 606 insertions(+), 16 deletions(-) create mode 100644 packages/ui/components/sidebar/VaultBrowser.tsx create mode 100644 packages/ui/hooks/useVaultBrowser.ts diff --git a/CLAUDE.md b/CLAUDE.md index 1a03facc..41370906 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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=`) | +| `/api/reference/obsidian/doc` | GET | Read a vault markdown file (`?vaultPath=&path=`) | | `/api/plan/vscode-diff` | POST | Open diff in VS Code (body: baseVersion) | | `/api/doc` | GET | Serve linked .md/.mdx file (`?path=`) | diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index c749e3f8..158ffaff 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -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} /> )} diff --git a/packages/server/index.ts b/packages/server/index.ts index 18bc72ed..e2aecbb0 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -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; +} diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index 30df54b1..f71dee08 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -678,6 +678,29 @@ tags: [plan, ...] ---`} + +
+
+
+
Vault Browser
+
+ Browse and annotate vault files from the sidebar +
+
+ +
+
)} diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index 6884e606..878523e2 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -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(({ plan - Linked File + {linkedDocInfo.label || 'Linked File'} { + 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} + + + ); + } else { + parts.push( + {display} + ); + } + remaining = remaining.slice(match[0].length); + continue; + } + // Links: [text](url) match = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/); if (match) { diff --git a/packages/ui/components/sidebar/SidebarContainer.tsx b/packages/ui/components/sidebar/SidebarContainer.tsx index 0a413807..d67c8d1c 100644 --- a/packages/ui/components/sidebar/SidebarContainer.tsx +++ b/packages/ui/components/sidebar/SidebarContainer.tsx @@ -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 = ({ onTocNavigate, linkedDocFilepath, onLinkedDocBack, + showVaultTab, + vaultPath, + vaultBrowser, + onVaultSelectFile, + onVaultFetchTree, versionInfo, versions, projectPlans, @@ -112,6 +125,28 @@ export const SidebarContainer: React.FC = ({ } label="Versions" /> + {showVaultTab && ( + onTabChange("vault")} + icon={ + + + + } + label="Vault" + /> + )}
); diff --git a/packages/ui/components/sidebar/SidebarTabs.tsx b/packages/ui/components/sidebar/SidebarTabs.tsx index 8c3bb174..cf1215de 100644 --- a/packages/ui/components/sidebar/SidebarTabs.tsx +++ b/packages/ui/components/sidebar/SidebarTabs.tsx @@ -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 = ({ activeTab, onToggleTab, hasDiff, + showVaultTab, className, }) => { return ( @@ -70,6 +72,29 @@ export const SidebarTabs: React.FC = ({ )} + + {/* Vault tab */} + {showVaultTab && ( + + )} ); }; diff --git a/packages/ui/components/sidebar/VaultBrowser.tsx b/packages/ui/components/sidebar/VaultBrowser.tsx new file mode 100644 index 00000000..fb3ed943 --- /dev/null +++ b/packages/ui/components/sidebar/VaultBrowser.tsx @@ -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; + onToggleFolder: (path: string) => void; + onSelectFile: (relativePath: string) => void; + activeFile: string | null; + onFetchTree: () => void; +} + +const TreeNode: React.FC<{ + node: VaultNode; + depth: number; + expandedFolders: Set; + 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 ( + <> + + {isExpanded && node.children?.map((child) => ( + + ))} + + ); + } + + // File node + const displayName = node.name.replace(/\.md$/i, ""); + return ( + + ); +}; + +export const VaultBrowser: React.FC = ({ + vaultPath, + tree, + isLoading, + error, + expandedFolders, + onToggleFolder, + onSelectFile, + activeFile, + onFetchTree, +}) => { + const vaultName = vaultPath.split("/").pop() || "Vault"; + + if (isLoading) { + return ( +
+ Loading vault... +
+ ); + } + + if (error) { + return ( +
+
{error}
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ {vaultName} +
+
+ + {/* Tree */} +
+ {tree.length === 0 ? ( +
+ No markdown files found +
+ ) : ( + tree.map((node) => ( + + )) + )} +
+
+ ); +}; diff --git a/packages/ui/hooks/useLinkedDoc.ts b/packages/ui/hooks/useLinkedDoc.ts index 6d0d39a1..d73ac791 100644 --- a/packages/ui/hooks/useLinkedDoc.ts +++ b/packages/ui/hooks/useLinkedDoc.ts @@ -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; + open: (docPath: string, buildUrl?: (path: string) => string) => Promise; /** 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>(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; diff --git a/packages/ui/hooks/useSidebar.ts b/packages/ui/hooks/useSidebar.ts index c2ecb9fe..f8d46f37 100644 --- a/packages/ui/hooks/useSidebar.ts +++ b/packages/ui/hooks/useSidebar.ts @@ -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; diff --git a/packages/ui/hooks/useVaultBrowser.ts b/packages/ui/hooks/useVaultBrowser.ts new file mode 100644 index 00000000..09ce51d2 --- /dev/null +++ b/packages/ui/hooks/useVaultBrowser.ts @@ -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; + toggleFolder: (path: string) => void; + fetchTree: (vaultPath: string) => void; + activeFile: string | null; + setActiveFile: (path: string | null) => void; +} + +export function useVaultBrowser(): UseVaultBrowserReturn { + const [tree, setTree] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [expandedFolders, setExpandedFolders] = useState>(new Set()); + const [activeFile, setActiveFile] = useState(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, + }; +} diff --git a/packages/ui/types.ts b/packages/ui/types.ts index d8ec7775..78e532f1 100644 --- a/packages/ui/types.ts +++ b/packages/ui/types.ts @@ -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[]; +} diff --git a/packages/ui/utils/obsidian.ts b/packages/ui/utils/obsidian.ts index 91933633..a8a057c7 100644 --- a/packages/ui/utils/obsidian.ts +++ b/packages/ui/utils/obsidian.ts @@ -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 *