A local-first note taking app
0

Configure Feed

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

Flesh out sidebar tree UI

Ethan Graf (Jun 12, 2026, 10:21 PM EDT) 6aea5b5e 9f35b85f

+738 -201
+8
.eslintrc.json
··· 21 21 } 22 22 }, 23 23 "rules": { 24 + "@typescript-eslint/no-unused-vars": [ 25 + "error", 26 + { 27 + "argsIgnorePattern": "^_", 28 + "varsIgnorePattern": "^_", 29 + "caughtErrorsIgnorePattern": "^_" 30 + } 31 + ], 24 32 "no-restricted-syntax": [ 25 33 "error", 26 34 {
+222 -167
src/components/AppSidebar.tsx
··· 1 1 import { 2 2 useCallback, 3 3 useEffect, 4 - useMemo, 5 4 useRef, 6 5 useState, 7 6 } from 'react'; 8 7 import { useQuery, useQueryClient } from '@tanstack/react-query'; 9 - import type { FileSystemEntry, FileSystemProvider, FileSystemTreeNode } from '../filesystem/types'; 10 - import { DocFileIcon, NewDocumentIcon, NewDirectoryIcon, StackedChevronsIcon, PlusIcon } from '../icons'; 8 + import type { FileSystemProvider } from '../filesystem/types'; 9 + import { NewDocumentIcon, NewDirectoryIcon, StackedChevronsIcon, PlusIcon } from '../icons'; 11 10 import type { OpenDocRequest } from '../workspaces/workspace'; 12 11 import { FileSystemTree } from './VaultTree'; 12 + import type { ContextMenuItem, InlineCreateState } from './VaultTree'; 13 13 14 14 type AppSidebarProps = { 15 15 open: boolean; ··· 24 24 25 25 type Selection = { providerId: string; entryId: string } | null; 26 26 27 - function filesQueryKey(providerId: string) { 28 - return ['files', providerId] as const; 29 - } 30 - 31 27 function treeQueryKey(providerId: string) { 32 28 return ['tree', providerId] as const; 33 29 } ··· 37 33 selection: Selection; 38 34 onSelect: (sel: Selection) => void; 39 35 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; 36 + renameNodeId: string | null; 37 + renameValue: string; 38 + renameExt: string; 39 + renameError: string | null; 40 + onRenameValueChange: (value: string) => void; 41 + onRenameSubmit: (value: string) => void; 42 + onRenameCancel: () => void; 43 + inlineCreate: InlineCreateState | null; 44 + onInlineCreateChange: (name: string) => void; 45 + onInlineCreateSubmit: (name: string) => void; 46 + onInlineCreateCancel: () => void; 47 + onContextMenuAction: (action: string, nodeId: string, nodeName: string, nodeType: 'file' | 'folder') => void; 48 + onTreeRootRightClick: (action: string) => void; 47 49 }; 48 50 49 51 function ProviderSection({ ··· 51 53 selection, 52 54 onSelect, 53 55 onOpenEntry, 54 - creatingDir, 55 - newDirName, 56 - newDirError, 57 - onNewDirNameChange, 58 - onNewDirErrorChange, 59 - onNewDirSubmit, 60 - onNewDirCancel, 56 + renameNodeId, 57 + renameValue, 58 + renameExt, 59 + renameError, 60 + onRenameValueChange, 61 + onRenameSubmit, 62 + onRenameCancel, 63 + inlineCreate, 64 + onInlineCreateChange, 65 + onInlineCreateSubmit, 66 + onInlineCreateCancel, 67 + onContextMenuAction, 68 + onTreeRootRightClick, 61 69 }: ProviderSectionProps) { 62 70 const queryClient = useQueryClient(); 63 - const inputRef = useRef<HTMLInputElement>(null); 64 71 65 72 // Tree query for all providers — flat sources return single-level trees. 66 73 const treeQuery = useQuery({ ··· 78 85 }); 79 86 }, [provider, queryClient]); 80 87 81 - // Focus inline input when entering directory creation mode. 82 - useEffect(() => { 83 - if (creatingDir) { 84 - inputRef.current?.focus(); 85 - } 86 - }, [creatingDir]); 87 - 88 - const isSelected = (nodeId: string) => 89 - selection?.providerId === provider.id && selection.entryId === nodeId; 90 - 91 88 const handleSelect = useCallback( 92 89 (node: { id: string; name: string; type: 'file' | 'folder' }) => { 93 90 if (node.type === 'file') { ··· 102 99 [provider.id, onSelect, onOpenEntry], 103 100 ); 104 101 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 - }; 102 + const handleContextMenu = useCallback( 103 + async (node: { id: string; name: string; type: 'file' | 'folder' }, items: ContextMenuItem[]) => { 104 + const api = window.textile?.contextMenu; 105 + if (!api) return; 106 + const action = await api.show(items.map((item) => ({ id: item.id, label: item.label }))); 107 + if (action) { 108 + onContextMenuAction(action, node.id, node.name, node.type); 109 + } 110 + }, 111 + [onContextMenuAction], 112 + ); 111 113 112 - const handleDirKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { 113 - if (e.key === 'Enter') { 114 + const handleTreeRootRightClick = useCallback( 115 + async (e: React.MouseEvent) => { 114 116 e.preventDefault(); 115 - const trimmed = newDirName.trim(); 116 - if (trimmed.length > 0 && !newDirError) { 117 - onNewDirSubmit(trimmed); 117 + const api = window.textile?.contextMenu; 118 + if (!api) return; 119 + const action = await api.show([ 120 + { id: 'new-note', label: 'New Note' }, 121 + { id: 'new-folder', label: 'New Folder' }, 122 + ]); 123 + if (action === 'new-note' || action === 'new-folder') { 124 + onTreeRootRightClick(action); 118 125 } 119 - } else if (e.key === 'Escape') { 120 - onNewDirCancel(); 121 - } 122 - }; 126 + }, 127 + [onTreeRootRightClick], 128 + ); 123 129 124 130 return ( 125 - <section className="min-w-0"> 131 + <section className="min-w-0 flex flex-col flex-1"> 126 132 <h2 className="text-foreground/80 mb-0.5 flex items-center gap-1.5 pl-2.5 text-[11px] font-semibold tracking-wide uppercase"> 127 133 {provider.displayName} 128 134 </h2> 129 - <div className="flex flex-col gap-px"> 135 + <div className="flex flex-col flex-1 min-h-0 gap-px"> 130 136 {!provider.isReady() ? ( 131 137 <p className="text-foreground/60 px-2 text-xs leading-snug"> 132 138 {provider.idleHint} ··· 141 147 </p> 142 148 ) : ( 143 149 <> 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" 156 - /> 157 - </div> 158 - {newDirError && ( 159 - <p className="text-destructive text-[10px] px-2 pb-0.5"> 160 - {newDirError} 161 - </p> 162 - )} 150 + <div className="flex-1 min-h-0" onContextMenu={handleTreeRootRightClick}> 151 + <FileSystemTree 152 + nodes={treeQuery.data ?? []} 153 + onSelect={handleSelect} 154 + onContextMenu={handleContextMenu} 155 + selectedId={ 156 + selection?.providerId === provider.id ? selection.entryId : undefined 157 + } 158 + renameNodeId={renameNodeId ?? undefined} 159 + renameValue={renameValue} 160 + renameExt={renameExt} 161 + renameError={renameError} 162 + onRenameValueChange={onRenameValueChange} 163 + onRenameSubmit={onRenameSubmit} 164 + onRenameCancel={onRenameCancel} 165 + inlineCreate={inlineCreate ?? undefined} 166 + onInlineCreateChange={onInlineCreateChange} 167 + onInlineCreateSubmit={onInlineCreateSubmit} 168 + onInlineCreateCancel={onInlineCreateCancel} 169 + /> 163 170 </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 && ( 171 + {treeQuery.data?.length === 0 && !inlineCreate && ( 173 172 <p className="text-foreground/60 px-2 text-xs leading-snug"> 174 173 No files. 175 174 </p> ··· 253 252 }: AppSidebarProps) { 254 253 const queryClient = useQueryClient(); 255 254 const [selection, setSelection] = useState<Selection>(null); 256 - const [creatingFor, setCreatingFor] = useState<string | null>(null); 257 255 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); 256 + const [inlineCreate, setInlineCreate] = useState<InlineCreateState | null>(null); 257 + const [renameNodeId, setRenameNodeId] = useState<string | null>(null); 258 + const [renameValue, setRenameValue] = useState(''); 259 + const [renameExt, setRenameExt] = useState(''); 260 + const [renameError, setRenameError] = useState<string | null>(null); 261 261 262 262 const selectedProvider = providers.find((p) => p.id === selectedProviderId); 263 263 264 264 // Clear stale errors and creation state when switching providers. 265 265 useEffect(() => { 266 266 setCreateError(null); 267 - setCreatingDir(false); 268 - setNewDirName(''); 269 - setNewDirError(null); 267 + setInlineCreate(null); 268 + setRenameNodeId(null); 269 + setRenameValue(''); 270 + setRenameExt(''); 271 + setRenameError(null); 270 272 }, [selectedProviderId]); 271 273 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 - }; 274 + const handleContextMenuAction = useCallback( 275 + async (action: string, nodeId: string, nodeName: string, _nodeType: 'file' | 'folder') => { 276 + if (!selectedProvider) return; 277 + switch (action) { 278 + case 'new-note': { 279 + setInlineCreate({ parentId: nodeId, type: 'file', name: '', error: null }); 280 + break; 281 + } 282 + case 'new-folder': { 283 + setInlineCreate({ parentId: nodeId, type: 'folder', name: '', error: null }); 284 + break; 285 + } 286 + case 'copy': { 287 + try { 288 + await selectedProvider.copyFile(nodeId); 289 + void queryClient.invalidateQueries({ queryKey: treeQueryKey(selectedProvider.id) }); 290 + } catch (e: unknown) { 291 + setCreateError(e instanceof Error ? e.message : 'Could not copy file'); 292 + } 293 + break; 294 + } 295 + case 'copy-path': { 296 + const path = selectedProvider.getPath(nodeId); 297 + if (path) { 298 + void navigator.clipboard.writeText(path); 299 + } 300 + break; 301 + } 302 + case 'rename': { 303 + setRenameNodeId(nodeId); 304 + const ext = nodeName.includes('.') ? nodeName.slice(nodeName.lastIndexOf('.')) : ''; 305 + const base = ext ? nodeName.slice(0, -ext.length) : nodeName; 306 + setRenameValue(base); 307 + setRenameExt(ext); 308 + setRenameError(null); 309 + break; 310 + } 311 + case 'delete': { 312 + if (window.confirm(`Delete "${nodeName}"? This cannot be undone.`)) { 313 + try { 314 + await selectedProvider.deleteFile(nodeId); 315 + void queryClient.invalidateQueries({ queryKey: treeQueryKey(selectedProvider.id) }); 316 + } catch (e: unknown) { 317 + setCreateError(e instanceof Error ? e.message : 'Could not delete'); 318 + } 319 + } 320 + break; 321 + } 322 + } 323 + }, 324 + [selectedProvider, queryClient], 325 + ); 276 326 277 - const handleCreate = useCallback( 278 - async (provider: FileSystemProvider) => { 327 + const handleInlineCreateSubmit = useCallback( 328 + async (name: string) => { 329 + if (!selectedProvider || !inlineCreate) return; 279 330 setCreateError(null); 280 - setCreatingFor(provider.id); 281 - try { 282 - const entry = await provider.createFile(); 283 - queryClient.setQueryData<FileSystemEntry[]>( 284 - filesQueryKey(provider.id), 285 - (entries) => 286 - entries && !entries.some((e) => e.id === entry.id) 287 - ? [...entries, entry] 288 - : (entries ?? [entry]), 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 - ); 301 - setSelection({ providerId: provider.id, entryId: entry.id }); 302 - onOpenEntry?.({ 303 - providerId: provider.id, 304 - entryId: entry.id, 305 - title: entry.name, 306 - }); 307 - void queryClient.invalidateQueries({ queryKey: treeQueryKey(provider.id) }); 308 - } catch (e: unknown) { 309 - setCreateError( 310 - e instanceof Error ? e.message : 'Could not create document', 311 - ); 312 - } finally { 313 - setCreatingFor(null); 331 + const trimmed = name.trim(); 332 + 333 + if (inlineCreate.type === 'file') { 334 + try { 335 + const entry = await selectedProvider.createFile(inlineCreate.parentId, trimmed || undefined); 336 + setInlineCreate(null); 337 + setSelection({ providerId: selectedProvider.id, entryId: entry.id }); 338 + onOpenEntry?.({ 339 + providerId: selectedProvider.id, 340 + entryId: entry.id, 341 + title: entry.name, 342 + }); 343 + void queryClient.invalidateQueries({ queryKey: treeQueryKey(selectedProvider.id) }); 344 + } catch (e: unknown) { 345 + setInlineCreate({ ...inlineCreate, error: e instanceof Error ? e.message : 'Could not create file' }); 346 + } 347 + } else { 348 + try { 349 + await selectedProvider.createDirectory(trimmed || 'Untitled', inlineCreate.parentId); 350 + setInlineCreate(null); 351 + void queryClient.invalidateQueries({ queryKey: treeQueryKey(selectedProvider.id) }); 352 + } catch (e: unknown) { 353 + setInlineCreate({ ...inlineCreate, error: e instanceof Error ? e.message : 'Could not create directory' }); 354 + } 314 355 } 315 356 }, 316 - [queryClient, setSelection, onOpenEntry], 357 + [selectedProvider, inlineCreate, queryClient, onOpenEntry, setSelection], 317 358 ); 318 359 319 - const handleCreateDirectory = useCallback( 320 - async (name: string) => { 321 - if (!selectedProvider) return; 360 + const handleInlineCreateCancel = useCallback(() => { 361 + setInlineCreate(null); 362 + }, []); 363 + 364 + const handleInlineCreateChange = useCallback((name: string) => { 365 + setInlineCreate((prev) => prev ? { ...prev, name, error: null } : null); 366 + }, []); 367 + 368 + const handleRenameSubmit = useCallback( 369 + async (newName: string) => { 370 + if (!selectedProvider || !renameNodeId) return; 371 + const trimmed = newName.trim(); 372 + if (!trimmed) { 373 + setRenameNodeId(null); 374 + setRenameValue(''); 375 + setRenameExt(''); 376 + setRenameError(null); 377 + return; 378 + } 379 + const finalName = trimmed + renameExt; 322 380 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); 381 + await selectedProvider.renameFile(renameNodeId, finalName); 382 + setRenameNodeId(null); 383 + setRenameValue(''); 384 + setRenameExt(''); 385 + setRenameError(null); 338 386 void queryClient.invalidateQueries({ queryKey: treeQueryKey(selectedProvider.id) }); 339 387 } catch (e: unknown) { 340 - setNewDirError( 341 - e instanceof Error ? e.message : 'Could not create directory', 342 - ); 388 + setRenameError(e instanceof Error ? e.message : 'Could not rename'); 343 389 } 344 390 }, 345 - [selectedProvider, queryClient], 391 + [selectedProvider, renameNodeId, renameExt, queryClient], 346 392 ); 347 393 348 - const supportsDirectory = !!selectedProvider; 394 + const handleRenameCancel = useCallback(() => { 395 + setRenameNodeId(null); 396 + setRenameValue(''); 397 + setRenameExt(''); 398 + setRenameError(null); 399 + }, []); 349 400 350 401 return ( 351 402 <aside ··· 367 418 <div className="border-border flex shrink-0 items-center gap-1 border-b px-2 py-1"> 368 419 <button 369 420 type="button" 370 - disabled={creatingFor !== null} 421 + disabled={inlineCreate !== null} 371 422 title={`New document in ${selectedProvider.displayName}`} 372 423 aria-label={`New document in ${selectedProvider.displayName}`} 373 - aria-busy={creatingFor === selectedProvider.id} 374 - onClick={() => void handleCreate(selectedProvider)} 424 + aria-busy={inlineCreate?.type === 'file' && inlineCreate?.parentId === undefined} 425 + onClick={() => setInlineCreate({ type: 'file', name: '', error: null })} 375 426 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" 376 427 > 377 428 <NewDocumentIcon className="size-3.5 shrink-0" /> 378 429 </button> 379 430 <button 380 431 type="button" 381 - disabled={creatingDir} 432 + disabled={inlineCreate !== null} 382 433 title="New directory" 383 434 aria-label="New directory" 384 - onClick={() => { 385 - setCreatingDir(true); 386 - setNewDirName(''); 387 - setNewDirError(null); 388 - }} 435 + onClick={() => setInlineCreate({ type: 'folder', name: '', error: null })} 389 436 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 437 > 391 438 <NewDirectoryIcon className="size-3.5 shrink-0" /> ··· 406 453 selection={selection} 407 454 onSelect={setSelection} 408 455 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); 456 + renameNodeId={renameNodeId} 457 + renameValue={renameValue} 458 + renameExt={renameExt} 459 + renameError={renameError} 460 + onRenameValueChange={setRenameValue} 461 + onRenameSubmit={handleRenameSubmit} 462 + onRenameCancel={handleRenameCancel} 463 + inlineCreate={inlineCreate} 464 + onInlineCreateChange={handleInlineCreateChange} 465 + onInlineCreateSubmit={handleInlineCreateSubmit} 466 + onInlineCreateCancel={handleInlineCreateCancel} 467 + onContextMenuAction={handleContextMenuAction} 468 + onTreeRootRightClick={(action) => { 469 + if (action === 'new-note') { 470 + setInlineCreate({ type: 'file', name: '', error: null }); 471 + } else if (action === 'new-folder') { 472 + setInlineCreate({ type: 'folder', name: '', error: null }); 473 + } 419 474 }} 420 475 /> 421 476 ) : providers.length === 0 ? (
+271 -15
src/components/VaultTree.tsx
··· 1 - import { useState, useCallback } from 'react'; 1 + import { useState, useCallback, useRef, useEffect } from 'react'; 2 2 import type { FileSystemTreeNode } from '../filesystem/types'; 3 3 4 - type FileSystemTreeProps = { 4 + export type ContextMenuItem = { id: string; label: string }; 5 + 6 + export type InlineCreateState = { 7 + parentId?: string; 8 + type: 'file' | 'folder'; 9 + name: string; 10 + error: string | null; 11 + }; 12 + 13 + export type FileSystemTreeProps = { 5 14 nodes: FileSystemTreeNode[]; 6 15 onSelect?: (node: FileSystemTreeNode) => void; 16 + onContextMenu?: (node: FileSystemTreeNode, items: ContextMenuItem[]) => void; 7 17 selectedId?: string; 18 + renameNodeId?: string; 19 + renameValue?: string; 20 + renameExt?: string; 21 + renameError?: string | null; 22 + onRenameValueChange?: (value: string) => void; 23 + onRenameSubmit?: (value: string) => void; 24 + onRenameCancel?: () => void; 25 + inlineCreate?: InlineCreateState; 26 + onInlineCreateChange?: (name: string) => void; 27 + onInlineCreateSubmit?: (name: string) => void; 28 + onInlineCreateCancel?: () => void; 8 29 }; 30 + 31 + /** Shared row style for the active (highlighted) item in the tree. */ 32 + const ACTIVE_ROW = 'bg-accent/10 text-foreground'; 33 + const INACTIVE_ROW = 'text-foreground/70 hover:bg-border/35'; 9 34 10 35 function TriangleRight({ className }: { className?: string }) { 11 36 return ( ··· 33 58 ); 34 59 } 35 60 61 + function InlineCreateInput({ 62 + depth, 63 + type, 64 + name, 65 + error, 66 + onChange, 67 + onSubmit, 68 + onCancel, 69 + }: { 70 + depth: number; 71 + type: 'file' | 'folder'; 72 + name: string; 73 + error: string | null; 74 + onChange: (value: string) => void; 75 + onSubmit: (value: string) => void; 76 + onCancel: () => void; 77 + }) { 78 + const inputRef = useRef<HTMLInputElement>(null); 79 + 80 + useEffect(() => { 81 + inputRef.current?.focus(); 82 + inputRef.current?.select(); 83 + }, []); 84 + 85 + return ( 86 + <div 87 + className={`flex w-full items-center gap-1 px-2 py-0.5 text-left text-xs ${ACTIVE_ROW}`} 88 + style={{ paddingLeft: `${depth * 12}px` }} 89 + > 90 + <span className="w-1.5 shrink-0" /> 91 + <div className="flex flex-col flex-1 min-w-0"> 92 + <input 93 + ref={inputRef} 94 + type="text" 95 + value={name} 96 + placeholder={type === 'file' ? 'Untitled.md' : 'New Folder'} 97 + onChange={(e) => onChange(e.target.value)} 98 + onKeyDown={(e) => { 99 + if (e.key === 'Enter') { 100 + e.preventDefault(); 101 + onSubmit(e.currentTarget.value.trim()); 102 + } else if (e.key === 'Escape') { 103 + onCancel(); 104 + } 105 + }} 106 + onBlur={() => onCancel()} 107 + className="min-w-0 bg-transparent text-foreground text-xs outline-none border-0 p-0 w-full" 108 + /> 109 + {error && ( 110 + <span className="text-destructive text-[10px]">{error}</span> 111 + )} 112 + </div> 113 + </div> 114 + ); 115 + } 116 + 36 117 function TreeNode({ 37 118 node, 38 119 depth, 39 120 selectedId, 40 121 onSelect, 122 + onContextMenu, 123 + renameNodeId, 124 + renameValue, 125 + renameExt, 126 + renameError, 127 + onRenameValueChange, 128 + onRenameSubmit, 129 + onRenameCancel, 130 + inlineCreate, 131 + onInlineCreateChange, 132 + onInlineCreateSubmit, 133 + onInlineCreateCancel, 41 134 }: { 42 135 node: FileSystemTreeNode; 43 136 depth: number; 44 137 selectedId?: string; 45 138 onSelect?: (node: FileSystemTreeNode) => void; 139 + onContextMenu?: (node: FileSystemTreeNode, items: ContextMenuItem[]) => void; 140 + renameNodeId?: string; 141 + renameValue?: string; 142 + renameExt?: string; 143 + renameError?: string | null; 144 + onRenameValueChange?: (value: string) => void; 145 + onRenameSubmit?: (value: string) => void; 146 + onRenameCancel?: () => void; 147 + inlineCreate?: InlineCreateState; 148 + onInlineCreateChange?: (name: string) => void; 149 + onInlineCreateSubmit?: (name: string) => void; 150 + onInlineCreateCancel?: () => void; 46 151 }) { 47 152 const [expanded, setExpanded] = useState(depth < 2); 48 - const isSelected = node.id === selectedId; 153 + const isActive = node.id === renameNodeId || (node.id === selectedId && !inlineCreate); 49 154 const hasChildren = node.children && node.children.length > 0; 155 + const isRenaming = node.id === renameNodeId; 156 + const isCreatingHere = inlineCreate?.parentId === node.id; 157 + const inputRef = useRef<HTMLInputElement>(null); 158 + 159 + useEffect(() => { 160 + if (isRenaming) { 161 + inputRef.current?.focus(); 162 + inputRef.current?.select(); 163 + } 164 + }, [isRenaming]); 165 + 166 + useEffect(() => { 167 + if (isCreatingHere) { 168 + setExpanded(true); 169 + } 170 + }, [isCreatingHere]); 50 171 51 172 const handleClick = useCallback(() => { 52 173 if (node.type === 'folder') { ··· 55 176 onSelect?.(node); 56 177 }, [node, onSelect]); 57 178 179 + const handleContextMenu = useCallback((e: React.MouseEvent) => { 180 + e.preventDefault(); 181 + e.stopPropagation(); 182 + if (!onContextMenu) return; 183 + const items: ContextMenuItem[] = 184 + node.type === 'folder' 185 + ? [ 186 + { id: 'new-note', label: 'New Note' }, 187 + { id: 'new-folder', label: 'New Folder' }, 188 + { id: 'copy', label: 'Copy' }, 189 + { id: 'copy-path', label: 'Copy Path' }, 190 + { id: 'rename', label: 'Rename' }, 191 + { id: 'delete', label: 'Delete' }, 192 + ] 193 + : [ 194 + { id: 'copy', label: 'Copy' }, 195 + { id: 'rename', label: 'Rename' }, 196 + { id: 'delete', label: 'Delete' }, 197 + ]; 198 + onContextMenu(node, items); 199 + }, [node, onContextMenu]); 200 + 58 201 return ( 59 202 <div> 60 - <button 61 - type="button" 62 - onClick={handleClick} 203 + <div 63 204 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' 205 + isActive ? ACTIVE_ROW : INACTIVE_ROW 67 206 }`} 68 207 style={{ paddingLeft: `${depth * 12}px` }} 208 + onContextMenu={handleContextMenu} 69 209 > 70 210 {node.type === 'folder' && ( 71 - <span className="text-foreground/40 w-1.5 h-1.5 flex items-center justify-center"> 211 + <button 212 + type="button" 213 + onClick={() => setExpanded((e) => !e)} 214 + className="text-foreground/40 w-1.5 h-1.5 flex items-center justify-center shrink-0 cursor-pointer" 215 + > 72 216 {expanded 73 217 ? <TriangleDown className="w-1.5 h-1.5" /> 74 218 : <TriangleRight className="w-1.5 h-1.5" />} 75 - </span> 219 + </button> 76 220 )} 77 221 {node.type === 'file' && <span className="w-1.5" />} 78 - <span className="truncate">{node.name}</span> 79 - </button> 222 + {isRenaming ? ( 223 + <div className="flex flex-col flex-1 min-w-0"> 224 + <div className="flex items-center min-w-0"> 225 + <input 226 + ref={inputRef} 227 + type="text" 228 + value={renameValue ?? ''} 229 + onChange={(e) => onRenameValueChange?.(e.target.value)} 230 + onKeyDown={(e) => { 231 + if (e.key === 'Enter') { 232 + e.preventDefault(); 233 + onRenameSubmit?.(e.currentTarget.value.trim()); 234 + } else if (e.key === 'Escape') { 235 + onRenameCancel?.(); 236 + } 237 + }} 238 + onBlur={() => onRenameCancel?.()} 239 + className="min-w-0 bg-transparent text-foreground text-xs outline-none border-0 p-0" 240 + /> 241 + {renameExt && ( 242 + <span className="text-foreground/40 text-xs shrink-0">{renameExt}</span> 243 + )} 244 + </div> 245 + {renameError && ( 246 + <span className="text-destructive text-[10px]">{renameError}</span> 247 + )} 248 + </div> 249 + ) : ( 250 + <button 251 + type="button" 252 + onClick={handleClick} 253 + className="flex-1 min-w-0 text-left cursor-pointer bg-transparent border-0 p-0" 254 + > 255 + <span className="truncate">{node.name}</span> 256 + </button> 257 + )} 258 + </div> 80 259 {hasChildren && expanded && ( 81 260 <div> 261 + {isCreatingHere && ( 262 + <InlineCreateInput 263 + depth={depth + 1} 264 + type={inlineCreate!.type} 265 + name={inlineCreate!.name} 266 + error={inlineCreate!.error} 267 + onChange={onInlineCreateChange!} 268 + onSubmit={onInlineCreateSubmit!} 269 + onCancel={onInlineCreateCancel!} 270 + /> 271 + )} 82 272 {node.children!.map((child) => ( 83 273 <TreeNode 84 274 key={child.id} ··· 86 276 depth={depth + 1} 87 277 selectedId={selectedId} 88 278 onSelect={onSelect} 279 + onContextMenu={onContextMenu} 280 + renameNodeId={renameNodeId} 281 + renameValue={renameValue} 282 + renameExt={renameExt} 283 + renameError={renameError} 284 + onRenameValueChange={onRenameValueChange} 285 + onRenameSubmit={onRenameSubmit} 286 + onRenameCancel={onRenameCancel} 287 + inlineCreate={inlineCreate} 288 + onInlineCreateChange={onInlineCreateChange} 289 + onInlineCreateSubmit={onInlineCreateSubmit} 290 + onInlineCreateCancel={onInlineCreateCancel} 89 291 /> 90 292 ))} 91 293 </div> 92 294 )} 295 + {node.type === 'folder' && !hasChildren && isCreatingHere && ( 296 + <div> 297 + <InlineCreateInput 298 + depth={depth + 1} 299 + type={inlineCreate!.type} 300 + name={inlineCreate!.name} 301 + error={inlineCreate!.error} 302 + onChange={onInlineCreateChange!} 303 + onSubmit={onInlineCreateSubmit!} 304 + onCancel={onInlineCreateCancel!} 305 + /> 306 + </div> 307 + )} 93 308 </div> 94 309 ); 95 310 } 96 311 97 - export function FileSystemTree({ nodes, onSelect, selectedId }: FileSystemTreeProps) { 312 + export function FileSystemTree({ 313 + nodes, 314 + onSelect, 315 + onContextMenu, 316 + selectedId, 317 + renameNodeId, 318 + renameValue, 319 + renameExt, 320 + renameError, 321 + onRenameValueChange, 322 + onRenameSubmit, 323 + onRenameCancel, 324 + inlineCreate, 325 + onInlineCreateChange, 326 + onInlineCreateSubmit, 327 + onInlineCreateCancel, 328 + }: FileSystemTreeProps) { 329 + const isCreatingAtRoot = inlineCreate && inlineCreate.parentId === undefined; 330 + 98 331 return ( 99 - <div className="flex flex-col"> 332 + <div className="flex flex-col h-full"> 333 + {isCreatingAtRoot && ( 334 + <InlineCreateInput 335 + depth={0} 336 + type={inlineCreate!.type} 337 + name={inlineCreate!.name} 338 + error={inlineCreate!.error} 339 + onChange={onInlineCreateChange!} 340 + onSubmit={onInlineCreateSubmit!} 341 + onCancel={onInlineCreateCancel!} 342 + /> 343 + )} 100 344 {nodes.map((node) => ( 101 345 <TreeNode 102 346 key={node.id} ··· 104 348 depth={0} 105 349 selectedId={selectedId} 106 350 onSelect={onSelect} 351 + onContextMenu={onContextMenu} 352 + renameNodeId={renameNodeId} 353 + renameValue={renameValue} 354 + renameExt={renameExt} 355 + renameError={renameError} 356 + onRenameValueChange={onRenameValueChange} 357 + onRenameSubmit={onRenameSubmit} 358 + onRenameCancel={onRenameCancel} 359 + inlineCreate={inlineCreate} 360 + onInlineCreateChange={onInlineCreateChange} 361 + onInlineCreateSubmit={onInlineCreateSubmit} 362 + onInlineCreateCancel={onInlineCreateCancel} 107 363 /> 108 364 ))} 109 365 </div>
+18 -2
src/filesystem/providers/automergeFilesystemProvider.ts
··· 48 48 return subscribeEntryTitleChanges(listener); 49 49 } 50 50 51 - async createFile(): Promise<FileSystemEntry> { 51 + async createFile(_parentId?: string, _name?: string): Promise<FileSystemEntry> { 52 52 const did = habitatAuth.getAuthInfo()?.did; 53 53 if (!did) { 54 54 throw new Error( ··· 59 59 return { id: uri, name: 'Untitled' }; 60 60 } 61 61 62 - async createDirectory(_name: string): Promise<FileSystemEntry> { 62 + async createDirectory(_name: string, _parentId?: string): Promise<FileSystemEntry> { 63 63 throw new Error('Automerge Docs does not support directories'); 64 + } 65 + 66 + async renameFile(_entryId: string, _newName: string): Promise<FileSystemEntry> { 67 + throw new Error('Automerge Docs does not support renaming'); 68 + } 69 + 70 + async deleteFile(_entryId: string): Promise<void> { 71 + throw new Error('Automerge Docs does not support deleting'); 72 + } 73 + 74 + async copyFile(_entryId: string): Promise<FileSystemEntry> { 75 + throw new Error('Automerge Docs does not support copying'); 76 + } 77 + 78 + getPath(_entryId: string): string { 79 + return ''; 64 80 } 65 81 66 82 private async listAutomergeDocsRecords(
+18 -2
src/filesystem/providers/habitatFilesystemProvider.ts
··· 50 50 return subscribeEntryTitleChanges(listener); 51 51 } 52 52 53 - async createFile(): Promise<FileSystemEntry> { 53 + async createFile(_parentId?: string, _name?: string): Promise<FileSystemEntry> { 54 54 const did = habitatAuth.getAuthInfo()?.did; 55 55 if (!did) { 56 56 throw new Error( ··· 61 61 return { id: uri, name: 'Untitled' }; 62 62 } 63 63 64 - async createDirectory(_name: string): Promise<FileSystemEntry> { 64 + async createDirectory(_name: string, _parentId?: string): Promise<FileSystemEntry> { 65 65 throw new Error('Habitat Docs does not support directories'); 66 + } 67 + 68 + async renameFile(_entryId: string, _newName: string): Promise<FileSystemEntry> { 69 + throw new Error('Habitat Docs does not support renaming'); 70 + } 71 + 72 + async deleteFile(_entryId: string): Promise<void> { 73 + throw new Error('Habitat Docs does not support deleting'); 74 + } 75 + 76 + async copyFile(_entryId: string): Promise<FileSystemEntry> { 77 + throw new Error('Habitat Docs does not support copying'); 78 + } 79 + 80 + getPath(_entryId: string): string { 81 + return ''; 66 82 } 67 83 68 84 private async listHabitatDocsRecords(
+63 -9
src/filesystem/providers/localFilesystemProvider.ts
··· 1 1 import type { FileSystemEntry, FileSystemProvider, FileSystemTreeNode, FilesystemApi } from '../types'; 2 2 import type { DocumentProvider } from '../../documents/types'; 3 3 import type { EditorKind } from '../../editors/types'; 4 - import { join } from '../vaultFs'; 4 + import { basename, dirname, join } from '../vaultFs'; 5 5 6 6 export type LocalFilesystemProviderOptions = { 7 7 /** Absolute filesystem path to the root directory. */ ··· 72 72 return tree.map((node) => ({ id: node.id, name: node.name })); 73 73 } 74 74 75 - async createFile(): Promise<FileSystemEntry> { 76 - let fileName = 'Untitled.md'; 77 - let fullPath = join(this.rootPath, fileName); 78 - let counter = 1; 79 - try { 75 + async createFile(parentId?: string, name?: string): Promise<FileSystemEntry> { 76 + const parent = parentId ?? this.rootPath; 77 + let fileName: string; 78 + if (!name || !name.trim()) { 79 + fileName = 'Untitled.md'; 80 + let fullPath = join(parent, fileName); 81 + let counter = 1; 80 82 while (await this.fs.fileExists(fullPath)) { 81 83 const base = fileName.replace(/\.md$/, ''); 82 84 fileName = `${base}-${counter}.md`; 83 - fullPath = join(this.rootPath, fileName); 85 + fullPath = join(parent, fileName); 84 86 counter++; 85 87 } 88 + } else { 89 + fileName = name.trim(); 90 + if (!fileName.includes('.')) { 91 + fileName += '.md'; 92 + } 93 + } 94 + const fullPath = join(parent, fileName); 95 + try { 96 + if (await this.fs.fileExists(fullPath)) { 97 + throw new Error(`A file named "${fileName}" already exists`); 98 + } 86 99 await this.fs.writeFile(fullPath, ''); 87 100 } catch (error) { 88 101 throw new Error( ··· 92 105 return { id: fullPath, name: fileName }; 93 106 } 94 107 95 - async createDirectory(name: string): Promise<FileSystemEntry> { 96 - const dirPath = join(this.rootPath, name); 108 + async createDirectory(name: string, parentId?: string): Promise<FileSystemEntry> { 109 + const parent = parentId ?? this.rootPath; 110 + const dirPath = join(parent, name); 97 111 try { 98 112 if (await this.fs.fileExists(dirPath)) { 99 113 throw new Error(`A directory named "${name}" already exists`); ··· 105 119 ); 106 120 } 107 121 return { id: dirPath, name }; 122 + } 123 + 124 + async renameFile(entryId: string, newName: string): Promise<FileSystemEntry> { 125 + const parent = dirname(entryId); 126 + const newPath = join(parent, newName); 127 + try { 128 + await this.fs.rename(entryId, newPath); 129 + } catch (error) { 130 + throw new Error( 131 + `Could not rename: ${error instanceof Error ? error.message : String(error)}`, 132 + ); 133 + } 134 + return { id: newPath, name: newName }; 135 + } 136 + 137 + async deleteFile(entryId: string): Promise<void> { 138 + await this.fs.deletePath(entryId); 139 + } 140 + 141 + async copyFile(entryId: string): Promise<FileSystemEntry> { 142 + const name = basename(entryId); 143 + const parent = dirname(entryId); 144 + const extMatch = name.match(/(\.[a-zA-Z0-9]+)$/); 145 + const ext = extMatch ? extMatch[1] : ''; 146 + const base = ext ? name.slice(0, -ext.length) : name; 147 + let newName = `${base} copy${ext}`; 148 + let newPath = join(parent, newName); 149 + let counter = 2; 150 + while (await this.fs.fileExists(newPath)) { 151 + newName = `${base} copy ${counter}${ext}`; 152 + newPath = join(parent, newName); 153 + counter++; 154 + } 155 + const content = await this.fs.readFile(entryId); 156 + await this.fs.writeFile(newPath, content); 157 + return { id: newPath, name: newName }; 158 + } 159 + 160 + getPath(entryId: string): string { 161 + return entryId; 108 162 } 109 163 110 164 // --- Local directory scanning (via IPC) ---
+18 -2
src/filesystem/providers/remoteSyncFilesystemProvider.ts
··· 58 58 return []; 59 59 } 60 60 61 - async createFile(): Promise<FileSystemEntry> { 61 + async createFile(_parentId?: string, _name?: string): Promise<FileSystemEntry> { 62 62 throw new Error('Remote sync is not yet implemented'); 63 63 } 64 64 65 - async createDirectory(_name: string): Promise<FileSystemEntry> { 65 + async createDirectory(_name: string, _parentId?: string): Promise<FileSystemEntry> { 66 66 throw new Error('Remote sync is not yet implemented'); 67 + } 68 + 69 + async renameFile(_entryId: string, _newName: string): Promise<FileSystemEntry> { 70 + throw new Error('Remote sync is not yet implemented'); 71 + } 72 + 73 + async deleteFile(_entryId: string): Promise<void> { 74 + throw new Error('Remote sync is not yet implemented'); 75 + } 76 + 77 + async copyFile(_entryId: string): Promise<FileSystemEntry> { 78 + throw new Error('Remote sync is not yet implemented'); 79 + } 80 + 81 + getPath(_entryId: string): string { 82 + return ''; 67 83 } 68 84 }
+18 -2
src/filesystem/providers/subtextFilesystemProvider.ts
··· 57 57 return subscribeSubtextTitleChanges(listener); 58 58 } 59 59 60 - async createFile(): Promise<FileSystemEntry> { 60 + async createFile(_parentId?: string, _name?: string): Promise<FileSystemEntry> { 61 61 const handle = await this.documents.createDocument(); 62 62 return { id: handle.id, name: handle.getTitle() }; 63 63 } 64 64 65 - async createDirectory(_name: string): Promise<FileSystemEntry> { 65 + async createDirectory(_name: string, _parentId?: string): Promise<FileSystemEntry> { 66 66 throw new Error('Subtext does not support directories'); 67 + } 68 + 69 + async renameFile(_entryId: string, _newName: string): Promise<FileSystemEntry> { 70 + throw new Error('Subtext does not support renaming'); 71 + } 72 + 73 + async deleteFile(_entryId: string): Promise<void> { 74 + throw new Error('Subtext does not support deleting'); 75 + } 76 + 77 + async copyFile(_entryId: string): Promise<FileSystemEntry> { 78 + throw new Error('Subtext does not support copying'); 79 + } 80 + 81 + getPath(_entryId: string): string { 82 + return ''; 67 83 } 68 84 }
+23 -2
src/filesystem/types.ts
··· 55 55 * Create a new persisted entry in this source (e.g. a new Habitat docs 56 56 * record). The shell optimistically inserts it, then refreshes the listing. 57 57 */ 58 - createFile(): Promise<FileSystemEntry>; 58 + createFile(parentId?: string, name?: string): Promise<FileSystemEntry>; 59 59 /** 60 60 * Create a new directory in this source. 61 61 * Providers without directory support may throw or no-op. 62 62 */ 63 - createDirectory(name: string): Promise<FileSystemEntry>; 63 + createDirectory(name: string, parentId?: string): Promise<FileSystemEntry>; 64 + /** 65 + * Rename an entry (file or directory). 66 + * Returns the updated entry with the new id and name. 67 + */ 68 + renameFile(entryId: string, newName: string): Promise<FileSystemEntry>; 69 + /** 70 + * Delete an entry (file or directory) permanently. 71 + */ 72 + deleteFile(entryId: string): Promise<void>; 73 + /** 74 + * Duplicate a file. Returns the new entry. 75 + */ 76 + copyFile(entryId: string): Promise<FileSystemEntry>; 77 + /** 78 + * Get the absolute filesystem path for an entry. 79 + * Used for "Copy Path" to clipboard. 80 + */ 81 + getPath(entryId: string): string; 64 82 /** 65 83 * When implemented, called when a listed entry's display name changes 66 84 * (e.g. habitat doc heading edits). Used to keep the sidebar in sync. ··· 85 103 writeFile(filePath: string, content: string): Promise<void>; 86 104 fileExists(filePath: string): Promise<boolean>; 87 105 mkdir(dirPath: string): Promise<void>; 106 + rename(oldPath: string, newPath: string): Promise<void>; 107 + deletePath(filePath: string): Promise<void>; 108 + copyFile(srcPath: string, destPath: string): Promise<void>; 88 109 }
+25
src/filesystem/vaultFs.ts
··· 7 7 writeFile: (filePath: string, content: string) => Promise<void>; 8 8 fileExists: (filePath: string) => Promise<boolean>; 9 9 mkdir: (dirPath: string) => Promise<void>; 10 + rename: (oldPath: string, newPath: string) => Promise<void>; 11 + delete: (filePath: string) => Promise<void>; 12 + copyFile: (srcPath: string, destPath: string) => Promise<void>; 10 13 }; 11 14 12 15 export class VaultFilesystemApi implements FilesystemApi { ··· 31 34 async mkdir(dirPath: string): Promise<void> { 32 35 return this.ipc.mkdir(dirPath); 33 36 } 37 + 38 + async rename(oldPath: string, newPath: string): Promise<void> { 39 + return this.ipc.rename(oldPath, newPath); 40 + } 41 + 42 + async deletePath(filePath: string): Promise<void> { 43 + return this.ipc.delete(filePath); 44 + } 45 + 46 + async copyFile(srcPath: string, destPath: string): Promise<void> { 47 + return this.ipc.copyFile(srcPath, destPath); 48 + } 34 49 } 35 50 36 51 /** ··· 40 55 export function basename(filePath: string): string { 41 56 const parts = filePath.split(/[/\\]/); 42 57 return parts[parts.length - 1] || ''; 58 + } 59 + 60 + /** 61 + * Get the directory name of a path (everything except the last component). 62 + * Works with both / and \ separators. 63 + */ 64 + export function dirname(filePath: string): string { 65 + const parts = filePath.split(/[/\\]/); 66 + parts.pop(); 67 + return parts.join('/') || '/'; 43 68 } 44 69 45 70 /**
+41
src/main.ts
··· 374 374 await fs.mkdir(expandPath(dirPath), { recursive: true }); 375 375 }); 376 376 377 + ipcMain.handle('vault:rename', async (_event, { oldPath, newPath }) => { 378 + await fs.rename(expandPath(oldPath), expandPath(newPath)); 379 + }); 380 + 381 + ipcMain.handle('vault:delete', async (_event, { filePath }) => { 382 + await fs.rm(expandPath(filePath), { recursive: true, force: true }); 383 + }); 384 + 385 + ipcMain.handle('vault:copy-file', async (_event, { srcPath, destPath }) => { 386 + await fs.copyFile(expandPath(srcPath), expandPath(destPath)); 387 + }); 388 + 389 + ipcMain.handle('context-menu:show', async (event, { items }: { items: Array<{ id: string; label: string; type?: 'normal' | 'separator' }> }) => { 390 + const win = BrowserWindow.fromWebContents(event.sender); 391 + if (!win) return null; 392 + return new Promise<string | null>((resolve) => { 393 + let resolved = false; 394 + const menu = Menu.buildFromTemplate( 395 + items.map((item) => ({ 396 + label: item.label, 397 + type: item.type === 'separator' ? 'separator' as const : 'normal' as const, 398 + click: () => { 399 + if (!resolved) { 400 + resolved = true; 401 + resolve(item.id); 402 + } 403 + }, 404 + })), 405 + ); 406 + menu.popup({ 407 + window: win, 408 + callback: () => { 409 + if (!resolved) { 410 + resolved = true; 411 + resolve(null); 412 + } 413 + }, 414 + }); 415 + }); 416 + }); 417 + 377 418 ipcMain.handle( 378 419 'auth:start-habitat-login', 379 420 async (_event, payload: { redirectUri: string; url: string }) => {
+7
src/preload.ts
··· 9 9 }, 10 10 startHabitatLogin: (url: string, redirectUri: string) => 11 11 ipcRenderer.invoke('auth:start-habitat-login', { redirectUri, url }), 12 + contextMenu: { 13 + show: (items: Array<{ id: string; label: string; type?: 'normal' | 'separator' }>) => 14 + ipcRenderer.invoke('context-menu:show', { items }), 15 + }, 12 16 vault: { 13 17 showOpenDialog: () => ipcRenderer.invoke('vault:show-open-dialog'), 14 18 readdir: (dirPath: string) => ipcRenderer.invoke('vault:readdir', { dirPath }), ··· 16 20 writeFile: (filePath: string, content: string) => ipcRenderer.invoke('vault:write-file', { filePath, content }), 17 21 fileExists: (filePath: string) => ipcRenderer.invoke('vault:file-exists', { filePath }), 18 22 mkdir: (dirPath: string) => ipcRenderer.invoke('vault:mkdir', { dirPath }), 23 + rename: (oldPath: string, newPath: string) => ipcRenderer.invoke('vault:rename', { oldPath, newPath }), 24 + delete: (filePath: string) => ipcRenderer.invoke('vault:delete', { filePath }), 25 + copyFile: (srcPath: string, destPath: string) => ipcRenderer.invoke('vault:copy-file', { srcPath, destPath }), 19 26 }, 20 27 subtext: { 21 28 getFullState: (vaultPath: string) =>
+6
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 + contextMenu: { 10 + show: (items: Array<{ id: string; label: string; type?: 'normal' | 'separator' }>) => Promise<string | null>; 11 + }; 9 12 vault: { 10 13 showOpenDialog: () => Promise<{ canceled: boolean; filePaths: string[] }>; 11 14 readdir: (dirPath: string) => Promise<{ name: string; isDirectory: boolean; isFile: boolean }[]>; ··· 13 16 writeFile: (filePath: string, content: string) => Promise<void>; 14 17 fileExists: (filePath: string) => Promise<boolean>; 15 18 mkdir: (dirPath: string) => Promise<void>; 19 + rename: (oldPath: string, newPath: string) => Promise<void>; 20 + delete: (filePath: string) => Promise<void>; 21 + copyFile: (srcPath: string, destPath: string) => Promise<void>; 16 22 }; 17 23 subtext: { 18 24 getFullState: (vaultPath: string) => Promise<Uint8Array | null>;