A local-first note taking app
0

Configure Feed

Select the types of activity you want to include in your feed.

Basic local filesystem with sidebar tree, and basic stubs for a synced filesystem

Ethan Graf (Jun 10, 2026, 8:35 PM EDT) 9f35b85f 48b743b2

+1084 -111
+2
package-lock.json
··· 18 18 "@codemirror/language": "^6.12.3", 19 19 "@codemirror/state": "^6.6.0", 20 20 "@codemirror/view": "^6.41.1", 21 + "@lezer/highlight": "^1.2.3", 22 + "@lezer/markdown": "^1.6.3", 21 23 "@libp2p/circuit-relay-v2": "^3.2.24", 22 24 "@libp2p/identify": "^3.0.39", 23 25 "@libp2p/interface": "^2.11.0",
+71 -3
src/App.tsx
··· 13 13 import { HabitatFilesystemProvider } from './filesystem/providers/habitatFilesystemProvider'; 14 14 import type { FileSystemProvider } from './filesystem/types'; 15 15 import { SubtextFilesystemProvider } from './filesystem/providers/subtextFilesystemProvider'; 16 + import { LocalFilesystemProvider } from './filesystem/providers/localFilesystemProvider'; 17 + import { RemoteSyncFilesystemProvider } from './filesystem/providers/remoteSyncFilesystemProvider'; 18 + import { VaultDocumentProvider } from './documents/providers/vaultDocumentsProvider'; 19 + import { VaultFilesystemApi } from './filesystem/vaultFs'; 20 + import { VaultManager } from './components/VaultManager'; 21 + import { 22 + getOpenVaults, 23 + addOpenVault, 24 + subscribeVaults, 25 + vaultIdFromPath, 26 + } from './vault/registry'; 16 27 import { getRendererPlatform } from './lib/platform'; 17 28 import { AboutPage } from './pages/AboutPage'; 18 29 import { ··· 80 91 const [openRequest, setOpenRequest] = useState<OpenDocRequest | null>(null); 81 92 const [openRequestId, setOpenRequestId] = useState(0); 82 93 const [selectedProviderId, setSelectedProviderId] = useState<string>(''); 94 + const [vaultManagerOpen, setVaultManagerOpen] = useState(false); 95 + const [openVaults, setOpenVaults] = useState(() => getOpenVaults()); 83 96 84 97 const isMac = getRendererPlatform() === 'darwin'; 85 98 ··· 91 104 92 105 const navigate = useNavigate(); 93 106 94 - const fileSystemProviders = useMemo<FileSystemProvider[]>(() => { 107 + // Subscribe to vault registry changes. 108 + useEffect(() => { 109 + return subscribeVaults(() => { 110 + setOpenVaults(getOpenVaults()); 111 + }); 112 + }, []); 113 + 114 + const staticProviders = useMemo<FileSystemProvider[]>(() => { 95 115 const automergeDocuments = new AutomergeDocumentsProvider(); 96 116 const habitatDocuments = new HabitatDocumentsProvider(); 97 117 const providers: FileSystemProvider[] = [ ··· 104 124 defaultEditorKind: 'automerge', 105 125 }), 106 126 ]; 107 - // TODO: When the Vault concept is introduced, each vault will choose its own 108 - // source; this conditional registration will move to per-vault configuration. 109 127 if (isSubtextEnabled()) { 110 128 providers.push( 111 129 new SubtextFilesystemProvider({ ··· 116 134 } 117 135 return providers; 118 136 }, []); 137 + 138 + const vaultProviders = useMemo<FileSystemProvider[]>(() => { 139 + const vault = window.textile?.vault; 140 + if (!vault) return []; 141 + const fs = new VaultFilesystemApi(vault); 142 + return openVaults.map((vaultEntry) => { 143 + if (vaultEntry.type === 'synced') { 144 + return new RemoteSyncFilesystemProvider({ 145 + id: `vault-${vaultEntry.id}`, 146 + rootPath: vaultEntry.path, 147 + displayName: vaultEntry.name, 148 + documents: new VaultDocumentProvider({ fs, rootPath: vaultEntry.path }), 149 + defaultEditorKind: 'automerge', 150 + }); 151 + } 152 + return new LocalFilesystemProvider({ 153 + id: `vault-${vaultEntry.id}`, 154 + rootPath: vaultEntry.path, 155 + displayName: vaultEntry.name, 156 + documents: new VaultDocumentProvider({ fs, rootPath: vaultEntry.path }), 157 + defaultEditorKind: 'automerge', 158 + fs, 159 + }); 160 + }); 161 + }, [openVaults]); 162 + 163 + const fileSystemProviders = useMemo<FileSystemProvider[]>( 164 + () => [...staticProviders, ...vaultProviders], 165 + [staticProviders, vaultProviders], 166 + ); 167 + 119 168 const resolveProvider = useCallback( 120 169 (providerId: string) => 121 170 fileSystemProviders.find((p) => p.id === providerId), ··· 203 252 setOpenRequestId((id) => id + 1); 204 253 }, []); 205 254 255 + const handleOpenVault = useCallback( 256 + (vaultPath: string, vaultType: 'local' | 'synced', did: string | null) => { 257 + const id = vaultIdFromPath(vaultPath); 258 + const name = vaultPath.split('/').pop() || vaultPath; 259 + const providerId = `vault-${id}`; 260 + addOpenVault({ id, path: vaultPath, type: vaultType, name, did }); 261 + // Auto-switch to the newly created vault — same path as the selector. 262 + setSelectedProviderId(providerId); 263 + }, 264 + [], 265 + ); 266 + 206 267 return ( 207 268 <ActionProvider 208 269 actionSets={actionSets} ··· 231 292 providers={fileSystemProviders} 232 293 selectedProviderId={selectedProviderId} 233 294 onSelectProviderId={setSelectedProviderId} 295 + onOpenVaultManager={() => setVaultManagerOpen(true)} 234 296 onOpenEntry={handleOpenEntry} 235 297 /> 236 298 <main className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden"> ··· 261 323 } 262 324 workspaceKind={workspaceKind} 263 325 onWorkspaceKindChange={setWorkspaceKind} 326 + /> 327 + <VaultManager 328 + open={vaultManagerOpen} 329 + onClose={() => setVaultManagerOpen(false)} 330 + onOpenVault={handleOpenVault} 331 + onOpenSettings={() => setSettingsOpen(true)} 264 332 /> 265 333 </div> 266 334 </ActionProvider>
+66 -1
src/main.ts
··· 1 - import { app, BrowserWindow, Menu, ipcMain } from 'electron'; 1 + import { app, BrowserWindow, Menu, ipcMain, dialog } from 'electron'; 2 2 import { SubtextTCPClient } from './subtext/tcpClient'; 3 3 import path from 'node:path'; 4 + import os from 'node:os'; 5 + import fs from 'node:fs/promises'; 4 6 import started from 'electron-squirrel-startup'; 5 7 6 8 const APP_PROTOCOL_SCHEME = 'io.github.eagraf'; 9 + 10 + /** 11 + * Expand a path that may start with `~` to the user's home directory. 12 + * Node.js fs does not expand `~` automatically. 13 + */ 14 + function expandPath(inputPath: string): string { 15 + if (inputPath === '~' || inputPath.startsWith('~/')) { 16 + return path.join(os.homedir(), inputPath.slice(1)); 17 + } 18 + if (inputPath.startsWith('~\\')) { 19 + return path.join(os.homedir(), inputPath.slice(1)); 20 + } 21 + return inputPath; 22 + } 7 23 8 24 // Handle creating/removing shortcuts on Windows when installing/uninstalling. 9 25 if (started) { ··· 307 323 308 324 ipcMain.handle('subtext:status', async () => { 309 325 return { connected: subtextClient.isConnected }; 326 + }); 327 + 328 + ipcMain.handle( 329 + 'vault:show-open-dialog', 330 + async (_event) => { 331 + if (!mainWindow) return { canceled: true, filePaths: [] }; 332 + const result = await dialog.showOpenDialog(mainWindow, { 333 + properties: ['openDirectory', 'createDirectory'], 334 + }); 335 + return result; 336 + }, 337 + ); 338 + 339 + ipcMain.handle('vault:readdir', async (_event, { dirPath }) => { 340 + const expanded = expandPath(dirPath); 341 + // Ensure the directory exists before listing; create it if missing. 342 + await fs.mkdir(expanded, { recursive: true }); 343 + const entries = await fs.readdir(expanded, { withFileTypes: true }); 344 + return entries.map((e) => ({ 345 + name: e.name, 346 + isDirectory: e.isDirectory(), 347 + isFile: e.isFile(), 348 + })); 349 + }); 350 + 351 + ipcMain.handle('vault:read-file', async (_event, { filePath }) => { 352 + const expanded = expandPath(filePath); 353 + const content = await fs.readFile(expanded, 'utf-8'); 354 + return content; 355 + }); 356 + 357 + ipcMain.handle('vault:write-file', async (_event, { filePath, content }) => { 358 + const expanded = expandPath(filePath); 359 + // Ensure parent directories exist before writing. 360 + await fs.mkdir(path.dirname(expanded), { recursive: true }); 361 + await fs.writeFile(expanded, content, 'utf-8'); 362 + }); 363 + 364 + ipcMain.handle('vault:file-exists', async (_event, { filePath }) => { 365 + try { 366 + await fs.access(expandPath(filePath)); 367 + return true; 368 + } catch { 369 + return false; 370 + } 371 + }); 372 + 373 + ipcMain.handle('vault:mkdir', async (_event, { dirPath }) => { 374 + await fs.mkdir(expandPath(dirPath), { recursive: true }); 310 375 }); 311 376 312 377 ipcMain.handle(
+8
src/preload.ts
··· 9 9 }, 10 10 startHabitatLogin: (url: string, redirectUri: string) => 11 11 ipcRenderer.invoke('auth:start-habitat-login', { redirectUri, url }), 12 + vault: { 13 + showOpenDialog: () => ipcRenderer.invoke('vault:show-open-dialog'), 14 + readdir: (dirPath: string) => ipcRenderer.invoke('vault:readdir', { dirPath }), 15 + readFile: (filePath: string) => ipcRenderer.invoke('vault:read-file', { filePath }), 16 + writeFile: (filePath: string, content: string) => ipcRenderer.invoke('vault:write-file', { filePath, content }), 17 + fileExists: (filePath: string) => ipcRenderer.invoke('vault:file-exists', { filePath }), 18 + mkdir: (dirPath: string) => ipcRenderer.invoke('vault:mkdir', { dirPath }), 19 + }, 12 20 subtext: { 13 21 getFullState: (vaultPath: string) => 14 22 ipcRenderer.invoke('subtext:get-full-state', { vaultPath }),
+8
src/vite-env.d.ts
··· 6 6 platform: NodeJS.Platform; 7 7 onOpenSettings: (callback: () => void) => () => void; 8 8 startHabitatLogin: (url: string, redirectUri: string) => Promise<string>; 9 + vault: { 10 + showOpenDialog: () => Promise<{ canceled: boolean; filePaths: string[] }>; 11 + readdir: (dirPath: string) => Promise<{ name: string; isDirectory: boolean; isFile: boolean }[]>; 12 + readFile: (filePath: string) => Promise<string>; 13 + writeFile: (filePath: string, content: string) => Promise<void>; 14 + fileExists: (filePath: string) => Promise<boolean>; 15 + mkdir: (dirPath: string) => Promise<void>; 16 + }; 9 17 subtext: { 10 18 getFullState: (vaultPath: string) => Promise<Uint8Array | null>; 11 19 applyUpdate: (vaultPath: string, update: Uint8Array) => Promise<void>;
+208 -104
src/components/AppSidebar.tsx
··· 6 6 useState, 7 7 } from 'react'; 8 8 import { useQuery, useQueryClient } from '@tanstack/react-query'; 9 - import type { FileSystemEntry, FileSystemProvider } from '../filesystem/types'; 10 - import { DocFileIcon, NewDocumentIcon, StackedChevronsIcon } from '../icons'; 9 + import type { FileSystemEntry, FileSystemProvider, FileSystemTreeNode } from '../filesystem/types'; 10 + import { DocFileIcon, NewDocumentIcon, NewDirectoryIcon, StackedChevronsIcon, PlusIcon } from '../icons'; 11 11 import type { OpenDocRequest } from '../workspaces/workspace'; 12 + import { FileSystemTree } from './VaultTree'; 12 13 13 14 type AppSidebarProps = { 14 15 open: boolean; 15 16 providers: FileSystemProvider[]; 16 17 selectedProviderId: string; 17 18 onSelectProviderId: (id: string) => void; 19 + /** Emitted when the user wants to open/create a new vault. */ 20 + onOpenVaultManager?: () => void; 18 21 /** Emitted when the user picks an entry; the shell forwards it to the active workspace. */ 19 22 onOpenEntry?: (request: OpenDocRequest) => void; 20 23 }; ··· 25 28 return ['files', providerId] as const; 26 29 } 27 30 31 + function treeQueryKey(providerId: string) { 32 + return ['tree', providerId] as const; 33 + } 34 + 28 35 type ProviderSectionProps = { 29 36 provider: FileSystemProvider; 30 37 selection: Selection; 31 38 onSelect: (sel: Selection) => void; 32 39 onOpenEntry?: (request: OpenDocRequest) => void; 40 + creatingDir: boolean; 41 + newDirName: string; 42 + newDirError: string | null; 43 + onNewDirNameChange: (name: string) => void; 44 + onNewDirErrorChange: (error: string | null) => void; 45 + onNewDirSubmit: (name: string) => void; 46 + onNewDirCancel: () => void; 33 47 }; 34 48 35 49 function ProviderSection({ ··· 37 51 selection, 38 52 onSelect, 39 53 onOpenEntry, 54 + creatingDir, 55 + newDirName, 56 + newDirError, 57 + onNewDirNameChange, 58 + onNewDirErrorChange, 59 + onNewDirSubmit, 60 + onNewDirCancel, 40 61 }: ProviderSectionProps) { 41 62 const queryClient = useQueryClient(); 63 + const inputRef = useRef<HTMLInputElement>(null); 42 64 43 - // Core listing query. 44 - const query = useQuery({ 45 - queryKey: filesQueryKey(provider.id), 46 - queryFn: () => provider.listFiles(), 65 + // Tree query for all providers — flat sources return single-level trees. 66 + const treeQuery = useQuery({ 67 + queryKey: treeQueryKey(provider.id), 68 + queryFn: () => provider.getTree(), 47 69 enabled: provider.isReady(), 70 + refetchInterval: 3_000, 48 71 }); 49 72 50 - // Invalidate (re-fetch) when the provider transitions from not-ready → ready, 51 - // e.g. the user signs in. 73 + // Invalidate when provider becomes ready. 52 74 useEffect(() => { 53 75 if (!provider.subscribeReady) return; 54 76 return provider.subscribeReady(() => { 55 - void queryClient.invalidateQueries({ 56 - queryKey: filesQueryKey(provider.id), 57 - }); 77 + void queryClient.invalidateQueries({ queryKey: treeQueryKey(provider.id) }); 58 78 }); 59 79 }, [provider, queryClient]); 60 80 61 - // Patch individual entry names in the cache as the user edits document 62 - // headings, without requiring a full re-fetch. 81 + // Focus inline input when entering directory creation mode. 63 82 useEffect(() => { 64 - if (!provider.subscribeEntryTitles) return; 65 - return provider.subscribeEntryTitles((entryId, name) => { 66 - queryClient.setQueryData<FileSystemEntry[]>( 67 - filesQueryKey(provider.id), 68 - (entries) => { 69 - if (!entries) return entries; 70 - const index = entries.findIndex((e) => e.id === entryId); 71 - if (index < 0 || entries[index]?.name === name) return entries; 72 - const next = entries.slice(); 73 - next[index] = { id: entryId, name }; 74 - return next; 75 - }, 76 - ); 77 - }); 78 - }, [provider, queryClient]); 79 - 80 - // Clear the selection if the selected entry disappears from this provider's 81 - // listing (e.g. deleted remotely). 82 - useEffect(() => { 83 - if (!selection || selection.providerId !== provider.id) return; 84 - if (query.isPending) return; 85 - if (query.data && !query.data.some((e) => e.id === selection.entryId)) { 86 - onSelect(null); 83 + if (creatingDir) { 84 + inputRef.current?.focus(); 87 85 } 88 - }, [query.data, query.isPending, selection, provider.id, onSelect]); 86 + }, [creatingDir]); 89 87 90 - const entries = useMemo( 91 - () => [...(query.data ?? [])].sort((a, b) => a.name.localeCompare(b.name)), 92 - [query.data], 88 + const isSelected = (nodeId: string) => 89 + selection?.providerId === provider.id && selection.entryId === nodeId; 90 + 91 + const handleSelect = useCallback( 92 + (node: { id: string; name: string; type: 'file' | 'folder' }) => { 93 + if (node.type === 'file') { 94 + onSelect({ providerId: provider.id, entryId: node.id }); 95 + onOpenEntry?.({ 96 + providerId: provider.id, 97 + entryId: node.id, 98 + title: node.name, 99 + }); 100 + } 101 + }, 102 + [provider.id, onSelect, onOpenEntry], 93 103 ); 94 - const idleCopy = 95 - provider.idleHint ?? 96 - 'Connect an account in Settings (⌘,) to see files here.'; 104 + 105 + const handleDirInputChange = (value: string) => { 106 + onNewDirNameChange(value); 107 + const trimmed = value.trim(); 108 + const exists = treeQuery.data?.some((node) => node.name === trimmed); 109 + onNewDirErrorChange(exists && trimmed.length > 0 ? `A directory named "${trimmed}" already exists` : null); 110 + }; 111 + 112 + const handleDirKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { 113 + if (e.key === 'Enter') { 114 + e.preventDefault(); 115 + const trimmed = newDirName.trim(); 116 + if (trimmed.length > 0 && !newDirError) { 117 + onNewDirSubmit(trimmed); 118 + } 119 + } else if (e.key === 'Escape') { 120 + onNewDirCancel(); 121 + } 122 + }; 97 123 98 124 return ( 99 125 <section className="min-w-0"> 100 - <h2 className="text-foreground/70 mb-0.5 text-[11px] font-semibold tracking-wide uppercase"> 126 + <h2 className="text-foreground/80 mb-0.5 flex items-center gap-1.5 pl-2.5 text-[11px] font-semibold tracking-wide uppercase"> 101 127 {provider.displayName} 102 128 </h2> 103 129 <div className="flex flex-col gap-px"> 104 130 {!provider.isReady() ? ( 105 - <p className="text-foreground/50 px-1.5 text-[11px] leading-snug"> 106 - {idleCopy} 131 + <p className="text-foreground/60 px-2 text-xs leading-snug"> 132 + {provider.idleHint} 107 133 </p> 108 - ) : query.isLoading ? ( 109 - <p className="text-foreground/50 px-1.5 text-[11px]">Loading…</p> 110 - ) : query.isError ? ( 111 - <p className="text-destructive px-1.5 text-[11px] leading-snug"> 112 - {query.error instanceof Error 113 - ? query.error.message 114 - : 'Failed to load files'} 115 - </p> 116 - ) : entries.length === 0 ? ( 117 - <p className="text-foreground/50 px-1.5 text-[11px] leading-snug"> 118 - No files. 134 + ) : treeQuery.isLoading ? ( 135 + <p className="text-foreground/60 px-2 text-xs">Loading…</p> 136 + ) : treeQuery.isError ? ( 137 + <p className="text-destructive px-2 text-xs leading-snug"> 138 + {treeQuery.error instanceof Error 139 + ? treeQuery.error.message 140 + : 'Failed to load'} 119 141 </p> 120 142 ) : ( 121 - <div role="list" className="flex flex-col gap-px"> 122 - {entries.map((entry) => { 123 - const isSelected = 124 - selection?.providerId === provider.id && 125 - selection.entryId === entry.id; 126 - return ( 127 - <button 128 - key={`${provider.id}:${entry.id}`} 129 - type="button" 130 - role="listitem" 131 - title={entry.name} 132 - aria-current={isSelected ? 'true' : undefined} 133 - onClick={() => { 134 - onSelect({ 135 - providerId: provider.id, 136 - entryId: entry.id, 137 - }); 138 - onOpenEntry?.({ 139 - providerId: provider.id, 140 - entryId: entry.id, 141 - title: entry.name, 142 - }); 143 - }} 144 - className={`font-inherit focus-visible:ring-accent/50 flex w-full min-w-0 shrink-0 cursor-pointer items-center gap-1.5 rounded border-0 px-1.5 py-1 text-left text-xs outline-none select-none focus-visible:ring-2 ${ 145 - isSelected 146 - ? 'bg-accent/10 text-foreground' 147 - : 'text-foreground/70 hover:bg-border/35 bg-transparent' 148 - }`} 149 - > 150 - <DocFileIcon 151 - className={`size-3.5 shrink-0 ${isSelected ? 'text-accent' : 'text-foreground/40'}`} 143 + <> 144 + {creatingDir && ( 145 + <div className="flex flex-col"> 146 + <div className="flex items-center gap-1 px-2 py-0.5"> 147 + <NewDirectoryIcon className="size-3 shrink-0 text-foreground/40" /> 148 + <input 149 + ref={inputRef} 150 + type="text" 151 + value={newDirName} 152 + onChange={(e) => handleDirInputChange(e.target.value)} 153 + onKeyDown={handleDirKeyDown} 154 + onBlur={onNewDirCancel} 155 + className="flex-1 min-w-0 bg-transparent text-foreground text-[11px] outline-none border-0 p-0" 152 156 /> 153 - <span className="truncate">{entry.name}</span> 154 - </button> 155 - ); 156 - })} 157 - </div> 157 + </div> 158 + {newDirError && ( 159 + <p className="text-destructive text-[10px] px-2 pb-0.5"> 160 + {newDirError} 161 + </p> 162 + )} 163 + </div> 164 + )} 165 + <FileSystemTree 166 + nodes={treeQuery.data ?? []} 167 + onSelect={handleSelect} 168 + selectedId={ 169 + selection?.providerId === provider.id ? selection.entryId : undefined 170 + } 171 + /> 172 + {treeQuery.data?.length === 0 && !creatingDir && ( 173 + <p className="text-foreground/60 px-2 text-xs leading-snug"> 174 + No files. 175 + </p> 176 + )} 177 + </> 158 178 )} 159 179 </div> 160 180 </section> ··· 194 214 className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs hover:bg-border/20 transition-colors cursor-pointer" 195 215 > 196 216 <StackedChevronsIcon className="size-3.5 shrink-0 text-foreground/50" /> 197 - <span className="truncate text-foreground/60 uppercase tracking-wider text-[10px]"> 217 + <span className="flex items-center gap-1.5 truncate text-foreground/60 uppercase tracking-wider text-[10px]"> 198 218 {selected?.displayName ?? 'Select source'} 199 219 </span> 200 220 </button> ··· 228 248 providers, 229 249 selectedProviderId, 230 250 onSelectProviderId, 251 + onOpenVaultManager, 231 252 onOpenEntry, 232 253 }: AppSidebarProps) { 233 254 const queryClient = useQueryClient(); 234 255 const [selection, setSelection] = useState<Selection>(null); 235 256 const [creatingFor, setCreatingFor] = useState<string | null>(null); 236 257 const [createError, setCreateError] = useState<string | null>(null); 258 + const [creatingDir, setCreatingDir] = useState(false); 259 + const [newDirName, setNewDirName] = useState(''); 260 + const [newDirError, setNewDirError] = useState<string | null>(null); 237 261 238 262 const selectedProvider = providers.find((p) => p.id === selectedProviderId); 239 263 240 - // Clear stale errors when switching providers. 264 + // Clear stale errors and creation state when switching providers. 241 265 useEffect(() => { 242 266 setCreateError(null); 267 + setCreatingDir(false); 268 + setNewDirName(''); 269 + setNewDirError(null); 243 270 }, [selectedProviderId]); 271 + 272 + const sortTreeNodes = (a: FileSystemTreeNode, b: FileSystemTreeNode): number => { 273 + if (a.type === b.type) return a.name.localeCompare(b.name); 274 + return a.type === 'folder' ? -1 : 1; 275 + }; 244 276 245 277 const handleCreate = useCallback( 246 278 async (provider: FileSystemProvider) => { ··· 248 280 setCreatingFor(provider.id); 249 281 try { 250 282 const entry = await provider.createFile(); 251 - // Optimistically insert so the selection effect doesn't race and clear 252 - // the selection before the invalidation re-fetch resolves. 253 283 queryClient.setQueryData<FileSystemEntry[]>( 254 284 filesQueryKey(provider.id), 255 285 (entries) => ··· 257 287 ? [...entries, entry] 258 288 : (entries ?? [entry]), 259 289 ); 290 + // Optimistically insert into tree cache. 291 + queryClient.setQueryData<FileSystemTreeNode[]>( 292 + treeQueryKey(provider.id), 293 + (nodes) => { 294 + const newNode: FileSystemTreeNode = { id: entry.id, name: entry.name, type: 'file' }; 295 + if (!nodes) return [newNode]; 296 + const next = [...nodes, newNode]; 297 + next.sort(sortTreeNodes); 298 + return next; 299 + }, 300 + ); 260 301 setSelection({ providerId: provider.id, entryId: entry.id }); 261 302 onOpenEntry?.({ 262 303 providerId: provider.id, 263 304 entryId: entry.id, 264 305 title: entry.name, 265 306 }); 266 - void queryClient.invalidateQueries({ 267 - queryKey: filesQueryKey(provider.id), 268 - }); 307 + void queryClient.invalidateQueries({ queryKey: treeQueryKey(provider.id) }); 269 308 } catch (e: unknown) { 270 309 setCreateError( 271 310 e instanceof Error ? e.message : 'Could not create document', ··· 276 315 }, 277 316 [queryClient, setSelection, onOpenEntry], 278 317 ); 318 + 319 + const handleCreateDirectory = useCallback( 320 + async (name: string) => { 321 + if (!selectedProvider) return; 322 + try { 323 + const entry = await selectedProvider.createDirectory(name); 324 + // Optimistically insert into tree cache. 325 + queryClient.setQueryData<FileSystemTreeNode[]>( 326 + treeQueryKey(selectedProvider.id), 327 + (nodes) => { 328 + const newNode: FileSystemTreeNode = { id: entry.id, name: entry.name, type: 'folder', children: [] }; 329 + if (!nodes) return [newNode]; 330 + const next = [...nodes, newNode]; 331 + next.sort(sortTreeNodes); 332 + return next; 333 + }, 334 + ); 335 + setCreatingDir(false); 336 + setNewDirName(''); 337 + setNewDirError(null); 338 + void queryClient.invalidateQueries({ queryKey: treeQueryKey(selectedProvider.id) }); 339 + } catch (e: unknown) { 340 + setNewDirError( 341 + e instanceof Error ? e.message : 'Could not create directory', 342 + ); 343 + } 344 + }, 345 + [selectedProvider, queryClient], 346 + ); 347 + 348 + const supportsDirectory = !!selectedProvider; 279 349 280 350 return ( 281 351 <aside ··· 292 362 aria-hidden={!open} 293 363 > 294 364 <div className="flex h-full min-h-0 w-56 flex-col"> 295 - {/* Top bar: create button for the selected provider only. */} 365 + {/* Top bar: create buttons for the selected provider. */} 296 366 {selectedProvider && selectedProvider.isReady() ? ( 297 - <div className="border-border flex shrink-0 items-center border-b px-2 py-1"> 367 + <div className="border-border flex shrink-0 items-center gap-1 border-b px-2 py-1"> 298 368 <button 299 369 type="button" 300 370 disabled={creatingFor !== null} ··· 305 375 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" 306 376 > 307 377 <NewDocumentIcon className="size-3.5 shrink-0" /> 378 + </button> 379 + <button 380 + type="button" 381 + disabled={creatingDir} 382 + title="New directory" 383 + aria-label="New directory" 384 + onClick={() => { 385 + setCreatingDir(true); 386 + setNewDirName(''); 387 + setNewDirError(null); 388 + }} 389 + 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" 390 + > 391 + <NewDirectoryIcon className="size-3.5 shrink-0" /> 308 392 </button> 309 393 {createError ? ( 310 394 <p className="text-destructive ml-1.5 text-[11px] leading-snug"> ··· 322 406 selection={selection} 323 407 onSelect={setSelection} 324 408 onOpenEntry={onOpenEntry} 409 + creatingDir={creatingDir} 410 + newDirName={newDirName} 411 + newDirError={newDirError} 412 + onNewDirNameChange={setNewDirName} 413 + onNewDirErrorChange={setNewDirError} 414 + onNewDirSubmit={handleCreateDirectory} 415 + onNewDirCancel={() => { 416 + setCreatingDir(false); 417 + setNewDirName(''); 418 + setNewDirError(null); 419 + }} 325 420 /> 326 421 ) : providers.length === 0 ? ( 327 422 <p className="text-foreground/60 px-1.5 text-[11px] leading-snug"> ··· 330 425 ) : null} 331 426 </div> 332 427 333 - {/* Bottom selector: tacked to the bottom of the sidebar. */} 428 + {/* Bottom selector + vault create button. */} 334 429 {providers.length > 0 && ( 335 - <div className="border-border flex shrink-0 items-center border-t px-2 py-1"> 430 + <div className="border-border flex shrink-0 items-center gap-1 border-t px-2 py-1"> 336 431 <ProviderSelector 337 432 providers={providers} 338 433 selectedId={selectedProviderId} 339 434 onSelect={onSelectProviderId} 340 435 /> 436 + <button 437 + type="button" 438 + title="Open or create a vault" 439 + aria-label="Open or create a vault" 440 + onClick={() => onOpenVaultManager?.()} 441 + className="text-foreground/60 hover:bg-border/40 focus-visible:ring-accent/50 cursor-pointer rounded p-1 outline-none focus-visible:ring-2" 442 + > 443 + <PlusIcon className="size-3.5 shrink-0" /> 444 + </button> 341 445 </div> 342 446 )} 343 447 </div>
+111
src/components/VaultTree.tsx
··· 1 + import { useState, useCallback } from 'react'; 2 + import type { FileSystemTreeNode } from '../filesystem/types'; 3 + 4 + type FileSystemTreeProps = { 5 + nodes: FileSystemTreeNode[]; 6 + onSelect?: (node: FileSystemTreeNode) => void; 7 + selectedId?: string; 8 + }; 9 + 10 + function TriangleRight({ className }: { className?: string }) { 11 + return ( 12 + <svg 13 + className={className} 14 + viewBox="0 0 3 4" 15 + fill="currentColor" 16 + aria-hidden 17 + > 18 + <polygon points="0,0 3,2 0,4" /> 19 + </svg> 20 + ); 21 + } 22 + 23 + function TriangleDown({ className }: { className?: string }) { 24 + return ( 25 + <svg 26 + className={className} 27 + viewBox="0 0 4 3" 28 + fill="currentColor" 29 + aria-hidden 30 + > 31 + <polygon points="0,0 4,0 2,3" /> 32 + </svg> 33 + ); 34 + } 35 + 36 + function TreeNode({ 37 + node, 38 + depth, 39 + selectedId, 40 + onSelect, 41 + }: { 42 + node: FileSystemTreeNode; 43 + depth: number; 44 + selectedId?: string; 45 + onSelect?: (node: FileSystemTreeNode) => void; 46 + }) { 47 + const [expanded, setExpanded] = useState(depth < 2); 48 + const isSelected = node.id === selectedId; 49 + const hasChildren = node.children && node.children.length > 0; 50 + 51 + const handleClick = useCallback(() => { 52 + if (node.type === 'folder') { 53 + setExpanded((e) => !e); 54 + } 55 + onSelect?.(node); 56 + }, [node, onSelect]); 57 + 58 + return ( 59 + <div> 60 + <button 61 + type="button" 62 + onClick={handleClick} 63 + className={`flex w-full items-center gap-1 px-2 py-0.5 text-left text-xs ${ 64 + isSelected 65 + ? 'bg-accent/10 text-foreground' 66 + : 'text-foreground/70 hover:bg-border/35' 67 + }`} 68 + style={{ paddingLeft: `${depth * 12}px` }} 69 + > 70 + {node.type === 'folder' && ( 71 + <span className="text-foreground/40 w-1.5 h-1.5 flex items-center justify-center"> 72 + {expanded 73 + ? <TriangleDown className="w-1.5 h-1.5" /> 74 + : <TriangleRight className="w-1.5 h-1.5" />} 75 + </span> 76 + )} 77 + {node.type === 'file' && <span className="w-1.5" />} 78 + <span className="truncate">{node.name}</span> 79 + </button> 80 + {hasChildren && expanded && ( 81 + <div> 82 + {node.children!.map((child) => ( 83 + <TreeNode 84 + key={child.id} 85 + node={child} 86 + depth={depth + 1} 87 + selectedId={selectedId} 88 + onSelect={onSelect} 89 + /> 90 + ))} 91 + </div> 92 + )} 93 + </div> 94 + ); 95 + } 96 + 97 + export function FileSystemTree({ nodes, onSelect, selectedId }: FileSystemTreeProps) { 98 + return ( 99 + <div className="flex flex-col"> 100 + {nodes.map((node) => ( 101 + <TreeNode 102 + key={node.id} 103 + node={node} 104 + depth={0} 105 + selectedId={selectedId} 106 + onSelect={onSelect} 107 + /> 108 + ))} 109 + </div> 110 + ); 111 + }
+34
src/filesystem/types.ts
··· 14 14 name: string; 15 15 }; 16 16 17 + /** Raw filesystem directory entry (name + type flags). */ 18 + export type FsDirent = { 19 + name: string; 20 + isDirectory: boolean; 21 + isFile: boolean; 22 + }; 23 + 24 + /** Recursive tree node returned by `getTree()`. */ 25 + export type FileSystemTreeNode = { 26 + id: string; 27 + name: string; 28 + type: 'file' | 'folder'; 29 + children?: FileSystemTreeNode[]; 30 + }; 31 + 17 32 export type FileSystemProvider = { 18 33 /** Stable key for React and selection state (e.g. `habitat-docs`). */ 19 34 id: string; ··· 32 47 /** List entries for this source; reject on transport errors. */ 33 48 listFiles(): Promise<FileSystemEntry[]>; 34 49 /** 50 + * Build a recursive tree of the source's contents. 51 + * Providers without directories return a flat list of files. 52 + */ 53 + getTree(): Promise<FileSystemTreeNode[]>; 54 + /** 35 55 * Create a new persisted entry in this source (e.g. a new Habitat docs 36 56 * record). The shell optimistically inserts it, then refreshes the listing. 37 57 */ 38 58 createFile(): Promise<FileSystemEntry>; 59 + /** 60 + * Create a new directory in this source. 61 + * Providers without directory support may throw or no-op. 62 + */ 63 + createDirectory(name: string): Promise<FileSystemEntry>; 39 64 /** 40 65 * When implemented, called when a listed entry's display name changes 41 66 * (e.g. habitat doc heading edits). Used to keep the sidebar in sync. ··· 52 77 /** Live-document backend, injected at the composition root (`App.tsx`). */ 53 78 documents: DocumentProvider; 54 79 }; 80 + 81 + /** Low-level filesystem API used by providers that access the host disk. */ 82 + export interface FilesystemApi { 83 + readdir(dirPath: string): Promise<FsDirent[]>; 84 + readFile(filePath: string): Promise<string>; 85 + writeFile(filePath: string, content: string): Promise<void>; 86 + fileExists(filePath: string): Promise<boolean>; 87 + mkdir(dirPath: string): Promise<void>; 88 + }
+53
src/filesystem/vaultFs.ts
··· 1 + import type { FsDirent, FilesystemApi } from './types'; 2 + 3 + export type VaultIpcApi = { 4 + showOpenDialog: () => Promise<{ canceled: boolean; filePaths: string[] }>; 5 + readdir: (dirPath: string) => Promise<FsDirent[]>; 6 + readFile: (filePath: string) => Promise<string>; 7 + writeFile: (filePath: string, content: string) => Promise<void>; 8 + fileExists: (filePath: string) => Promise<boolean>; 9 + mkdir: (dirPath: string) => Promise<void>; 10 + }; 11 + 12 + export class VaultFilesystemApi implements FilesystemApi { 13 + constructor(private ipc: VaultIpcApi) {} 14 + 15 + async readdir(dirPath: string): Promise<FsDirent[]> { 16 + return this.ipc.readdir(dirPath); 17 + } 18 + 19 + async readFile(filePath: string): Promise<string> { 20 + return this.ipc.readFile(filePath); 21 + } 22 + 23 + async writeFile(filePath: string, content: string): Promise<void> { 24 + return this.ipc.writeFile(filePath, content); 25 + } 26 + 27 + async fileExists(filePath: string): Promise<boolean> { 28 + return this.ipc.fileExists(filePath); 29 + } 30 + 31 + async mkdir(dirPath: string): Promise<void> { 32 + return this.ipc.mkdir(dirPath); 33 + } 34 + } 35 + 36 + /** 37 + * Get the basename of a path (last component). 38 + * Works with both / and \ separators. 39 + */ 40 + export function basename(filePath: string): string { 41 + const parts = filePath.split(/[/\\]/); 42 + return parts[parts.length - 1] || ''; 43 + } 44 + 45 + /** 46 + * Join path segments. 47 + */ 48 + export function join(...segments: string[]): string { 49 + return segments 50 + .map((s) => s.replace(/\\/g, '/').replace(/\/$/, '')) 51 + .filter(Boolean) 52 + .join('/'); 53 + }
+77
src/icons/index.tsx
··· 141 141 </svg> 142 142 ); 143 143 } 144 + 145 + /** Cloud icon for synced / Habitat-backed vaults. */ 146 + export function CloudIcon({ className }: IconProps) { 147 + return ( 148 + <svg 149 + className={className} 150 + viewBox="0 0 24 24" 151 + fill="none" 152 + stroke="currentColor" 153 + strokeWidth="2" 154 + strokeLinecap="round" 155 + strokeLinejoin="round" 156 + aria-hidden 157 + > 158 + <path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" /> 159 + </svg> 160 + ); 161 + } 162 + 163 + /** Laptop icon for local-only vaults. */ 164 + export function LaptopIcon({ className }: IconProps) { 165 + return ( 166 + <svg 167 + className={className} 168 + viewBox="0 0 24 24" 169 + fill="none" 170 + stroke="currentColor" 171 + strokeWidth="2" 172 + strokeLinecap="round" 173 + strokeLinejoin="round" 174 + aria-hidden 175 + > 176 + <rect width="18" height="12" x="3" y="4" rx="2" ry="2" /> 177 + <line x1="2" x2="22" y1="20" y2="20" /> 178 + </svg> 179 + ); 180 + } 181 + 182 + /** Info circle icon for tooltips and hints. */ 183 + export function InfoIcon({ className }: IconProps) { 184 + return ( 185 + <svg 186 + className={className} 187 + viewBox="0 0 24 24" 188 + fill="none" 189 + stroke="currentColor" 190 + strokeWidth="2" 191 + strokeLinecap="round" 192 + strokeLinejoin="round" 193 + aria-hidden 194 + > 195 + <circle cx="12" cy="12" r="10" /> 196 + <path d="M12 16v-4" /> 197 + <path d="M12 8h.01" /> 198 + </svg> 199 + ); 200 + } 201 + 202 + /** Folder with plus icon for creating new directories. */ 203 + export function NewDirectoryIcon({ className }: IconProps) { 204 + return ( 205 + <svg 206 + className={className} 207 + viewBox="0 0 24 24" 208 + fill="none" 209 + stroke="currentColor" 210 + strokeWidth="2" 211 + strokeLinecap="round" 212 + strokeLinejoin="round" 213 + aria-hidden 214 + > 215 + <path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" /> 216 + <line x1="12" y1="11" x2="12" y2="17" /> 217 + <line x1="9" y1="14" x2="15" y2="14" /> 218 + </svg> 219 + ); 220 + }
+87
src/vault/registry.ts
··· 1 + import type { VaultType } from './types'; 2 + 3 + export interface OpenVault { 4 + /** Unique identifier for this open vault instance. */ 5 + id: string; 6 + /** Display name (last path component or user-defined). */ 7 + name: string; 8 + /** Absolute filesystem path to the vault root. */ 9 + path: string; 10 + type: VaultType; 11 + /** DID of the Atmosphere account that owns this vault (null for local-only). */ 12 + did: string | null; 13 + } 14 + 15 + const STORAGE_KEY = 'textile.openVaults'; 16 + 17 + let listeners: Set<() => void> = new Set(); 18 + 19 + function loadPersisted(): OpenVault[] { 20 + if (typeof localStorage === 'undefined') return []; 21 + try { 22 + const raw = localStorage.getItem(STORAGE_KEY); 23 + if (!raw) return []; 24 + const parsed = JSON.parse(raw) as Array<Partial<OpenVault>>; 25 + if (!Array.isArray(parsed)) return []; 26 + // Migrate old entries that may not have the `did` field. 27 + return parsed.map((entry) => ({ 28 + id: entry.id ?? '', 29 + name: entry.name ?? '', 30 + path: entry.path ?? '', 31 + type: entry.type ?? 'local', 32 + did: entry.did ?? null, 33 + })); 34 + } catch { 35 + return []; 36 + } 37 + } 38 + 39 + function savePersisted(vaults: OpenVault[]) { 40 + if (typeof localStorage === 'undefined') return; 41 + try { 42 + localStorage.setItem(STORAGE_KEY, JSON.stringify(vaults)); 43 + } catch { 44 + // Storage may be unavailable. 45 + } 46 + } 47 + 48 + let vaults: OpenVault[] = loadPersisted(); 49 + 50 + export function getOpenVaults(): OpenVault[] { 51 + return vaults; 52 + } 53 + 54 + export function addOpenVault(vault: OpenVault): void { 55 + vaults = [...vaults, vault]; 56 + savePersisted(vaults); 57 + for (const listener of listeners) { 58 + listener(); 59 + } 60 + } 61 + 62 + export function removeOpenVault(id: string): void { 63 + vaults = vaults.filter((v) => v.id !== id); 64 + savePersisted(vaults); 65 + for (const listener of listeners) { 66 + listener(); 67 + } 68 + } 69 + 70 + export function subscribeVaults(listener: () => void): () => void { 71 + listeners.add(listener); 72 + return () => { 73 + listeners.delete(listener); 74 + }; 75 + } 76 + 77 + /** Generate a stable vault id from its path. */ 78 + export function vaultIdFromPath(path: string): string { 79 + // Simple hash-like id 80 + let hash = 0; 81 + for (let i = 0; i < path.length; i++) { 82 + const char = path.charCodeAt(i); 83 + hash = (hash << 5) - hash + char; 84 + hash = hash & hash; 85 + } 86 + return `vault-${Math.abs(hash).toString(36)}`; 87 + }
+10
src/vault/types.ts
··· 1 + /** 2 + * Habitat URI branded string. Uses at:// URIs instead of automerge: URLs. 3 + */ 4 + export type HabitatUri = string & { __brand: 'HabitatUri' }; 5 + 6 + export function isHabitatUri(value: string): value is HabitatUri { 7 + return value.startsWith('at://'); 8 + } 9 + 10 + export type VaultType = 'local' | 'synced';
+109
src/documents/providers/vaultDocumentsProvider.ts
··· 1 + import * as Automerge from '@automerge/automerge'; 2 + import { createAutomergeBinding } from '../../editors/automerge/automergeEditorBinding'; 3 + import type { DocumentHandle, DocumentProvider } from '../types'; 4 + import { basename, join } from '../../filesystem/vaultFs'; 5 + import type { FilesystemApi } from '../../filesystem/types'; 6 + 7 + export type VaultDocumentProviderOptions = { 8 + fs: FilesystemApi; 9 + /** Absolute path to the vault root. Used for creating new untitled documents. */ 10 + rootPath: string; 11 + }; 12 + 13 + /** 14 + * Document provider for vault files. 15 + * Reads/writes files via IPC to the main process. 16 + * Uses an in-memory Automerge doc for editing (no CRDT persistence or sync). 17 + * Synced vaults are not yet implemented. 18 + */ 19 + export class VaultDocumentProvider implements DocumentProvider { 20 + readonly id = 'vault'; 21 + private fs: FilesystemApi; 22 + private rootPath: string; 23 + 24 + constructor(options: VaultDocumentProviderOptions) { 25 + this.fs = options.fs; 26 + this.rootPath = options.rootPath; 27 + } 28 + 29 + async openDocument(entryId: string): Promise<DocumentHandle> { 30 + return this.openLocalDocument(entryId); 31 + } 32 + 33 + async createDocument(options?: { id?: string }): Promise<DocumentHandle> { 34 + if (options?.id) { 35 + return this.openDocument(options.id); 36 + } 37 + 38 + // Create a new untitled file in the vault root. 39 + let fileName = 'Untitled.md'; 40 + let filePath = join(this.rootPath, fileName); 41 + let counter = 1; 42 + while (await this.fs.fileExists(filePath)) { 43 + fileName = `Untitled-${counter}.md`; 44 + filePath = join(this.rootPath, fileName); 45 + counter++; 46 + } 47 + await this.fs.writeFile(filePath, ''); 48 + return this.openLocalDocument(filePath); 49 + } 50 + 51 + private async openLocalDocument(filePath: string): Promise<DocumentHandle> { 52 + let content = ''; 53 + try { 54 + content = await this.fs.readFile(filePath); 55 + } catch (error) { 56 + // File might not exist yet; that's okay for new files. 57 + if ( 58 + error instanceof Error && 59 + 'code' in error && 60 + (error as NodeJS.ErrnoException).code !== 'ENOENT' 61 + ) { 62 + throw error; 63 + } 64 + } 65 + 66 + const name = basename(filePath); 67 + 68 + // Create an in-memory Automerge doc for editing. 69 + // Local vaults don't persist the CRDT; we just use it for the editor binding. 70 + let doc = Automerge.from<{ name: string; content: string }>({ 71 + name, 72 + content, 73 + }); 74 + 75 + const listeners = new Set<() => void>(); 76 + 77 + const applyChange = (fn: (d: typeof doc) => void) => { 78 + const newDoc = Automerge.change(doc, fn); 79 + if (newDoc !== doc) { 80 + doc = newDoc; 81 + // Write plain text back to disk via IPC (no CRDT persistence) 82 + this.fs.writeFile(filePath, doc.content).catch(() => {}); 83 + for (const listener of listeners) { 84 + listener(); 85 + } 86 + } 87 + }; 88 + 89 + return { 90 + id: filePath, 91 + loadPromise: Promise.resolve(), 92 + getTitle: () => name, 93 + subscribeTitle() { 94 + return () => {}; 95 + }, 96 + release: () => {}, 97 + getEditorBinding: () => 98 + createAutomergeBinding({ 99 + getDoc: () => doc, 100 + applyChange, 101 + subscribeToChanges: (cb) => { 102 + listeners.add(cb); 103 + return () => listeners.delete(cb); 104 + }, 105 + caretProvider: null, 106 + }), 107 + }; 108 + } 109 + }
+10 -1
src/filesystem/providers/automergeFilesystemProvider.ts
··· 6 6 import { subscribeEntryTitleChanges } from '../../habitat/automergeDocSession'; 7 7 import { listPrivateRecords } from '../../habitat/xrpc'; 8 8 import type { HabitatDoc } from '../../habitat/types'; 9 - import type { FileSystemEntry, FileSystemProvider } from '../types'; 9 + import type { FileSystemEntry, FileSystemProvider, FileSystemTreeNode } from '../types'; 10 10 11 11 export type AutomergeFilesystemProviderOptions = { 12 12 documents: DocumentProvider; ··· 37 37 return this.listAutomergeDocsRecords(); 38 38 } 39 39 40 + async getTree(): Promise<FileSystemTreeNode[]> { 41 + const entries = await this.listAutomergeDocsRecords(); 42 + return entries.map((e) => ({ id: e.id, name: e.name, type: 'file' as const })); 43 + } 44 + 40 45 subscribeEntryTitles( 41 46 listener: (entryId: string, name: string) => void, 42 47 ): () => void { ··· 52 57 } 53 58 const { uri } = await createAutomergeDoc(did); 54 59 return { id: uri, name: 'Untitled' }; 60 + } 61 + 62 + async createDirectory(_name: string): Promise<FileSystemEntry> { 63 + throw new Error('Automerge Docs does not support directories'); 55 64 } 56 65 57 66 private async listAutomergeDocsRecords(
+10 -1
src/filesystem/providers/habitatFilesystemProvider.ts
··· 6 6 import { subscribeEntryTitleChanges } from '../../habitat/docSession'; 7 7 import { listPrivateRecords } from '../../habitat/xrpc'; 8 8 import type { HabitatDoc } from '../../habitat/types'; 9 - import type { FileSystemEntry, FileSystemProvider } from '../types'; 9 + import type { FileSystemEntry, FileSystemProvider, FileSystemTreeNode } from '../types'; 10 10 11 11 export type HabitatFilesystemProviderOptions = { 12 12 /** Injected at the composition root; keeps listing separate from CRDT open. */ ··· 39 39 return this.listHabitatDocsRecords(); 40 40 } 41 41 42 + async getTree(): Promise<FileSystemTreeNode[]> { 43 + const entries = await this.listHabitatDocsRecords(); 44 + return entries.map((e) => ({ id: e.id, name: e.name, type: 'file' as const })); 45 + } 46 + 42 47 subscribeEntryTitles( 43 48 listener: (entryId: string, name: string) => void, 44 49 ): () => void { ··· 54 59 } 55 60 const { uri } = await createHabitatDoc(did); 56 61 return { id: uri, name: 'Untitled' }; 62 + } 63 + 64 + async createDirectory(_name: string): Promise<FileSystemEntry> { 65 + throw new Error('Habitat Docs does not support directories'); 57 66 } 58 67 59 68 private async listHabitatDocsRecords(
+142
src/filesystem/providers/localFilesystemProvider.ts
··· 1 + import type { FileSystemEntry, FileSystemProvider, FileSystemTreeNode, FilesystemApi } from '../types'; 2 + import type { DocumentProvider } from '../../documents/types'; 3 + import type { EditorKind } from '../../editors/types'; 4 + import { join } from '../vaultFs'; 5 + 6 + export type LocalFilesystemProviderOptions = { 7 + /** Absolute filesystem path to the root directory. */ 8 + rootPath: string; 9 + /** Display name shown in the sidebar. */ 10 + displayName: string; 11 + /** Stable id for React keys. */ 12 + id: string; 13 + documents: DocumentProvider; 14 + defaultEditorKind?: EditorKind; 15 + fs: FilesystemApi; 16 + }; 17 + 18 + /** 19 + * Filesystem provider for a single local directory. 20 + * Renders the directory's recursive tree in the sidebar. 21 + * All filesystem operations route through the main process via IPC. 22 + * No syncing — purely local. 23 + */ 24 + export class LocalFilesystemProvider implements FileSystemProvider { 25 + readonly id: string; 26 + readonly displayName: string; 27 + readonly idleHint = 'Directory is loading…'; 28 + readonly documents: DocumentProvider; 29 + readonly defaultEditorKind: EditorKind; 30 + readonly rootPath: string; 31 + private fs: FilesystemApi; 32 + 33 + private readyListeners: Set<() => void> = new Set(); 34 + private titleListeners: Set<(entryId: string, name: string) => void> = new Set(); 35 + private _isReady = true; 36 + 37 + constructor(options: LocalFilesystemProviderOptions) { 38 + this.id = options.id; 39 + this.displayName = options.displayName; 40 + this.rootPath = options.rootPath; 41 + this.documents = options.documents; 42 + this.defaultEditorKind = options.defaultEditorKind ?? 'automerge'; 43 + this.fs = options.fs; 44 + } 45 + 46 + isReady(): boolean { 47 + return this._isReady; 48 + } 49 + 50 + subscribeReady(listener: () => void): () => void { 51 + this.readyListeners.add(listener); 52 + return () => { 53 + this.readyListeners.delete(listener); 54 + }; 55 + } 56 + 57 + subscribeEntryTitles( 58 + listener: (entryId: string, name: string) => void, 59 + ): () => void { 60 + this.titleListeners.add(listener); 61 + return () => { 62 + this.titleListeners.delete(listener); 63 + }; 64 + } 65 + 66 + async getTree(): Promise<FileSystemTreeNode[]> { 67 + return this.scanTree(this.rootPath); 68 + } 69 + 70 + async listFiles(): Promise<FileSystemEntry[]> { 71 + const tree = await this.getTree(); 72 + return tree.map((node) => ({ id: node.id, name: node.name })); 73 + } 74 + 75 + async createFile(): Promise<FileSystemEntry> { 76 + let fileName = 'Untitled.md'; 77 + let fullPath = join(this.rootPath, fileName); 78 + let counter = 1; 79 + try { 80 + while (await this.fs.fileExists(fullPath)) { 81 + const base = fileName.replace(/\.md$/, ''); 82 + fileName = `${base}-${counter}.md`; 83 + fullPath = join(this.rootPath, fileName); 84 + counter++; 85 + } 86 + await this.fs.writeFile(fullPath, ''); 87 + } catch (error) { 88 + throw new Error( 89 + `Could not create file: ${error instanceof Error ? error.message : String(error)}`, 90 + ); 91 + } 92 + return { id: fullPath, name: fileName }; 93 + } 94 + 95 + async createDirectory(name: string): Promise<FileSystemEntry> { 96 + const dirPath = join(this.rootPath, name); 97 + try { 98 + if (await this.fs.fileExists(dirPath)) { 99 + throw new Error(`A directory named "${name}" already exists`); 100 + } 101 + await this.fs.mkdir(dirPath); 102 + } catch (error) { 103 + throw new Error( 104 + `Could not create directory: ${error instanceof Error ? error.message : String(error)}`, 105 + ); 106 + } 107 + return { id: dirPath, name }; 108 + } 109 + 110 + // --- Local directory scanning (via IPC) --- 111 + 112 + private async scanTree(dirPath: string): Promise<FileSystemTreeNode[]> { 113 + const entries = await this.fs.readdir(dirPath); 114 + const nodes: FileSystemTreeNode[] = []; 115 + 116 + for (const entry of entries) { 117 + // Skip hidden files and .textile metadata 118 + if (entry.name.startsWith('.')) continue; 119 + 120 + const fullPath = join(dirPath, entry.name); 121 + if (entry.isDirectory) { 122 + nodes.push({ 123 + id: fullPath, 124 + name: entry.name, 125 + type: 'folder', 126 + children: await this.scanTree(fullPath), 127 + }); 128 + } else if (entry.isFile) { 129 + nodes.push({ 130 + id: fullPath, 131 + name: entry.name, 132 + type: 'file', 133 + }); 134 + } 135 + } 136 + 137 + return nodes.sort((a, b) => { 138 + if (a.type === b.type) return a.name.localeCompare(b.name); 139 + return a.type === 'folder' ? -1 : 1; 140 + }); 141 + } 142 + }
+68
src/filesystem/providers/remoteSyncFilesystemProvider.ts
··· 1 + import type { FileSystemEntry, FileSystemProvider, FileSystemTreeNode } from '../types'; 2 + import type { DocumentProvider } from '../../documents/types'; 3 + import type { EditorKind } from '../../editors/types'; 4 + 5 + export type RemoteSyncFilesystemProviderOptions = { 6 + /** Absolute filesystem path to the root directory. */ 7 + rootPath: string; 8 + /** Display name shown in the sidebar. */ 9 + displayName: string; 10 + /** Stable id for React keys. */ 11 + id: string; 12 + documents: DocumentProvider; 13 + defaultEditorKind?: EditorKind; 14 + }; 15 + 16 + /** 17 + * Unimplemented remote-sync filesystem provider. 18 + * Reserved for future PDS-backed vault syncing. 19 + * All mutation operations throw; reads return empty results. 20 + */ 21 + export class RemoteSyncFilesystemProvider implements FileSystemProvider { 22 + readonly id: string; 23 + readonly displayName: string; 24 + readonly idleHint = 'Remote sync is not yet implemented.'; 25 + readonly documents: DocumentProvider; 26 + readonly defaultEditorKind: EditorKind; 27 + readonly rootPath: string; 28 + 29 + private _isReady = true; 30 + 31 + constructor(options: RemoteSyncFilesystemProviderOptions) { 32 + this.id = options.id; 33 + this.displayName = options.displayName; 34 + this.rootPath = options.rootPath; 35 + this.documents = options.documents; 36 + this.defaultEditorKind = options.defaultEditorKind ?? 'automerge'; 37 + } 38 + 39 + isReady(): boolean { 40 + return this._isReady; 41 + } 42 + 43 + subscribeReady(_listener: () => void): () => void { 44 + return () => {}; 45 + } 46 + 47 + subscribeEntryTitles( 48 + _listener: (entryId: string, name: string) => void, 49 + ): () => void { 50 + return () => {}; 51 + } 52 + 53 + async getTree(): Promise<FileSystemTreeNode[]> { 54 + return []; 55 + } 56 + 57 + async listFiles(): Promise<FileSystemEntry[]> { 58 + return []; 59 + } 60 + 61 + async createFile(): Promise<FileSystemEntry> { 62 + throw new Error('Remote sync is not yet implemented'); 63 + } 64 + 65 + async createDirectory(_name: string): Promise<FileSystemEntry> { 66 + throw new Error('Remote sync is not yet implemented'); 67 + } 68 + }
+10 -1
src/filesystem/providers/subtextFilesystemProvider.ts
··· 2 2 import type { EditorKind } from '../../editors/types'; 3 3 import { listCachedVaults } from '../../subtext/cache'; 4 4 import { scanPaths, subscribeSubtextTitleChanges } from '../../subtext/index'; 5 - import type { FileSystemEntry, FileSystemProvider } from '../types'; 5 + import type { FileSystemEntry, FileSystemProvider, FileSystemTreeNode } from '../types'; 6 6 7 7 export type SubtextFilesystemProviderOptions = { 8 8 documents: DocumentProvider; ··· 46 46 })); 47 47 } 48 48 49 + async getTree(): Promise<FileSystemTreeNode[]> { 50 + const entries = await this.listFiles(); 51 + return entries.map((e) => ({ id: e.id, name: e.name, type: 'file' as const })); 52 + } 53 + 49 54 subscribeEntryTitles( 50 55 listener: (entryId: string, name: string) => void, 51 56 ): () => void { ··· 55 60 async createFile(): Promise<FileSystemEntry> { 56 61 const handle = await this.documents.createDocument(); 57 62 return { id: handle.id, name: handle.getTitle() }; 63 + } 64 + 65 + async createDirectory(_name: string): Promise<FileSystemEntry> { 66 + throw new Error('Subtext does not support directories'); 58 67 } 59 68 }