wiki-viewer 1.12.1 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +1 -0
  3. package/.next/standalone/.next/build-manifest.json +3 -3
  4. package/.next/standalone/.next/routes-manifest.json +6 -0
  5. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  12. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  13. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  14. package/.next/standalone/.next/server/app/_not-found.rsc +2 -2
  15. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  16. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  18. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  21. package/.next/standalone/.next/server/app/api/agent/activity/route.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/api/agent/events/[...path]/route.js.nft.json +1 -1
  23. package/.next/standalone/.next/server/app/api/agent/files/[...path]/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/agent/fs/file/[...path]/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/agent/fs/ls/[[...path]]/route.js +1 -1
  26. package/.next/standalone/.next/server/app/api/agent/fs/ls/[[...path]]/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/agent/fs/move/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/agent/fs/search/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/agent/internal/span/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/agent/settings/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/agent/sidecar/[...path]/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/app-proxy/[...path]/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/assets/[...path]/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/share/[token]/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/share/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/system/browse/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/api/system/reveal/route.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/api/system/set-root/route.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/api/system/workspaces/[id]/open/route.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/api/system/workspaces/[id]/refresh/route.js.nft.json +1 -1
  41. package/.next/standalone/.next/server/app/api/system/workspaces/[id]/route.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/api/system/workspaces/route.js.nft.json +1 -1
  43. package/.next/standalone/.next/server/app/api/upload/[...path]/route.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/api/wiki/app/route.js.nft.json +1 -1
  45. package/.next/standalone/.next/server/app/api/wiki/backlinks/route/app-paths-manifest.json +3 -0
  46. package/.next/standalone/.next/server/app/api/wiki/backlinks/route/build-manifest.json +9 -0
  47. package/.next/standalone/.next/server/app/api/wiki/backlinks/route/server-reference-manifest.json +4 -0
  48. package/.next/standalone/.next/server/app/api/wiki/backlinks/route.js +16 -0
  49. package/.next/standalone/.next/server/app/api/wiki/backlinks/route.js.map +5 -0
  50. package/.next/standalone/.next/server/app/api/wiki/backlinks/route.js.nft.json +1 -0
  51. package/.next/standalone/.next/server/app/api/wiki/backlinks/route_client-reference-manifest.js +3 -0
  52. package/.next/standalone/.next/server/app/api/wiki/content/route.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/api/wiki/download/route.js.nft.json +1 -1
  54. package/.next/standalone/.next/server/app/api/wiki/file/route.js.nft.json +1 -1
  55. package/.next/standalone/.next/server/app/api/wiki/folder/route.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/api/wiki/git-branches/route.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/api/wiki/git-checkout/route.js.nft.json +1 -1
  58. package/.next/standalone/.next/server/app/api/wiki/git-diff/route.js.nft.json +1 -1
  59. package/.next/standalone/.next/server/app/api/wiki/git-file-info/route.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/api/wiki/git-history/route.js.nft.json +1 -1
  61. package/.next/standalone/.next/server/app/api/wiki/git-pull/route.js.nft.json +1 -1
  62. package/.next/standalone/.next/server/app/api/wiki/move/route.js.nft.json +1 -1
  63. package/.next/standalone/.next/server/app/api/wiki/new-file/route.js.nft.json +1 -1
  64. package/.next/standalone/.next/server/app/api/wiki/page/route.js.nft.json +1 -1
  65. package/.next/standalone/.next/server/app/api/wiki/presence/route.js.nft.json +1 -1
  66. package/.next/standalone/.next/server/app/api/wiki/route.js.nft.json +1 -1
  67. package/.next/standalone/.next/server/app/api/wiki/search/route.js.nft.json +1 -1
  68. package/.next/standalone/.next/server/app/api/wiki/slugs/route.js.nft.json +1 -1
  69. package/.next/standalone/.next/server/app/api/wiki/upload/route.js.nft.json +1 -1
  70. package/.next/standalone/.next/server/app/api/wiki/watch/route.js.nft.json +1 -1
  71. package/.next/standalone/.next/server/app/index.html +1 -1
  72. package/.next/standalone/.next/server/app/index.rsc +3 -3
  73. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  74. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +3 -3
  75. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  76. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +2 -2
  77. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  78. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  79. package/.next/standalone/.next/server/app/s/[token]/page_client-reference-manifest.js +1 -1
  80. package/.next/standalone/.next/server/app/signin/page_client-reference-manifest.js +1 -1
  81. package/.next/standalone/.next/server/app-paths-manifest.json +1 -0
  82. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__07tq6o4._.js → [root-of-the-server]__06w0aet._.js} +2 -2
  83. package/.next/standalone/.next/server/chunks/[root-of-the-server]__11a3dhw._.js +3 -0
  84. package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_wiki_backlinks_route_actions_0a3zyu0.js +3 -0
  85. package/.next/standalone/.next/server/chunks/src_lib_search_0d-_j3u._.js +13 -5
  86. package/.next/standalone/.next/server/chunks/src_lib_search_0ymmp0c._.js +12 -4
  87. package/.next/standalone/.next/server/chunks/ssr/_0wmhtx3._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/ssr/_0yevh-d._.js +3 -3
  89. package/.next/standalone/.next/server/chunks/ssr/_0~a9dyk._.js +4 -4
  90. package/.next/standalone/.next/server/chunks/ssr/src_app_page_tsx_0ss2.w7._.js +2 -2
  91. package/.next/standalone/.next/server/functions-config-manifest.json +1 -0
  92. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  93. package/.next/standalone/.next/server/middleware-manifest.json +1 -1
  94. package/.next/standalone/.next/server/pages/404.html +1 -1
  95. package/.next/standalone/.next/server/pages/500.html +1 -1
  96. package/.next/standalone/.next/static/chunks/{0rmw2y5~zh8p4.js → 0._793qr3oswh.js} +4 -4
  97. package/.next/standalone/.next/static/chunks/063q-x.i1hea6.js +17 -0
  98. package/.next/standalone/.next/static/chunks/{132w2~-ghm05e.js → 08bdsfl84~3l..js} +3 -3
  99. package/.next/standalone/.next/static/chunks/0ijkjbr218a8c.css +5 -0
  100. package/.next/standalone/.next/static/chunks/{0vhw1us0pjfdy.js → 0wxju9pxda4c2.js} +1 -1
  101. package/.next/standalone/package.json +1 -1
  102. package/.next/standalone/src/app/api/wiki/backlinks/route.ts +29 -0
  103. package/.next/standalone/src/app/globals.css +24 -0
  104. package/.next/standalone/src/app/page.tsx +795 -478
  105. package/.next/standalone/src/components/ai-panel/activity-row.tsx +6 -1
  106. package/.next/standalone/src/components/editor/backlinks-panel.tsx +24 -67
  107. package/.next/standalone/src/components/editor/document-outline.tsx +13 -4
  108. package/.next/standalone/src/components/editor/editor.tsx +50 -11
  109. package/.next/standalone/src/lib/markdown/to-html.ts +81 -5
  110. package/.next/standalone/src/lib/search/indexer.ts +67 -0
  111. package/.next/standalone/src/stores/editor-store.ts +93 -5
  112. package/.next/standalone/src/stores/search-store.ts +27 -9
  113. package/.next/standalone/src/tests/proof/search-index.test.ts +37 -0
  114. package/.next/standalone/tsconfig.tsbuildinfo +1 -1
  115. package/package.json +1 -1
  116. package/.next/standalone/.next/static/chunks/0.9~g4nvqt9if.css +0 -5
  117. package/.next/standalone/.next/static/chunks/0qiiie0s9~eqg.js +0 -17
  118. /package/.next/standalone/.next/static/{ucX5u3D8Ca9KdVyeRk_D_ → WEwVoG1heo2pirWLYZp2y}/_buildManifest.js +0 -0
  119. /package/.next/standalone/.next/static/{ucX5u3D8Ca9KdVyeRk_D_ → WEwVoG1heo2pirWLYZp2y}/_clientMiddlewareManifest.js +0 -0
  120. /package/.next/standalone/.next/static/{ucX5u3D8Ca9KdVyeRk_D_ → WEwVoG1heo2pirWLYZp2y}/_ssgManifest.js +0 -0
@@ -43,7 +43,7 @@ import {
43
43
  Upload,
44
44
  X,
45
45
  } from "lucide-react";
46
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
46
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
47
47
  import { createPortal } from "react-dom";
48
48
 
49
49
  import { ConfirmDialog } from "@/components/confirm-dialog";
@@ -93,7 +93,7 @@ import { AIPanel } from "@/components/ai-panel/ai-panel";
93
93
  import { SearchCommandDialog } from "@/components/search/search-command-dialog";
94
94
  import { SidebarSearchBox } from "@/components/search/sidebar-search-box";
95
95
  import { useAIPanelStore } from "@/stores/ai-panel-store";
