diff --git a/apps/pi-extension/server/serverAnnotate.ts b/apps/pi-extension/server/serverAnnotate.ts index e2c6e296..c2b96e34 100644 --- a/apps/pi-extension/server/serverAnnotate.ts +++ b/apps/pi-extension/server/serverAnnotate.ts @@ -15,7 +15,13 @@ import { html, json, parseBody, requestUrl } from "./helpers.js"; import { listenOnPort } from "./network.js"; import { getRepoInfo } from "./project.js"; -import { handleDocRequest, handleFileBrowserRequest } from "./reference.js"; +import { + handleDocRequest, + handleFileBrowserRequest, + handleObsidianVaultsRequest, + handleObsidianFilesRequest, + handleObsidianDocRequest, +} from "./reference.js"; import { createExternalAnnotationHandler } from "./external-annotations.js"; export interface AnnotateServerResult { @@ -106,6 +112,12 @@ export async function startAnnotateServer(options: { url.searchParams.set("base", dirname(resolvePath(options.filePath))); } handleDocRequest(res, url); + } else if (url.pathname === "/api/obsidian/vaults") { + handleObsidianVaultsRequest(res); + } else if (url.pathname === "/api/reference/obsidian/files" && req.method === "GET") { + handleObsidianFilesRequest(res, url); + } else if (url.pathname === "/api/reference/obsidian/doc" && req.method === "GET") { + handleObsidianDocRequest(res, url); } else if (url.pathname === "/api/reference/files" && req.method === "GET") { handleFileBrowserRequest(res, url); } else if (url.pathname === "/favicon.svg") { diff --git a/obsidian-icon-raw.svg b/obsidian-icon-raw.svg new file mode 100644 index 00000000..cce79091 --- /dev/null +++ b/obsidian-icon-raw.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/obsidian-icon.svg b/obsidian-icon.svg new file mode 100644 index 00000000..531adfdd --- /dev/null +++ b/obsidian-icon.svg @@ -0,0 +1,55 @@ + \ No newline at end of file diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index d75e7303..5499be10 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -50,7 +50,6 @@ 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 { useAnnotationDraft } from '@plannotator/ui/hooks/useAnnotationDraft'; import { useArchive } from '@plannotator/ui/hooks/useArchive'; import { useEditorAnnotations } from '@plannotator/ui/hooks/useEditorAnnotations'; @@ -226,43 +225,16 @@ const App: React.FC = () => { setMarkdown, setAnnotations, setSelectedAnnotationId, setSubmitted, }); - // Obsidian vault browser - const vaultBrowser = useVaultBrowser(); - - const showVaultTab = useMemo(() => isVaultBrowserEnabled(), [uiPrefs]); + // Markdown file browser (also handles vault dirs via isVault flag) + const fileBrowser = useFileBrowser(); 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)}`, - [] + if (!isVaultBrowserEnabled()) return ''; + return getEffectiveVaultPath(getObsidianSettings()); + }, [uiPrefs]); + const showFilesTab = useMemo( + () => !!projectRoot || isFileBrowserEnabled() || isVaultBrowserEnabled(), + [projectRoot, uiPrefs] ); - - // 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]); - - // Markdown file browser - const fileBrowser = useFileBrowser(); - const showFilesTab = useMemo(() => !!projectRoot || isFileBrowserEnabled(), [projectRoot, uiPrefs]); const fileBrowserDirs = useMemo(() => { const projectDirs = projectRoot ? [projectRoot] : []; const userDirs = isFileBrowserEnabled() @@ -276,30 +248,46 @@ const App: React.FC = () => { if (!showFilesTab) fileBrowser.setActiveFile(null); }, [showFilesTab]); + // When vault is disabled, prune any stale vault dirs immediately + useEffect(() => { + if (!vaultPath) fileBrowser.clearVaultDirs(); + }, [vaultPath]); + useEffect(() => { - if (sidebar.activeTab === 'files' && showFilesTab && fileBrowserDirs.length > 0) { - const loadedPaths = fileBrowser.dirs.map((d) => d.path); - const needsFetch = fileBrowserDirs.length !== loadedPaths.length - || fileBrowserDirs.some((d) => !loadedPaths.includes(d)); - if (needsFetch) { - fileBrowser.fetchAll(fileBrowserDirs); + if (sidebar.activeTab === 'files' && showFilesTab) { + // Load regular dirs + if (fileBrowserDirs.length > 0) { + const regularLoaded = fileBrowser.dirs.filter(d => !d.isVault).map(d => d.path); + const needsRegular = fileBrowserDirs.some(d => !regularLoaded.includes(d)) + || regularLoaded.some(d => !fileBrowserDirs.includes(d)); + if (needsRegular) fileBrowser.fetchAll(fileBrowserDirs); + } + // Load vault dir; addVaultDir atomically replaces any existing vault entry so + // switching vault paths never accumulates stale sections + if (vaultPath && !fileBrowser.dirs.find(d => d.isVault && d.path === vaultPath && !d.error)) { + fileBrowser.addVaultDir(vaultPath); } } - }, [sidebar.activeTab, showFilesTab, fileBrowserDirs]); + }, [sidebar.activeTab, showFilesTab, fileBrowserDirs, vaultPath]); // File browser file selection: open via linked doc system + // For vault dirs (isVault), use the Obsidian doc endpoint; otherwise use generic /api/doc const handleFileBrowserSelect = React.useCallback((absolutePath: string, dirPath: string) => { - const buildUrl = (path: string) => - `/api/doc?path=${encodeURIComponent(path)}&base=${encodeURIComponent(dirPath)}`; + const dirState = fileBrowser.dirs.find(d => d.path === dirPath); + const buildUrl = dirState?.isVault + ? (path: string) => `/api/reference/obsidian/doc?vaultPath=${encodeURIComponent(dirPath)}&path=${encodeURIComponent(path)}` + : (path: string) => `/api/doc?path=${encodeURIComponent(path)}&base=${encodeURIComponent(dirPath)}`; linkedDocHook.open(absolutePath, buildUrl, 'files'); fileBrowser.setActiveFile(absolutePath); - vaultBrowser.setActiveFile(null); }, [linkedDocHook, fileBrowser]); - // Route linked doc opens through vault/file browser endpoint when viewing one of those files + // Route linked doc opens through the correct endpoint based on current context const handleOpenLinkedDoc = React.useCallback((docPath: string) => { - if (vaultBrowser.activeFile && vaultPath) { - linkedDocHook.open(docPath, buildVaultDocUrl(vaultPath)); + const activeDirState = fileBrowser.dirs.find(d => d.path === fileBrowser.activeDirPath); + if (activeDirState?.isVault && fileBrowser.activeDirPath) { + linkedDocHook.open(docPath, (path) => + `/api/reference/obsidian/doc?vaultPath=${encodeURIComponent(fileBrowser.activeDirPath!)}&path=${encodeURIComponent(path)}` + ); } else if (fileBrowser.activeFile && fileBrowser.activeDirPath) { // When viewing a file browser doc, resolve links relative to current file's directory const baseDir = linkedDocHook.filepath?.replace(/\/[^/]+$/, '') || fileBrowser.activeDirPath; @@ -319,15 +307,14 @@ const App: React.FC = () => { linkedDocHook.open(docPath); } } - }, [vaultBrowser.activeFile, vaultPath, fileBrowser.activeFile, fileBrowser.activeDirPath, linkedDocHook, buildVaultDocUrl, imageBaseDir]); + }, [fileBrowser.dirs, fileBrowser.activeDirPath, fileBrowser.activeFile, linkedDocHook, imageBaseDir]); - // Wrap linked doc back to also clear vault/file browser active file + // Wrap linked doc back to also clear file browser active file const handleLinkedDocBack = React.useCallback(() => { linkedDocHook.back(); - vaultBrowser.setActiveFile(null); fileBrowser.setActiveFile(null); archive.clearSelection(); - }, [linkedDocHook, vaultBrowser, fileBrowser, archive]); + }, [linkedDocHook, fileBrowser, archive]); // Derive annotation counts per file from linked doc cache (includes active doc's live state) const allAnnotationCounts = useMemo(() => { @@ -339,33 +326,20 @@ const App: React.FC = () => { return counts; }, [linkedDocHook.getDocAnnotations, annotations, globalAttachments]); - // FileBrowser counts: only files under file browser directories + // FileBrowser counts: all files under any loaded dir (regular + vault) const fileAnnotationCounts = useMemo(() => { - if (fileBrowserDirs.length === 0) return allAnnotationCounts; + const allDirPaths = fileBrowser.dirs.map(d => d.path); + if (allDirPaths.length === 0) return allAnnotationCounts; const counts = new Map(); for (const [fp, count] of allAnnotationCounts) { - if (fileBrowserDirs.some(dir => fp.startsWith(dir + '/'))) { + if (allDirPaths.some(dir => fp.startsWith(dir + '/'))) { counts.set(fp, count); } } return counts; - }, [allAnnotationCounts, fileBrowserDirs]); - - // VaultBrowser uses relative paths — strip vaultPath prefix for lookup - const vaultAnnotationCounts = useMemo(() => { - if (!vaultPath) return new Map(); - const prefix = vaultPath.endsWith('/') ? vaultPath : vaultPath + '/'; - const counts = new Map(); - for (const [fp, count] of allAnnotationCounts) { - if (fp.startsWith(prefix)) { - counts.set(fp.slice(prefix.length), count); - } - } - return counts; - }, [allAnnotationCounts, vaultPath]); + }, [allAnnotationCounts, fileBrowser.dirs]); const hasFileAnnotations = fileAnnotationCounts.size > 0; - const hasVaultAnnotations = vaultAnnotationCounts.size > 0; // Annotations in other files (not the current view) — for the right panel "+N" indicator const otherFileAnnotations = useMemo(() => { @@ -387,9 +361,9 @@ const App: React.FC = () => { const handleFlashAnnotatedFiles = React.useCallback(() => { const filePaths = new Set(allAnnotationCounts.keys()); if (filePaths.size === 0) return; - // Open sidebar to the relevant tab so the flash is visible - if (!sidebar.isOpen || (sidebar.activeTab !== 'files' && sidebar.activeTab !== 'vault')) { - sidebar.open(hasVaultAnnotations && !hasFileAnnotations ? 'vault' : 'files'); + // Open sidebar to the files tab so the flash is visible + if (!sidebar.isOpen || sidebar.activeTab !== 'files') { + sidebar.open('files'); } // Cancel any pending clear from a previous flash if (flashTimerRef.current) clearTimeout(flashTimerRef.current); @@ -399,18 +373,7 @@ const App: React.FC = () => { setHighlightedFiles(filePaths); flashTimerRef.current = setTimeout(() => setHighlightedFiles(undefined), 1200); }); - }, [allAnnotationCounts, sidebar, hasVaultAnnotations, hasFileAnnotations]); - - // Derive vault-relative highlighted files for VaultBrowser - const vaultHighlightedFiles = useMemo(() => { - if (!highlightedFiles || !vaultPath) return undefined; - const prefix = vaultPath.endsWith('/') ? vaultPath : vaultPath + '/'; - const relative = new Set(); - for (const fp of highlightedFiles) { - if (fp.startsWith(prefix)) relative.add(fp.slice(prefix.length)); - } - return relative.size > 0 ? relative : undefined; - }, [highlightedFiles, vaultPath]); + }, [allAnnotationCounts, sidebar, hasFileAnnotations]); // Context-aware back label for linked doc navigation const backLabel = annotateSource === 'folder' ? 'file list' @@ -418,10 +381,6 @@ const App: React.FC = () => { : annotateSource === 'message' ? 'message' : 'plan'; - 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, scrollViewport); @@ -1457,10 +1416,9 @@ const App: React.FC = () => { activeTab={sidebar.activeTab} onToggleTab={sidebar.toggleTab} hasDiff={planDiff.hasPreviousVersion} + showVersionsTab={versionInfo !== null && versionInfo.totalVersions > 1} showFilesTab={showFilesTab && !archive.archiveMode} - showVaultTab={showVaultTab} hasFileAnnotations={hasFileAnnotations} - hasVaultAnnotations={hasVaultAnnotations} className="hidden lg:flex absolute left-0 top-0 z-10" /> )} @@ -1489,15 +1447,9 @@ const App: React.FC = () => { fileBrowser={fileBrowser} onFilesSelectFile={handleFileBrowserSelect} onFilesFetchAll={() => fileBrowser.fetchAll(fileBrowserDirs)} - showVaultTab={showVaultTab && !archive.archiveMode} - vaultPath={vaultPath} - vaultBrowser={vaultBrowser} - vaultAnnotationCounts={vaultAnnotationCounts} - vaultHighlightedFiles={vaultHighlightedFiles} - onVaultSelectFile={handleVaultFileSelect} - onVaultFetchTree={handleVaultFetchTree} + onFilesRetryVaultDir={(vaultPath) => fileBrowser.addVaultDir(vaultPath)} hasFileAnnotations={hasFileAnnotations} - hasVaultAnnotations={hasVaultAnnotations} + showVersionsTab={versionInfo !== null && versionInfo.totalVersions > 1} versionInfo={versionInfo} versions={planDiff.versions} selectedBaseVersion={planDiff.diffBaseVersion} @@ -1540,9 +1492,10 @@ const App: React.FC = () => { {/* Sticky header lane — ghost bar that pins the toolstrip + badges at top: 12px once the user scrolls. Invisible at top of doc; original toolstrip/badges remain the source of - truth there. Hidden in plan diff, archive, linked-doc mode, - or when sticky actions are disabled. */} - {!isPlanDiffActive && !archive.archiveMode && !linkedDocHook.isActive && uiPrefs.stickyActionsEnabled && ( + truth there. Hidden in plan diff or archive mode, or when + sticky actions are disabled. remountToken re-anchors the + ResizeObserver when Viewer swaps content (linked docs). */} + {!isPlanDiffActive && !archive.archiveMode && uiPrefs.stickyActionsEnabled && ( { showDemoBadge={!isApiMode && !isLoadingShared && !isSharedSession} maxWidth={planMaxWidth} onOpenLinkedDoc={handleOpenLinkedDoc} - linkedDocInfo={linkedDocHook.isActive ? { filepath: linkedDocHook.filepath!, onBack: handleLinkedDocBack, label: vaultBrowser.activeFile ? 'Vault File' : fileBrowser.activeFile ? 'File' : undefined, backLabel } : null} + linkedDocInfo={linkedDocHook.isActive ? { filepath: linkedDocHook.filepath!, onBack: handleLinkedDocBack, label: fileBrowser.dirs.find(d => d.path === fileBrowser.activeDirPath)?.isVault ? 'Vault File' : fileBrowser.activeFile ? 'File' : undefined, backLabel } : null} imageBaseDir={imageBaseDir} copyLabel={annotateSource === 'message' ? 'Copy message' : annotateSource === 'file' || annotateSource === 'folder' ? 'Copy file' : undefined} archiveInfo={archive.currentInfo} diff --git a/packages/server/annotate.ts b/packages/server/annotate.ts index fd339d43..6d6b697d 100644 --- a/packages/server/annotate.ts +++ b/packages/server/annotate.ts @@ -15,7 +15,7 @@ import { isRemoteSession, getServerPort } from "./remote"; import { getRepoInfo } from "./repo"; import type { Origin } from "@plannotator/shared/agents"; import { handleImage, handleUpload, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, handleFavicon } from "./shared-handlers"; -import { handleDoc, handleFileBrowserFiles } from "./reference-handlers"; +import { handleDoc, handleFileBrowserFiles, handleObsidianVaults, handleObsidianFiles, handleObsidianDoc } from "./reference-handlers"; import { contentHash, deleteDraft } from "./draft"; import { createExternalAnnotationHandler } from "./external-annotations"; import { saveConfig, detectGitUser, getServerConfig } from "./config"; @@ -179,6 +179,21 @@ export async function startAnnotateServer( return handleDoc(req); } + // API: Detect Obsidian vaults + if (url.pathname === "/api/obsidian/vaults") { + return handleObsidianVaults(); + } + + // API: List Obsidian vault files as a tree + if (url.pathname === "/api/reference/obsidian/files" && req.method === "GET") { + return handleObsidianFiles(req); + } + + // API: Read an Obsidian vault document + if (url.pathname === "/api/reference/obsidian/doc" && req.method === "GET") { + return handleObsidianDoc(req); + } + // API: List markdown files in a directory as a tree if (url.pathname === "/api/reference/files" && req.method === "GET") { return handleFileBrowserFiles(req); diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index 5f53ec88..38e6a7f6 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -445,9 +445,7 @@ export const Viewer = forwardRef(({
{/* Repo info + plan diff badge + demo badge + linked doc badge + archive badge - top left */} diff --git a/packages/ui/components/icons/ObsidianIcons.tsx b/packages/ui/components/icons/ObsidianIcons.tsx new file mode 100644 index 00000000..ab48bfec --- /dev/null +++ b/packages/ui/components/icons/ObsidianIcons.tsx @@ -0,0 +1,208 @@ +import React from 'react'; + +/** + * Obsidian icons. + * + * ObsidianIcon — Full branded logo with background rect and gradients. Use for + * larger display contexts (settings, onboarding, etc.). + * ObsidianIconRaw — Minimal path-only mark in Obsidian purple. Use inline in compact + * UI surfaces like the file browser directory header. + */ + +interface IconProps { + className?: string; +} + +export const ObsidianIcon: React.FC = ({ className = 'w-8 h-8' }) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export const ObsidianIconRaw: React.FC = ({ className = 'w-[14px] h-[16px]' }) => ( + + + + + + +); diff --git a/packages/ui/components/sidebar/FileBrowser.tsx b/packages/ui/components/sidebar/FileBrowser.tsx index e08bee34..734fcb90 100644 --- a/packages/ui/components/sidebar/FileBrowser.tsx +++ b/packages/ui/components/sidebar/FileBrowser.tsx @@ -9,6 +9,7 @@ import React from "react"; import type { VaultNode } from "../../types"; import type { DirState } from "../../hooks/useFileBrowser"; import { CountBadge } from "./CountBadge"; +import { ObsidianIconRaw } from "../icons/ObsidianIcons"; interface FileBrowserProps { dirs: DirState[]; @@ -19,6 +20,7 @@ interface FileBrowserProps { onSelectFile: (absolutePath: string, dirPath: string) => void; activeFile: string | null; onFetchAll: () => void; + onRetryVaultDir?: (vaultPath: string) => void; annotationCounts?: Map; highlightedFiles?: Set; } @@ -190,6 +192,7 @@ export const FileBrowser: React.FC = ({ onSelectFile, activeFile, onFetchAll, + onRetryVaultDir, annotationCounts, highlightedFiles, }) => { @@ -230,6 +233,7 @@ export const FileBrowser: React.FC = ({ > + {dir.isVault && }
{dir.name}
@@ -241,7 +245,7 @@ export const FileBrowser: React.FC = ({ onToggleFolder={onToggleFolder} onSelectFile={onSelectFile} activeFile={activeFile} - onRetry={onFetchAll} + onRetry={dir.isVault && onRetryVaultDir ? () => onRetryVaultDir(dir.path) : onFetchAll} annotationCounts={annotationCounts} highlightedFiles={highlightedFiles} /> diff --git a/packages/ui/components/sidebar/SidebarContainer.tsx b/packages/ui/components/sidebar/SidebarContainer.tsx index 0824e27e..5d82b0dc 100644 --- a/packages/ui/components/sidebar/SidebarContainer.tsx +++ b/packages/ui/components/sidebar/SidebarContainer.tsx @@ -1,7 +1,7 @@ /** * SidebarContainer — Shared sidebar shell * - * Houses the Table of Contents, Version Browser, Vault Browser, and Archive Browser views. + * Houses the Table of Contents, Version Browser, File Browser, and Archive Browser views. * Tab bar at top switches between them. */ @@ -9,11 +9,9 @@ import React from "react"; import type { SidebarTab } from "../../hooks/useSidebar"; import type { Block, Annotation } from "../../types"; import type { VersionInfo, VersionEntry } from "../../hooks/usePlanDiff"; -import type { UseVaultBrowserReturn } from "../../hooks/useVaultBrowser"; import type { UseFileBrowserReturn } from "../../hooks/useFileBrowser"; import { TableOfContents } from "../TableOfContents"; import { VersionBrowser } from "./VersionBrowser"; -import { VaultBrowser } from "./VaultBrowser"; import { FileBrowser } from "./FileBrowser"; import { ArchiveBrowser, type ArchivedPlan } from "./ArchiveBrowser"; import { OverlayScrollArea } from "../OverlayScrollArea"; @@ -38,15 +36,9 @@ interface SidebarContainerProps { fileBrowser?: UseFileBrowserReturn; onFilesSelectFile?: (absolutePath: string, dirPath: string) => void; onFilesFetchAll?: () => void; - // Vault Browser props - showVaultTab?: boolean; - vaultPath?: string; - vaultBrowser?: UseVaultBrowserReturn; - vaultAnnotationCounts?: Map; - vaultHighlightedFiles?: Set; - onVaultSelectFile?: (relativePath: string) => void; - onVaultFetchTree?: () => void; + onFilesRetryVaultDir?: (vaultPath: string) => void; // Version Browser props + showVersionsTab?: boolean; versionInfo: VersionInfo | null; versions: VersionEntry[]; selectedBaseVersion: number | null; @@ -60,7 +52,6 @@ interface SidebarContainerProps { onFetchVersions: () => void; // Annotation indicators hasFileAnnotations?: boolean; - hasVaultAnnotations?: boolean; // Archive Browser props showArchiveTab?: boolean; archivePlans: ArchivedPlan[]; @@ -87,13 +78,8 @@ export const SidebarContainer: React.FC = ({ fileBrowser, onFilesSelectFile, onFilesFetchAll, - showVaultTab, - vaultPath, - vaultBrowser, - vaultAnnotationCounts, - vaultHighlightedFiles, - onVaultSelectFile, - onVaultFetchTree, + onFilesRetryVaultDir, + showVersionsTab, versionInfo, versions, selectedBaseVersion, @@ -106,7 +92,6 @@ export const SidebarContainer: React.FC = ({ fetchingVersion, onFetchVersions, hasFileAnnotations, - hasVaultAnnotations, showArchiveTab, archivePlans, selectedArchiveFile, @@ -140,30 +125,10 @@ export const SidebarContainer: React.FC = ({ } label="Contents" /> - onTabChange("versions")} - icon={ - - - - } - label="Versions" - /> - {showFilesTab && ( + {showVersionsTab && ( onTabChange("files")} + active={activeTab === "versions"} + onClick={() => onTabChange("versions")} icon={ = ({ } - label="Files" - badge={hasFileAnnotations} + label="Versions" /> )} - {showVaultTab && ( + {showFilesTab && ( onTabChange("vault")} + active={activeTab === "files"} + onClick={() => onTabChange("files")} icon={ = ({ } - label="Vault" - badge={hasVaultAnnotations} + label="Files" + badge={hasFileAnnotations} /> )} {showArchiveTab && ( @@ -289,25 +253,11 @@ export const SidebarContainer: React.FC = ({ onSelectFile={onFilesSelectFile ?? (() => {})} activeFile={fileBrowser.activeFile} onFetchAll={onFilesFetchAll ?? (() => {})} + onRetryVaultDir={onFilesRetryVaultDir} annotationCounts={fileAnnotationCounts} highlightedFiles={highlightedFiles} /> )} - {activeTab === "vault" && showVaultTab && vaultPath && vaultBrowser && ( - {})} - activeFile={vaultBrowser.activeFile} - onFetchTree={onVaultFetchTree ?? (() => {})} - annotationCounts={vaultAnnotationCounts} - highlightedFiles={vaultHighlightedFiles} - /> - )} {activeTab === "archive" && showArchiveTab && ( void; hasDiff: boolean; + showVersionsTab?: boolean; showFilesTab?: boolean; - showVaultTab?: boolean; hasFileAnnotations?: boolean; - hasVaultAnnotations?: boolean; className?: string; } @@ -23,10 +22,9 @@ export const SidebarTabs: React.FC = ({ activeTab, onToggleTab, hasDiff, + showVersionsTab, showFilesTab, - showVaultTab, hasFileAnnotations, - hasVaultAnnotations, className, }) => { return ( @@ -54,37 +52,12 @@ export const SidebarTabs: React.FC = ({ - {/* Versions tab */} - - - {/* Files tab */} - {showFilesTab && ( + {/* Versions tab — only shown when multiple versions exist */} + {showVersionsTab && ( )} - {/* Vault tab */} - {showVaultTab && ( + {/* Files tab */} + {showFilesTab && ( diff --git a/packages/ui/components/sidebar/VaultBrowser.tsx b/packages/ui/components/sidebar/VaultBrowser.tsx deleted file mode 100644 index b2a12069..00000000 --- a/packages/ui/components/sidebar/VaultBrowser.tsx +++ /dev/null @@ -1,200 +0,0 @@ -/** - * 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"; -import { CountBadge } from "./CountBadge"; - -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; - annotationCounts?: Map; - highlightedFiles?: Set; -} - -/** Recursively sum annotation counts for all descendant files of a folder node */ -function getAggregateCount( - node: VaultNode, - counts: Map -): number { - if (node.type === "file") { - return counts.get(node.path) ?? 0; - } - let total = 0; - for (const child of node.children ?? []) { - total += getAggregateCount(child, counts); - } - return total; -} - -const TreeNode: React.FC<{ - node: VaultNode; - depth: number; - expandedFolders: Set; - onToggleFolder: (path: string) => void; - onSelectFile: (path: string) => void; - activeFile: string | null; - annotationCounts?: Map; - highlightedFiles?: Set; -}> = ({ node, depth, expandedFolders, onToggleFolder, onSelectFile, activeFile, annotationCounts, highlightedFiles }) => { - const isExpanded = expandedFolders.has(node.path); - const isActive = node.type === "file" && node.path === activeFile; - const paddingLeft = 8 + depth * 14; - - if (node.type === "folder") { - const aggregateCount = annotationCounts ? getAggregateCount(node, annotationCounts) : 0; - return ( - <> - - {isExpanded && node.children?.map((child) => ( - - ))} - - ); - } - - // File node - const displayName = node.name.replace(/\.md$/i, ""); - const fileCount = annotationCounts?.get(node.path) ?? 0; - const isHighlighted = highlightedFiles?.has(node.path); - return ( - - ); -}; - -export const VaultBrowser: React.FC = ({ - vaultPath, - tree, - isLoading, - error, - expandedFolders, - onToggleFolder, - onSelectFile, - activeFile, - onFetchTree, - annotationCounts, - highlightedFiles, -}) => { - const vaultName = vaultPath.split("/").pop() || "Vault"; - - if (isLoading) { - return ( -
- Loading vault... -
- ); - } - - if (error) { - return ( -
-
{error}
- -
- ); - } - - // Summary header - const totalCount = annotationCounts ? Array.from(annotationCounts.values()).reduce((s, c) => s + c, 0) : 0; - const fileCount = annotationCounts?.size ?? 0; - - return ( -
- {/* Header */} -
-
- {vaultName} -
-
- - {totalCount > 0 && ( -
- {totalCount} annotation{totalCount === 1 ? '' : 's'} in {fileCount} file{fileCount === 1 ? '' : 's'} -
- )} - - {/* Tree */} -
- {tree.length === 0 ? ( -
- No markdown files found -
- ) : ( - tree.map((node) => ( - - )) - )} -
-
- ); -}; diff --git a/packages/ui/hooks/useFileBrowser.ts b/packages/ui/hooks/useFileBrowser.ts index c34d2229..0547e8b5 100644 --- a/packages/ui/hooks/useFileBrowser.ts +++ b/packages/ui/hooks/useFileBrowser.ts @@ -3,6 +3,8 @@ * * Manages multiple directory file trees for the sidebar Files tab. * Each directory gets its own tree, loading, and error state. + * Vault directories are supported via the isVault flag — they fetch + * from the Obsidian vault endpoint instead of the generic files endpoint. */ import { useState, useCallback } from "react"; @@ -14,6 +16,8 @@ export interface DirState { tree: VaultNode[]; isLoading: boolean; error: string | null; + /** When true, fetches via /api/reference/obsidian/files and opens docs via /api/reference/obsidian/doc */ + isVault?: boolean; } export interface UseFileBrowserReturn { @@ -24,6 +28,8 @@ export interface UseFileBrowserReturn { toggleCollapse: (dirPath: string) => void; fetchTree: (dirPath: string) => void; fetchAll: (directories: string[]) => void; + addVaultDir: (vaultPath: string) => void; + clearVaultDirs: () => void; activeFile: string | null; activeDirPath: string | null; setActiveFile: (path: string | null) => void; @@ -97,20 +103,74 @@ export function useFileBrowser(): UseFileBrowserReturn { const fetchAll = useCallback( (directories: string[]) => { - setDirs( - directories.map((path) => ({ + setDirs((prev) => { + // Preserve any vault dirs that were already loaded + const vaultDirs = prev.filter((d) => d.isVault); + const regularDirs = directories.map((path) => ({ path, name: path.split("/").pop() || path, tree: [], isLoading: false, error: null, - })) - ); + })); + return [...regularDirs, ...vaultDirs]; + }); directories.forEach((d) => fetchTree(d)); }, [fetchTree] ); + const clearVaultDirs = useCallback(() => { + setDirs((prev) => prev.filter((d) => !d.isVault)); + }, []); + + const addVaultDir = useCallback(async (vaultPath: string) => { + const name = vaultPath.split("/").pop() || vaultPath; + + // Atomically replace any existing vault dirs (handles vault path change without accumulating stale entries) + setDirs((prev) => { + const nonVaultDirs = prev.filter((d) => !d.isVault); + return [...nonVaultDirs, { path: vaultPath, name, tree: [], isLoading: true, error: null, isVault: true }]; + }); + + try { + const res = await fetch( + `/api/reference/obsidian/files?vaultPath=${encodeURIComponent(vaultPath)}` + ); + const data = await res.json(); + + if (!res.ok || data.error) { + setDirs((prev) => + prev.map((d) => + d.path === vaultPath ? { ...d, isLoading: false, error: data.error || "Failed to load" } : d + ) + ); + return; + } + + setDirs((prev) => + prev.map((d) => + d.path === vaultPath ? { ...d, tree: data.tree, isLoading: false, isVault: true } : d + ) + ); + + const rootFolders = (data.tree as VaultNode[]) + .filter((n) => n.type === "folder") + .map((n) => `${vaultPath}:${n.path}`); + setExpandedFolders((prev) => { + const next = new Set(prev); + rootFolders.forEach((f) => next.add(f)); + return next; + }); + } catch { + setDirs((prev) => + prev.map((d) => + d.path === vaultPath ? { ...d, isLoading: false, error: "Failed to connect to server" } : d + ) + ); + } + }, []); + const toggleFolder = useCallback((key: string) => { setExpandedFolders((prev) => { const next = new Set(prev); @@ -131,6 +191,8 @@ export function useFileBrowser(): UseFileBrowserReturn { toggleCollapse, fetchTree, fetchAll, + addVaultDir, + clearVaultDirs, activeFile, activeDirPath: activeFile ? (dirs.find((d) => activeFile.startsWith(d.path + "/"))?.path ?? null) : null, setActiveFile, diff --git a/packages/ui/hooks/useSidebar.ts b/packages/ui/hooks/useSidebar.ts index 8d16893b..dc69131f 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, Versions, or Vault). - * The sidebar is shared between the Table of Contents, Version Browser, and Vault Browser views. + * Manages the shared left sidebar state: open/close and active tab. + * The sidebar is shared between the Table of Contents, Version Browser, File Browser, and Archive views. */ import { useState, useCallback } from "react"; -export type SidebarTab = "toc" | "versions" | "files" | "vault" | "archive"; +export type SidebarTab = "toc" | "versions" | "files" | "archive"; export interface UseSidebarReturn { isOpen: boolean; diff --git a/packages/ui/hooks/useVaultBrowser.ts b/packages/ui/hooks/useVaultBrowser.ts deleted file mode 100644 index 09ce51d2..00000000 --- a/packages/ui/hooks/useVaultBrowser.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * 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, - }; -}