Something went wrong. Try again.
A local-first note taking app
Something went wrong. Try again.
25 kB · 721 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722import { 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 ( <section className="flex min-w-0 flex-1 flex-col"> <h2 className="text-foreground/80 mb-0.5 flex items-center gap-1.5 pl-2.5 text-[11px] font-semibold tracking-wide uppercase"> {provider.displayName} </h2> <div className="flex min-h-0 flex-1 flex-col gap-px"> {!provider.isReady() ? ( <p className="text-foreground/60 px-2 text-xs leading-snug"> {provider.idleHint} </p> ) : treeQuery.isLoading ? ( <p className="text-foreground/60 px-2 text-xs">Loading…</p> ) : treeQuery.isError ? ( <p className="text-destructive px-2 text-xs leading-snug"> {treeQuery.error instanceof Error ? treeQuery.error.message : 'Failed to load'} </p> ) : ( <> <div className="min-h-0 flex-1" onContextMenu={handleTreeRootRightClick} > <FileSystemTree nodes={treeQuery.data ?? []} rootId={provider.rootId} onSelect={handleSelect} onContextMenu={handleContextMenu} onMove={handleMove} selectedId={ selection?.providerId === provider.id ? selection.entryId : undefined } renameNodeId={renameNodeId ?? undefined} renameValue={renameValue} renameExt={renameExt} renameError={renameError} onRenameValueChange={onRenameValueChange} onRenameSubmit={onRenameSubmit} onRenameCancel={onRenameCancel} inlineCreate={inlineCreate ?? undefined} onInlineCreateChange={onInlineCreateChange} onInlineCreateSubmit={onInlineCreateSubmit} onInlineCreateCancel={onInlineCreateCancel} /> </div> {treeQuery.data?.length === 0 && !inlineCreate && ( <p className="text-foreground/60 px-2 text-xs leading-snug"> No files. </p> )} </> )} </div> </section> );}
/** 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<HTMLDivElement>(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 ( <div ref={containerRef} className="relative w-full"> <button type="button" onClick={() => 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" > <StackedChevronsIcon className="text-foreground/50 size-3.5 shrink-0" /> <span className="text-foreground/60 flex items-center gap-1.5 truncate text-[10px] tracking-wider uppercase"> {selected?.displayName ?? 'Select source'} </span> </button> {open && ( <div className="border-border bg-background absolute right-0 bottom-full left-0 z-10 mb-1 overflow-hidden rounded-md border shadow-md"> {providers.map((provider) => ( <button key={provider.id} type="button" onClick={() => { 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' }`} > <span className="truncate">{provider.displayName}</span> </button> ))} {unsyncedVaults.length > 0 && ( <div className="border-border border-t"> {unsyncedVaults.map((vault) => ( <button key={vault.rootUri} type="button" title={`Sync "${vault.name}" from your PDS`} onClick={() => { 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" > <CloudDownloadIcon className="size-3.5 shrink-0" /> <span className="truncate">{vault.name}</span> <span className="text-foreground/40 ml-auto text-[10px] tracking-wider uppercase"> Unsynced </span> </button> ))} </div> )} </div> )} </div> );}
export function AppSidebar({ open, providers, selectedProviderId, onSelectProviderId, onOpenVaultManager, onOpenEntry, onOpenInSplit, unsyncedVaults, onSyncRemote, activeDocument,}: AppSidebarProps) { const queryClient = useQueryClient(); const [createError, setCreateError] = useState<string | null>(null); const [inlineCreate, setInlineCreate] = useState<InlineCreateState | null>( null, ); const [renameNodeId, setRenameNodeId] = useState<string | null>(null); const [renameValue, setRenameValue] = useState(''); const [renameExt, setRenameExt] = useState(''); const [renameError, setRenameError] = useState<string | null>(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 ( <aside id="app-sidebar" className={`border-border bg-background/95 relative shrink-0 overflow-hidden border-r ${ open ? '' : 'w-0 border-r-0' }`} style={ open ? { width: sidebarWidth, transitionProperty: 'width, background-color, border-color, color', transitionDuration: '200ms, 320ms, 320ms, 320ms', transitionTimingFunction: 'ease-out, ease-in-out, ease-in-out, ease-in-out', } : { transitionProperty: 'width, background-color, border-color, color', transitionDuration: '200ms, 320ms, 320ms, 320ms', transitionTimingFunction: 'ease-out, ease-in-out, ease-in-out, ease-in-out', } } aria-hidden={!open} > <div className="flex h-full min-h-0 flex-col"> {/* Resize handle */} <div className="hover:bg-accent/20 absolute top-0 right-0 bottom-0 z-10 w-1 cursor-col-resize transition-colors" onMouseDown={handleResizeStart} title="Resize sidebar" /> {/* Top bar: create buttons for the selected provider. */} {selectedProvider && selectedProvider.isReady() ? ( <div className="border-border flex shrink-0 items-center gap-1 border-b px-2 py-1"> <button type="button" disabled={inlineCreate !== null} title={`New document in ${selectedProvider.displayName}`} aria-label={`New document in ${selectedProvider.displayName}`} aria-busy={ inlineCreate?.type === 'file' && inlineCreate?.parentId === undefined } onClick={() => setInlineCreate({ type: 'file', name: '', error: null }) } className="text-foreground hover:bg-border/40 focus-visible:ring-accent/50 cursor-pointer rounded p-1 outline-none focus-visible:ring-2 disabled:opacity-50" > <NewDocumentIcon className="size-3.5 shrink-0" /> </button> <button type="button" disabled={inlineCreate !== null} title="New directory" aria-label="New directory" onClick={() => setInlineCreate({ type: 'folder', name: '', error: null }) } className="text-foreground hover:bg-border/40 focus-visible:ring-accent/50 cursor-pointer rounded p-1 outline-none focus-visible:ring-2 disabled:opacity-50" > <NewDirectoryIcon className="size-3.5 shrink-0" /> </button> {createError ? ( <p className="text-destructive ml-1.5 text-[11px] leading-snug"> {createError} </p> ) : null} </div> ) : null}
{/* Main scrollable area: only the selected provider. */} <div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-2"> {selectedProvider ? ( <ProviderSection provider={selectedProvider} selection={activeDocument} onOpenEntry={onOpenEntry} renameNodeId={renameNodeId} renameValue={renameValue} renameExt={renameExt} renameError={renameError} onRenameValueChange={setRenameValue} onRenameSubmit={handleRenameSubmit} onRenameCancel={handleRenameCancel} inlineCreate={inlineCreate} onInlineCreateChange={handleInlineCreateChange} onInlineCreateSubmit={handleInlineCreateSubmit} onInlineCreateCancel={handleInlineCreateCancel} onContextMenuAction={handleContextMenuAction} onOpenInSplit={onOpenInSplit} onTreeRootRightClick={(action) => { if (action === 'new-note') { setInlineCreate({ type: 'file', name: '', error: null }); } else if (action === 'new-folder') { setInlineCreate({ type: 'folder', name: '', error: null }); } }} /> ) : providers.length === 0 ? ( <p className="text-foreground/60 px-1.5 text-[11px] leading-snug"> No sources available. </p> ) : null} </div>
{/* Bottom selector + vault create button. */} {providers.length > 0 && ( <div className="border-border flex shrink-0 items-center gap-1 border-t px-2 py-1"> <ProviderSelector providers={providers} selectedId={selectedProviderId} onSelect={onSelectProviderId} unsyncedVaults={unsyncedVaults} onSyncRemote={onSyncRemote} /> <button type="button" title="Open or create a vault" aria-label="Open or create a vault" onClick={() => onOpenVaultManager?.()} className="text-foreground/60 hover:bg-border/40 focus-visible:ring-accent/50 cursor-pointer rounded p-1 outline-none focus-visible:ring-2" > <PlusIcon className="size-3.5 shrink-0" /> </button> </div> )} </div> </aside> );}