96
- import { useEditorStore } from "@/stores/editor-store";
96
+ import { useEditorStore, prefetchPage } from "@/stores/editor-store";
97
97
  import {
98
98
  useViewWidthStore,
99
99
  VIEW_WIDTH_CLASS,
@@ -269,6 +269,33 @@ async function fetchDir(dir: string): Promise<TreeNode[]> {
269
269
  }));
270
270
  }
271
271
 
272
+ // One-shot hover-prefetch cache for directory listings: warmed on tree-row hover,
273
+ // consumed (read + deleted) by the first expand so there's no long-lived staleness.
274
+ // reloadDir/the file-watcher always fetch fresh and bypass this.
275
+ const dirPrefetchCache = new Map<string, TreeNode[]>();
276
+ const dirPrefetchInflight = new Map<string, Promise<TreeNode[]>>();
277
+
278
+ function prefetchDir(dir: string): void {
279
+ if (dirPrefetchCache.has(dir) || dirPrefetchInflight.has(dir)) return;
280
+ const promise = fetchDir(dir)
281
+ .then((children) => {
282
+ dirPrefetchCache.set(dir, children);
283
+ return children;
284
+ })
285
+ .finally(() => dirPrefetchInflight.delete(dir));
286
+ dirPrefetchInflight.set(dir, promise);
287
+ promise.catch(() => {});
288
+ }
289
+
290
+ /** Consume a prefetched (or in-flight) dir listing, if any. One-shot. */
291
+ async function takePrefetchedDir(dir: string): Promise<TreeNode[] | null> {
292
+ const ready = dirPrefetchCache.get(dir);
293
+ if (ready) { dirPrefetchCache.delete(dir); return ready; }
294
+ const inflight = dirPrefetchInflight.get(dir);
295
+ if (inflight) { const r = await inflight; dirPrefetchCache.delete(dir); return r; }
296
+ return null;
297
+ }
298
+
272
299
  function updateNodes(
273
300
  nodes: TreeNode[],
274
301
  targetPath: string,
@@ -290,6 +317,615 @@ function removeNode(nodes: TreeNode[], targetPath: string): TreeNode[] {
290
317
  );
291
318
  }
292
319
 
