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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/app-path-routes-manifest.json +1 -0
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/routes-manifest.json +6 -0
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/agent/activity/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/events/[...path]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/files/[...path]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/fs/file/[...path]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/fs/ls/[[...path]]/route.js +1 -1
- package/.next/standalone/.next/server/app/api/agent/fs/ls/[[...path]]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/fs/move/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/fs/search/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/internal/span/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/settings/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/sidecar/[...path]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/app-proxy/[...path]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/assets/[...path]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/share/[token]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/share/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/system/browse/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/system/reveal/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/system/set-root/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/system/workspaces/[id]/open/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/system/workspaces/[id]/refresh/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/system/workspaces/[id]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/system/workspaces/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/upload/[...path]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/app/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/backlinks/route/app-paths-manifest.json +3 -0
- package/.next/standalone/.next/server/app/api/wiki/backlinks/route/build-manifest.json +9 -0
- package/.next/standalone/.next/server/app/api/wiki/backlinks/route/server-reference-manifest.json +4 -0
- package/.next/standalone/.next/server/app/api/wiki/backlinks/route.js +16 -0
- package/.next/standalone/.next/server/app/api/wiki/backlinks/route.js.map +5 -0
- package/.next/standalone/.next/server/app/api/wiki/backlinks/route.js.nft.json +1 -0
- package/.next/standalone/.next/server/app/api/wiki/backlinks/route_client-reference-manifest.js +3 -0
- package/.next/standalone/.next/server/app/api/wiki/content/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/download/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/file/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/folder/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/git-branches/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/git-checkout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/git-diff/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/git-file-info/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/git-history/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/git-pull/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/move/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/new-file/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/page/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/presence/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/search/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/slugs/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/upload/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/watch/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +3 -3
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/s/[token]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/signin/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app-paths-manifest.json +1 -0
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__07tq6o4._.js → [root-of-the-server]__06w0aet._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__11a3dhw._.js +3 -0
- package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_wiki_backlinks_route_actions_0a3zyu0.js +3 -0
- package/.next/standalone/.next/server/chunks/src_lib_search_0d-_j3u._.js +13 -5
- package/.next/standalone/.next/server/chunks/src_lib_search_0ymmp0c._.js +12 -4
- package/.next/standalone/.next/server/chunks/ssr/_0wmhtx3._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0yevh-d._.js +3 -3
- package/.next/standalone/.next/server/chunks/ssr/_0~a9dyk._.js +4 -4
- package/.next/standalone/.next/server/chunks/ssr/src_app_page_tsx_0ss2.w7._.js +2 -2
- package/.next/standalone/.next/server/functions-config-manifest.json +1 -0
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/middleware-manifest.json +1 -1
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/static/chunks/{0rmw2y5~zh8p4.js → 0._793qr3oswh.js} +4 -4
- package/.next/standalone/.next/static/chunks/063q-x.i1hea6.js +17 -0
- package/.next/standalone/.next/static/chunks/{132w2~-ghm05e.js → 08bdsfl84~3l..js} +3 -3
- package/.next/standalone/.next/static/chunks/0ijkjbr218a8c.css +5 -0
- package/.next/standalone/.next/static/chunks/{0vhw1us0pjfdy.js → 0wxju9pxda4c2.js} +1 -1
- package/.next/standalone/package.json +1 -1
- package/.next/standalone/src/app/api/wiki/backlinks/route.ts +29 -0
- package/.next/standalone/src/app/globals.css +24 -0
- package/.next/standalone/src/app/page.tsx +795 -478
- package/.next/standalone/src/components/ai-panel/activity-row.tsx +6 -1
- package/.next/standalone/src/components/editor/backlinks-panel.tsx +24 -67
- package/.next/standalone/src/components/editor/document-outline.tsx +13 -4
- package/.next/standalone/src/components/editor/editor.tsx +50 -11
- package/.next/standalone/src/lib/markdown/to-html.ts +81 -5
- package/.next/standalone/src/lib/search/indexer.ts +67 -0
- package/.next/standalone/src/stores/editor-store.ts +93 -5
- package/.next/standalone/src/stores/search-store.ts +27 -9
- package/.next/standalone/src/tests/proof/search-index.test.ts +37 -0
- package/.next/standalone/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/.next/standalone/.next/static/chunks/0.9~g4nvqt9if.css +0 -5
- package/.next/standalone/.next/static/chunks/0qiiie0s9~eqg.js +0 -17
- /package/.next/standalone/.next/static/{ucX5u3D8Ca9KdVyeRk_D_ → WEwVoG1heo2pirWLYZp2y}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{ucX5u3D8Ca9KdVyeRk_D_ → WEwVoG1heo2pirWLYZp2y}/_clientMiddlewareManifest.js +0 -0
- /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 (!
|
|
591
|
-
const path = openFile.path;
|
|
1253
|
+
if (!openPath) { setGitFileInfo(null); return; }
|
|
592
1254
|
let cancelled = false;
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
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
|
-
|
|
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">
|