A local-first note taking app
0

Configure Feed

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

Add pre-commit hook (format, lint, test) and format the repo

- .githooks/pre-commit runs `format:check`, `lint`, and `test`; commits
are blocked unless all pass. Enabled via core.hooksPath, wired by a
`prepare` npm script so `npm install` activates it on fresh clones.
- Ran `prettier --write .` across the repo so it's now Prettier-clean and
the hook's format:check passes going forward.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Ethan Graf (Jun 22, 2026, 9:05 PM EDT) d91e979c c597a192

+654 -317
+13
.githooks/pre-commit
··· 1 + #!/bin/sh 2 + # Pre-commit gate: formatting, linting, tests must all pass. 3 + # Enabled via `core.hooksPath` (set by the `prepare` npm script). 4 + set -e 5 + 6 + echo "pre-commit › prettier --check" 7 + npm run format:check 8 + 9 + echo "pre-commit › eslint" 10 + npm run lint 11 + 12 + echo "pre-commit › vitest" 13 + npm test
+2
.opencode/skills/textile-design/components.md
··· 77 77 ``` 78 78 79 79 The browser renders the OS-native tooltip: 80 + 80 81 - macOS: dark translucent bubble below the element 81 82 - Windows/Linux: follows OS theme 82 83 83 84 **Rules:** 85 + 84 86 - Every interactive control that needs extra context gets a `title` 85 87 - Icon-only buttons **must** also have `aria-label` (screen readers don't read `title` reliably) 86 88 - Never build a custom tooltip with CSS/JS when `title` is sufficient
+2 -1
package.json
··· 14 14 "format": "prettier --write .", 15 15 "format:check": "prettier --check .", 16 16 "test": "vitest run", 17 - "test:watch": "vitest" 17 + "test:watch": "vitest", 18 + "prepare": "git config core.hooksPath .githooks || true" 18 19 }, 19 20 "keywords": [], 20 21 "author": "ethan",
+11 -13
src/actions/CommandPalette.tsx
··· 79 79 // Scroll selected item into view 80 80 useEffect(() => { 81 81 if (listRef.current && filteredActions.length > 0) { 82 - const selectedEl = listRef.current.querySelector('[aria-selected="true"]'); 82 + const selectedEl = listRef.current.querySelector( 83 + '[aria-selected="true"]', 84 + ); 83 85 if (selectedEl && typeof selectedEl.scrollIntoView === 'function') { 84 86 selectedEl.scrollIntoView({ block: 'nearest' }); 85 87 } ··· 91 93 switch (e.key) { 92 94 case 'ArrowDown': 93 95 e.preventDefault(); 94 - setSelectedIndex((prev) => 95 - (prev + 1) % Math.max(filteredActions.length, 1), 96 + setSelectedIndex( 97 + (prev) => (prev + 1) % Math.max(filteredActions.length, 1), 96 98 ); 97 99 break; 98 100 case 'ArrowUp': 99 101 e.preventDefault(); 100 102 setSelectedIndex((prev) => 101 - prev <= 0 102 - ? Math.max(filteredActions.length - 1, 0) 103 - : prev - 1, 103 + prev <= 0 ? Math.max(filteredActions.length - 1, 0) : prev - 1, 104 104 ); 105 105 break; 106 106 case 'Enter': ··· 151 151 <input 152 152 ref={inputRef} 153 153 type="text" 154 - className="bg-background text-foreground w-full rounded-md border border-border px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-accent/50" 154 + className="bg-background text-foreground border-border focus-visible:ring-accent/50 w-full rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2" 155 155 placeholder={placeholder} 156 156 value={query} 157 157 onChange={(e) => setQuery(e.target.value)} ··· 165 165 </div> 166 166 167 167 {/* Results */} 168 - <div 169 - className="max-h-[50vh] min-h-0 overflow-y-auto p-1" 170 - ref={listRef} 171 - > 168 + <div className="max-h-[50vh] min-h-0 overflow-y-auto p-1" ref={listRef}> 172 169 {filteredActions.length === 0 ? ( 173 170 <div className="text-foreground/50 px-2 py-3 text-center text-sm"> 174 171 {query.trim() ··· 181 178 <div role="listbox" id={listId}> 182 179 {filteredActions.map((action, index) => { 183 180 const isSelected = index === selectedIndex; 184 - const effectiveHotkeys = hotkeyStore.getEffectiveHotkeys(action); 181 + const effectiveHotkeys = 182 + hotkeyStore.getEffectiveHotkeys(action); 185 183 const hotkeyHint = effectiveHotkeys[0] 186 184 ? formatHotkeyForDisplay(effectiveHotkeys[0], isMac) 187 185 : undefined; ··· 192 190 id={`command-palette-item-${index}`} 193 191 role="option" 194 192 aria-selected={isSelected} 195 - className={`flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm select-none transition-none ${ 193 + className={`flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-none select-none ${ 196 194 isSelected 197 195 ? 'bg-accent/10 text-foreground' 198 196 : 'text-foreground/70 hover:bg-border/35'
+3 -1
src/actions/hotkey-manager.ts
··· 211 211 if (target && target.tagName) { 212 212 const tagName = target.tagName.toLowerCase(); 213 213 const isInput = 214 - tagName === 'input' || tagName === 'textarea' || target.isContentEditable; 214 + tagName === 'input' || 215 + tagName === 'textarea' || 216 + target.isContentEditable; 215 217 if (isInput && !event.ctrlKey && !event.metaKey && !event.altKey) { 216 218 return; 217 219 }
+7 -1
src/actions/hotkey-utils.ts
··· 2 2 const MODIFIER_ORDER: Modifier[] = ['Ctrl', 'Meta', 'Alt', 'Shift']; 3 3 4 4 /** Known modifier names for validation. */ 5 - const KNOWN_MODIFIERS = new Set<string>(['Ctrl', 'Meta', 'Alt', 'Shift', 'Mod']); 5 + const KNOWN_MODIFIERS = new Set<string>([ 6 + 'Ctrl', 7 + 'Meta', 8 + 'Alt', 9 + 'Shift', 10 + 'Mod', 11 + ]); 6 12 7 13 import type { Modifier, Hotkey } from './types'; 8 14
+4 -1
src/actions/hotkeyStore.ts
··· 99 99 * Find hotkey conflicts among all registered actions using effective hotkeys. 100 100 * Returns a map of normalized hotkey string -> array of conflicting action IDs. 101 101 */ 102 - getConflicts(registry: ActionRegistry, isMac: boolean): Map<string, string[]> { 102 + getConflicts( 103 + registry: ActionRegistry, 104 + isMac: boolean, 105 + ): Map<string, string[]> { 103 106 const bindings = new Map<string, string[]>(); 104 107 105 108 for (const action of registry.getAll()) {
+4 -1
src/actions/registry.ts
··· 111 111 112 112 try { 113 113 const result = action.execute(); 114 - if (result && typeof (result as PromiseLike<unknown>).then === 'function') { 114 + if ( 115 + result && 116 + typeof (result as PromiseLike<unknown>).then === 'function' 117 + ) { 115 118 await result; 116 119 } 117 120 return true;
+3 -7
src/actions/services.ts
··· 14 14 const registry = new ActionRegistry(); 15 15 const hotkeyStore = new HotkeyStore(); 16 16 17 - const hotkeyManager = new HotkeyManager( 18 - registry, 19 - isMac, 20 - (actionId) => { 21 - void registry.execute(actionId); 22 - }, 23 - ); 17 + const hotkeyManager = new HotkeyManager(registry, isMac, (actionId) => { 18 + void registry.execute(actionId); 19 + }); 24 20 25 21 return { registry, hotkeyManager, hotkeyStore }; 26 22 }
+3 -1
src/actions/sets/sidebarActionSet.ts
··· 7 7 readonly id = 'sidebar'; 8 8 9 9 constructor( 10 - private setSidebarOpen: (open: boolean | ((prev: boolean) => boolean)) => void, 10 + private setSidebarOpen: ( 11 + open: boolean | ((prev: boolean) => boolean), 12 + ) => void, 11 13 ) {} 12 14 13 15 getActions(): ActionDefinition[] {
+3 -1
src/actions/sets/themeActionSet.ts
··· 7 7 readonly id = 'theme'; 8 8 9 9 constructor( 10 - private setTheme: (theme: 'light' | 'dark' | ((prev: 'light' | 'dark') => 'light' | 'dark')) => void, 10 + private setTheme: ( 11 + theme: 'light' | 'dark' | ((prev: 'light' | 'dark') => 'light' | 'dark'), 12 + ) => void, 11 13 ) {} 12 14 13 15 getActions(): ActionDefinition[] {
+1 -3
src/actions/sets/tilingActionSet.ts
··· 18 18 export class TilingActionSet implements ActionSet { 19 19 readonly id = 'tiling'; 20 20 21 - constructor( 22 - private handleRef: MutableRefObject<TilingHandle | null>, 23 - ) {} 21 + constructor(private handleRef: MutableRefObject<TilingHandle | null>) {} 24 22 25 23 getActions(): ActionDefinition[] { 26 24 return [
+150 -72
src/components/AppSidebar.tsx
··· 1 - import { 2 - useCallback, 3 - useEffect, 4 - useRef, 5 - useState, 6 - } from 'react'; 1 + import { useCallback, useEffect, useRef, useState } from 'react'; 7 2 import { useQuery, useQueryClient } from '@tanstack/react-query'; 8 3 import type { FileSystemProvider } from '../filesystem/types'; 9 - import { NewDocumentIcon, NewDirectoryIcon, StackedChevronsIcon, PlusIcon, CloudDownloadIcon } from '../icons'; 4 + import { 5 + NewDocumentIcon, 6 + NewDirectoryIcon, 7 + StackedChevronsIcon, 8 + PlusIcon, 9 + CloudDownloadIcon, 10 + } from '../icons'; 10 11 import type { RemoteVault } from '../vault/registry'; 11 12 import type { OpenDocRequest } from '../workspaces/workspace'; 12 13 import { FileSystemTree } from './VaultTree'; ··· 52 53 onInlineCreateChange: (name: string) => void; 53 54 onInlineCreateSubmit: (name: string) => void; 54 55 onInlineCreateCancel: () => void; 55 - onContextMenuAction: (action: string, nodeId: string, nodeName: string, nodeType: 'file' | 'folder') => void; 56 + onContextMenuAction: ( 57 + action: string, 58 + nodeId: string, 59 + nodeName: string, 60 + nodeType: 'file' | 'folder', 61 + ) => void; 56 62 onTreeRootRightClick: (action: string) => void; 57 63 }; 58 64 ··· 89 95 useEffect(() => { 90 96 if (!provider.subscribeReady) return; 91 97 return provider.subscribeReady(() => { 92 - void queryClient.invalidateQueries({ queryKey: treeQueryKey(provider.id) }); 98 + void queryClient.invalidateQueries({ 99 + queryKey: treeQueryKey(provider.id), 100 + }); 93 101 }); 94 102 }, [provider, queryClient]); 95 103 ··· 108 116 ); 109 117 110 118 const handleContextMenu = useCallback( 111 - async (node: { id: string; name: string; type: 'file' | 'folder' }, items: ContextMenuItem[]) => { 119 + async ( 120 + node: { id: string; name: string; type: 'file' | 'folder' }, 121 + items: ContextMenuItem[], 122 + ) => { 112 123 const api = window.textile?.contextMenu; 113 124 if (!api) return; 114 - const action = await api.show(items.map((item) => ({ id: item.id, label: item.label }))); 125 + const action = await api.show( 126 + items.map((item) => ({ id: item.id, label: item.label })), 127 + ); 115 128 if (action) { 116 129 onContextMenuAction(action, node.id, node.name, node.type); 117 130 } ··· 136 149 ); 137 150 138 151 return ( 139 - <section className="min-w-0 flex flex-col flex-1"> 152 + <section className="flex min-w-0 flex-1 flex-col"> 140 153 <h2 className="text-foreground/80 mb-0.5 flex items-center gap-1.5 pl-2.5 text-[11px] font-semibold tracking-wide uppercase"> 141 154 {provider.displayName} 142 155 </h2> 143 - <div className="flex flex-col flex-1 min-h-0 gap-px"> 156 + <div className="flex min-h-0 flex-1 flex-col gap-px"> 144 157 {!provider.isReady() ? ( 145 158 <p className="text-foreground/60 px-2 text-xs leading-snug"> 146 159 {provider.idleHint} ··· 155 168 </p> 156 169 ) : ( 157 170 <> 158 - <div className="flex-1 min-h-0" onContextMenu={handleTreeRootRightClick}> 159 - <FileSystemTree 160 - nodes={treeQuery.data ?? []} 161 - onSelect={handleSelect} 162 - onContextMenu={handleContextMenu} 163 - selectedId={ 164 - selection?.providerId === provider.id ? selection.entryId : undefined 165 - } 166 - renameNodeId={renameNodeId ?? undefined} 167 - renameValue={renameValue} 168 - renameExt={renameExt} 169 - renameError={renameError} 170 - onRenameValueChange={onRenameValueChange} 171 - onRenameSubmit={onRenameSubmit} 172 - onRenameCancel={onRenameCancel} 173 - inlineCreate={inlineCreate ?? undefined} 174 - onInlineCreateChange={onInlineCreateChange} 175 - onInlineCreateSubmit={onInlineCreateSubmit} 176 - onInlineCreateCancel={onInlineCreateCancel} 177 - /> 178 - </div> 171 + <div 172 + className="min-h-0 flex-1" 173 + onContextMenu={handleTreeRootRightClick} 174 + > 175 + <FileSystemTree 176 + nodes={treeQuery.data ?? []} 177 + onSelect={handleSelect} 178 + onContextMenu={handleContextMenu} 179 + selectedId={ 180 + selection?.providerId === provider.id 181 + ? selection.entryId 182 + : undefined 183 + } 184 + renameNodeId={renameNodeId ?? undefined} 185 + renameValue={renameValue} 186 + renameExt={renameExt} 187 + renameError={renameError} 188 + onRenameValueChange={onRenameValueChange} 189 + onRenameSubmit={onRenameSubmit} 190 + onRenameCancel={onRenameCancel} 191 + inlineCreate={inlineCreate ?? undefined} 192 + onInlineCreateChange={onInlineCreateChange} 193 + onInlineCreateSubmit={onInlineCreateSubmit} 194 + onInlineCreateCancel={onInlineCreateCancel} 195 + /> 196 + </div> 179 197 {treeQuery.data?.length === 0 && !inlineCreate && ( 180 198 <p className="text-foreground/60 px-2 text-xs leading-snug"> 181 199 No files. ··· 222 240 <button 223 241 type="button" 224 242 onClick={() => setOpen((o) => !o)} 225 - className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs hover:bg-border/20 transition-colors cursor-pointer" 243 + className="hover:bg-border/20 flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-xs transition-colors" 226 244 > 227 - <StackedChevronsIcon className="size-3.5 shrink-0 text-foreground/50" /> 228 - <span className="flex items-center gap-1.5 truncate text-foreground/60 uppercase tracking-wider text-[10px]"> 245 + <StackedChevronsIcon className="text-foreground/50 size-3.5 shrink-0" /> 246 + <span className="text-foreground/60 flex items-center gap-1.5 truncate text-[10px] tracking-wider uppercase"> 229 247 {selected?.displayName ?? 'Select source'} 230 248 </span> 231 249 </button> 232 250 {open && ( 233 - <div className="border-border bg-background absolute bottom-full left-0 right-0 z-10 mb-1 overflow-hidden rounded-md border shadow-md"> 251 + <div className="border-border bg-background absolute right-0 bottom-full left-0 z-10 mb-1 overflow-hidden rounded-md border shadow-md"> 234 252 {providers.map((provider) => ( 235 253 <button 236 254 key={provider.id} ··· 239 257 onSelect(provider.id); 240 258 setOpen(false); 241 259 }} 242 - className={`flex w-full items-center px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${ 260 + className={`flex w-full cursor-pointer items-center px-2.5 py-1.5 text-left text-xs transition-colors ${ 243 261 provider.id === selectedId 244 262 ? 'bg-accent/10 text-foreground' 245 263 : 'text-foreground/80 hover:bg-border/20' ··· 259 277 onSyncRemote?.(vault); 260 278 setOpen(false); 261 279 }} 262 - className="text-foreground/60 hover:bg-border/20 flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer" 280 + className="text-foreground/60 hover:bg-border/20 flex w-full cursor-pointer items-center gap-1.5 px-2.5 py-1.5 text-left text-xs transition-colors" 263 281 > 264 282 <CloudDownloadIcon className="size-3.5 shrink-0" /> 265 283 <span className="truncate">{vault.name}</span> 266 - <span className="text-foreground/40 ml-auto text-[10px] uppercase tracking-wider"> 284 + <span className="text-foreground/40 ml-auto text-[10px] tracking-wider uppercase"> 267 285 Unsynced 268 286 </span> 269 287 </button> ··· 289 307 const queryClient = useQueryClient(); 290 308 const [selection, setSelection] = useState<Selection>(null); 291 309 const [createError, setCreateError] = useState<string | null>(null); 292 - const [inlineCreate, setInlineCreate] = useState<InlineCreateState | null>(null); 310 + const [inlineCreate, setInlineCreate] = useState<InlineCreateState | null>( 311 + null, 312 + ); 293 313 const [renameNodeId, setRenameNodeId] = useState<string | null>(null); 294 314 const [renameValue, setRenameValue] = useState(''); 295 315 const [renameExt, setRenameExt] = useState(''); ··· 312 332 }, [selectedProviderId]); 313 333 314 334 const handleContextMenuAction = useCallback( 315 - async (action: string, nodeId: string, nodeName: string, _nodeType: 'file' | 'folder') => { 335 + async ( 336 + action: string, 337 + nodeId: string, 338 + nodeName: string, 339 + _nodeType: 'file' | 'folder', 340 + ) => { 316 341 if (!selectedProvider) return; 317 342 switch (action) { 318 343 case 'new-note': { 319 - setInlineCreate({ parentId: nodeId, type: 'file', name: '', error: null }); 344 + setInlineCreate({ 345 + parentId: nodeId, 346 + type: 'file', 347 + name: '', 348 + error: null, 349 + }); 320 350 break; 321 351 } 322 352 case 'new-folder': { 323 - setInlineCreate({ parentId: nodeId, type: 'folder', name: '', error: null }); 353 + setInlineCreate({ 354 + parentId: nodeId, 355 + type: 'folder', 356 + name: '', 357 + error: null, 358 + }); 324 359 break; 325 360 } 326 361 case 'copy': { 327 362 try { 328 363 await selectedProvider.copyFile(nodeId); 329 - void queryClient.invalidateQueries({ queryKey: treeQueryKey(selectedProvider.id) }); 364 + void queryClient.invalidateQueries({ 365 + queryKey: treeQueryKey(selectedProvider.id), 366 + }); 330 367 } catch (e: unknown) { 331 - setCreateError(e instanceof Error ? e.message : 'Could not copy file'); 368 + setCreateError( 369 + e instanceof Error ? e.message : 'Could not copy file', 370 + ); 332 371 } 333 372 break; 334 373 } ··· 341 380 } 342 381 case 'rename': { 343 382 setRenameNodeId(nodeId); 344 - const ext = nodeName.includes('.') ? nodeName.slice(nodeName.lastIndexOf('.')) : ''; 383 + const ext = nodeName.includes('.') 384 + ? nodeName.slice(nodeName.lastIndexOf('.')) 385 + : ''; 345 386 const base = ext ? nodeName.slice(0, -ext.length) : nodeName; 346 387 setRenameValue(base); 347 388 setRenameExt(ext); ··· 352 393 if (window.confirm(`Delete "${nodeName}"? This cannot be undone.`)) { 353 394 try { 354 395 await selectedProvider.deleteFile(nodeId); 355 - void queryClient.invalidateQueries({ queryKey: treeQueryKey(selectedProvider.id) }); 396 + void queryClient.invalidateQueries({ 397 + queryKey: treeQueryKey(selectedProvider.id), 398 + }); 356 399 } catch (e: unknown) { 357 - setCreateError(e instanceof Error ? e.message : 'Could not delete'); 400 + setCreateError( 401 + e instanceof Error ? e.message : 'Could not delete', 402 + ); 358 403 } 359 404 } 360 405 break; ··· 372 417 373 418 if (inlineCreate.type === 'file') { 374 419 try { 375 - const entry = await selectedProvider.createFile(inlineCreate.parentId, trimmed || undefined); 420 + const entry = await selectedProvider.createFile( 421 + inlineCreate.parentId, 422 + trimmed || undefined, 423 + ); 376 424 setInlineCreate(null); 377 425 setSelection({ providerId: selectedProvider.id, entryId: entry.id }); 378 426 onOpenEntry?.({ ··· 380 428 entryId: entry.id, 381 429 title: entry.name, 382 430 }); 383 - void queryClient.invalidateQueries({ queryKey: treeQueryKey(selectedProvider.id) }); 431 + void queryClient.invalidateQueries({ 432 + queryKey: treeQueryKey(selectedProvider.id), 433 + }); 384 434 } catch (e: unknown) { 385 - setInlineCreate({ ...inlineCreate, error: e instanceof Error ? e.message : 'Could not create file' }); 435 + setInlineCreate({ 436 + ...inlineCreate, 437 + error: e instanceof Error ? e.message : 'Could not create file', 438 + }); 386 439 } 387 440 } else { 388 441 try { 389 - await selectedProvider.createDirectory(trimmed || 'Untitled', inlineCreate.parentId); 442 + await selectedProvider.createDirectory( 443 + trimmed || 'Untitled', 444 + inlineCreate.parentId, 445 + ); 390 446 setInlineCreate(null); 391 - void queryClient.invalidateQueries({ queryKey: treeQueryKey(selectedProvider.id) }); 447 + void queryClient.invalidateQueries({ 448 + queryKey: treeQueryKey(selectedProvider.id), 449 + }); 392 450 } catch (e: unknown) { 393 - setInlineCreate({ ...inlineCreate, error: e instanceof Error ? e.message : 'Could not create directory' }); 451 + setInlineCreate({ 452 + ...inlineCreate, 453 + error: 454 + e instanceof Error ? e.message : 'Could not create directory', 455 + }); 394 456 } 395 457 } 396 458 }, ··· 402 464 }, []); 403 465 404 466 const handleInlineCreateChange = useCallback((name: string) => { 405 - setInlineCreate((prev) => prev ? { ...prev, name, error: null } : null); 467 + setInlineCreate((prev) => (prev ? { ...prev, name, error: null } : null)); 406 468 }, []); 407 469 408 470 const handleRenameSubmit = useCallback( ··· 423 485 setRenameValue(''); 424 486 setRenameExt(''); 425 487 setRenameError(null); 426 - void queryClient.invalidateQueries({ queryKey: treeQueryKey(selectedProvider.id) }); 488 + void queryClient.invalidateQueries({ 489 + queryKey: treeQueryKey(selectedProvider.id), 490 + }); 427 491 } catch (e: unknown) { 428 492 setRenameError(e instanceof Error ? e.message : 'Could not rename'); 429 493 } ··· 438 502 setRenameError(null); 439 503 }, []); 440 504 441 - const handleResizeStart = useCallback((e: React.MouseEvent) => { 442 - e.preventDefault(); 443 - resizeStartX.current = e.clientX; 444 - resizeStartWidth.current = sidebarWidth; 445 - setIsResizing(true); 446 - }, [sidebarWidth]); 505 + const handleResizeStart = useCallback( 506 + (e: React.MouseEvent) => { 507 + e.preventDefault(); 508 + resizeStartX.current = e.clientX; 509 + resizeStartWidth.current = sidebarWidth; 510 + setIsResizing(true); 511 + }, 512 + [sidebarWidth], 513 + ); 447 514 448 515 useEffect(() => { 449 516 if (!isResizing) return; 450 517 const handleMouseMove = (e: MouseEvent) => { 451 518 const maxWidth = window.innerWidth * 0.5; 452 - const newWidth = resizeStartWidth.current + (e.clientX - resizeStartX.current); 453 - setSidebarWidth(Math.max(MIN_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth))); 519 + const newWidth = 520 + resizeStartWidth.current + (e.clientX - resizeStartX.current); 521 + setSidebarWidth( 522 + Math.max(MIN_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth)), 523 + ); 454 524 }; 455 525 const handleMouseUp = () => { 456 526 setIsResizing(false); ··· 477 547 return ( 478 548 <aside 479 549 id="app-sidebar" 480 - className={`border-border bg-background/95 shrink-0 overflow-hidden border-r relative ${ 550 + className={`border-border bg-background/95 relative shrink-0 overflow-hidden border-r ${ 481 551 open ? '' : 'w-0 border-r-0' 482 552 }`} 483 553 style={ ··· 489 559 transitionTimingFunction: 'ease-in-out, ease-in-out, ease-in-out', 490 560 } 491 561 : { 492 - transitionProperty: 'width, background-color, border-color, color', 562 + transitionProperty: 563 + 'width, background-color, border-color, color', 493 564 transitionDuration: '200ms, 320ms, 320ms, 320ms', 494 565 transitionTimingFunction: 495 566 'ease-out, ease-in-out, ease-in-out, ease-in-out', ··· 500 571 <div className="flex h-full min-h-0 flex-col"> 501 572 {/* Resize handle */} 502 573 <div 503 - className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize z-10 hover:bg-accent/20 transition-colors" 574 + className="hover:bg-accent/20 absolute top-0 right-0 bottom-0 z-10 w-1 cursor-col-resize transition-colors" 504 575 onMouseDown={handleResizeStart} 505 576 title="Resize sidebar" 506 577 /> ··· 512 583 disabled={inlineCreate !== null} 513 584 title={`New document in ${selectedProvider.displayName}`} 514 585 aria-label={`New document in ${selectedProvider.displayName}`} 515 - aria-busy={inlineCreate?.type === 'file' && inlineCreate?.parentId === undefined} 516 - onClick={() => setInlineCreate({ type: 'file', name: '', error: null })} 586 + aria-busy={ 587 + inlineCreate?.type === 'file' && 588 + inlineCreate?.parentId === undefined 589 + } 590 + onClick={() => 591 + setInlineCreate({ type: 'file', name: '', error: null }) 592 + } 517 593 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" 518 594 > 519 595 <NewDocumentIcon className="size-3.5 shrink-0" /> ··· 523 599 disabled={inlineCreate !== null} 524 600 title="New directory" 525 601 aria-label="New directory" 526 - onClick={() => setInlineCreate({ type: 'folder', name: '', error: null })} 602 + onClick={() => 603 + setInlineCreate({ type: 'folder', name: '', error: null }) 604 + } 527 605 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" 528 606 > 529 607 <NewDirectoryIcon className="size-3.5 shrink-0" />
+15 -18
src/components/HotkeysPane.tsx
··· 1 1 import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 2 - import { 3 - useActionRegistry, 4 - useHotkeyStore, 5 - } from '../actions/ActionContext'; 2 + import { useActionRegistry, useHotkeyStore } from '../actions/ActionContext'; 6 3 import { 7 4 formatHotkeyForDisplay, 8 5 eventToHotkey, ··· 14 11 import type { RegisteredAction, Hotkey } from '../actions/types'; 15 12 16 13 /** Parse editingKey into action ID and slot info. */ 17 - function parseEditingKey( 18 - key: string | null, 19 - ): { actionId: string | null; slot: number | 'new' | null } { 14 + function parseEditingKey(key: string | null): { 15 + actionId: string | null; 16 + slot: number | 'new' | null; 17 + } { 20 18 if (!key) return { actionId: null, slot: null }; 21 19 const colonIdx = key.lastIndexOf(':'); 22 20 if (colonIdx < 0) return { actionId: null, slot: null }; ··· 193 191 }; 194 192 195 193 return ( 196 - <div className="flex flex-1 min-h-0 flex-col -mx-5 px-5 -my-5 py-5"> 194 + <div className="-mx-5 -my-5 flex min-h-0 flex-1 flex-col px-5 py-5"> 197 195 {/* Conflict banner */} 198 196 {liveConflicts.size > 0 ? ( 199 197 <div className="bg-warning-bg border-warning mb-3 rounded-md border p-2.5 text-sm"> ··· 210 208 211 209 {/* Filter bar */} 212 210 <div className="relative mb-3"> 213 - <SearchIcon className="text-foreground/40 pointer-events-none absolute left-2 top-1/2 size-4 -translate-y-1/2" /> 211 + <SearchIcon className="text-foreground/40 pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2" /> 214 212 <input 215 213 type="text" 216 214 value={filter} 217 215 onChange={(e) => setFilter(e.target.value)} 218 216 placeholder="Filter by name or description…" 219 - className="border-border bg-foreground/5 text-foreground placeholder:text-foreground/30 w-full rounded-md border py-1.5 pl-8 pr-3 text-sm outline-none focus:border-accent focus:ring-1 focus:ring-accent" 217 + className="border-border bg-foreground/5 text-foreground placeholder:text-foreground/30 focus:border-accent focus:ring-accent w-full rounded-md border py-1.5 pr-3 pl-8 text-sm outline-none focus:ring-1" 220 218 /> 221 219 </div> 222 220 ··· 224 222 <div className="border-border divide-border min-h-0 flex-1 divide-y overflow-y-auto rounded-md border"> 225 223 {filteredActions.map((action: RegisteredAction) => { 226 224 const effective = hotkeyStore.getEffectiveHotkeys(action); 227 - const { actionId: editingActionId, slot } = parseEditingKey( 228 - editingKey, 229 - ); 225 + const { actionId: editingActionId, slot } = 226 + parseEditingKey(editingKey); 230 227 const isEditingAction = editingActionId === action.id; 231 228 232 229 return ( ··· 235 232 className="flex items-start overflow-hidden px-3 py-1.5" 236 233 > 237 234 <div className="min-w-[40%] flex-1 overflow-hidden pr-4"> 238 - <span className="text-foreground text-xs block truncate leading-snug"> 235 + <span className="text-foreground block truncate text-xs leading-snug"> 239 236 {action.name} 240 237 </span> 241 238 {action.description ? ( ··· 245 242 ) : null} 246 243 </div> 247 244 <div className="flex min-w-0 flex-1 items-center justify-end overflow-hidden"> 248 - <div className="flex min-w-0 flex-wrap items-center gap-y-1 justify-end"> 245 + <div className="flex min-w-0 flex-wrap items-center justify-end gap-y-1"> 249 246 {effective.map((hk, index) => { 250 247 const isEditing = isEditingAction && slot === index; 251 248 return ( 252 249 <span 253 250 key={`${action.id}-${index}`} 254 - className="inline-flex items-center gap-1 ml-1.5 rounded border border-border bg-foreground/5 px-1 py-0.5" 251 + className="border-border bg-foreground/5 ml-1.5 inline-flex items-center gap-1 rounded border px-1 py-0.5" 255 252 > 256 253 {isEditing ? ( 257 254 <span ··· 289 286 ); 290 287 })} 291 288 </div> 292 - <div className="shrink-0 flex items-center"> 289 + <div className="flex shrink-0 items-center"> 293 290 {isEditingAction && slot === 'new' ? ( 294 291 <span 295 - className={`border-border mr-1 shrink-0 inline-flex items-center animate-pulse rounded border px-1 py-0.5 text-xs ${isCapturedConflicting ? 'bg-destructive/10 text-destructive' : 'bg-accent/10 text-accent'}`} 292 + className={`border-border mr-1 inline-flex shrink-0 animate-pulse items-center rounded border px-1 py-0.5 text-xs ${isCapturedConflicting ? 'bg-destructive/10 text-destructive' : 'bg-accent/10 text-accent'}`} 296 293 > 297 294 {captured 298 295 ? formatHotkeyForDisplay(captured, isMac)
+3 -1
src/components/SettingsPane.tsx
··· 19 19 <aside className="border-border bg-background/90 w-60 shrink-0 border-r p-3"> 20 20 {sidebar} 21 21 </aside> 22 - <section className="flex min-h-0 min-w-0 flex-1 flex-col p-5">{children}</section> 22 + <section className="flex min-h-0 min-w-0 flex-1 flex-col p-5"> 23 + {children} 24 + </section> 23 25 </div> 24 26 ); 25 27 }
+4 -4
src/components/SyncRemoteVaultModal.tsx
··· 70 70 > 71 71 <div className="space-y-5"> 72 72 <div className="flex items-center gap-2"> 73 - <CloudDownloadIcon className="size-4 shrink-0 text-foreground/60" /> 73 + <CloudDownloadIcon className="text-foreground/60 size-4 shrink-0" /> 74 74 <span className="text-foreground/80 truncate text-sm font-medium"> 75 75 {target?.name ?? ''} 76 76 </span> ··· 88 88 type="button" 89 89 onClick={handleSelectDirectory} 90 90 disabled={syncing} 91 - className="bg-primary/10 text-primary hover:bg-primary/20 border-primary/30 shrink-0 rounded-md border px-2.5 py-1.5 text-xs transition-colors cursor-pointer disabled:opacity-50" 91 + className="bg-primary/10 text-primary hover:bg-primary/20 border-primary/30 shrink-0 cursor-pointer rounded-md border px-2.5 py-1.5 text-xs transition-colors disabled:opacity-50" 92 92 > 93 93 Browse… 94 94 </button> ··· 102 102 type="button" 103 103 onClick={onClose} 104 104 disabled={syncing} 105 - className="border-border bg-background text-foreground hover:bg-border/30 rounded-md border px-3 py-1.5 text-xs cursor-pointer disabled:opacity-50" 105 + className="border-border bg-background text-foreground hover:bg-border/30 cursor-pointer rounded-md border px-3 py-1.5 text-xs disabled:opacity-50" 106 106 > 107 107 Cancel 108 108 </button> ··· 110 110 type="button" 111 111 onClick={handleConfirm} 112 112 disabled={syncing || !path} 113 - className="bg-primary text-primary-foreground rounded-md px-3 py-1.5 text-xs cursor-pointer hover:opacity-90 disabled:opacity-50" 113 + className="bg-primary text-primary-foreground cursor-pointer rounded-md px-3 py-1.5 text-xs hover:opacity-90 disabled:opacity-50" 114 114 > 115 115 {syncing ? 'Syncing…' : 'Sync Vault'} 116 116 </button>
+36 -21
src/components/VaultManager.tsx
··· 6 6 type VaultManagerProps = { 7 7 open: boolean; 8 8 onClose: () => void; 9 - onOpenVault: (path: string, type: 'local' | 'synced', did: string | null) => void; 9 + onOpenVault: ( 10 + path: string, 11 + type: 'local' | 'synced', 12 + did: string | null, 13 + ) => void; 10 14 onOpenSettings?: () => void; 11 15 }; 12 16 13 - export function VaultManager({ open, onClose, onOpenVault, onOpenSettings }: VaultManagerProps) { 17 + export function VaultManager({ 18 + open, 19 + onClose, 20 + onOpenVault, 21 + onOpenSettings, 22 + }: VaultManagerProps) { 14 23 const [path, setPath] = useState(''); 15 24 const [error, setError] = useState<string | null>(null); 16 25 const [avatarUrl, setAvatarUrl] = useState<string | null>(null); ··· 41 50 let cancelled = false; 42 51 async function fetchAvatar() { 43 52 try { 44 - const res = await fetch(`https://api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(authInfo!.handle)}`); 53 + const res = await fetch( 54 + `https://api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(authInfo!.handle)}`, 55 + ); 45 56 if (!res.ok) return; 46 57 const data = await res.json(); 47 58 if (!cancelled && data.avatar) { ··· 52 63 } 53 64 } 54 65 fetchAvatar(); 55 - return () => { cancelled = true; }; 66 + return () => { 67 + cancelled = true; 68 + }; 56 69 }, [isAuthenticated, authInfo]); 57 70 58 71 const handleOpen = () => { ··· 91 104 <span className="text-foreground/80 mb-1 block text-xs font-medium"> 92 105 Directory 93 106 </span> 94 - <div className="flex gap-2 items-center"> 95 - <span className="flex-1 truncate text-xs text-foreground/60"> 107 + <div className="flex items-center gap-2"> 108 + <span className="text-foreground/60 flex-1 truncate text-xs"> 96 109 {path || 'No directory selected'} 97 110 </span> 98 111 <button 99 112 type="button" 100 113 onClick={handleSelectDirectory} 101 - className="bg-primary/10 text-primary hover:bg-primary/20 border border-primary/30 rounded-md px-2.5 py-1.5 text-xs shrink-0 transition-colors cursor-pointer" 114 + className="bg-primary/10 text-primary hover:bg-primary/20 border-primary/30 shrink-0 cursor-pointer rounded-md border px-2.5 py-1.5 text-xs transition-colors" 102 115 > 103 116 Browse… 104 117 </button> 105 118 </div> 106 - {error && <p className="text-destructive text-xs mt-1">{error}</p>} 119 + {error && <p className="text-destructive mt-1 text-xs">{error}</p>} 107 120 </div> 108 121 109 122 <div> 110 - <div className="flex items-center gap-1 mb-1.5"> 123 + <div className="mb-1.5 flex items-center gap-1"> 111 124 <span className="text-foreground/80 text-xs font-medium"> 112 125 Sync to your Personal Data Server (PDS) 113 126 </span> 114 127 <button 115 128 type="button" 116 - className="text-foreground/40 hover:text-foreground/70 cursor-help inline-block border-0 bg-transparent p-0" 129 + className="text-foreground/40 hover:text-foreground/70 inline-block cursor-help border-0 bg-transparent p-0" 117 130 title="Syncing your files to your Personal Data Server (PDS) enables sharing files to other devices and is needed for live collaboration. Keeping your files local will ensure Textile never sends your files off your device." 118 131 aria-label="Info about PDS sync" 119 132 onClick={(e) => e.preventDefault()} ··· 121 134 <InfoIcon className="size-3.5" /> 122 135 </button> 123 136 </div> 124 - <div className="flex border border-border rounded-md overflow-hidden"> 137 + <div className="border-border flex overflow-hidden rounded-md border"> 125 138 <button 126 139 type="button" 127 140 onClick={() => isAuthenticated && setVaultType('synced')} 128 141 disabled={!isAuthenticated} 129 - className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 text-xs cursor-pointer ${ 142 + className={`flex flex-1 cursor-pointer items-center justify-center gap-1.5 py-1.5 text-xs ${ 130 143 vaultType === 'synced' 131 144 ? 'bg-primary text-primary-foreground font-medium' 132 145 : 'bg-background text-foreground/60 hover:bg-border/20' 133 - } ${!isAuthenticated ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`} 146 + } ${!isAuthenticated ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`} 134 147 > 135 148 <CloudIcon className="size-3.5" /> 136 149 Sync to PDS ··· 138 151 <button 139 152 type="button" 140 153 onClick={() => setVaultType('local')} 141 - className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 text-xs border-l border-border cursor-pointer ${ 154 + className={`border-border flex flex-1 cursor-pointer items-center justify-center gap-1.5 border-l py-1.5 text-xs ${ 142 155 vaultType === 'local' 143 156 ? 'bg-primary text-primary-foreground font-medium' 144 157 : 'bg-background text-foreground/60 hover:bg-border/20' ··· 149 162 </button> 150 163 </div> 151 164 {isAuthenticated && ( 152 - <div className={`flex items-center gap-1.5 mt-1.5 ${vaultType === 'synced' ? '' : 'invisible'}`}> 165 + <div 166 + className={`mt-1.5 flex items-center gap-1.5 ${vaultType === 'synced' ? '' : 'invisible'}`} 167 + > 153 168 {avatarUrl ? ( 154 169 <img 155 170 src={avatarUrl} 156 171 alt="" 157 - className="rounded-full size-5 object-cover" 172 + className="size-5 rounded-full object-cover" 158 173 aria-hidden 159 174 /> 160 175 ) : ( 161 - <div className="bg-primary text-primary-foreground flex items-center justify-center rounded-full size-5 text-[10px] font-semibold"> 176 + <div className="bg-primary text-primary-foreground flex size-5 items-center justify-center rounded-full text-[10px] font-semibold"> 162 177 {authInfo.handle.charAt(0).toUpperCase()} 163 178 </div> 164 179 )} ··· 178 193 </div> 179 194 )} 180 195 {!isAuthenticated && ( 181 - <p className="text-foreground/60 text-xs mt-1"> 196 + <p className="text-foreground/60 mt-1 text-xs"> 182 197 <button 183 198 type="button" 184 199 onClick={() => { 185 200 onClose(); 186 201 onOpenSettings?.(); 187 202 }} 188 - className="text-primary hover:underline cursor-pointer border-0 bg-transparent p-0 text-xs" 203 + className="text-primary cursor-pointer border-0 bg-transparent p-0 text-xs hover:underline" 189 204 > 190 205 Sign in to your Atmosphere Account 191 206 </button>{' '} ··· 199 214 <button 200 215 type="button" 201 216 onClick={onClose} 202 - className="border-border bg-background text-foreground hover:bg-border/30 rounded-md border px-3 py-1.5 text-xs cursor-pointer" 217 + className="border-border bg-background text-foreground hover:bg-border/30 cursor-pointer rounded-md border px-3 py-1.5 text-xs" 203 218 > 204 219 Cancel 205 220 </button> 206 221 <button 207 222 type="button" 208 223 onClick={handleOpen} 209 - className="bg-primary text-primary-foreground hover:opacity-90 rounded-md px-3 py-1.5 text-xs cursor-pointer" 224 + className="bg-primary text-primary-foreground cursor-pointer rounded-md px-3 py-1.5 text-xs hover:opacity-90" 210 225 > 211 226 Create Vault 212 227 </button>
+46 -38
src/components/VaultTree.tsx
··· 88 88 style={{ paddingLeft: `${depth * 12}px` }} 89 89 > 90 90 <span className="w-1.5 shrink-0" /> 91 - <div className="flex flex-col flex-1 min-w-0"> 91 + <div className="flex min-w-0 flex-1 flex-col"> 92 92 <input 93 93 ref={inputRef} 94 94 type="text" ··· 104 104 } 105 105 }} 106 106 onBlur={() => onCancel()} 107 - className="min-w-0 bg-transparent text-foreground text-xs outline-none border-0 p-0 w-full" 107 + className="text-foreground w-full min-w-0 border-0 bg-transparent p-0 text-xs outline-none" 108 108 /> 109 - {error && ( 110 - <span className="text-destructive text-[10px]">{error}</span> 111 - )} 109 + {error && <span className="text-destructive text-[10px]">{error}</span>} 112 110 </div> 113 111 </div> 114 112 ); ··· 150 148 onInlineCreateCancel?: () => void; 151 149 }) { 152 150 const [expanded, setExpanded] = useState(depth < 2); 153 - const isActive = node.id === renameNodeId || (node.id === selectedId && !inlineCreate); 151 + const isActive = 152 + node.id === renameNodeId || (node.id === selectedId && !inlineCreate); 154 153 const hasChildren = node.children && node.children.length > 0; 155 154 const isRenaming = node.id === renameNodeId; 156 155 const isCreatingHere = inlineCreate?.parentId === node.id; ··· 176 175 onSelect?.(node); 177 176 }, [node, onSelect]); 178 177 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]); 178 + const handleContextMenu = useCallback( 179 + (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 + }, 200 + [node, onContextMenu], 201 + ); 200 202 201 203 return ( 202 204 <div> ··· 211 213 <button 212 214 type="button" 213 215 onClick={() => setExpanded((e) => !e)} 214 - className="text-foreground/40 w-1.5 h-1.5 flex items-center justify-center shrink-0 cursor-pointer" 216 + className="text-foreground/40 flex h-1.5 w-1.5 shrink-0 cursor-pointer items-center justify-center" 215 217 > 216 - {expanded 217 - ? <TriangleDown className="w-1.5 h-1.5" /> 218 - : <TriangleRight className="w-1.5 h-1.5" />} 218 + {expanded ? ( 219 + <TriangleDown className="h-1.5 w-1.5" /> 220 + ) : ( 221 + <TriangleRight className="h-1.5 w-1.5" /> 222 + )} 219 223 </button> 220 224 )} 221 225 {node.type === 'file' && <span className="w-1.5" />} 222 226 {isRenaming ? ( 223 - <div className="flex flex-col flex-1 min-w-0"> 224 - <div className="flex items-center min-w-0"> 227 + <div className="flex min-w-0 flex-1 flex-col"> 228 + <div className="flex min-w-0 items-center"> 225 229 <input 226 230 ref={inputRef} 227 231 type="text" ··· 236 240 } 237 241 }} 238 242 onBlur={() => onRenameCancel?.()} 239 - className="min-w-0 bg-transparent text-foreground text-xs outline-none border-0 p-0" 243 + className="text-foreground min-w-0 border-0 bg-transparent p-0 text-xs outline-none" 240 244 /> 241 245 {renameExt && ( 242 - <span className="text-foreground/40 text-xs shrink-0">{renameExt}</span> 246 + <span className="text-foreground/40 shrink-0 text-xs"> 247 + {renameExt} 248 + </span> 243 249 )} 244 250 </div> 245 251 {renameError && ( 246 - <span className="text-destructive text-[10px]">{renameError}</span> 252 + <span className="text-destructive text-[10px]"> 253 + {renameError} 254 + </span> 247 255 )} 248 256 </div> 249 257 ) : ( 250 258 <button 251 259 type="button" 252 260 onClick={handleClick} 253 - className="flex-1 min-w-0 text-left cursor-pointer bg-transparent border-0 p-0" 261 + className="min-w-0 flex-1 cursor-pointer border-0 bg-transparent p-0 text-left" 254 262 > 255 263 <span className="truncate">{node.name}</span> 256 264 </button> ··· 329 337 const isCreatingAtRoot = inlineCreate && inlineCreate.parentId === undefined; 330 338 331 339 return ( 332 - <div className="flex flex-col h-full"> 340 + <div className="flex h-full flex-col"> 333 341 {isCreatingAtRoot && ( 334 342 <InlineCreateInput 335 343 depth={0}
+15 -7
src/documents/providers/syncedVaultDocumentsProvider.test.ts
··· 94 94 it('opens an untracked on-disk file by provisioning + tracking the remote doc', async () => { 95 95 writeFile(tmpDir, 'note.md', 'hello'); 96 96 97 - const handle = await provider.openDocument(nodePath.join(tmpDir, 'note.md')); 97 + const handle = await provider.openDocument( 98 + nodePath.join(tmpDir, 'note.md'), 99 + ); 98 100 99 101 // Snapshot now tracks the file with a uri. 100 102 const entry = snapshotFile(tmpDir, 'note.md'); ··· 103 105 expect(handle.id).toBe(uri); 104 106 105 107 // The remote doc was created with the on-disk content. 106 - expect((repo.getDoc<FileDocument>(uri) as unknown as FileDocument).content).toBe('hello'); 108 + expect( 109 + (repo.getDoc<FileDocument>(uri) as unknown as FileDocument).content, 110 + ).toBe('hello'); 107 111 108 112 // The editor binding is wired to the live session doc. 109 113 const payload = getAutomergePayload(handle.getEditorBinding()); ··· 138 142 }); 139 143 const remoteHeads = remote.heads(); 140 144 141 - const handle = await provider.openDocument(nodePath.join(tmpDir, 'shared.md')); 145 + const handle = await provider.openDocument( 146 + nodePath.join(tmpDir, 'shared.md'), 147 + ); 142 148 143 149 expect(readFile(tmpDir, 'shared.md')).toBe('v2 from remote'); 144 - expect(getAutomergePayload(handle.getEditorBinding()).getDoc().content).toBe( 145 - 'v2 from remote', 146 - ); 150 + expect( 151 + getAutomergePayload(handle.getEditorBinding()).getDoc().content, 152 + ).toBe('v2 from remote'); 147 153 expect(snapshotFile(tmpDir, 'shared.md')!.head).toEqual(remoteHeads); 148 154 }); 149 155 ··· 163 169 164 170 // Mirror wrote to disk; the shared store (remote) reflects the edit too. 165 171 expect(readFile(tmpDir, 'doc.md')).toBe('edited'); 166 - expect((repo.getDoc<FileDocument>(uri) as unknown as FileDocument).content).toBe('edited'); 172 + expect( 173 + (repo.getDoc<FileDocument>(uri) as unknown as FileDocument).content, 174 + ).toBe('edited'); 167 175 168 176 // The snapshot was updated, so a steady-state sync sees no phantom change. 169 177 vi.useRealTimers();
+10 -2
src/documents/providers/syncedVaultDocumentsProvider.ts
··· 64 64 // snapshot yet — push it first so a remote doc + uri exist. 65 65 let uri = await this.resolveUri(relPath); 66 66 if (!uri) { 67 - await this.opts.engine.pushFile(this.opts.vault, this.opts.rootPath, relPath); 67 + await this.opts.engine.pushFile( 68 + this.opts.vault, 69 + this.opts.rootPath, 70 + relPath, 71 + ); 68 72 uri = await this.resolveUri(relPath); 69 73 } 70 74 if (!uri) { ··· 72 76 } 73 77 74 78 // Pull the latest merged remote state to disk before binding. 75 - await this.opts.engine.openFile(this.opts.vault, this.opts.rootPath, relPath); 79 + await this.opts.engine.openFile( 80 + this.opts.vault, 81 + this.opts.rootPath, 82 + relPath, 83 + ); 76 84 77 85 const session = this.acquireSession(uri); 78 86 const stopMirror = this.mirrorSessionToDisk(session, relPath, uri);
+25 -12
src/editors/automerge/automergeDocumentEditor.tsx
··· 1 1 import { useRef, useEffect, useState } from 'react'; 2 2 import { EditorState, Compartment } from '@codemirror/state'; 3 - import { EditorView, drawSelection, keymap, placeholder } from '@codemirror/view'; 3 + import { 4 + EditorView, 5 + drawSelection, 6 + keymap, 7 + placeholder, 8 + } from '@codemirror/view'; 4 9 import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; 5 10 import { GFM } from '@lezer/markdown'; 6 - import { defaultKeymap, indentWithTab, history, historyKeymap } from '@codemirror/commands'; 11 + import { 12 + defaultKeymap, 13 + indentWithTab, 14 + history, 15 + historyKeymap, 16 + } from '@codemirror/commands'; 7 17 import { syntaxHighlighting } from '@codemirror/language'; 8 18 import { classHighlighter } from '@lezer/highlight'; 9 19 ··· 24 34 className, 25 35 placeholder: placeholderText, 26 36 }: EditorProps) { 27 - const { getDoc, applyChange, subscribeToChanges } = getAutomergePayload(binding); 37 + const { getDoc, applyChange, subscribeToChanges } = 38 + getAutomergePayload(binding); 28 39 const containerRef = useRef<HTMLDivElement>(null); 29 40 const viewRef = useRef<EditorView | null>(null); 30 41 const applyChangeRef = useRef(applyChange); ··· 96 107 saveTimerRef.current = setTimeout(() => { 97 108 saveTimerRef.current = null; 98 109 isWriting.current = true; 99 - applyChangeRef.current((d) => updateTextContent(d, ['content'], newContent)); 110 + applyChangeRef.current((d) => 111 + updateTextContent(d, ['content'], newContent), 112 + ); 100 113 }, 50); 101 114 } 102 115 }); ··· 112 125 // Syntax highlighting for code blocks 113 126 syntaxHighlighting(classHighlighter), 114 127 // Keymap 115 - keymap.of([ 116 - ...defaultKeymap, 117 - ...historyKeymap, 118 - indentWithTab, 119 - ]), 128 + keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]), 120 129 // GFM markdown (strikethrough, tables, task lists) 121 130 // addKeymap: false to avoid shadowing our custom Enter/Tab handlers 122 131 markdown({ ··· 146 155 clearTimeout(saveTimerRef.current); 147 156 saveTimerRef.current = null; 148 157 const finalContent = view.state.doc.toString(); 149 - applyChangeRef.current((d) => updateTextContent(d, ['content'], finalContent)); 158 + applyChangeRef.current((d) => 159 + updateTextContent(d, ['content'], finalContent), 160 + ); 150 161 } 151 162 view.destroy(); 152 163 viewRef.current = null; ··· 154 165 }, []); 155 166 156 167 return ( 157 - <div className={`automerge-editor-container flex flex-col ${className ?? ''}`}> 158 - <div ref={containerRef} className="flex-1 overflow-hidden min-h-0" /> 168 + <div 169 + className={`automerge-editor-container flex flex-col ${className ?? ''}`} 170 + > 171 + <div ref={containerRef} className="min-h-0 flex-1 overflow-hidden" /> 159 172 </div> 160 173 ); 161 174 }
+58 -18
src/editors/automerge/automergeEditor.css
··· 12 12 13 13 .automerge-editor-container .cm-scroller { 14 14 overflow: auto; 15 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 15 + font-family: 16 + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 16 17 font-size: 16px; 17 18 line-height: 1.6; 18 19 padding: 1rem; ··· 26 27 } 27 28 28 29 /* Headings */ 29 - .automerge-editor-container .cm-heading { font-weight: 700; } 30 - .automerge-editor-container .cm-heading-1 { font-size: 2em; line-height: 1.2; } 31 - .automerge-editor-container .cm-heading-2 { font-size: 1.5em; line-height: 1.25; } 32 - .automerge-editor-container .cm-heading-3 { font-size: 1.17em; line-height: 1.3; } 33 - .automerge-editor-container .cm-heading-4 { font-size: 1em; line-height: 1.4; } 34 - .automerge-editor-container .cm-heading-5 { font-size: 0.83em; line-height: 1.45; } 35 - .automerge-editor-container .cm-heading-6 { font-size: 0.67em; line-height: 1.5; } 30 + .automerge-editor-container .cm-heading { 31 + font-weight: 700; 32 + } 33 + .automerge-editor-container .cm-heading-1 { 34 + font-size: 2em; 35 + line-height: 1.2; 36 + } 37 + .automerge-editor-container .cm-heading-2 { 38 + font-size: 1.5em; 39 + line-height: 1.25; 40 + } 41 + .automerge-editor-container .cm-heading-3 { 42 + font-size: 1.17em; 43 + line-height: 1.3; 44 + } 45 + .automerge-editor-container .cm-heading-4 { 46 + font-size: 1em; 47 + line-height: 1.4; 48 + } 49 + .automerge-editor-container .cm-heading-5 { 50 + font-size: 0.83em; 51 + line-height: 1.45; 52 + } 53 + .automerge-editor-container .cm-heading-6 { 54 + font-size: 0.67em; 55 + line-height: 1.5; 56 + } 36 57 37 58 /* Bold / Italic */ 38 - .automerge-editor-container .cm-strong { font-weight: 700; } 39 - .automerge-editor-container .cm-em { font-style: italic; } 59 + .automerge-editor-container .cm-strong { 60 + font-weight: 700; 61 + } 62 + .automerge-editor-container .cm-em { 63 + font-style: italic; 64 + } 40 65 41 66 /* Links */ 42 - .automerge-editor-container .cm-link { color: #0969da; text-decoration: none; } 43 - .automerge-editor-container .cm-link:hover { text-decoration: underline; } 44 - .automerge-editor-container .cm-url { color: #0969da; } 67 + .automerge-editor-container .cm-link { 68 + color: #0969da; 69 + text-decoration: none; 70 + } 71 + .automerge-editor-container .cm-link:hover { 72 + text-decoration: underline; 73 + } 74 + .automerge-editor-container .cm-url { 75 + color: #0969da; 76 + } 45 77 46 78 /* Code */ 47 79 .automerge-editor-container .cm-inline-code { 48 80 background: rgba(175, 184, 193, 0.2); 49 81 padding: 0.2em 0.4em; 50 82 border-radius: 3px; 51 - font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; 83 + font-family: 84 + ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; 52 85 font-size: 0.9em; 53 86 } 54 87 ··· 57 90 border-radius: 6px; 58 91 padding: 0.8em 1em; 59 92 margin: 0.5em 0; 60 - font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; 93 + font-family: 94 + ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; 61 95 font-size: 0.9em; 62 96 line-height: 1.5; 63 97 } 64 98 65 99 /* Lists */ 66 - .automerge-editor-container .cm-list-1 { padding-left: 1.5em; } 67 - .automerge-editor-container .cm-list-2 { padding-left: 2.5em; } 68 - .automerge-editor-container .cm-list-3 { padding-left: 3.5em; } 100 + .automerge-editor-container .cm-list-1 { 101 + padding-left: 1.5em; 102 + } 103 + .automerge-editor-container .cm-list-2 { 104 + padding-left: 2.5em; 105 + } 106 + .automerge-editor-container .cm-list-3 { 107 + padding-left: 3.5em; 108 + } 69 109 70 110 /* Blockquotes */ 71 111 .automerge-editor-container .cm-quote {
+6 -2
src/editors/automerge/automergeEditorBinding.ts
··· 26 26 content: string; 27 27 }; 28 28 29 - export function createAutomergeBinding(payload: AutomergeEditorPayload): EditorBinding { 29 + export function createAutomergeBinding( 30 + payload: AutomergeEditorPayload, 31 + ): EditorBinding { 30 32 return { kind: 'automerge', payload }; 31 33 } 32 34 33 - export function getAutomergePayload(binding: EditorBinding): AutomergeEditorPayload { 35 + export function getAutomergePayload( 36 + binding: EditorBinding, 37 + ): AutomergeEditorPayload { 34 38 if (binding.kind !== 'automerge') { 35 39 throw new Error( 36 40 `Expected automerge editor binding, got "${String(binding.kind)}"`,
+64 -15
src/editors/automerge/livePreview.ts
··· 8 8 */ 9 9 import { syntaxTree } from '@codemirror/language'; 10 10 import { RangeSetBuilder } from '@codemirror/state'; 11 - import { Decoration, type DecorationSet, EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view'; 11 + import { 12 + Decoration, 13 + type DecorationSet, 14 + EditorView, 15 + ViewPlugin, 16 + type ViewUpdate, 17 + } from '@codemirror/view'; 12 18 13 19 const FORMATTING_HIDDEN = 'cm-formatting-hidden'; 14 20 ··· 24 30 25 31 function buildDecorations(view: EditorView): DecorationSet { 26 32 const builder = new RangeSetBuilder<Decoration>(); 27 - const cursorLine = view.state.doc.lineAt(view.state.selection.main.head).number; 33 + const cursorLine = view.state.doc.lineAt( 34 + view.state.selection.main.head, 35 + ).number; 28 36 const decos: Array<{ from: number; to: number; deco: Decoration }> = []; 29 37 30 - const ranges = view.visibleRanges.length > 0 31 - ? view.visibleRanges 32 - : [{ from: 0, to: view.state.doc.length }]; 38 + const ranges = 39 + view.visibleRanges.length > 0 40 + ? view.visibleRanges 41 + : [{ from: 0, to: view.state.doc.length }]; 33 42 34 43 for (const { from: rangeFrom, to: rangeTo } of ranges) { 35 44 syntaxTree(view.state).iterate({ ··· 41 50 if (headingMatch) { 42 51 const level = parseInt(headingMatch[1], 10); 43 52 const line = view.state.doc.lineAt(node.from); 44 - decos.push({ from: line.from, to: line.from, deco: HEADING_LINE_DECOS[level - 1] }); 53 + decos.push({ 54 + from: line.from, 55 + to: line.from, 56 + deco: HEADING_LINE_DECOS[level - 1], 57 + }); 45 58 if (line.number !== cursorLine) { 46 59 const headerMark = node.node.getChild('HeaderMark'); 47 60 if (headerMark) { 48 61 const hideEnd = Math.min(headerMark.to + 1, line.to); 49 - decos.push({ from: headerMark.from, to: hideEnd, deco: hiddenMark }); 62 + decos.push({ 63 + from: headerMark.from, 64 + to: hideEnd, 65 + deco: hiddenMark, 66 + }); 50 67 } 51 68 } 52 69 return; ··· 56 73 if (node.name === 'StrongEmphasis') { 57 74 const openDelim = node.node.getChild('EmphasisMark'); 58 75 if (openDelim) { 59 - decos.push({ from: openDelim.from, to: openDelim.to, deco: hiddenMark }); 76 + decos.push({ 77 + from: openDelim.from, 78 + to: openDelim.to, 79 + deco: hiddenMark, 80 + }); 60 81 } 61 82 // Find closing delimiter 62 83 const children = node.node.getChildren('EmphasisMark'); 63 84 if (children.length >= 2) { 64 85 const lastMark = children[children.length - 1]; 65 - decos.push({ from: lastMark.from, to: lastMark.to, deco: hiddenMark }); 86 + decos.push({ 87 + from: lastMark.from, 88 + to: lastMark.to, 89 + deco: hiddenMark, 90 + }); 66 91 } 67 92 return; 68 93 } ··· 71 96 if (node.name === 'Emphasis') { 72 97 const openDelim = node.node.getChild('EmphasisMark'); 73 98 if (openDelim) { 74 - decos.push({ from: openDelim.from, to: openDelim.to, deco: hiddenMark }); 99 + decos.push({ 100 + from: openDelim.from, 101 + to: openDelim.to, 102 + deco: hiddenMark, 103 + }); 75 104 } 76 105 const children = node.node.getChildren('EmphasisMark'); 77 106 if (children.length >= 2) { 78 107 const lastMark = children[children.length - 1]; 79 - decos.push({ from: lastMark.from, to: lastMark.to, deco: hiddenMark }); 108 + decos.push({ 109 + from: lastMark.from, 110 + to: lastMark.to, 111 + deco: hiddenMark, 112 + }); 80 113 } 81 114 return; 82 115 } ··· 85 118 if (node.name === 'InlineCode') { 86 119 const openDelim = node.node.getChild('CodeMark'); 87 120 if (openDelim) { 88 - decos.push({ from: openDelim.from, to: openDelim.to, deco: hiddenMark }); 121 + decos.push({ 122 + from: openDelim.from, 123 + to: openDelim.to, 124 + deco: hiddenMark, 125 + }); 89 126 } 90 127 const children = node.node.getChildren('CodeMark'); 91 128 if (children.length >= 2) { 92 129 const lastMark = children[children.length - 1]; 93 - decos.push({ from: lastMark.from, to: lastMark.to, deco: hiddenMark }); 130 + decos.push({ 131 + from: lastMark.from, 132 + to: lastMark.to, 133 + deco: hiddenMark, 134 + }); 94 135 } 95 136 return; 96 137 } ··· 100 141 const linkMark = node.node.getChild('LinkMark'); 101 142 if (linkMark) { 102 143 // Hide opening [ 103 - decos.push({ from: linkMark.from, to: linkMark.to, deco: hiddenMark }); 144 + decos.push({ 145 + from: linkMark.from, 146 + to: linkMark.to, 147 + deco: hiddenMark, 148 + }); 104 149 } 105 150 // Find closing ] and (url) 106 151 const closeBracket = node.node.getChild('CloseBracket'); 107 152 if (closeBracket) { 108 - decos.push({ from: closeBracket.from, to: closeBracket.to, deco: hiddenMark }); 153 + decos.push({ 154 + from: closeBracket.from, 155 + to: closeBracket.to, 156 + deco: hiddenMark, 157 + }); 109 158 } 110 159 } 111 160 },
+12 -3
src/filesystem/providers/localFilesystemProvider.ts
··· 1 - import type { FileSystemEntry, FileSystemProvider, FileSystemTreeNode, FilesystemApi } from '../types'; 1 + import type { 2 + FileSystemEntry, 3 + FileSystemProvider, 4 + FileSystemTreeNode, 5 + FilesystemApi, 6 + } from '../types'; 2 7 import type { DocumentProvider } from '../../documents/types'; 3 8 import type { EditorKind } from '../../editors/types'; 4 9 import { basename, dirname, join } from '../vaultFs'; ··· 31 36 private fs: FilesystemApi; 32 37 33 38 private readyListeners: Set<() => void> = new Set(); 34 - private titleListeners: Set<(entryId: string, name: string) => void> = new Set(); 39 + private titleListeners: Set<(entryId: string, name: string) => void> = 40 + new Set(); 35 41 private _isReady = true; 36 42 37 43 constructor(options: LocalFilesystemProviderOptions) { ··· 105 111 return { id: fullPath, name: fileName }; 106 112 } 107 113 108 - async createDirectory(name: string, parentId?: string): Promise<FileSystemEntry> { 114 + async createDirectory( 115 + name: string, 116 + parentId?: string, 117 + ): Promise<FileSystemEntry> { 109 118 const parent = parentId ?? this.rootPath; 110 119 const dirPath = join(parent, name); 111 120 try {
+14 -16
src/habitat/automergeConnectionProvider.ts
··· 33 33 _stub: true; 34 34 }; 35 35 36 - export class Libp2pAutomergeConnectionProvider 37 - extends ObservableV2<{ 38 - 'connection-close': ( 39 - event: CloseEvent | null, 40 - provider: Libp2pAutomergeConnectionProvider, 41 - ) => void; 42 - status: (event: { 43 - status: 'connected' | 'disconnected' | 'connecting'; 44 - }) => void; 45 - 'connection-error': ( 46 - event: Event, 47 - provider: Libp2pAutomergeConnectionProvider, 48 - ) => void; 49 - sync: (state: boolean) => void; 50 - }> 51 - { 36 + export class Libp2pAutomergeConnectionProvider extends ObservableV2<{ 37 + 'connection-close': ( 38 + event: CloseEvent | null, 39 + provider: Libp2pAutomergeConnectionProvider, 40 + ) => void; 41 + status: (event: { 42 + status: 'connected' | 'disconnected' | 'connecting'; 43 + }) => void; 44 + 'connection-error': ( 45 + event: Event, 46 + provider: Libp2pAutomergeConnectionProvider, 47 + ) => void; 48 + sync: (state: boolean) => void; 49 + }> { 52 50 /** Not enumerable — must not appear on React props / DevTools walks. */ 53 51 #node: Node; 54 52 topic: string;
+9 -7
src/habitat/automergeDoc.ts
··· 56 56 ): string { 57 57 const bytes = Automerge.save(doc); 58 58 let binary = ''; 59 - for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!); 59 + for (let i = 0; i < bytes.length; i++) 60 + binary += String.fromCharCode(bytes[i]!); 60 61 return btoa(binary); 61 62 } 62 63 ··· 86 87 ); 87 88 88 89 const docAfterOwner = applyAutomergeBlob(initialDoc, ownerRecord.value.blob); 89 - const { merged: editsMerged, doc } = await mergeAllEdits(ownerRecord, docAfterOwner); 90 + const { merged: editsMerged, doc } = await mergeAllEdits( 91 + ownerRecord, 92 + docAfterOwner, 93 + ); 90 94 91 95 return { ownerRecord, editsMerged, doc }; 92 96 } ··· 128 132 129 133 const results = await Promise.all( 130 134 memberDids.map((did) => 131 - getPrivateRecord<HabitatDoc>( 132 - editCollection, 133 - editRkey, 134 - did, 135 - ).catch(() => null), 135 + getPrivateRecord<HabitatDoc>(editCollection, editRkey, did).catch( 136 + () => null, 137 + ), 136 138 ), 137 139 ); 138 140
+4 -2
src/habitat/config.ts
··· 16 16 17 17 /** Collection NSIDs for vault documents (roots, directories, and files). */ 18 18 export const HABITAT_VAULT_ROOT_COLLECTION = 'network.habitat.vault.root'; 19 - export const HABITAT_VAULT_DIRECTORY_COLLECTION = 'network.habitat.vault.directory'; 19 + export const HABITAT_VAULT_DIRECTORY_COLLECTION = 20 + 'network.habitat.vault.directory'; 20 21 export const HABITAT_VAULT_FILE_COLLECTION = 'network.habitat.vault.file'; 21 - export const HABITAT_VAULT_FILE_EDIT_COLLECTION = 'network.habitat.vault.file.edit'; 22 + export const HABITAT_VAULT_FILE_EDIT_COLLECTION = 23 + 'network.habitat.vault.file.edit';
+5 -4
src/habitat/topic.ts
··· 5 5 * to parse each other's sync messages. 6 6 */ 7 7 8 - export function makeGossipsubTopic(uri: string, crdtType?: 'yjs' | 'automerge'): string { 9 - return crdtType === 'automerge' 10 - ? `${uri}?crdt=automerge` 11 - : uri; 8 + export function makeGossipsubTopic( 9 + uri: string, 10 + crdtType?: 'yjs' | 'automerge', 11 + ): string { 12 + return crdtType === 'automerge' ? `${uri}?crdt=automerge` : uri; 12 13 }
+69 -23
src/vault/engine.test.ts
··· 49 49 let fs: NodeFilesystemApi; 50 50 51 51 beforeEach(async () => { 52 - tmpDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'textile-vault-test-')); 52 + tmpDir = nodeFs.mkdtempSync( 53 + nodePath.join(os.tmpdir(), 'textile-vault-test-'), 54 + ); 53 55 repo = new InMemoryHabitatRepo(); 54 56 55 57 const { uri } = await repo.createDirectory(makeRootDirectoryDoc()); ··· 132 134 const snapshot = JSON.parse(snapshotRaw); 133 135 134 136 // Both the file and its parent directory are in the snapshot 135 - const fileEntry = snapshot.files.find(([p]: [string]) => p === 'notes/ideas.md'); 137 + const fileEntry = snapshot.files.find( 138 + ([p]: [string]) => p === 'notes/ideas.md', 139 + ); 136 140 expect(fileEntry).toBeDefined(); 137 - const dirEntry = snapshot.directories.find(([p]: [string]) => p === 'notes'); 141 + const dirEntry = snapshot.directories.find( 142 + ([p]: [string]) => p === 'notes', 143 + ); 138 144 expect(dirEntry).toBeDefined(); 139 145 }); 140 146 ··· 175 181 // ------------------------------------------------------------------------- 176 182 177 183 it('detects a rename as a move, reusing the existing doc URI', async () => { 178 - writeFile(tmpDir, 'original.md', 'content that is long enough for similarity'); 184 + writeFile( 185 + tmpDir, 186 + 'original.md', 187 + 'content that is long enough for similarity', 188 + ); 179 189 await engine.sync(vaultConfig, tmpDir); 180 190 181 191 const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); 182 192 const snapshot = JSON.parse(snapshotRaw); 183 - const originalUri = snapshot.files.find(([p]: [string]) => p === 'original.md')[1].uri; 193 + const originalUri = snapshot.files.find( 194 + ([p]: [string]) => p === 'original.md', 195 + )[1].uri; 184 196 185 197 // Rename: remove old, create new with same content 186 198 nodeFs.rmSync(nodePath.join(tmpDir, 'original.md')); 187 - writeFile(tmpDir, 'renamed.md', 'content that is long enough for similarity'); 199 + writeFile( 200 + tmpDir, 201 + 'renamed.md', 202 + 'content that is long enough for similarity', 203 + ); 188 204 189 205 const result = await engine.sync(vaultConfig, tmpDir); 190 206 ··· 192 208 193 209 const snapshotRaw2 = readFile(tmpDir, '.textile/snapshot.json'); 194 210 const snapshot2 = JSON.parse(snapshotRaw2); 195 - const renamedEntry = snapshot2.files.find(([p]: [string]) => p === 'renamed.md'); 211 + const renamedEntry = snapshot2.files.find( 212 + ([p]: [string]) => p === 'renamed.md', 213 + ); 196 214 expect(renamedEntry).toBeDefined(); 197 215 // Same URI as the original — doc was updated in place, not recreated 198 216 expect(renamedEntry[1].uri).toBe(originalUri); 199 217 // Old path is gone 200 - expect(snapshot2.files.find(([p]: [string]) => p === 'original.md')).toBeUndefined(); 218 + expect( 219 + snapshot2.files.find(([p]: [string]) => p === 'original.md'), 220 + ).toBeUndefined(); 201 221 }); 202 222 203 223 // ------------------------------------------------------------------------- ··· 238 258 239 259 const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); 240 260 const snapshot = JSON.parse(snapshotRaw); 241 - const fileUri = snapshot.files.find(([p]: [string]) => p === 'shared.md')[1].uri as HabitatUri; 261 + const fileUri = snapshot.files.find(([p]: [string]) => p === 'shared.md')[1] 262 + .uri as HabitatUri; 242 263 243 264 // Remote edit lands on the doc. 244 265 const handle = await repo.find<FileDocument>(fileUri); ··· 263 284 264 285 const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); 265 286 const snapshot = JSON.parse(snapshotRaw); 266 - const fileUri = snapshot.files.find(([p]: [string]) => p === 'shared.md')[1].uri as HabitatUri; 287 + const fileUri = snapshot.files.find(([p]: [string]) => p === 'shared.md')[1] 288 + .uri as HabitatUri; 267 289 268 290 const handle = await repo.find<FileDocument>(fileUri); 269 291 handle.change((d) => { ··· 305 327 const fileEntry = snapshot.files.find(([p]: [string]) => p === 'note.md'); 306 328 expect(fileEntry).toBeDefined(); 307 329 const fileUri = fileEntry[1].uri as HabitatUri; 308 - expect((repo.getDoc<FileDocument>(fileUri) as unknown as FileDocument).content).toBe('# Note'); 330 + expect( 331 + (repo.getDoc<FileDocument>(fileUri) as unknown as FileDocument).content, 332 + ).toBe('# Note'); 309 333 310 334 // The root directory doc references it. 311 - const rootDoc = repo.getDoc<DirectoryDocument>(rootUri) as unknown as DirectoryDocument; 335 + const rootDoc = repo.getDoc<DirectoryDocument>( 336 + rootUri, 337 + ) as unknown as DirectoryDocument; 312 338 expect(rootDoc.docs.some((e) => e.url === fileUri)).toBe(true); 313 339 }); 314 340 ··· 317 343 await engine.sync(vaultConfig, tmpDir); 318 344 319 345 const snap1 = JSON.parse(readFile(tmpDir, '.textile/snapshot.json')); 320 - const uriBefore = snap1.files.find(([p]: [string]) => p === 'doc.md')[1].uri; 346 + const uriBefore = snap1.files.find(([p]: [string]) => p === 'doc.md')[1] 347 + .uri; 321 348 const docCountBefore = repo.docCount(); 322 349 323 350 writeFile(tmpDir, 'doc.md', 'v2'); ··· 327 354 const snap2 = JSON.parse(readFile(tmpDir, '.textile/snapshot.json')); 328 355 const entry = snap2.files.find(([p]: [string]) => p === 'doc.md')[1]; 329 356 expect(entry.uri).toBe(uriBefore); // same doc, edited in place 330 - expect((repo.getDoc<FileDocument>(uriBefore) as unknown as FileDocument).content).toBe('v2'); 357 + expect( 358 + (repo.getDoc<FileDocument>(uriBefore) as unknown as FileDocument).content, 359 + ).toBe('v2'); 331 360 }); 332 361 333 362 it('pushFile is a no-op when the file content is unchanged', async () => { ··· 335 364 await engine.sync(vaultConfig, tmpDir); 336 365 337 366 const snapBefore = JSON.parse(readFile(tmpDir, '.textile/snapshot.json')); 338 - const headBefore = snapBefore.files.find(([p]: [string]) => p === 'doc.md')[1].head; 367 + const headBefore = snapBefore.files.find( 368 + ([p]: [string]) => p === 'doc.md', 369 + )[1].head; 339 370 340 371 await engine.pushFile(vaultConfig, tmpDir, 'doc.md'); 341 372 342 373 const snapAfter = JSON.parse(readFile(tmpDir, '.textile/snapshot.json')); 343 - const headAfter = snapAfter.files.find(([p]: [string]) => p === 'doc.md')[1].head; 374 + const headAfter = snapAfter.files.find(([p]: [string]) => p === 'doc.md')[1] 375 + .head; 344 376 expect(headAfter).toEqual(headBefore); // doc was not touched 345 377 }); 346 378 ··· 351 383 await engine.pushFile(vaultConfig, tmpDir, 'notes/ideas.md'); 352 384 353 385 const snapshot = JSON.parse(readFile(tmpDir, '.textile/snapshot.json')); 354 - expect(snapshot.files.find(([p]: [string]) => p === 'notes/ideas.md')).toBeDefined(); 355 - const dirEntry = snapshot.directories.find(([p]: [string]) => p === 'notes'); 386 + expect( 387 + snapshot.files.find(([p]: [string]) => p === 'notes/ideas.md'), 388 + ).toBeDefined(); 389 + const dirEntry = snapshot.directories.find( 390 + ([p]: [string]) => p === 'notes', 391 + ); 356 392 expect(dirEntry).toBeDefined(); 357 393 358 394 // Parent dir doc references the file; root dir doc references the subdir. 359 395 const dirUri = dirEntry[1].uri as HabitatUri; 360 - const dirDoc = repo.getDoc<DirectoryDocument>(dirUri) as unknown as DirectoryDocument; 396 + const dirDoc = repo.getDoc<DirectoryDocument>( 397 + dirUri, 398 + ) as unknown as DirectoryDocument; 361 399 expect(dirDoc.docs.some((e) => e.name === 'ideas.md')).toBe(true); 362 - const rootDoc = repo.getDoc<DirectoryDocument>(rootUri) as unknown as DirectoryDocument; 400 + const rootDoc = repo.getDoc<DirectoryDocument>( 401 + rootUri, 402 + ) as unknown as DirectoryDocument; 363 403 expect(rootDoc.docs.some((e) => e.url === dirUri)).toBe(true); 364 404 }); 365 405 ··· 381 421 382 422 const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); 383 423 const snapshot = JSON.parse(snapshotRaw); 384 - const fileEntry = snapshot.files.find(([p]: [string]) => p === 'tracked.md')[1]; 424 + const fileEntry = snapshot.files.find( 425 + ([p]: [string]) => p === 'tracked.md', 426 + )[1]; 385 427 const fileUri = fileEntry.uri as HabitatUri; 386 428 387 429 const handle = await repo.find<FileDocument>(fileUri); ··· 394 436 395 437 const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); 396 438 const snapshot = JSON.parse(snapshotRaw); 397 - const fileEntry = snapshot.files.find(([p]: [string]) => p === 'tracked.md')[1]; 439 + const fileEntry = snapshot.files.find( 440 + ([p]: [string]) => p === 'tracked.md', 441 + )[1]; 398 442 399 443 // Compute expected hash the same way the engine does 400 444 const { createHash } = await import('crypto'); 401 - const expected = 'sha256:' + createHash('sha256').update('hello world', 'utf8').digest('hex'); 445 + const expected = 446 + 'sha256:' + 447 + createHash('sha256').update('hello world', 'utf8').digest('hex'); 402 448 expect(fileEntry.contentHash).toBe(expected); 403 449 }); 404 450 });
+1 -1
src/vault/habitat-repo.ts
··· 53 53 * 54 54 * Uses `acquireDocSession` from the existing textile sync stack 55 55 * to load/save Habitat records. No `automerge-repo` dependency. 56 - * 56 + * 57 57 * Directory documents are stored in `network.habitat.vault.directory` 58 58 * File documents are stored in `network.habitat.vault.file` 59 59 */
+3 -1
src/vault/move-detection.ts
··· 24 24 getContent: (path: string) => Promise<string | null>, 25 25 threshold = DEFAULT_THRESHOLD, 26 26 ): Promise<MoveDetectionResult> { 27 - const deletions = changes.filter((c) => c.type === 'delete' && !c.isDirectory); 27 + const deletions = changes.filter( 28 + (c) => c.type === 'delete' && !c.isDirectory, 29 + ); 28 30 const additions = changes.filter((c) => c.type === 'add' && !c.isDirectory); 29 31 const others = changes.filter( 30 32 (c) => (c.type !== 'delete' && c.type !== 'add') || c.isDirectory,
+4 -6
src/vault/registry.test.ts
··· 1 1 import { describe, expect, it } from 'vitest'; 2 2 3 - import { 4 - VaultRegistry, 5 - type MountedVault, 6 - type RemoteVault, 7 - } from './registry'; 3 + import { VaultRegistry, type MountedVault, type RemoteVault } from './registry'; 8 4 import type { HabitatUri } from './types'; 9 5 10 6 const remote = (id: string): RemoteVault => ({ ··· 39 35 it('filters out roots already synced (matched by rootUri)', () => { 40 36 const a = remote('a'); 41 37 const b = remote('b'); 42 - expect(VaultRegistry.unsynced([a, b], [syncedMount(a.rootUri)])).toEqual([b]); 38 + expect(VaultRegistry.unsynced([a, b], [syncedMount(a.rootUri)])).toEqual([ 39 + b, 40 + ]); 43 41 }); 44 42 45 43 it('ignores local mounts when filtering', () => {
+8 -2
src/vault/registry.ts
··· 106 106 { subjects: [did] }, 107 107 ); 108 108 return records.map((record) => { 109 - const fallback = record.uri.split('/').filter(Boolean).pop() ?? record.uri; 109 + const fallback = 110 + record.uri.split('/').filter(Boolean).pop() ?? record.uri; 110 111 const name = record.value.name?.trim() ? record.value.name : fallback; 111 - return { state: 'remote-unsynced', name, did, rootUri: record.uri as HabitatUri }; 112 + return { 113 + state: 'remote-unsynced', 114 + name, 115 + did, 116 + rootUri: record.uri as HabitatUri, 117 + }; 112 118 }); 113 119 } 114 120
+15 -4
src/vault/testing/fakeHabitat.ts
··· 95 95 } 96 96 97 97 change(fn: (d: T) => void): void { 98 - const next = Automerge.change(this.current, fn as Automerge.ChangeFn<unknown>); 98 + const next = Automerge.change( 99 + this.current, 100 + fn as Automerge.ChangeFn<unknown>, 101 + ); 99 102 this.store.set(this.uri, next); 100 103 } 101 104 ··· 143 146 initial: DirectoryDocument, 144 147 ): Promise<{ uri: HabitatUri; handle: HabitatDocHandle<DirectoryDocument> }> { 145 148 const uri = this.makeUri('network.habitat.vault.directory'); 146 - this.store.set(uri, Automerge.from(initial as unknown as Record<string, unknown>)); 149 + this.store.set( 150 + uri, 151 + Automerge.from(initial as unknown as Record<string, unknown>), 152 + ); 147 153 return { uri, handle: this.handle<DirectoryDocument>(uri) }; 148 154 } 149 155 ··· 151 157 initial: FileDocument, 152 158 ): Promise<{ uri: HabitatUri; handle: HabitatDocHandle<FileDocument> }> { 153 159 const uri = this.makeUri('network.habitat.vault.file'); 154 - this.store.set(uri, Automerge.from(initial as unknown as Record<string, unknown>)); 160 + this.store.set( 161 + uri, 162 + Automerge.from(initial as unknown as Record<string, unknown>), 163 + ); 155 164 return { uri, handle: this.handle<FileDocument>(uri) }; 156 165 } 157 166 ··· 189 198 ) {} 190 199 191 200 get doc(): Automerge.Doc<AutomergeDocSchema> { 192 - return this.store.get(this.uri as HabitatUri) as Automerge.Doc<AutomergeDocSchema>; 201 + return this.store.get( 202 + this.uri as HabitatUri, 203 + ) as Automerge.Doc<AutomergeDocSchema>; 193 204 } 194 205 195 206 getTitle = (): string => {
+1 -1
src/vault/types.ts
··· 188 188 /** 189 189 * HabitatRepo mirrors the automerge-repo Repo API 190 190 * but uses HabitatUri and is backed by Habitat PDS + AutomergeDocSession. 191 - * 191 + * 192 192 * Uses separate collections for directories and files: 193 193 * - network.habitat.vault.directory 194 194 * - network.habitat.vault.file
+9 -3
src/vault/utils/content.test.ts
··· 22 22 }); 23 23 24 24 it('hashes Uint8Array and matches the equivalent UTF-8 string', () => { 25 - expect(contentHash(new TextEncoder().encode('abc'))).toBe(contentHash('abc')); 25 + expect(contentHash(new TextEncoder().encode('abc'))).toBe( 26 + contentHash('abc'), 27 + ); 26 28 }); 27 29 }); 28 30 ··· 30 32 it('compares strings and byte arrays', () => { 31 33 expect(isContentEqual('a', 'a')).toBe(true); 32 34 expect(isContentEqual('a', 'b')).toBe(false); 33 - expect(isContentEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2]))).toBe(true); 34 - expect(isContentEqual(new Uint8Array([1]), new Uint8Array([1, 2]))).toBe(false); 35 + expect(isContentEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2]))).toBe( 36 + true, 37 + ); 38 + expect(isContentEqual(new Uint8Array([1]), new Uint8Array([1, 2]))).toBe( 39 + false, 40 + ); 35 41 }); 36 42 });
+12 -4
src/vault/utils/text-diff.test.ts
··· 13 13 let a = Automerge.clone(base, { actor: 'aaaaaaaaaaaaaaaa' }); 14 14 let b = Automerge.clone(base, { actor: 'bbbbbbbbbbbbbbbb' }); 15 15 16 - a = Automerge.change(a, (d) => updateTextContent(d, ['content'], 'HELLO world')); 17 - b = Automerge.change(b, (d) => updateTextContent(d, ['content'], 'hello WORLD')); 16 + a = Automerge.change(a, (d) => 17 + updateTextContent(d, ['content'], 'HELLO world'), 18 + ); 19 + b = Automerge.change(b, (d) => 20 + updateTextContent(d, ['content'], 'hello WORLD'), 21 + ); 18 22 19 23 const merged = Automerge.merge(a, b); 20 24 ··· 25 29 26 30 it('replaces content for a single editor', () => { 27 31 let doc = Automerge.from<Doc>({ content: 'v1' }); 28 - doc = Automerge.change(doc, (d) => updateTextContent(d, ['content'], 'v2 longer text')); 32 + doc = Automerge.change(doc, (d) => 33 + updateTextContent(d, ['content'], 'v2 longer text'), 34 + ); 29 35 expect(readDocContent(doc.content)).toBe('v2 longer text'); 30 36 }); 31 37 32 38 it('assigns into an empty field', () => { 33 39 let doc = Automerge.from<Doc>({ content: '' }); 34 - doc = Automerge.change(doc, (d) => updateTextContent(d, ['content'], 'fresh')); 40 + doc = Automerge.change(doc, (d) => 41 + updateTextContent(d, ['content'], 'fresh'), 42 + ); 35 43 expect(readDocContent(doc.content)).toBe('fresh'); 36 44 }); 37 45 });