320
+ /**
321
+ * Stable handler bundle passed to FileTree. Every method is referentially stable
322
+ * across Page() renders (backed by a ref dispatcher), so FileTree's React.memo
323
+ * holds and the whole tree skips re-rendering when unrelated Page state changes
324
+ * (dialogs, search, editor typing, sidebar resize, dropdowns, etc.).
325
+ */
326
+ interface TreeCtx {
327
+ toggleFolder: (node: TreeNode) => void;
328
+ openViewer: (node: TreeNode) => void;
329
+ copyPath: (path: string) => void;
330
+ copyWikiLink: (name: string) => void;
331
+ copyUrl: (path: string) => void;
332
+ copyRawContent: (path: string) => void;
333
+ copyFormattedContent: (path: string, name: string) => void;
334
+ handleDownload: (node: TreeNode) => void;
335
+ triggerUpload: (dir: string) => void;
336
+ handleCreateFile: () => void;
337
+ handleCreateFolder: () => void;
338
+ handleDragStart: (e: React.DragEvent, node: TreeNode) => void;
339
+ handleDragOver: (e: React.DragEvent, targetPath: string, targetType: "dir" | "root") => void;
340
+ handleDropOnFolder: (e: React.DragEvent, targetDirPath: string) => void;
341
+ handleGitPull: (nodePath: string, parentDir: string) => void;
342
+ handleCheckout: (nodePath: string, branch: string, parentDir: string) => void;
343
+ loadBranches: (nodePath: string) => void;
344
+ prefetch: (node: TreeNode) => void;
345
+ togglePin: (node: TreeNode, wsId: string | null) => void;
346
+ setDragOverPath: (p: string | null) => void;
347
+ setSidebarCollapsed: (b: boolean) => void;
348
+ setBranchDropdownNode: (p: string | null) => void;
349
+ setBranchDropdownPos: (p: { top: number; left: number } | null) => void;
350
+ setNewFileParent: (p: string | null) => void;
351
+ setNewFileName: (s: string) => void;
352
+ setFileCreateError: (s: string | null) => void;
353
+ setNewFolderParent: (p: string | null) => void;
354
+ setNewFolderName: (s: string) => void;
355
+ setFolderError: (s: string | null) => void;
356
+ setDeletingPath: (p: string | null) => void;
357
+ setDeletingIsDir: (b: boolean) => void;
358
+ }
359
+
360
+ interface FileTreeProps {
361
+ ctx: TreeCtx;
362
+ nodes: TreeNode[];
363
+ openPath: string | null;
364
+ dragOverPath: string | null;
365
+ branchDropdownNode: string | null;
366
+ branchDropdownPos: { top: number; left: number } | null;
367
+ nodeBranches: Record<string, { name: string; current: boolean }[]>;
368
+ branchesLoading: Record<string, boolean>;
369
+ checkingOutBranch: string | null;
370
+ pullingRepo: string | null;
371
+ activePaths: Set<string>;
372
+ pins: Array<{ path: string }>;
373
+ isMobile: boolean;
374
+ activeWorkspaceId: string | null;
375
+ newFileParent: string | null;
376
+ newFileName: string;
377
+ fileCreateError: string | null;
378
+ newFolderParent: string | null;
379
+ newFolderName: string;
380
+ folderError: string | null;
381
+ sidebarScrollRef: React.RefObject<HTMLDivElement | null>;
382
+ }
383
+
384
+ // content-visibility:auto lets the browser skip layout/paint for off-screen
385
+ // rows — virtualization without JS scroll math, and rows stay in the DOM so
386
+ // keyboard nav (querySelectorAll) and Ctrl+F still work. Combined with per-row
387
+ // memo (below), large trees stay cheap on both the React and browser sides.
388
+ const ROW_CV: React.CSSProperties = { contentVisibility: "auto", containIntrinsicSize: "auto 32px" };
389
+
390
+ interface TreeRowViewProps {
391
+ node: TreeNode;
392
+ depth: number;
393
+ ctx: TreeCtx;
394
+ isMobile: boolean;
395
+ activeWorkspaceId: string | null;
396
+ sidebarScrollRef: React.RefObject<HTMLDivElement | null>;
397
+ isActive: boolean;
398
+ isDragOver: boolean;
399
+ isPinned: boolean;
400
+ isAgentActive: boolean;
401
+ isPulling: boolean;
402
+ branchOpen: boolean;
403
+ branchPos: { top: number; left: number } | null;
404
+ branches: { name: string; current: boolean }[];
405
+ branchLoading: boolean;
406
+ checkingOut: string | null;
407
+ onHoverEnter: (node: TreeNode) => void;
408
+ onHoverLeave: () => void;
409
+ }
410
+
411
+ // Memoized row: re-renders only when its OWN derived props change. Navigation
412
+ // (openPath change) re-renders just the two affected rows, not the whole tree.
413
+ const TreeRowView = memo(function TreeRowView({
414
+ node, depth, ctx, isMobile, activeWorkspaceId, sidebarScrollRef,
415
+ isActive, isDragOver, isPinned, isAgentActive, isPulling,
416
+ branchOpen, branchPos, branches, branchLoading, checkingOut,
417
+ onHoverEnter, onHoverLeave,
418
+ }: TreeRowViewProps) {
419
+ return (
420
+ <ContextMenu>
421
+ <ContextMenuTrigger asChild>
422
+ <div
423
+ role="treeitem"
424
+ tabIndex={0}
425
+ draggable={!isMobile}
426
+ onKeyDown={(e) => {
427
+ if (e.key === "Enter" || e.key === " ") {
428
+ e.preventDefault();
429
+ if (node.type === "dir") ctx.toggleFolder(node);
430
+ else if (node.type === "app" || node.type === "node-app") { ctx.openViewer(node); ctx.toggleFolder(node); }
431
+ else ctx.openViewer(node);
432
+ } else if (e.key === "ArrowDown") {
433
+ e.preventDefault();
434
+ const container = sidebarScrollRef.current;
435
+ if (!container) return;
436
+ const items = Array.from(container.querySelectorAll<HTMLElement>('[role="treeitem"]'));
437
+ const idx = items.indexOf(e.currentTarget as HTMLElement);
438
+ items[idx + 1]?.focus();
439
+ } else if (e.key === "ArrowUp") {
440
+ e.preventDefault();
441
+ const container = sidebarScrollRef.current;
442
+ if (!container) return;
443
+ const items = Array.from(container.querySelectorAll<HTMLElement>('[role="treeitem"]'));
444
+ const idx = items.indexOf(e.currentTarget as HTMLElement);
445
+ items[idx - 1]?.focus();
446
+ } else if (e.key === "ArrowRight") {
447
+ e.preventDefault();
448
+ if (node.type === "dir" || node.type === "app" || node.type === "node-app") {
449
+ if (!node.expanded) {
450
+ ctx.toggleFolder(node);
451
+ } else {
452
+ const container = sidebarScrollRef.current;
453
+ if (!container) return;
454
+ const items = Array.from(container.querySelectorAll<HTMLElement>('[role="treeitem"]'));
455
+ const idx = items.indexOf(e.currentTarget as HTMLElement);
456
+ items[idx + 1]?.focus();
457
+ }
458
+ }
459
+ } else if (e.key === "ArrowLeft") {
460
+ e.preventDefault();
461
+ if ((node.type === "dir" || node.type === "app" || node.type === "node-app") && node.expanded) {
462
+ ctx.toggleFolder(node);
463
+ } else if (depth > 0) {
464
+ const container = sidebarScrollRef.current;
465
+ if (!container) return;
466
+ const items = Array.from(container.querySelectorAll<HTMLElement>('[role="treeitem"]'));
467
+ const current = e.currentTarget as HTMLElement;
468
+ const idx = items.indexOf(current);
469
+ const currentPL = Number.parseInt(current.style.paddingLeft ?? "0", 10);
470
+ for (let i = idx - 1; i >= 0; i--) {
471
+ const pl = Number.parseInt(items[i].style.paddingLeft ?? "0", 10);
472
+ if (pl < currentPL) { items[i].focus(); break; }
473
+ }
474
+ }
475
+ }
476
+ }}
477
+ onDragStart={(e) => ctx.handleDragStart(e, node)}
478
+ onDragOver={(e) =>
479
+ node.type === "dir"
480
+ ? ctx.handleDragOver(e, node.path, "dir")
481
+ : e.preventDefault()
482
+ }
483
+ onDragLeave={() => ctx.setDragOverPath(null)}
484
+ onDrop={(e) =>
485
+ node.type === "dir"
486
+ ? ctx.handleDropOnFolder(e, node.path)
487
+ : e.preventDefault()
488
+ }
489
+ onMouseEnter={() => onHoverEnter(node)}
490
+ onMouseLeave={onHoverLeave}
491
+ className={cn(
492
+ "tree-row-reveal flex items-center gap-1.5 rounded-sm px-2 py-1 text-sm cursor-pointer group transition-colors select-none touch-target",
493
+ isActive
494
+ ? "bg-accent-soft text-foreground font-medium"
495
+ : "hover:bg-muted",
496
+ isDragOver && "ring-2 ring-primary bg-primary-soft",
497
+ node.name.startsWith(".") && "opacity-40",
498
+ )}
499
+ style={{ paddingLeft: `${depth * 14 + 8}px`, ...ROW_CV }}
500
+ onClick={() => {
501
+ if (node.type === "dir") ctx.toggleFolder(node);
502
+ else if (node.type === "app" || node.type === "node-app") { ctx.openViewer(node); ctx.toggleFolder(node); if (isMobile) ctx.setSidebarCollapsed(true); }
503
+ else { ctx.openViewer(node); if (isMobile) ctx.setSidebarCollapsed(true); }
504
+ }}
505
+ >
506
+ {(node.type === "dir" || node.type === "app" || node.type === "node-app") ? (
507
+ node.loading ? (
508
+ <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
509
+ ) : (
510
+ <ChevronRight
511
+ className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-200 ease-out"
512
+ style={{ transform: node.expanded ? "rotate(90deg)" : "rotate(0deg)" }}
513
+ />
514
+ )
515
+ ) : (
516
+ <span className="w-3.5 shrink-0" />
517
+ )}
518
+
519
+ <span className="editorial-tree-typeicon">{node.type === "dir" ? (
520
+ node.expanded ? (
521
+ <FolderOpen className={cn("h-4 w-4 shrink-0", !isActive && "text-warning")} />
522
+ ) : (
523
+ <Folder className={cn("h-4 w-4 shrink-0", !isActive && "text-warning")} />
524
+ )
525
+ ) : node.type === "app" ? (
526
+ <Globe className={cn("h-4 w-4 shrink-0", !isActive && "text-foreground/70")} />
527
+ ) : node.type === "node-app" ? (
528
+ <Terminal className={cn("h-4 w-4 shrink-0", !isActive && "text-emerald-500")} />
529
+ ) : isHtmlFile(node.name) ? (
530
+ <Globe className={cn("h-4 w-4 shrink-0", !isActive && "text-foreground/60")} />
531
+ ) : isImage(node.name) ? (
532
+ <ImageIcon className={cn("h-4 w-4 shrink-0", !isActive && "text-sunshine-700")} />
533
+ ) : isText(node.name) ? (
534
+ <FileText className={cn("h-4 w-4 shrink-0", !isActive && "text-foreground/70")} />
535
+ ) : (
536
+ <File className={cn("h-4 w-4 shrink-0", !isActive && "text-foreground/60")} />
537
+ )}</span>
538
+
539
+ <span className="min-w-0 flex-1 truncate">{node.name}</span>
540
+
541
+ {/* Git repo badge */}
542
+ {node.git && (
543
+ <span className="relative flex shrink-0 items-center gap-0.5 rounded-sm bg-muted px-1 py-0.5 text-[10px] text-muted-foreground">
544
+ <button
545
+ type="button"
546
+ className="flex items-center gap-0.5 hover:text-foreground"
547
+ title="Switch branch"
548
+ onClick={(e) => {
549
+ e.stopPropagation();
550
+ if (branchOpen) { ctx.setBranchDropdownNode(null); ctx.setBranchDropdownPos(null); return; }
551
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
552
+ ctx.setBranchDropdownPos({ top: rect.bottom + 4, left: rect.left });
553
+ ctx.setBranchDropdownNode(node.path);
554
+ ctx.loadBranches(node.path);
555
+ }}
556
+ >
557
+ <GitBranch className="h-2.5 w-2.5" />
558
+ {node.git.branch}
559
+ {node.git.dirty && <span className="ml-0.5 text-warning">*</span>}
560
+ </button>
561
+ {isPulling ? (
562
+ <Loader2 className="ml-0.5 h-2.5 w-2.5 animate-spin" />
563
+ ) : (
564
+ <button
565
+ type="button"
566
+ onClick={(e) => {
567
+ e.stopPropagation();
568
+ const parentDir = node.path.includes("/")
569
+ ? node.path.substring(0, node.path.lastIndexOf("/"))
570
+ : "";
571
+ ctx.handleGitPull(node.path, parentDir);
572
+ }}
573
+ className="ml-0.5 text-muted-foreground hover:text-foreground"
574
+ title="Pull latest"
575
+ >
576
+ <RefreshCw className="h-2.5 w-2.5" />
577
+ </button>
578
+ )}
579
+ {/* Branch dropdown rendered as portal to escape overflow-hidden */}
580
+ {branchOpen && branchPos && typeof document !== "undefined" && createPortal(
581
+ <div
582
+ style={{ position: "fixed", top: branchPos.top, left: branchPos.left }}
583
+ className="z-[9999] min-w-[120px] rounded-md border bg-popover p-1 shadow-md"
584
+ onKeyDown={(e) => { if (e.key === "Escape") { ctx.setBranchDropdownNode(null); ctx.setBranchDropdownPos(null); } }}
585
+ onClick={(e) => e.stopPropagation()}
586
+ >
587
+ {branchLoading ? (
588
+ <div className="flex justify-center py-2">
589
+ <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
590
+ </div>
591
+ ) : branches.length === 0 ? (
592
+ <p className="px-2 py-1 text-[10px] text-muted-foreground">No branches</p>
593
+ ) : (
594
+ branches.map((b) => {
595
+ const parentDir = node.path.includes("/")
596
+ ? node.path.substring(0, node.path.lastIndexOf("/"))
597
+ : "";
598
+ return (
599
+ <button
600
+ key={b.name}
601
+ type="button"
602
+ disabled={checkingOut !== null || b.current}
603
+ className={cn(
604
+ "flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-[11px] hover:bg-muted",
605
+ b.current && "font-semibold text-foreground",
606
+ !b.current && "text-muted-foreground",
607
+ )}
608
+ onClick={(e) => {
609
+ e.stopPropagation();
610
+ if (!b.current) ctx.handleCheckout(node.path, b.name, parentDir);
611
+ }}
612
+ >
613
+ {b.current ? <Check className="h-3 w-3 shrink-0" /> : <span className="w-3 shrink-0" />}
614
+ {checkingOut === b.name ? <Loader2 className="h-3 w-3 animate-spin" /> : b.name}
615
+ </button>
616
+ );
617
+ })
618
+ )}
619
+ </div>,
620
+ document.body,
621
+ )}
622
+ </span>
623
+ )}
624
+
625
+ {/* Agent presence dot */}
626
+ {isAgentActive && (
627
+ <span
628
+ className="ml-0.5 h-1.5 w-1.5 rounded-full bg-emerald-400 shrink-0 animate-pulse"
629
+ title="Agent recently active"
630
+ />
631
+ )}
632
+
633
+ <div
634
+ className="hover-reveal flex max-w-0 shrink-0 items-center overflow-hidden opacity-0 transition-all duration-150 group-hover:max-w-7 group-hover:opacity-100 focus-within:max-w-7 focus-within:opacity-100"
635
+ onClick={(e) => e.stopPropagation()}
636
+ onKeyDown={(e) => e.stopPropagation()}
637
+ >
638
+ <DropdownMenu>
639
+ <DropdownMenuTrigger asChild>
640
+ <Button
641
+ size="sm"
642
+ variant="ghost"
643
+ className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
644
+ title="File actions"
645
+ >
646
+ <MoreHorizontal className="h-3.5 w-3.5" />
647
+ </Button>
648
+ </DropdownMenuTrigger>
649
+ <DropdownMenuContent align="end" className="w-48">
650
+ <DropdownMenuItem onClick={() => ctx.copyPath(node.path)}>
651
+ <Copy className="mr-2 h-3.5 w-3.5" />
652
+ Copy path
653
+ </DropdownMenuItem>
654
+ {isMarkdown(node.name) && (
655
+ <DropdownMenuItem onClick={() => ctx.copyWikiLink(node.name)}>
656
+ <FileText className="mr-2 h-3.5 w-3.5" />
657
+ Copy wiki link
658
+ </DropdownMenuItem>
659
+ )}
660
+ <DropdownMenuItem onClick={() => ctx.copyUrl(node.path)}>
661
+ <Link className="mr-2 h-3.5 w-3.5" />
662
+ Copy URL
663
+ </DropdownMenuItem>
664
+ {node.type === "file" && isText(node.name) && (
665
+ <>
666
+ <DropdownMenuItem onClick={() => ctx.copyRawContent(node.path)}>
667
+ <FileText className="mr-2 h-3.5 w-3.5" />
668
+ Copy raw content
669
+ </DropdownMenuItem>
670
+ <DropdownMenuItem onClick={() => ctx.copyFormattedContent(node.path, node.name)}>
671
+ <FileText className="mr-2 h-3.5 w-3.5" />
672
+ Copy formatted content
673
+ </DropdownMenuItem>
674
+ </>
675
+ )}
676
+ <DropdownMenuSeparator />
677
+ {node.type === "dir" && (
678
+ <>
679
+ <DropdownMenuItem
680
+ onClick={async () => {
681
+ if (!node.expanded) ctx.toggleFolder(node);
682
+ ctx.setNewFileParent(node.path);
683
+ ctx.setNewFileName("");
684
+ ctx.setFileCreateError(null);
685
+ }}
686
+ >
687
+ <FilePlus className="mr-2 h-3.5 w-3.5" />
688
+ New file here
689
+ </DropdownMenuItem>
690
+ <DropdownMenuItem onClick={() => ctx.triggerUpload(node.path)}>
691
+ <Upload className="mr-2 h-3.5 w-3.5" />
692
+ Upload here
693
+ </DropdownMenuItem>
694
+ <DropdownMenuItem
695
+ onClick={() => {
696
+ ctx.setNewFolderParent(node.path);
697
+ ctx.setNewFolderName("");
698
+ ctx.setFolderError(null);
699
+ }}
700
+ >
701
+ <FolderPlus className="mr-2 h-3.5 w-3.5" />
702
+ New subfolder
703
+ </DropdownMenuItem>
704
+ <DropdownMenuSeparator />
705
+ </>
706
+ )}
707
+ <DropdownMenuItem onClick={() => ctx.handleDownload(node)}>
708
+ <Download className="mr-2 h-3.5 w-3.5" />
709
+ {node.type === "file" ? "Download" : "Download as zip"}
710
+ </DropdownMenuItem>
711
+ <DropdownMenuItem
712
+ onClick={() => ctx.togglePin(node, activeWorkspaceId)}
713
+ >
714
+ <Star className={cn("mr-2 h-3.5 w-3.5", isPinned && "fill-current text-amber-400")} />
715
+ {isPinned ? "Unpin" : "Pin to top"}
716
+ </DropdownMenuItem>
717
+ <DropdownMenuSeparator />
718
+ <DropdownMenuItem
719
+ className="text-destructive focus:text-destructive"
720
+ onClick={() => {
721
+ ctx.setDeletingPath(node.path);
722
+ ctx.setDeletingIsDir(node.type !== "file");
723
+ }}
724
+ >
725
+ <Trash2 className="mr-2 h-3.5 w-3.5" />
726
+ Delete
727
+ </DropdownMenuItem>
728
+ </DropdownMenuContent>
729
+ </DropdownMenu>
730
+ </div>
731
+ </div>
732
+ </ContextMenuTrigger>
733
+ <ContextMenuContent>
734
+ <ContextMenuItem onSelect={() => ctx.copyPath(node.path)}>
735
+ Copy path
736
+ </ContextMenuItem>
737
+ {isMarkdown(node.name) && (
738
+ <ContextMenuItem onSelect={() => ctx.copyWikiLink(node.name)}>
739
+ Copy wiki link
740
+ </ContextMenuItem>
741
+ )}
742
+ <ContextMenuSeparator />
743
+ <ContextMenuItem onSelect={() => ctx.copyUrl(node.path)}>
744
+ Copy URL
745
+ </ContextMenuItem>
746
+ {node.type === "file" && isText(node.name) && (
747
+ <>
748
+ <ContextMenuSeparator />
749
+ <ContextMenuItem onSelect={() => ctx.copyRawContent(node.path)}>
750
+ Copy raw content
751
+ </ContextMenuItem>
752
+ <ContextMenuItem onSelect={() => ctx.copyFormattedContent(node.path, node.name)}>
753
+ Copy formatted content
754
+ </ContextMenuItem>
755
+ </>
756
+ )}
757
+ </ContextMenuContent>
758
+ </ContextMenu>
759
+ );
760
+ });
761
+
762
+ const FileTree = memo(function FileTree(p: FileTreeProps) {
763
+ const {
764
+ ctx,
765
+ openPath,
766
+ dragOverPath,
767
+ branchDropdownNode,
768
+ branchDropdownPos,
769
+ nodeBranches,
770
+ branchesLoading,
771
+ checkingOutBranch,
772
+ pullingRepo,
773
+ activePaths,
774
+ pins,
775
+ isMobile,
776
+ activeWorkspaceId,
777
+ newFileParent,
778
+ newFileName,
779
+ fileCreateError,
780
+ newFolderParent,
781
+ newFolderName,
782
+ folderError,
783
+ sidebarScrollRef,
784
+ } = p;
785
+
786
+ // Hover-intent prefetch: a single shared timer so only the row the pointer
787
+ // settles on (>120ms) is prefetched — passing the cursor over rows doesn't
788
+ // fire a request per row. Stable identities so TreeRowView's memo holds.
789
+ const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
790
+ const onHoverEnter = useCallback((node: TreeNode) => {
791
+ const isCollapsedDir =
792
+ (node.type === "dir" || node.type === "app" || node.type === "node-app") &&
793
+ !node.expanded &&
794
+ node.children === undefined;
795
+ if (node.type !== "file" && !isCollapsedDir) return;
796
+ if (hoverTimer.current) clearTimeout(hoverTimer.current);
797
+ hoverTimer.current = setTimeout(() => ctx.prefetch(node), 120);
798
+ }, [ctx]);
799
+ const onHoverLeave = useCallback(() => {
800
+ if (hoverTimer.current) { clearTimeout(hoverTimer.current); hoverTimer.current = null; }
801
+ }, []);
802
+
803
+ // Flatten the visible tree into a list. Removes the recursion that blocks
804
+ // per-row memo and lets create-inputs / empty markers be plain siblings.
805
+ // Recomputed only when the tree shape or which folder is mid-create changes.
806
+ const flat = useMemo(() => {
807
+ const out: Array<{ kind: "row" | "newfolder" | "newfile" | "empty"; node: TreeNode; depth: number }> = [];
808
+ const walk = (nodes: TreeNode[], depth: number) => {
809
+ for (const node of nodes) {
810
+ out.push({ kind: "row", node, depth });
811
+ if (node.type === "dir") {
812
+ if (newFolderParent === node.path) out.push({ kind: "newfolder", node, depth });
813
+ if (newFileParent === node.path) out.push({ kind: "newfile", node, depth });
814
+ }
815
+ if ((node.type === "dir" || node.type === "app" || node.type === "node-app") && node.expanded) {
816
+ if (node.children && node.children.length > 0) walk(node.children, depth + 1);
817
+ else if (node.children?.length === 0) out.push({ kind: "empty", node, depth });
818
+ }
819
+ }
820
+ };
821
+ walk(p.nodes, 0);
822
+ return out;
823
+ }, [p.nodes, newFileParent, newFolderParent]);
824
+
825
+ return (
826
+ <>
827
+ {flat.map((item) => {
828
+ const { node, depth } = item;
829
+ if (item.kind === "row") {
830
+ const branchOpen = branchDropdownNode === node.path;
831
+ return (
832
+ <TreeRowView
833
+ key={node.path}
834
+ node={node}
835
+ depth={depth}
836
+ ctx={ctx}
837
+ isMobile={isMobile}
838
+ activeWorkspaceId={activeWorkspaceId}
839
+ sidebarScrollRef={sidebarScrollRef}
840
+ isActive={openPath === node.path}
841
+ isDragOver={dragOverPath === node.path}
842
+ isPinned={pins.some((pin) => pin.path === node.path)}
843
+ isAgentActive={activePaths.has(node.path)}
844
+ isPulling={pullingRepo === node.path}
845
+ branchOpen={branchOpen}
846
+ branchPos={branchOpen ? branchDropdownPos : null}
847
+ branches={node.git ? (nodeBranches[node.path] ?? []) : []}
848
+ branchLoading={node.git ? !!branchesLoading[node.path] : false}
849
+ checkingOut={branchOpen ? checkingOutBranch : null}
850
+ onHoverEnter={onHoverEnter}
851
+ onHoverLeave={onHoverLeave}
852
+ />
853
+ );
854
+ }
855
+ if (item.kind === "newfolder") {
856
+ return (
857
+ <div
858
+ key={`nf-${node.path}`}
859
+ className="flex items-center gap-1.5 px-2 py-1"
860
+ style={{ paddingLeft: `${(depth + 1) * 14 + 8}px`, ...ROW_CV }}
861
+ >
862
+ <span className="w-3.5 shrink-0" />
863
+ <Folder className="h-4 w-4 shrink-0 text-warning" />
864
+ <input
865
+ className="flex-1 bg-transparent text-sm outline-none border-b border-border min-w-0"
866
+ placeholder="Folder name"
867
+ value={newFolderName}
868
+ onChange={(e) => ctx.setNewFolderName(e.target.value)}
869
+ onKeyDown={(e) => {
870
+ if (e.key === "Enter") ctx.handleCreateFolder();
871
+ if (e.key === "Escape") { ctx.setNewFolderParent(null); ctx.setNewFolderName(""); }
872
+ }}
873
+ />
874
+ {folderError && <span className="text-xs text-destructive">{folderError}</span>}
875
+ <Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={ctx.handleCreateFolder}>
876
+ <Check className="h-3 w-3" />
877
+ </Button>
878
+ <Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={() => { ctx.setNewFolderParent(null); ctx.setNewFolderName(""); }}>
879
+ <X className="h-3 w-3" />
880
+ </Button>
881
+ </div>
882
+ );
883
+ }
884
+ if (item.kind === "newfile") {
885
+ return (
886
+ <div
887
+ key={`ff-${node.path}`}
888
+ className="flex items-center gap-1.5 px-2 py-1"
889
+ style={{ paddingLeft: `${(depth + 1) * 14 + 8}px`, ...ROW_CV }}
890
+ >
891
+ <span className="w-3.5 shrink-0" />
892
+ <FileText className="h-4 w-4 shrink-0 text-accent" />
893
+ <input
894
+ autoFocus
895
+ className="flex-1 bg-transparent text-sm outline-none border-b border-border min-w-0"
896
+ placeholder="filename (default .md)"
897
+ value={newFileName}
898
+ onChange={(e) => ctx.setNewFileName(e.target.value)}
899
+ onKeyDown={(e) => {
900
+ if (e.key === "Enter") ctx.handleCreateFile();
901
+ if (e.key === "Escape") { ctx.setNewFileParent(null); ctx.setNewFileName(""); }
902
+ }}
903
+ />
904
+ {fileCreateError && <span className="text-xs text-destructive">{fileCreateError}</span>}
905
+ <Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={ctx.handleCreateFile}>
906
+ <Check className="h-3 w-3" />
907
+ </Button>
908
+ <Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={() => { ctx.setNewFileParent(null); ctx.setNewFileName(""); }}>
909
+ <X className="h-3 w-3" />
910
+ </Button>
911
+ </div>
912
+ );
913
+ }
914
+ // empty
915
+ return (
916
+ <div
917
+ key={`empty-${node.path}`}
918
+ className="text-xs text-muted-foreground/50 py-0.5"
919
+ style={{ paddingLeft: `${(depth + 1) * 14 + 8 + 14 + 6 + 16 + 6}px`, ...ROW_CV }}
920
+ >
921
+ Empty
922
+ </div>
923
+ );
924
+ })}
925
+ </>
926
+ );
927
+ });
928
+
293
929
  export default function Page() {
294
930
  const slugsLoadedAt = useWikiSlugsStore((s) => s.loadedAt);
295
931
  useEffect(() => {
@@ -383,6 +1019,31 @@ export default function Page() {
383
1019
  usePinStore.getState().loadForWorkspace(activeWorkspaceId);
384
1020
  }, [activeWorkspaceId]);
385
1021
 
1022
+ // Speculative warm-up: at idle, prefetch the markdown files the user is most
1023
+ // likely to open next — pins and recents (the top of the sidebar). prefetchPage
1024
+ // dedups, so this is a no-op for already-cached pages. Bounded to keep it cheap.
1025
+ useEffect(() => {
1026
+ const paths = [
1027
+ ...pins.map((p) => p.path),
1028
+ ...recents.slice(0, 8).map((r) => r.path),
1029
+ ]
1030
+ .filter((p, i, arr) => isMarkdown(p) && arr.indexOf(p) === i)
1031
+ .slice(0, 12);
1032
+ if (paths.length === 0) return;
1033
+ const ric: (cb: () => void) => number =
1034
+ typeof window !== "undefined" && "requestIdleCallback" in window
1035
+ ? (cb) => (window as unknown as { requestIdleCallback: (c: () => void) => number }).requestIdleCallback(cb)
1036
+ : (cb) => window.setTimeout(cb, 400);
1037
+ const id = ric(() => { for (const p of paths) prefetchPage(p); });
1038
+ return () => {
1039
+ if (typeof window !== "undefined" && "cancelIdleCallback" in window) {
1040
+ (window as unknown as { cancelIdleCallback: (h: number) => void }).cancelIdleCallback(id);
1041
+ } else {
1042
+ clearTimeout(id);
1043
+ }
1044
+ };
1045
+ }, [pins, recents]);
1046
+
386
1047
  // Path captured from the URL at first render, before any effect can clear it.
387
1048
  // Restore reads from this ref (never the live URL) so URL sync can't break it.
388
1049
  const initialUrlPathRef = useRef<string | null>(
@@ -585,22 +1246,26 @@ const [shareDialogOpen, setShareDialogOpen] = useState(false);
585
1246
  setDiffContent(null);
586
1247
  }, [openFile?.path]);
587
1248
 
588
- // Fetch git metadata for open file
1249
+ // Fetch git metadata for open file. Keyed on path only: re-fetching on every
1250
+ // openFile object re-ref (unrelated re-renders) wastes a git round-trip.
1251
+ const openPath = openFile?.path;
589
1252
  useEffect(() => {
590
- if (!openFile) { setGitFileInfo(null); return; }
591
- const path = openFile.path;
1253
+ if (!openPath) { setGitFileInfo(null); return; }
592
1254
  let cancelled = false;
593
- void (async () => {
594
- try {
595
- const res = await wsFetch(`/api/wiki/git-file-info?path=${encodeURIComponent(path)}`);
596
- if (cancelled) return;
597
- if (!res.ok) { setGitFileInfo(null); return; }
598
- const d: { info: { sha: string; author: string; date: string } | null } = await res.json();
599
- if (!cancelled) setGitFileInfo(d.info);
600
- } catch { if (!cancelled) setGitFileInfo(null); }
601
- })();
602
- return () => { cancelled = true; };
603
- }, [openFile]);
1255
+ // Debounced: rapid navigation shouldn't fire a git round-trip per pass-through.
1256
+ const timer = setTimeout(() => {
1257
+ void (async () => {
1258
+ try {
1259
+ const res = await wsFetch(`/api/wiki/git-file-info?path=${encodeURIComponent(openPath)}`);
1260
+ if (cancelled) return;
1261
+ if (!res.ok) { setGitFileInfo(null); return; }
1262
+ const d: { info: { sha: string; author: string; date: string } | null } = await res.json();
1263
+ if (!cancelled) setGitFileInfo(d.info);
1264
+ } catch { if (!cancelled) setGitFileInfo(null); }
1265
+ })();
1266
+ }, 200);
1267
+ return () => { cancelled = true; clearTimeout(timer); };
1268
+ }, [openPath]);
604
1269
 
