import { useCallback, useEffect, useRef, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { FileSystemProvider } from '../filesystem/types'; import { NewDocumentIcon, NewDirectoryIcon, StackedChevronsIcon, PlusIcon, CloudDownloadIcon, } from '../icons'; import type { RemoteVault } from '../vault/registry'; import type { OpenDocRequest } from '../workspaces/workspace'; import { useContextMenu, type ContextMenuItem } from '../platform/contextMenu'; import { treeQueryKey } from '../filesystem/treeQuery'; import { FileSystemTree } from './VaultTree'; import type { InlineCreateState } from './VaultTree'; /** Open a file by first splitting the active tile to the right / below. */ export type OpenInSplit = ( request: OpenDocRequest, direction: 'right' | 'down', ) => void; type AppSidebarProps = { open: boolean; providers: FileSystemProvider[]; selectedProviderId: string; onSelectProviderId: (id: string) => void; /** Emitted when the user wants to open/create a new vault. */ onOpenVaultManager?: () => void; /** Emitted when the user picks an entry; the shell forwards it to the active workspace. */ onOpenEntry?: (request: OpenDocRequest) => void; /** Open a file in a new tile (split the active tile first). */ onOpenInSplit: OpenInSplit; /** Remote vault roots in the PDS not yet synced into a local directory. */ unsyncedVaults?: RemoteVault[]; /** Emitted when the user clicks an unsynced remote vault to sync it. */ onSyncRemote?: (vault: RemoteVault) => void; /** * The document the active workspace is currently showing, which the tree * highlights. Read-only: the sidebar never sets it, so the highlight tracks * what is open rather than what was last clicked here. */ activeDocument: Selection; }; /** * Which document the tree highlights. * * Derived, never owned here: it comes from the workspace's report of what it is * actually showing (`App`'s `activeDocument`). The sidebar deliberately does * NOT set this when you click a file — it calls `onOpenEntry` and lets the * highlight follow the document actually opening. That is what keeps the * highlight correct for documents opened from anywhere else (wikilinks, * internal links, new file, tab switches) instead of only from this tree. */ type Selection = { providerId: string; entryId: string } | null; const MIN_SIDEBAR_WIDTH = 112; const DEFAULT_SIDEBAR_WIDTH = 224; type ProviderSectionProps = { provider: FileSystemProvider; selection: Selection; onOpenEntry?: (request: OpenDocRequest) => void; renameNodeId: string | null; renameValue: string; renameExt: string; renameError: string | null; onRenameValueChange: (value: string) => void; onRenameSubmit: (value: string) => void; onRenameCancel: () => void; inlineCreate: InlineCreateState | null; onInlineCreateChange: (name: string) => void; onInlineCreateSubmit: (name: string) => void; onInlineCreateCancel: () => void; onContextMenuAction: ( action: string, nodeId: string, nodeName: string, nodeType: 'file' | 'folder', ) => void; onOpenInSplit: OpenInSplit; onTreeRootRightClick: (action: string) => void; }; function ProviderSection({ provider, selection, onOpenEntry, renameNodeId, renameValue, renameExt, renameError, onRenameValueChange, onRenameSubmit, onRenameCancel, inlineCreate, onInlineCreateChange, onInlineCreateSubmit, onInlineCreateCancel, onContextMenuAction, onOpenInSplit, onTreeRootRightClick, }: ProviderSectionProps) { const queryClient = useQueryClient(); const showContextMenu = useContextMenu(); // Tree query for all providers — flat sources return single-level trees. const treeQuery = useQuery({ queryKey: treeQueryKey(provider.id), queryFn: () => provider.getTree(), enabled: provider.isReady(), refetchInterval: 3_000, }); // Invalidate when provider becomes ready. useEffect(() => { if (!provider.subscribeReady) return; return provider.subscribeReady(() => { void queryClient.invalidateQueries({ queryKey: treeQueryKey(provider.id), }); }); }, [provider, queryClient]); // Only asks for the document to open. The highlight follows from the // workspace actually showing it — see the `Selection` docblock. const handleSelect = useCallback( (node: { id: string; name: string; type: 'file' | 'folder' }) => { if (node.type === 'file') { onOpenEntry?.({ providerId: provider.id, entryId: node.id, title: node.name, }); } }, [provider.id, onOpenEntry], ); const handleMove = useCallback( async (entryId: string, targetDirId: string) => { try { await provider.moveFile(entryId, targetDirId); void queryClient.invalidateQueries({ queryKey: treeQueryKey(provider.id), }); } catch (e) { console.error('[sidebar] move failed', e); } }, [provider, queryClient], ); const handleContextMenu = useCallback( async ( node: { id: string; name: string; type: 'file' | 'folder' }, items: ContextMenuItem[], ) => { const action = await showContextMenu(items); if (!action) return; if ( (action === 'open-right' || action === 'open-below') && node.type === 'file' ) { onOpenInSplit( { providerId: provider.id, entryId: node.id, title: node.name }, action === 'open-right' ? 'right' : 'down', ); return; } onContextMenuAction(action, node.id, node.name, node.type); }, [showContextMenu, onOpenInSplit, onContextMenuAction, provider.id], ); const handleTreeRootRightClick = useCallback( async (e: React.MouseEvent) => { e.preventDefault(); const action = await showContextMenu([ { id: 'new-note', label: 'New Note' }, { id: 'new-folder', label: 'New Folder' }, ]); if (action === 'new-note' || action === 'new-folder') { onTreeRootRightClick(action); } }, [showContextMenu, onTreeRootRightClick], ); return ( {provider.displayName} {!provider.isReady() ? ( {provider.idleHint} ) : treeQuery.isLoading ? ( Loading… ) : treeQuery.isError ? ( {treeQuery.error instanceof Error ? treeQuery.error.message : 'Failed to load'} ) : ( <> {treeQuery.data?.length === 0 && !inlineCreate && ( No files. )} > )} ); } /** Dropdown selector that tacks to the bottom of the sidebar. */ function ProviderSelector({ providers, selectedId, onSelect, unsyncedVaults = [], onSyncRemote, }: { providers: FileSystemProvider[]; selectedId: string; onSelect: (id: string) => void; unsyncedVaults?: RemoteVault[]; onSyncRemote?: (vault: RemoteVault) => void; }) { const [open, setOpen] = useState(false); const selected = providers.find((p) => p.id === selectedId); const containerRef = useRef(null); useEffect(() => { if (!open) return; function handleClick(e: MouseEvent) { if (!containerRef.current?.contains(e.target as Node)) { setOpen(false); } } document.addEventListener('mousedown', handleClick); return () => document.removeEventListener('mousedown', handleClick); }, [open]); return ( setOpen((o) => !o)} className="hover:bg-border/20 flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-xs transition-colors" > {selected?.displayName ?? 'Select source'} {open && ( {providers.map((provider) => ( { onSelect(provider.id); setOpen(false); }} className={`flex w-full cursor-pointer items-center px-2.5 py-1.5 text-left text-xs transition-colors ${ provider.id === selectedId ? 'bg-accent/10 text-foreground' : 'text-foreground/80 hover:bg-border/20' }`} > {provider.displayName} ))} {unsyncedVaults.length > 0 && ( {unsyncedVaults.map((vault) => ( { onSyncRemote?.(vault); setOpen(false); }} className="text-foreground/60 hover:bg-border/20 flex w-full cursor-pointer items-center gap-1.5 px-2.5 py-1.5 text-left text-xs transition-colors" > {vault.name} Unsynced ))} )} )} ); } export function AppSidebar({ open, providers, selectedProviderId, onSelectProviderId, onOpenVaultManager, onOpenEntry, onOpenInSplit, unsyncedVaults, onSyncRemote, activeDocument, }: AppSidebarProps) { const queryClient = useQueryClient(); const [createError, setCreateError] = useState(null); const [inlineCreate, setInlineCreate] = useState( null, ); const [renameNodeId, setRenameNodeId] = useState(null); const [renameValue, setRenameValue] = useState(''); const [renameExt, setRenameExt] = useState(''); const [renameError, setRenameError] = useState(null); const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_SIDEBAR_WIDTH); const [isResizing, setIsResizing] = useState(false); const resizeStartX = useRef(0); const resizeStartWidth = useRef(0); const selectedProvider = providers.find((p) => p.id === selectedProviderId); // Clear stale errors and creation state when switching providers. useEffect(() => { setCreateError(null); setInlineCreate(null); setRenameNodeId(null); setRenameValue(''); setRenameExt(''); setRenameError(null); }, [selectedProviderId]); const handleContextMenuAction = useCallback( async ( action: string, nodeId: string, nodeName: string, _nodeType: 'file' | 'folder', ) => { if (!selectedProvider) return; switch (action) { case 'new-note': { setInlineCreate({ parentId: nodeId, type: 'file', name: '', error: null, }); break; } case 'new-folder': { setInlineCreate({ parentId: nodeId, type: 'folder', name: '', error: null, }); break; } case 'copy': { try { await selectedProvider.copyFile(nodeId); void queryClient.invalidateQueries({ queryKey: treeQueryKey(selectedProvider.id), }); } catch (e: unknown) { setCreateError( e instanceof Error ? e.message : 'Could not copy file', ); } break; } case 'copy-path': { const path = selectedProvider.getPath(nodeId); if (path) { void navigator.clipboard.writeText(path); } break; } case 'rename': { setRenameNodeId(nodeId); const ext = nodeName.includes('.') ? nodeName.slice(nodeName.lastIndexOf('.')) : ''; const base = ext ? nodeName.slice(0, -ext.length) : nodeName; setRenameValue(base); setRenameExt(ext); setRenameError(null); break; } case 'delete': { if (window.confirm(`Delete "${nodeName}"? This cannot be undone.`)) { try { await selectedProvider.deleteFile(nodeId); void queryClient.invalidateQueries({ queryKey: treeQueryKey(selectedProvider.id), }); } catch (e: unknown) { setCreateError( e instanceof Error ? e.message : 'Could not delete', ); } } break; } } }, [selectedProvider, queryClient], ); const handleInlineCreateSubmit = useCallback( async (name: string) => { if (!selectedProvider || !inlineCreate) return; setCreateError(null); const trimmed = name.trim(); if (inlineCreate.type === 'file') { try { const entry = await selectedProvider.createFile( inlineCreate.parentId, trimmed || undefined, ); setInlineCreate(null); onOpenEntry?.({ providerId: selectedProvider.id, entryId: entry.id, title: entry.name, }); void queryClient.invalidateQueries({ queryKey: treeQueryKey(selectedProvider.id), }); } catch (e: unknown) { setInlineCreate({ ...inlineCreate, error: e instanceof Error ? e.message : 'Could not create file', }); } } else { try { await selectedProvider.createDirectory( trimmed || 'Untitled', inlineCreate.parentId, ); setInlineCreate(null); void queryClient.invalidateQueries({ queryKey: treeQueryKey(selectedProvider.id), }); } catch (e: unknown) { setInlineCreate({ ...inlineCreate, error: e instanceof Error ? e.message : 'Could not create directory', }); } } }, [selectedProvider, inlineCreate, queryClient, onOpenEntry], ); const handleInlineCreateCancel = useCallback(() => { setInlineCreate(null); }, []); const handleInlineCreateChange = useCallback((name: string) => { setInlineCreate((prev) => (prev ? { ...prev, name, error: null } : null)); }, []); const handleRenameSubmit = useCallback( async (newName: string) => { if (!selectedProvider || !renameNodeId) return; const trimmed = newName.trim(); if (!trimmed) { setRenameNodeId(null); setRenameValue(''); setRenameExt(''); setRenameError(null); return; } const finalName = trimmed + renameExt; try { await selectedProvider.renameFile(renameNodeId, finalName); setRenameNodeId(null); setRenameValue(''); setRenameExt(''); setRenameError(null); void queryClient.invalidateQueries({ queryKey: treeQueryKey(selectedProvider.id), }); } catch (e: unknown) { setRenameError(e instanceof Error ? e.message : 'Could not rename'); } }, [selectedProvider, renameNodeId, renameExt, queryClient], ); const handleRenameCancel = useCallback(() => { setRenameNodeId(null); setRenameValue(''); setRenameExt(''); setRenameError(null); }, []); const handleResizeStart = useCallback( (e: React.MouseEvent) => { e.preventDefault(); resizeStartX.current = e.clientX; resizeStartWidth.current = sidebarWidth; setIsResizing(true); }, [sidebarWidth], ); useEffect(() => { if (!isResizing) return; const handleMouseMove = (e: MouseEvent) => { const maxWidth = window.innerWidth * 0.5; const newWidth = resizeStartWidth.current + (e.clientX - resizeStartX.current); setSidebarWidth( Math.max(MIN_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth)), ); }; const handleMouseUp = () => { setIsResizing(false); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, [isResizing]); useEffect(() => { const handleWindowResize = () => { const maxWidth = window.innerWidth * 0.5; if (sidebarWidth > maxWidth) { setSidebarWidth(Math.max(MIN_SIDEBAR_WIDTH, maxWidth)); } }; window.addEventListener('resize', handleWindowResize); return () => window.removeEventListener('resize', handleWindowResize); }, [sidebarWidth]); return ( ); }
{provider.idleHint}
Loading…
{treeQuery.error instanceof Error ? treeQuery.error.message : 'Failed to load'}
No files.