A local-first note taking app
0

Configure Feed

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

Sidebar file drag-and-drop to move files between directories

Drag a file in the sidebar onto a directory (or the vault root) to move it.

- FileSystemProvider gains moveFile(entryId, targetDirId) and a rootId.
Local implementation renames across directories (guards same-location
and name conflicts); the synced provider syncs afterward like rename.
- VaultTree: files are draggable (independent off-screen drag ghost);
every row carries data-drop-dir for its enclosing folder, and the tree
container resolves the drop target via closest() — so it picks the
deepest folder under the cursor, falls through to the root over empty
space / root-level files, and clears reliably. The target directory's
whole subtree (or the whole tree, for the root) is highlighted.
- AppSidebar: handleMove calls provider.moveFile then refreshes the tree.

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

Ethan Graf (Jun 29, 2026, 9:16 PM EDT) 0d2903d1 aa77d352

+223 -36
+16
src/components/AppSidebar.tsx
··· 127 127 [provider.id, onSelect, onOpenEntry], 128 128 ); 129 129 130 + const handleMove = useCallback( 131 + async (entryId: string, targetDirId: string) => { 132 + try { 133 + await provider.moveFile(entryId, targetDirId); 134 + void queryClient.invalidateQueries({ 135 + queryKey: treeQueryKey(provider.id), 136 + }); 137 + } catch (e) { 138 + console.error('[sidebar] move failed', e); 139 + } 140 + }, 141 + [provider, queryClient], 142 + ); 143 + 130 144 const handleContextMenu = useCallback( 131 145 async ( 132 146 node: { id: string; name: string; type: 'file' | 'folder' }, ··· 189 203 > 190 204 <FileSystemTree 191 205 nodes={treeQuery.data ?? []} 206 + rootId={provider.rootId} 192 207 onSelect={handleSelect} 193 208 onContextMenu={handleContextMenu} 209 + onMove={handleMove} 194 210 selectedId={ 195 211 selection?.providerId === provider.id 196 212 ? selection.entryId
+165 -36
src/components/VaultTree.tsx
··· 1 - import { useState, useCallback, useRef, useEffect } from 'react'; 1 + import { 2 + createContext, 3 + useContext, 4 + useEffect, 5 + useCallback, 6 + useRef, 7 + useState, 8 + } from 'react'; 2 9 import type { FileSystemTreeNode } from '../filesystem/types'; 3 10 import type { ContextMenuItem } from '../platform/contextMenu'; 4 11 12 + /** 13 + * Shared drag-and-drop state for the tree. While a file is dragged, exactly one 14 + * directory is the drop target (the deepest folder under the cursor); it 15 + * highlights its whole subtree. 16 + */ 17 + type TreeDnd = { 18 + targetDirId: string | null; 19 + setTargetDirId: (id: string | null) => void; 20 + }; 21 + const TreeDndContext = createContext<TreeDnd | null>(null); 22 + 5 23 export type InlineCreateState = { 6 24 parentId?: string; 7 25 type: 'file' | 'folder'; ··· 11 29 12 30 export type FileSystemTreeProps = { 13 31 nodes: FileSystemTreeNode[]; 32 + /** Id of the vault root, so files can be dropped at the top level. */ 33 + rootId?: string; 14 34 onSelect?: (node: FileSystemTreeNode) => void; 15 35 onContextMenu?: (node: FileSystemTreeNode, items: ContextMenuItem[]) => void; 36 + /** Move `entryId` into the directory `targetDirId` (file drag-and-drop). */ 37 + onMove?: (entryId: string, targetDirId: string) => void; 16 38 selectedId?: string; 17 39 renameNodeId?: string; 18 40 renameValue?: string; ··· 30 52 /** Shared row style for the active (highlighted) item in the tree. */ 31 53 const ACTIVE_ROW = 'bg-accent/10 text-foreground'; 32 54 const INACTIVE_ROW = 'text-foreground/70 hover:bg-border/35'; 55 + /** Tint applied to a directory's whole subtree while it is the drop target. */ 56 + const DROP_TARGET_REGION = 'rounded-sm bg-accent/15'; 57 + 58 + /** Drag payload for moving a file: carries the entry id. */ 59 + const FILE_DND_MIME = 'application/x-textile-file'; 60 + 61 + /** Build an independent, off-screen drag image labelled with the file name. */ 62 + function makeFileDragImage(name: string): HTMLElement { 63 + const ghost = document.createElement('div'); 64 + ghost.textContent = name; 65 + Object.assign(ghost.style, { 66 + position: 'fixed', 67 + top: '-1000px', 68 + left: '0', 69 + padding: '2px 8px', 70 + fontSize: '12px', 71 + borderRadius: '6px', 72 + background: 'var(--color-background)', 73 + color: 'var(--color-foreground)', 74 + border: '1px solid var(--color-border)', 75 + boxShadow: '0 2px 8px rgb(0 0 0 / 0.25)', 76 + whiteSpace: 'nowrap', 77 + pointerEvents: 'none', 78 + }); 79 + document.body.appendChild(ghost); 80 + return ghost; 81 + } 33 82 34 83 function TriangleRight({ className }: { className?: string }) { 35 84 return ( ··· 114 163 function TreeNode({ 115 164 node, 116 165 depth, 166 + parentDirId, 117 167 selectedId, 118 168 onSelect, 119 169 onContextMenu, ··· 131 181 }: { 132 182 node: FileSystemTreeNode; 133 183 depth: number; 184 + /** Id of the directory containing this node (undefined at the vault root). */ 185 + parentDirId?: string; 134 186 selectedId?: string; 135 187 onSelect?: (node: FileSystemTreeNode) => void; 136 188 onContextMenu?: (node: FileSystemTreeNode, items: ContextMenuItem[]) => void; ··· 147 199 onInlineCreateCancel?: () => void; 148 200 }) { 149 201 const [expanded, setExpanded] = useState(depth < 2); 202 + const dnd = useContext(TreeDndContext); 150 203 const isActive = 151 204 node.id === renameNodeId || (node.id === selectedId && !inlineCreate); 152 205 const hasChildren = node.children && node.children.length > 0; ··· 202 255 [node, onContextMenu], 203 256 ); 204 257 258 + const isFile = node.type === 'file'; 259 + const isFolder = node.type === 'folder'; 260 + // The directory a drop here lands in: this folder itself, or — for a file — 261 + // the folder that contains it. Undefined for items at the vault root. 262 + const enclosingDirId = isFolder ? node.id : parentDirId; 263 + const isDropTarget = isFolder && dnd?.targetDirId === node.id; 264 + 265 + const handleDragStart = (e: React.DragEvent) => { 266 + e.dataTransfer.setData(FILE_DND_MIME, node.id); 267 + e.dataTransfer.effectAllowed = 'move'; 268 + const ghost = makeFileDragImage(node.name); 269 + e.dataTransfer.setDragImage(ghost, 8, ghost.offsetHeight / 2); 270 + window.setTimeout(() => ghost.remove(), 0); 271 + }; 272 + 205 273 return ( 206 - <div> 274 + <div className={isDropTarget ? DROP_TARGET_REGION : undefined}> 207 275 <div 208 276 className={`flex w-full items-center gap-1 px-2 py-0.5 text-left text-xs ${ 209 277 isActive ? ACTIVE_ROW : INACTIVE_ROW 210 278 }`} 211 279 style={{ paddingLeft: `${depth * 12}px` }} 280 + // The directory this row drops into; the tree container resolves the 281 + // target via the nearest ancestor carrying this attribute. 282 + data-drop-dir={enclosingDirId} 283 + draggable={isFile} 284 + onDragStart={isFile ? handleDragStart : undefined} 212 285 onContextMenu={handleContextMenu} 213 286 > 214 287 {node.type === 'folder' && ( ··· 284 357 key={child.id} 285 358 node={child} 286 359 depth={depth + 1} 360 + parentDirId={node.id} 287 361 selectedId={selectedId} 288 362 onSelect={onSelect} 289 363 onContextMenu={onContextMenu} ··· 321 395 322 396 export function FileSystemTree({ 323 397 nodes, 398 + rootId, 324 399 onSelect, 325 400 onContextMenu, 401 + onMove, 326 402 selectedId, 327 403 renameNodeId, 328 404 renameValue, ··· 337 413 onInlineCreateCancel, 338 414 }: FileSystemTreeProps) { 339 415 const isCreatingAtRoot = inlineCreate && inlineCreate.parentId === undefined; 416 + const [targetDirId, setTargetDirId] = useState<string | null>(null); 417 + 418 + // Clear the drop highlight whenever a drag ends anywhere (drop or cancel). 419 + useEffect(() => { 420 + if (targetDirId === null) return; 421 + const clear = () => setTargetDirId(null); 422 + window.addEventListener('dragend', clear); 423 + return () => window.removeEventListener('dragend', clear); 424 + }, [targetDirId]); 425 + 426 + // Resolve the directory the cursor is over from the nearest row carrying a 427 + // `data-drop-dir` (a folder, or a file's enclosing folder). null over a 428 + // root-level file or empty space. 429 + const dirUnderCursor = (e: React.DragEvent): string | null => 430 + (e.target as HTMLElement) 431 + .closest('[data-drop-dir]') 432 + ?.getAttribute('data-drop-dir') ?? null; 340 433 341 434 return ( 342 - <div className="flex h-full flex-col"> 343 - {isCreatingAtRoot && ( 344 - <InlineCreateInput 345 - depth={0} 346 - type={inlineCreate!.type} 347 - name={inlineCreate!.name} 348 - error={inlineCreate!.error} 349 - onChange={onInlineCreateChange!} 350 - onSubmit={onInlineCreateSubmit!} 351 - onCancel={onInlineCreateCancel!} 352 - /> 353 - )} 354 - {nodes.map((node) => ( 355 - <TreeNode 356 - key={node.id} 357 - node={node} 358 - depth={0} 359 - selectedId={selectedId} 360 - onSelect={onSelect} 361 - onContextMenu={onContextMenu} 362 - renameNodeId={renameNodeId} 363 - renameValue={renameValue} 364 - renameExt={renameExt} 365 - renameError={renameError} 366 - onRenameValueChange={onRenameValueChange} 367 - onRenameSubmit={onRenameSubmit} 368 - onRenameCancel={onRenameCancel} 369 - inlineCreate={inlineCreate} 370 - onInlineCreateChange={onInlineCreateChange} 371 - onInlineCreateSubmit={onInlineCreateSubmit} 372 - onInlineCreateCancel={onInlineCreateCancel} 373 - /> 374 - ))} 375 - </div> 435 + <TreeDndContext.Provider value={{ targetDirId, setTargetDirId }}> 436 + <div 437 + // The container is the vault root's drop target: rows below it carry 438 + // their own `data-drop-dir`, so `closest` resolves to the deepest 439 + // folder, and falls through to the root over empty space / root files. 440 + data-drop-dir={rootId} 441 + className={`flex h-full flex-col ${ 442 + rootId && targetDirId === rootId ? DROP_TARGET_REGION : '' 443 + }`} 444 + onDragOver={(e) => { 445 + if (!onMove || !e.dataTransfer.types.includes(FILE_DND_MIME)) return; 446 + const dirId = dirUnderCursor(e); 447 + setTargetDirId(dirId); 448 + // Only allow a drop (and show the move cursor) over a directory. 449 + if (dirId) { 450 + e.preventDefault(); 451 + e.dataTransfer.dropEffect = 'move'; 452 + } 453 + }} 454 + onDrop={(e) => { 455 + if (!onMove || !e.dataTransfer.types.includes(FILE_DND_MIME)) return; 456 + const dirId = dirUnderCursor(e); 457 + setTargetDirId(null); 458 + if (!dirId) return; 459 + e.preventDefault(); 460 + const entryId = e.dataTransfer.getData(FILE_DND_MIME); 461 + if (entryId) onMove(entryId, dirId); 462 + }} 463 + onDragLeave={(e) => { 464 + // Clear when the drag leaves the tree entirely (not when moving 465 + // between rows inside it). 466 + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { 467 + setTargetDirId(null); 468 + } 469 + }} 470 + > 471 + {isCreatingAtRoot && ( 472 + <InlineCreateInput 473 + depth={0} 474 + type={inlineCreate!.type} 475 + name={inlineCreate!.name} 476 + error={inlineCreate!.error} 477 + onChange={onInlineCreateChange!} 478 + onSubmit={onInlineCreateSubmit!} 479 + onCancel={onInlineCreateCancel!} 480 + /> 481 + )} 482 + {nodes.map((node) => ( 483 + <TreeNode 484 + key={node.id} 485 + node={node} 486 + depth={0} 487 + selectedId={selectedId} 488 + onSelect={onSelect} 489 + onContextMenu={onContextMenu} 490 + renameNodeId={renameNodeId} 491 + renameValue={renameValue} 492 + renameExt={renameExt} 493 + renameError={renameError} 494 + onRenameValueChange={onRenameValueChange} 495 + onRenameSubmit={onRenameSubmit} 496 + onRenameCancel={onRenameCancel} 497 + inlineCreate={inlineCreate} 498 + onInlineCreateChange={onInlineCreateChange} 499 + onInlineCreateSubmit={onInlineCreateSubmit} 500 + onInlineCreateCancel={onInlineCreateCancel} 501 + /> 502 + ))} 503 + </div> 504 + </TreeDndContext.Provider> 376 505 ); 377 506 }
+24
src/filesystem/providers/localFilesystemProvider.ts
··· 143 143 return { id: newPath, name: newName }; 144 144 } 145 145 146 + async moveFile( 147 + entryId: string, 148 + targetDirId: string, 149 + ): Promise<FileSystemEntry> { 150 + const name = basename(entryId); 151 + const newPath = join(targetDirId, name); 152 + if (newPath === entryId) return { id: entryId, name }; 153 + try { 154 + if (await this.fs.fileExists(newPath)) { 155 + throw new Error(`A file named "${name}" already exists here`); 156 + } 157 + await this.fs.rename(entryId, newPath); 158 + } catch (error) { 159 + throw new Error( 160 + `Could not move: ${error instanceof Error ? error.message : String(error)}`, 161 + ); 162 + } 163 + return { id: newPath, name }; 164 + } 165 + 146 166 async deleteFile(entryId: string): Promise<void> { 147 167 await this.fs.deletePath(entryId); 148 168 } ··· 164 184 const content = await this.fs.readFile(entryId); 165 185 await this.fs.writeFile(newPath, content); 166 186 return { id: newPath, name: newName }; 187 + } 188 + 189 + get rootId(): string { 190 + return this.rootPath; 167 191 } 168 192 169 193 getPath(entryId: string): string {
+11
src/filesystem/providers/syncedVaultFilesystemProvider.ts
··· 56 56 return entry; 57 57 } 58 58 59 + async moveFile( 60 + entryId: string, 61 + targetDirId: string, 62 + ): Promise<FileSystemEntry> { 63 + const entry = await super.moveFile(entryId, targetDirId); 64 + // Move = delete old + add new on disk; sync()'s move detection reconciles 65 + // the remote doc in place. 66 + await this.engine.sync(this.vault, this.rootPath); 67 + return entry; 68 + } 69 + 59 70 async deleteFile(entryId: string): Promise<void> { 60 71 await super.deleteFile(entryId); 61 72 await this.engine.sync(this.vault, this.rootPath);
+7
src/filesystem/types.ts
··· 34 34 id: string; 35 35 /** Shown as the sidebar section heading. */ 36 36 displayName: string; 37 + /** Id of this source's root directory; the move target for drops at the root. */ 38 + readonly rootId: string; 37 39 /** When `isReady()` is false, shell may show this instead of a generic idle line. */ 38 40 idleHint?: string; 39 41 /** When false, skip `listFiles` and show idle state. */ ··· 66 68 * Returns the updated entry with the new id and name. 67 69 */ 68 70 renameFile(entryId: string, newName: string): Promise<FileSystemEntry>; 71 + /** 72 + * Move an entry into a different directory (`targetDirId`). Returns the 73 + * updated entry with its new id. No-ops when the target is the current parent. 74 + */ 75 + moveFile(entryId: string, targetDirId: string): Promise<FileSystemEntry>; 69 76 /** 70 77 * Delete an entry (file or directory) permanently. 71 78 */