605
1270
  const loadHistory = useCallback(async () => {
606
1271
  if (!openFile) return;
@@ -828,6 +1493,18 @@ const [shareDialogOpen, setShareDialogOpen] = useState(false);
828
1493
  if (node.type !== "dir" && node.type !== "app" && node.type !== "node-app") return;
829
1494
  if (!node.expanded) {
830
1495
  if (node.children === undefined) {
1496
+ const prefetched = await takePrefetchedDir(node.path);
1497
+ if (prefetched) {
1498
+ setRoots((prev) =>
1499
+ updateNodes(prev, node.path, (n) => ({
1500
+ ...n,
1501
+ loading: false,
1502
+ children: prefetched,
1503
+ expanded: true,
1504
+ })),
1505
+ );
1506
+ return;
1507
+ }
831
1508
  setRoots((prev) =>
832
1509
  updateNodes(prev, node.path, (n) => ({ ...n, loading: true })),
833
1510
  );
@@ -1386,467 +2063,85 @@ const [shareDialogOpen, setShareDialogOpen] = useState(false);
1386
2063
  const contentWidthClass = widthAwareViewer ? VIEW_WIDTH_CLASS[viewWidth] : "";
1387
2064
  const contentAlignClass = widthAwareViewer ? VIEW_ALIGN_CLASS[viewAlign] : "";
1388
2065
 
1389
- function renderNodes(nodes: TreeNode[], depth = 0): React.ReactNode {
1390
- return nodes.map((node) => {
1391
- const isNodePinned = pins.some((p) => p.path === node.path);
1392
- return (
1393
- <ContextMenu key={node.path}>
1394
- <ContextMenuTrigger asChild>
1395
- <div>
1396
- <div
1397
- role="treeitem"
1398
- tabIndex={0}
1399
- draggable={!isMobile}
1400
- onKeyDown={(e) => {
1401
- if (e.key === "Enter" || e.key === " ") {
1402
- e.preventDefault();
1403
- if (node.type === "dir") toggleFolder(node);
1404
- else if (node.type === "app" || node.type === "node-app") { void openViewer(node); void toggleFolder(node); }
1405
- else void openViewer(node);
1406
- } else if (e.key === "ArrowDown") {
1407
- e.preventDefault();
1408
- const container = sidebarScrollRef.current;
1409
- if (!container) return;
1410
- const items = Array.from(container.querySelectorAll<HTMLElement>('[role="treeitem"]'));
1411
- const idx = items.indexOf(e.currentTarget as HTMLElement);
1412
- items[idx + 1]?.focus();
1413
- } else if (e.key === "ArrowUp") {
1414
- e.preventDefault();
1415
- const container = sidebarScrollRef.current;
1416
- if (!container) return;
1417
- const items = Array.from(container.querySelectorAll<HTMLElement>('[role="treeitem"]'));
1418
- const idx = items.indexOf(e.currentTarget as HTMLElement);
1419
- items[idx - 1]?.focus();
1420
- } else if (e.key === "ArrowRight") {
1421
- e.preventDefault();
1422
- if (node.type === "dir" || node.type === "app" || node.type === "node-app") {
1423
- if (!node.expanded) {
1424
- void toggleFolder(node);
1425
- } else {
1426
- // Focus first visible child
1427
- const container = sidebarScrollRef.current;
1428
- if (!container) return;
1429
- const items = Array.from(container.querySelectorAll<HTMLElement>('[role="treeitem"]'));
1430
- const idx = items.indexOf(e.currentTarget as HTMLElement);
1431
- items[idx + 1]?.focus();
1432
- }
1433
- }
1434
- } else if (e.key === "ArrowLeft") {
1435
- e.preventDefault();
1436
- if ((node.type === "dir" || node.type === "app" || node.type === "node-app") && node.expanded) {
1437
- void toggleFolder(node);
1438
- } else if (depth > 0) {
1439
- // Focus nearest ancestor (smaller paddingLeft)
1440
- const container = sidebarScrollRef.current;
1441
- if (!container) return;
1442
- const items = Array.from(container.querySelectorAll<HTMLElement>('[role="treeitem"]'));
1443
- const current = e.currentTarget as HTMLElement;
1444
- const idx = items.indexOf(current);
1445
- const currentPL = Number.parseInt(current.style.paddingLeft ?? "0", 10);
1446
- for (let i = idx - 1; i >= 0; i--) {
1447
- const pl = Number.parseInt(items[i].style.paddingLeft ?? "0", 10);
1448
- if (pl < currentPL) { items[i].focus(); break; }
1449
- }
1450
- }
1451
- }
1452
- }}
1453
- onDragStart={(e) => handleDragStart(e, node)}
1454
- onDragOver={(e) =>
1455
- node.type === "dir"
1456
- ? handleDragOver(e, node.path, "dir")
1457
- : e.preventDefault()
1458
- }
1459
- onDragLeave={() => setDragOverPath(null)}
1460
- onDrop={(e) =>
1461
- node.type === "dir"
1462
- ? handleDropOnFolder(e, node.path)
1463
- : e.preventDefault()
1464
- }
1465
- className={cn(
1466
- "flex items-center gap-1.5 rounded-sm px-2 py-1 text-sm cursor-pointer group transition-colors select-none touch-target",
1467
- openFile?.path === node.path
1468
- ? "bg-accent-soft text-foreground font-medium"
1469
- : "hover:bg-muted",
1470
- dragOverPath === node.path && "ring-2 ring-primary bg-primary-soft",
1471
- node.name.startsWith(".") && "opacity-40",
1472
- )}
1473
- style={{ paddingLeft: `${depth * 14 + 8}px` }}
1474
- onClick={() => {
1475
- if (node.type === "dir") toggleFolder(node);
1476
- else if (node.type === "app" || node.type === "node-app") { void openViewer(node); void toggleFolder(node); if (isMobile) setSidebarCollapsed(true); }
1477
- else { void openViewer(node); if (isMobile) setSidebarCollapsed(true); }
1478
- }}
1479
- >
1480
- {(node.type === "dir" || node.type === "app" || node.type === "node-app") ? (
1481
- node.loading ? (
1482
- <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
1483
- ) : node.expanded ? (
1484
- <ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
1485
- ) : (
1486
- <ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
1487
- )
1488
- ) : (
1489
- <span className="w-3.5 shrink-0" />
1490
- )}
1491
-
1492
- <span className="editorial-tree-typeicon">{node.type === "dir" ? (
1493
- node.expanded ? (
1494
- <FolderOpen className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-warning")} />
1495
- ) : (
1496
- <Folder className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-warning")} />
1497
- )
1498
- ) : node.type === "app" ? (
1499
- <Globe className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-foreground/70")} />
1500
- ) : node.type === "node-app" ? (
1501
- <Terminal className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-emerald-500")} />
1502
- ) : isHtmlFile(node.name) ? (
1503
- <Globe className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-foreground/60")} />
1504
- ) : isImage(node.name) ? (
1505
- <ImageIcon className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-sunshine-700")} />
1506
- ) : isText(node.name) ? (
1507
- <FileText className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-foreground/70")} />
1508
- ) : (
1509
- <File className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-foreground/60")} />
1510
- )}</span>
1511
-
1512
- <span className="min-w-0 flex-1 truncate">{node.name}</span>
1513
-
1514
- {/* Git repo badge */}
1515
- {node.git && (
1516
- <span className="relative flex shrink-0 items-center gap-0.5 rounded-sm bg-muted px-1 py-0.5 text-[10px] text-muted-foreground">
1517
- <button
1518
- type="button"
1519
- className="flex items-center gap-0.5 hover:text-foreground"
1520
- title="Switch branch"
1521
- onClick={(e) => {
1522
- e.stopPropagation();
1523
- const isOpen = branchDropdownNode === node.path;
1524
- if (isOpen) { setBranchDropdownNode(null); setBranchDropdownPos(null); return; }
1525
- const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
1526
- setBranchDropdownPos({ top: rect.bottom + 4, left: rect.left });
1527
- setBranchDropdownNode(node.path);
1528
- void loadBranches(node.path);
1529
- }}
1530
- >
1531
- <GitBranch className="h-2.5 w-2.5" />
1532
- {node.git.branch}
1533
- {node.git.dirty && <span className="ml-0.5 text-warning">*</span>}
1534
- </button>
1535
- {pullingRepo === node.path ? (
1536
- <Loader2 className="ml-0.5 h-2.5 w-2.5 animate-spin" />
1537
- ) : (
1538
- <button
1539
- type="button"
1540
- onClick={(e) => {
1541
- e.stopPropagation();
1542
- const parentDir = node.path.includes("/")
1543
- ? node.path.substring(0, node.path.lastIndexOf("/"))
1544
- : "";
1545
- void handleGitPull(node.path, parentDir);
1546
- }}
1547
- className="ml-0.5 text-muted-foreground hover:text-foreground"
1548
- title="Pull latest"
1549
- >
1550
- <RefreshCw className="h-2.5 w-2.5" />
1551
- </button>
1552
- )}
1553
- {/* Branch dropdown rendered as portal to escape overflow-hidden */}
1554
- {branchDropdownNode === node.path && branchDropdownPos && typeof document !== "undefined" && createPortal(
1555
- <div
1556
- style={{ position: "fixed", top: branchDropdownPos.top, left: branchDropdownPos.left }}
1557
- className="z-[9999] min-w-[120px] rounded-md border bg-popover p-1 shadow-md"
1558
- onKeyDown={(e) => { if (e.key === "Escape") { setBranchDropdownNode(null); setBranchDropdownPos(null); } }}
1559
- onClick={(e) => e.stopPropagation()}
1560
- >
1561
- {branchesLoading[node.path] ? (
1562
- <div className="flex justify-center py-2">
1563
- <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
1564
- </div>
1565
- ) : (nodeBranches[node.path] ?? []).length === 0 ? (
1566
- <p className="px-2 py-1 text-[10px] text-muted-foreground">No branches</p>
1567
- ) : (
1568
- (nodeBranches[node.path] ?? []).map((b) => {
1569
- const parentDir = node.path.includes("/")
1570
- ? node.path.substring(0, node.path.lastIndexOf("/"))
1571
- : "";
1572
- return (
1573
- <button
1574
- key={b.name}
1575
- type="button"
1576
- disabled={checkingOutBranch !== null || b.current}
1577
- className={cn(
1578
- "flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-[11px] hover:bg-muted",
1579
- b.current && "font-semibold text-foreground",
1580
- !b.current && "text-muted-foreground",
1581
- )}
1582
- onClick={(e) => {
1583
- e.stopPropagation();
1584
- if (!b.current) void handleCheckout(node.path, b.name, parentDir);
1585
- }}
1586
- >
1587
- {b.current ? <Check className="h-3 w-3 shrink-0" /> : <span className="w-3 shrink-0" />}
1588
- {checkingOutBranch === b.name ? <Loader2 className="h-3 w-3 animate-spin" /> : b.name}
1589
- </button>
1590
- );
1591
- })
1592
- )}
1593
- </div>,
1594
- document.body,
1595
- )}
1596
- </span>
1597
- )}
1598
-
1599
- {/* Agent presence dot */}
1600
- {activePaths.has(node.path) && (
1601
- <span
1602
- className="ml-0.5 h-1.5 w-1.5 rounded-full bg-emerald-400 shrink-0 animate-pulse"
1603
- title="Agent recently active"
1604
- />
1605
- )}
1606
-
1607
- <div
1608
- className="hover-reveal flex max-w-0 shrink-0 items-center overflow-hidden opacity-0 transition-all duration-150 group-hover:max-w-7 group-hover:opacity-100 focus-within:max-w-7 focus-within:opacity-100"
1609
- onClick={(e) => e.stopPropagation()}
1610
- onKeyDown={(e) => e.stopPropagation()}
1611
- >
1612
- <DropdownMenu>
1613
- <DropdownMenuTrigger asChild>
1614
- <Button
1615
- size="sm"
1616
- variant="ghost"
1617
- className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
1618
- title="File actions"
1619
- >
1620
- <MoreHorizontal className="h-3.5 w-3.5" />
1621
- </Button>
1622
- </DropdownMenuTrigger>
1623
- <DropdownMenuContent align="end" className="w-48">
1624
- <DropdownMenuItem onClick={() => copyPath(node.path)}>
1625
- <Copy className="mr-2 h-3.5 w-3.5" />
1626
- Copy path
1627
- </DropdownMenuItem>
1628
- {isMarkdown(node.name) && (
1629
- <DropdownMenuItem onClick={() => copyWikiLink(node.name)}>
1630
- <FileText className="mr-2 h-3.5 w-3.5" />
1631
- Copy wiki link
1632
- </DropdownMenuItem>
1633
- )}
1634
- <DropdownMenuItem onClick={() => copyUrl(node.path)}>
1635
- <Link className="mr-2 h-3.5 w-3.5" />
1636
- Copy URL
1637
- </DropdownMenuItem>
1638
- {node.type === "file" && isText(node.name) && (
1639
- <>
1640
- <DropdownMenuItem onClick={() => void copyRawContent(node.path)}>
1641
- <FileText className="mr-2 h-3.5 w-3.5" />
1642
- Copy raw content
1643
- </DropdownMenuItem>
1644
- <DropdownMenuItem onClick={() => void copyFormattedContent(node.path, node.name)}>
1645
- <FileText className="mr-2 h-3.5 w-3.5" />
1646
- Copy formatted content
1647
- </DropdownMenuItem>
1648
- </>
1649
- )}
1650
- <DropdownMenuSeparator />
1651
- {node.type === "dir" && (
1652
- <>
1653
- <DropdownMenuItem
1654
- onClick={async () => {
1655
- if (!node.expanded) await toggleFolder(node);
1656
- setNewFileParent(node.path);
1657
- setNewFileName("");
1658
- setFileCreateError(null);
1659
- }}
1660
- >
1661
- <FilePlus className="mr-2 h-3.5 w-3.5" />
1662
- New file here
1663
- </DropdownMenuItem>
1664
- <DropdownMenuItem onClick={() => triggerUpload(node.path)}>
1665
- <Upload className="mr-2 h-3.5 w-3.5" />
1666
- Upload here
1667
- </DropdownMenuItem>
1668
- <DropdownMenuItem
1669
- onClick={() => {
1670
- setNewFolderParent(node.path);
1671
- setNewFolderName("");
1672
- setFolderError(null);
1673
- }}
1674
- >
1675
- <FolderPlus className="mr-2 h-3.5 w-3.5" />
1676
- New subfolder
1677
- </DropdownMenuItem>
1678
- <DropdownMenuSeparator />
1679
- </>
1680
- )}
1681
- <DropdownMenuItem onClick={() => handleDownload(node)}>
1682
- <Download className="mr-2 h-3.5 w-3.5" />
1683
- {node.type === "file" ? "Download" : "Download as zip"}
1684
- </DropdownMenuItem>
1685
- <DropdownMenuItem
1686
- onClick={() =>
1687
- usePinStore
1688
- .getState()
1689
- .toggle({ path: node.path, name: node.name, type: (node.type === "dir" ? "file" : node.type) as "file" | "app" | "node-app" }, activeWorkspaceId)
1690
- }
1691
- >
1692
- <Star className={cn("mr-2 h-3.5 w-3.5", isNodePinned && "fill-current text-amber-400")} />
1693
- {isNodePinned ? "Unpin" : "Pin to top"}
1694
- </DropdownMenuItem>
1695
- <DropdownMenuSeparator />
1696
- <DropdownMenuItem
1697
- className="text-destructive focus:text-destructive"
1698
- onClick={() => {
1699
- setDeletingPath(node.path);
1700
- setDeletingIsDir(node.type !== "file");
1701
- }}
1702
- >
1703
- <Trash2 className="mr-2 h-3.5 w-3.5" />
1704
- Delete
1705
- </DropdownMenuItem>
1706
- </DropdownMenuContent>
1707
- </DropdownMenu>
1708
- </div>
1709
- </div>
1710
-
1711
- {newFolderParent === node.path && node.type === "dir" && (
1712
- <div
1713
- className="flex items-center gap-1.5 px-2 py-1"
1714
- style={{ paddingLeft: `${(depth + 1) * 14 + 8}px` }}
1715
- >
1716
- <span className="w-3.5 shrink-0" />
1717
- <Folder className="h-4 w-4 shrink-0 text-warning" />
1718
- <input
1719
- className="flex-1 bg-transparent text-sm outline-none border-b border-border min-w-0"
1720
- placeholder="Folder name"
1721
- value={newFolderName}
1722
- onChange={(e) => setNewFolderName(e.target.value)}
1723
- onKeyDown={(e) => {
1724
- if (e.key === "Enter") handleCreateFolder();
1725
- if (e.key === "Escape") {
1726
- setNewFolderParent(null);
1727
- setNewFolderName("");
1728
- }
1729
- }}
1730
- />
1731
- {folderError && (
1732
- <span className="text-xs text-destructive">{folderError}</span>
1733
- )}
1734
- <Button
1735
- size="sm"
1736
- variant="ghost"
1737
- className="h-6 w-6 p-0"
1738
- onClick={handleCreateFolder}
1739
- >
1740
- <Check className="h-3 w-3" />
1741
- </Button>
1742
- <Button
1743
- size="sm"
1744
- variant="ghost"
1745
- className="h-6 w-6 p-0"
1746
- onClick={() => {
1747
- setNewFolderParent(null);
1748
- setNewFolderName("");
1749
- }}
1750
- >
1751
- <X className="h-3 w-3" />
1752
- </Button>
1753
- </div>
1754
- )}
1755
-
1756
- {newFileParent === node.path && node.type === "dir" && (
1757
- <div
1758
- className="flex items-center gap-1.5 px-2 py-1"
1759
- style={{ paddingLeft: `${(depth + 1) * 14 + 8}px` }}
1760
- >
1761
- <span className="w-3.5 shrink-0" />
1762
- <FileText className="h-4 w-4 shrink-0 text-accent" />
1763
- <input
1764
- autoFocus
1765
- className="flex-1 bg-transparent text-sm outline-none border-b border-border min-w-0"
1766
- placeholder="filename (default .md)"
1767
- value={newFileName}
1768
- onChange={(e) => setNewFileName(e.target.value)}
1769
- onKeyDown={(e) => {
1770
- if (e.key === "Enter") handleCreateFile();
1771
- if (e.key === "Escape") {
1772
- setNewFileParent(null);
1773
- setNewFileName("");
1774
- }
1775
- }}
1776
- />
1777
- {fileCreateError && (
1778
- <span className="text-xs text-destructive">{fileCreateError}</span>
1779
- )}
1780
- <Button
1781
- size="sm"
1782
- variant="ghost"
1783
- className="h-6 w-6 p-0"
1784
- onClick={handleCreateFile}
1785
- >
1786
- <Check className="h-3 w-3" />
1787
- </Button>
1788
- <Button
1789
- size="sm"
1790
- variant="ghost"
1791
- className="h-6 w-6 p-0"
1792
- onClick={() => {
1793
- setNewFileParent(null);
1794
- setNewFileName("");
1795
- }}
1796
- >
1797
- <X className="h-3 w-3" />
1798
- </Button>
1799
- </div>
1800
- )}
1801
-
1802
- {(node.type === "dir" || node.type === "app" || node.type === "node-app") &&
1803
- node.expanded &&
1804
- node.children &&
1805
- node.children.length > 0 &&
1806
- renderNodes(node.children, depth + 1)}
1807
- {(node.type === "dir" || node.type === "app" || node.type === "node-app") &&
1808
- node.expanded &&
1809
- node.children?.length === 0 && (
1810
- <div
1811
- className="text-xs text-muted-foreground/50 py-0.5"
1812
- style={{
1813
- paddingLeft: `${(depth + 1) * 14 + 8 + 14 + 6 + 16 + 6}px`,
1814
- }}
1815
- >
1816
- Empty
1817
- </div>
1818
- )}
1819
- </div>
1820
- </ContextMenuTrigger>
1821
- <ContextMenuContent>
1822
- <ContextMenuItem onSelect={() => copyPath(node.path)}>
1823
- Copy path
1824
- </ContextMenuItem>
1825
- {isMarkdown(node.name) && (
1826
- <ContextMenuItem onSelect={() => copyWikiLink(node.name)}>
1827
- Copy wiki link
1828
- </ContextMenuItem>
1829
- )}
1830
- <ContextMenuSeparator />
1831
- <ContextMenuItem onSelect={() => copyUrl(node.path)}>
1832
- Copy URL
1833
- </ContextMenuItem>
1834
- {node.type === "file" && isText(node.name) && (
1835
- <>
1836
- <ContextMenuSeparator />
1837
- <ContextMenuItem onSelect={() => void copyRawContent(node.path)}>
1838
- Copy raw content
1839
- </ContextMenuItem>
1840
- <ContextMenuItem onSelect={() => void copyFormattedContent(node.path, node.name)}>
1841
- Copy formatted content
1842
- </ContextMenuItem>
1843
- </>
1844
- )}
1845
- </ContextMenuContent>
1846
- </ContextMenu>
1847
- );
1848
- });
1849
- }
2066
+ // Ref-backed dispatcher: handlers below are recreated each render, but the
2067
+ // dispatcher methods are referentially stable, so FileTree's React.memo holds.
2068
+ const treeHandlersRef = useRef<TreeCtx | null>(null);
2069
+ treeHandlersRef.current = {
2070
+ toggleFolder: (n) => void toggleFolder(n),
2071
+ openViewer: (n) => void openViewer(n),
2072
+ copyPath,
2073
+ copyWikiLink,
2074
+ copyUrl,
2075
+ copyRawContent: (path) => void copyRawContent(path),
2076
+ copyFormattedContent: (path, name) => void copyFormattedContent(path, name),
2077
+ handleDownload,
2078
+ triggerUpload,
2079
+ handleCreateFile: () => void handleCreateFile(),
2080
+ handleCreateFolder: () => void handleCreateFolder(),
2081
+ handleDragStart,
2082
+ handleDragOver,
2083
+ handleDropOnFolder: (e, p) => void handleDropOnFolder(e, p),
2084
+ handleGitPull: (p, d) => void handleGitPull(p, d),
2085
+ handleCheckout: (p, b, d) => void handleCheckout(p, b, d),
2086
+ loadBranches: (p) => void loadBranches(p),
2087
+ prefetch: (node) => {
2088
+ if (node.type === "file") { if (isMarkdown(node.name)) prefetchPage(node.path); }
2089
+ else prefetchDir(node.path);
2090
+ },
2091
+ togglePin: (node, wsId) =>
2092
+ usePinStore.getState().toggle(
2093
+ { path: node.path, name: node.name, type: (node.type === "dir" ? "file" : node.type) as "file" | "app" | "node-app" },
2094
+ wsId,
2095
+ ),
2096
+ setDragOverPath,
2097
+ setSidebarCollapsed,
2098
+ setBranchDropdownNode,
2099
+ setBranchDropdownPos,
2100
+ setNewFileParent,
2101
+ setNewFileName,
2102
+ setFileCreateError,
2103
+ setNewFolderParent,
2104
+ setNewFolderName,
2105
+ setFolderError,
2106
+ setDeletingPath,
2107
+ setDeletingIsDir,
2108
+ };
2109
+ const treeCtx = useMemo<TreeCtx>(() => {
2110
+ const r = treeHandlersRef;
2111
+ return {
2112
+ toggleFolder: (n) => r.current!.toggleFolder(n),
2113
+ openViewer: (n) => r.current!.openViewer(n),
2114
+ copyPath: (p) => r.current!.copyPath(p),
2115
+ copyWikiLink: (n) => r.current!.copyWikiLink(n),
2116
+ copyUrl: (p) => r.current!.copyUrl(p),
2117
+ copyRawContent: (p) => r.current!.copyRawContent(p),
2118
+ copyFormattedContent: (p, n) => r.current!.copyFormattedContent(p, n),
2119
+ handleDownload: (n) => r.current!.handleDownload(n),
2120
+ triggerUpload: (d) => r.current!.triggerUpload(d),
2121
+ handleCreateFile: () => r.current!.handleCreateFile(),
2122
+ handleCreateFolder: () => r.current!.handleCreateFolder(),
2123
+ handleDragStart: (e, n) => r.current!.handleDragStart(e, n),
2124
+ handleDragOver: (e, p, t) => r.current!.handleDragOver(e, p, t),
2125
+ handleDropOnFolder: (e, p) => r.current!.handleDropOnFolder(e, p),
2126
+ handleGitPull: (p, d) => r.current!.handleGitPull(p, d),
2127
+ handleCheckout: (p, b, d) => r.current!.handleCheckout(p, b, d),
2128
+ loadBranches: (p) => r.current!.loadBranches(p),
2129
+ prefetch: (n) => r.current!.prefetch(n),
2130
+ togglePin: (n, w) => r.current!.togglePin(n, w),
2131
+ setDragOverPath: (p) => r.current!.setDragOverPath(p),
2132
+ setSidebarCollapsed: (b) => r.current!.setSidebarCollapsed(b),
2133
+ setBranchDropdownNode: (p) => r.current!.setBranchDropdownNode(p),
2134
+ setBranchDropdownPos: (p) => r.current!.setBranchDropdownPos(p),
2135
+ setNewFileParent: (p) => r.current!.setNewFileParent(p),
2136
+ setNewFileName: (s) => r.current!.setNewFileName(s),
2137
+ setFileCreateError: (s) => r.current!.setFileCreateError(s),
2138
+ setNewFolderParent: (p) => r.current!.setNewFolderParent(p),
2139
+ setNewFolderName: (s) => r.current!.setNewFolderName(s),
2140
+ setFolderError: (s) => r.current!.setFolderError(s),
2141
+ setDeletingPath: (p) => r.current!.setDeletingPath(p),
2142
+ setDeletingIsDir: (b) => r.current!.setDeletingIsDir(b),
2143
+ };
2144
+ }, []);
1850
2145
 
1851
2146
  return (
1852
2147
  <div key={activeWorkspaceId ?? "none"} className="flex h-screen gap-0 overflow-hidden bg-background">
@@ -2233,7 +2528,29 @@ const [shareDialogOpen, setShareDialogOpen] = useState(false);
2233
2528
  </Button>
2234
2529
  </div>
2235
2530
  ) : (
2236
- renderNodes(roots)
2531
+ <FileTree
2532
+ ctx={treeCtx}
2533
+ nodes={roots}
2534
+ openPath={openFile?.path ?? null}
2535
+ dragOverPath={dragOverPath}
2536
+ branchDropdownNode={branchDropdownNode}
2537
+ branchDropdownPos={branchDropdownPos}
2538
+ nodeBranches={nodeBranches}
2539
+ branchesLoading={branchesLoading}
2540
+ checkingOutBranch={checkingOutBranch}
2541
+ pullingRepo={pullingRepo}
2542
+ activePaths={activePaths}
2543
+ pins={pins}
2544
+ isMobile={isMobile}
2545
+ activeWorkspaceId={activeWorkspaceId}
2546
+ newFileParent={newFileParent}
2547
+ newFileName={newFileName}
2548
+ fileCreateError={fileCreateError}
2549
+ newFolderParent={newFolderParent}
2550
+ newFolderName={newFolderName}
2551
+ folderError={folderError}
2552
+ sidebarScrollRef={sidebarScrollRef}
2553
+ />
2237
2554
  )}
2238
2555
  </div>
2239
2556
  <div className="border-t px-2 py-2 bg-muted shrink-0">