wiki-viewer 1.8.2 → 1.8.4
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/build-manifest.json +5 -5
- package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +2 -2
- 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/build-manifest.json +2 -2
- 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/app-proxy/[...path]/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/wiki/app/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/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/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/signin/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/signin/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-inabx._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__00lep-u._.js +1 -1
- package/.next/standalone/.next/server/chunks/node_modules__pnpm_0aj5g-w._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{0~w0_katex_dist_katex_mjs_04mhawr._.js → 0~w0_katex_dist_katex_mjs_0hyln.b._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__09~x2v~._.js → [root-of-the-server]__0du58sw._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_0pqaawz._.js +19 -19
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0shlang._.js → node_modules__pnpm_0.kyqfq._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_04n0h6.._.js → node_modules__pnpm_0.~goq~._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_011vy_q._.js → node_modules__pnpm_00y7foi._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0yiz.-t._.js → node_modules__pnpm_03.1jli._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_093dp2q._.js → node_modules__pnpm_03z3na-._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_01o_5qt._.js → node_modules__pnpm_05~w-ok._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0b8qx0g._.js → node_modules__pnpm_09d5yyu._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0wmx9kq._.js → node_modules__pnpm_09eo8jh._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0nw5ou2._.js → node_modules__pnpm_0adm8h6._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0bbc---._.js → node_modules__pnpm_0arbjru._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0t9jb7d._.js → node_modules__pnpm_0b5fla7._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_124u4o2._.js → node_modules__pnpm_0beormu._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_13tajgx._.js → node_modules__pnpm_0c7u76a._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_09ej62_._.js → node_modules__pnpm_0dbr7c2._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0s0ur~d._.js → node_modules__pnpm_0d~ida~._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_09ljbdz._.js → node_modules__pnpm_0elpfsb._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0hlzy8f._.js → node_modules__pnpm_0fh.6ir._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_09~_ob2._.js → node_modules__pnpm_0fm6wcx._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0o.dn8o._.js → node_modules__pnpm_0iikbcy._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0hk6xys._.js → node_modules__pnpm_0mvih7b._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0a01ng7._.js → node_modules__pnpm_0qd2ovj._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0c.309.._.js → node_modules__pnpm_0s4yn43._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0sq3c2r._.js → node_modules__pnpm_0s8fvbn._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0xfx0sf._.js → node_modules__pnpm_0tq8zs6._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_11sut1u._.js → node_modules__pnpm_0vyv.lz._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0c8aunj._.js → node_modules__pnpm_0xa.ko1._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0.96tmo._.js → node_modules__pnpm_12.ymhl._.js} +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
- 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/{0vp-ahhagxptc.js → 06iw_iy5e90d4.js} +1 -1
- package/.next/standalone/.next/static/chunks/125_k3o0s4cid.js +129 -0
- package/.next/standalone/.next/static/chunks/167ph4xws_-61.css +2 -0
- package/.next/standalone/.next/static/chunks/{turbopack-12~z-mr7b9b50.js → turbopack-0lf4~b-ofzbm6.js} +1 -1
- package/.next/standalone/AGENTS.md +104 -0
- package/.next/standalone/bin/wiki-viewer.js +0 -0
- package/.next/standalone/node_modules/.pnpm/better-sqlite3@12.10.0/node_modules/better-sqlite3/build/Release/better_sqlite3.node +0 -0
- package/.next/standalone/package.json +1 -1
- package/.next/standalone/packages/wiki-viewer-mcp/dist/index.js +0 -0
- package/.next/standalone/src/app/api/wiki/app/route.ts +7 -4
- package/.next/standalone/src/app/page.tsx +582 -192
- package/.next/standalone/src/components/editor/backlinks-panel.tsx +152 -0
- package/.next/standalone/src/components/editor/document-outline.tsx +221 -0
- package/.next/standalone/src/components/editor/editor.tsx +145 -48
- package/.next/standalone/src/components/editor/node-app-viewer.tsx +65 -7
- package/.next/standalone/src/components/editor/slash-commands.tsx +216 -41
- package/.next/standalone/src/components/editor/source-viewer.tsx +29 -2
- package/.next/standalone/src/components/search/search-command-dialog.tsx +183 -46
- package/.next/standalone/src/lib/app-runner.ts +57 -16
- package/.next/standalone/src/stores/pin-store.ts +56 -0
- package/.next/standalone/src/stores/recent-store.ts +51 -0
- package/.next/standalone/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -16
- package/.next/standalone/.next/static/chunks/0c_~zevf2n0r4.css +0 -2
- package/.next/standalone/.next/static/chunks/16r--8~rpehcz.js +0 -129
- package/.next/standalone/src/components/wiki/markdown-preview.tsx +0 -120
- /package/.next/standalone/.next/static/{t_k0ztuf0V-iA-kgeez18 → o1H_4UveBVPqClSUldzEv}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{t_k0ztuf0V-iA-kgeez18 → o1H_4UveBVPqClSUldzEv}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{t_k0ztuf0V-iA-kgeez18 → o1H_4UveBVPqClSUldzEv}/_ssgManifest.js +0 -0
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
Check,
|
|
7
7
|
ChevronDown,
|
|
8
8
|
ChevronRight,
|
|
9
|
+
Copy,
|
|
9
10
|
Download,
|
|
10
11
|
File,
|
|
11
12
|
FilePlus,
|
|
@@ -15,20 +16,24 @@ import {
|
|
|
15
16
|
FolderPlus,
|
|
16
17
|
Globe,
|
|
17
18
|
Image as ImageIcon,
|
|
19
|
+
Link,
|
|
18
20
|
Loader2,
|
|
19
21
|
Maximize2,
|
|
22
|
+
MoreHorizontal,
|
|
20
23
|
PanelLeftClose,
|
|
21
24
|
PanelLeftOpen,
|
|
22
25
|
Pencil,
|
|
26
|
+
Pin,
|
|
23
27
|
Plus,
|
|
24
28
|
RefreshCw,
|
|
25
29
|
Settings,
|
|
30
|
+
Star,
|
|
26
31
|
Terminal,
|
|
27
32
|
Trash2,
|
|
28
33
|
Upload,
|
|
29
34
|
X,
|
|
30
35
|
} from "lucide-react";
|
|
31
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
36
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
32
37
|
|
|
33
38
|
import { ConfirmDialog } from "@/components/confirm-dialog";
|
|
34
39
|
import { CsvViewer } from "@/components/editor/csv-viewer";
|
|
@@ -54,15 +59,22 @@ import {
|
|
|
54
59
|
DropdownMenuTrigger,
|
|
55
60
|
} from "@/components/ui/dropdown-menu";
|
|
56
61
|
import { getActiveWorkspaceId, withWs, wsFetch } from "@/lib/workspace-client";
|
|
62
|
+
import { markdownToHtml } from "@/lib/markdown/to-html";
|
|
57
63
|
import { ThemeToggle } from "@/components/theme-toggle";
|
|
58
64
|
import { AuthSettingsSheet } from "@/components/auth-settings-sheet";
|
|
59
65
|
import { Button } from "@/components/ui/button";
|
|
60
66
|
import { Card } from "@/components/ui/card";
|
|
61
|
-
import { FrontmatterHeader } from "@/components/wiki/frontmatter-header";
|
|
62
|
-
import { MarkdownPreview } from "@/components/wiki/markdown-preview";
|
|
63
|
-
import { parseFrontmatter } from "@/lib/markdown/parse-frontmatter";
|
|
64
67
|
|
|
65
|
-
import { showError } from "@/lib/toast";
|
|
68
|
+
import { showError, showSuccess } from "@/lib/toast";
|
|
69
|
+
import {
|
|
70
|
+
ContextMenu,
|
|
71
|
+
ContextMenuContent,
|
|
72
|
+
ContextMenuItem,
|
|
73
|
+
ContextMenuSeparator,
|
|
74
|
+
ContextMenuTrigger,
|
|
75
|
+
} from "@/components/ui/context-menu";
|
|
76
|
+
import { useRecentStore } from "@/stores/recent-store";
|
|
77
|
+
import { usePinStore } from "@/stores/pin-store";
|
|
66
78
|
import { cn } from "@/lib/utils";
|
|
67
79
|
import { AIPanel } from "@/components/ai-panel/ai-panel";
|
|
68
80
|
import { SearchCommandDialog } from "@/components/search/search-command-dialog";
|
|
@@ -113,8 +125,14 @@ function viewerKindFor(
|
|
|
113
125
|
if (nodeType === "node-app") return "node-app";
|
|
114
126
|
if (nodeType === "app") return "app";
|
|
115
127
|
if (nodeType === "dir") return "fallback";
|
|
128
|
+
const base = filename.split("/").pop() ?? filename;
|
|
129
|
+
// Dotfile with no real extension (".env", ".gitignore", ".bashrc"):
|
|
130
|
+
// `".env".split(".").pop()` -> "env", which would match nothing below,
|
|
131
|
+
// so treat any leading-dot name as text and let the viewer sniff bytes.
|
|
132
|
+
if (base.startsWith(".") && base.indexOf(".", 1) === -1) return "source";
|
|
116
133
|
const fileExt = ext(filename);
|
|
117
|
-
|
|
134
|
+
// No extension at all ("Makefile", "LICENSE", "Dockerfile"): assume text.
|
|
135
|
+
if (!fileExt) return "source";
|
|
118
136
|
if (["md", "markdown"].includes(fileExt)) return "editor";
|
|
119
137
|
if (fileExt === "txt") return "text";
|
|
120
138
|
if (["csv", "tsv"].includes(fileExt)) return "csv";
|
|
@@ -145,7 +163,11 @@ function viewerKindFor(
|
|
|
145
163
|
].includes(fileExt)
|
|
146
164
|
)
|
|
147
165
|
return "source";
|
|
148
|
-
|
|
166
|
+
// Default: assume text and let SourceViewer sniff the bytes. If the file is
|
|
167
|
+
// actually binary, SourceViewer degrades to a download/reveal fallback.
|
|
168
|
+
// This avoids a brittle text-extension whitelist that always misses
|
|
169
|
+
// something (.env, .ini, .lock, .gradle, .properties, ...).
|
|
170
|
+
return "source";
|
|
149
171
|
}
|
|
150
172
|
|
|
151
173
|
const TEXT_EDITABLE_EXTS = new Set([
|
|
@@ -267,6 +289,44 @@ export default function Page() {
|
|
|
267
289
|
|
|
268
290
|
const editorCurrentPath = useEditorStore((s) => s.currentPath);
|
|
269
291
|
|
|
292
|
+
// Recent files
|
|
293
|
+
const recents = useRecentStore((s) => s.recents);
|
|
294
|
+
const [pinnedCollapsed, setPinnedCollapsed] = useState(false);
|
|
295
|
+
const [recentCollapsed, setRecentCollapsed] = useState(true);
|
|
296
|
+
|
|
297
|
+
// Pins
|
|
298
|
+
const pins = usePinStore((s) => s.pins);
|
|
299
|
+
|
|
300
|
+
// Sidebar scroll container ref (for keyboard nav)
|
|
301
|
+
const sidebarScrollRef = useRef<HTMLDivElement>(null);
|
|
302
|
+
|
|
303
|
+
// Agent presence: paths touched by agents in last 60s
|
|
304
|
+
const activity = useAIPanelStore((s) => s.activity);
|
|
305
|
+
const activePaths = useMemo(() => {
|
|
306
|
+
const cutoff = new Date(Date.now() - 60 * 1000).toISOString();
|
|
307
|
+
const paths = new Set<string>();
|
|
308
|
+
for (const ev of activity) {
|
|
309
|
+
// Only agent-authored events count as "agent presence".
|
|
310
|
+
if (ev.at >= cutoff && ev.path && ev.by?.startsWith("ai:"))
|
|
311
|
+
paths.add(ev.path);
|
|
312
|
+
}
|
|
313
|
+
return paths;
|
|
314
|
+
}, [activity]);
|
|
315
|
+
|
|
316
|
+
// Poll activity for presence indicator
|
|
317
|
+
useEffect(() => {
|
|
318
|
+
const load = () => { void useAIPanelStore.getState().loadActivity(); };
|
|
319
|
+
load();
|
|
320
|
+
const id = setInterval(load, 10_000);
|
|
321
|
+
return () => clearInterval(id);
|
|
322
|
+
}, []);
|
|
323
|
+
|
|
324
|
+
// Reload recents + pins when workspace changes
|
|
325
|
+
useEffect(() => {
|
|
326
|
+
useRecentStore.getState().loadForWorkspace(activeWorkspaceId);
|
|
327
|
+
usePinStore.getState().loadForWorkspace(activeWorkspaceId);
|
|
328
|
+
}, [activeWorkspaceId]);
|
|
329
|
+
|
|
270
330
|
// Path captured from the URL at first render, before any effect can clear it.
|
|
271
331
|
// Restore reads from this ref (never the live URL) so URL sync can't break it.
|
|
272
332
|
const initialUrlPathRef = useRef<string | null>(
|
|
@@ -407,7 +467,7 @@ export default function Page() {
|
|
|
407
467
|
const collectExpandedPaths = useCallback((nodes: TreeNode[]): string[] => {
|
|
408
468
|
const paths: string[] = [];
|
|
409
469
|
for (const n of nodes) {
|
|
410
|
-
if ((n.type === "dir" || n.type === "app") && n.expanded && n.children) {
|
|
470
|
+
if ((n.type === "dir" || n.type === "app" || n.type === "node-app") && n.expanded && n.children) {
|
|
411
471
|
paths.push(n.path);
|
|
412
472
|
paths.push(...collectExpandedPaths(n.children));
|
|
413
473
|
}
|
|
@@ -438,6 +498,15 @@ export default function Page() {
|
|
|
438
498
|
|
|
439
499
|
const refreshViewer = useCallback(async () => {
|
|
440
500
|
if (!openFile) return;
|
|
501
|
+
if (isMarkdown(openFile.name)) {
|
|
502
|
+
setFileLoading(true);
|
|
503
|
+
try {
|
|
504
|
+
await useEditorStore.getState().loadPage(openFile.path);
|
|
505
|
+
} finally {
|
|
506
|
+
setFileLoading(false);
|
|
507
|
+
}
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
441
510
|
const kind = viewerKindFor(openFile.name, openFile.nodeType);
|
|
442
511
|
if (!["editor", "text"].includes(kind) && !isText(openFile.name)) return;
|
|
443
512
|
setFileLoading(true);
|
|
@@ -456,6 +525,12 @@ export default function Page() {
|
|
|
456
525
|
setFileLoading(false);
|
|
457
526
|
}, [openFile]);
|
|
458
527
|
|
|
528
|
+
useEffect(() => {
|
|
529
|
+
if (!openFile || !isMarkdown(openFile.name)) return;
|
|
530
|
+
if (useEditorStore.getState().currentPath === openFile.path) return;
|
|
531
|
+
void useEditorStore.getState().loadPage(openFile.path);
|
|
532
|
+
}, [openFile]);
|
|
533
|
+
|
|
459
534
|
// Keep watcher refs in sync
|
|
460
535
|
useEffect(() => {
|
|
461
536
|
openFileRef.current = openFile;
|
|
@@ -529,7 +604,7 @@ export default function Page() {
|
|
|
529
604
|
}, [rootConfigured, reloadDir]);
|
|
530
605
|
|
|
531
606
|
async function toggleFolder(node: TreeNode) {
|
|
532
|
-
if (node.type !== "dir" && node.type !== "app") return;
|
|
607
|
+
if (node.type !== "dir" && node.type !== "app" && node.type !== "node-app") return;
|
|
533
608
|
if (!node.expanded) {
|
|
534
609
|
if (node.children === undefined) {
|
|
535
610
|
setRoots((prev) =>
|
|
@@ -561,6 +636,13 @@ export default function Page() {
|
|
|
561
636
|
}, []);
|
|
562
637
|
|
|
563
638
|
async function openViewer(node: TreeNode) {
|
|
639
|
+
// Push to recent files (only real files, not dirs/apps)
|
|
640
|
+
if (node.type === "file") {
|
|
641
|
+
useRecentStore.getState().push(
|
|
642
|
+
{ path: node.path, name: node.name },
|
|
643
|
+
activeWorkspaceId,
|
|
644
|
+
);
|
|
645
|
+
}
|
|
564
646
|
setOpenFile({
|
|
565
647
|
path: node.path,
|
|
566
648
|
name: node.name,
|
|
@@ -575,6 +657,15 @@ export default function Page() {
|
|
|
575
657
|
setSaveError(null);
|
|
576
658
|
setFileContent(null);
|
|
577
659
|
setFileRevision(0);
|
|
660
|
+
if (node.type === "file" && isMarkdown(node.name)) {
|
|
661
|
+
setFileLoading(true);
|
|
662
|
+
try {
|
|
663
|
+
await useEditorStore.getState().loadPage(node.path);
|
|
664
|
+
} finally {
|
|
665
|
+
setFileLoading(false);
|
|
666
|
+
}
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
578
669
|
const kind = viewerKindFor(node.name, node.type);
|
|
579
670
|
if (!["editor", "text"].includes(kind) && !isText(node.name)) return;
|
|
580
671
|
setFileLoading(true);
|
|
@@ -837,6 +928,120 @@ export default function Page() {
|
|
|
837
928
|
a.remove();
|
|
838
929
|
}
|
|
839
930
|
|
|
931
|
+
function copyPath(path: string) {
|
|
932
|
+
void navigator.clipboard.writeText(path);
|
|
933
|
+
showSuccess("Path copied");
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function copyWikiLink(name: string) {
|
|
937
|
+
const slug = name.replace(/\.(md|markdown)$/i, "");
|
|
938
|
+
void navigator.clipboard.writeText(`[[${slug}]]`);
|
|
939
|
+
showSuccess("Wiki link copied");
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function copyUrl(path: string) {
|
|
943
|
+
const url = new URL(location.href);
|
|
944
|
+
url.searchParams.set("path", path);
|
|
945
|
+
if (activeWorkspaceId) url.searchParams.set("ws", activeWorkspaceId);
|
|
946
|
+
void navigator.clipboard.writeText(url.toString());
|
|
947
|
+
showSuccess("URL copied");
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
async function getTextContent(path: string) {
|
|
951
|
+
if (openFile?.path === path && fileContent !== null) return fileContent;
|
|
952
|
+
if (useEditorStore.getState().currentPath === path) {
|
|
953
|
+
return useEditorStore.getState().content;
|
|
954
|
+
}
|
|
955
|
+
const res = await wsFetch(`/api/wiki/content?path=${encodeURIComponent(path)}`);
|
|
956
|
+
if (!res.ok) throw new Error("Cannot copy content");
|
|
957
|
+
const data: { content: string } = await res.json();
|
|
958
|
+
return data.content;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
async function copyRawContent(path: string) {
|
|
962
|
+
try {
|
|
963
|
+
const content = await getTextContent(path);
|
|
964
|
+
await navigator.clipboard.writeText(content);
|
|
965
|
+
showSuccess("Raw content copied");
|
|
966
|
+
} catch {
|
|
967
|
+
showError("Could not copy file content");
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
async function copyFormattedContent(path: string, name: string) {
|
|
972
|
+
try {
|
|
973
|
+
const content = await getTextContent(path);
|
|
974
|
+
if (!isMarkdown(name)) {
|
|
975
|
+
await navigator.clipboard.writeText(content);
|
|
976
|
+
showSuccess("Content copied");
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
const html = await markdownToHtml(content, { pagePath: path, sanitize: true });
|
|
980
|
+
if ("ClipboardItem" in window && navigator.clipboard.write) {
|
|
981
|
+
await navigator.clipboard.write([
|
|
982
|
+
new ClipboardItem({
|
|
983
|
+
"text/html": new Blob([html], { type: "text/html" }),
|
|
984
|
+
"text/plain": new Blob([content], { type: "text/plain" }),
|
|
985
|
+
}),
|
|
986
|
+
]);
|
|
987
|
+
} else {
|
|
988
|
+
await navigator.clipboard.writeText(content);
|
|
989
|
+
}
|
|
990
|
+
showSuccess("Formatted content copied");
|
|
991
|
+
} catch {
|
|
992
|
+
showError("Could not copy formatted content");
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function renderCopyMenu(node: { path: string; name: string }) {
|
|
997
|
+
return (
|
|
998
|
+
<DropdownMenu>
|
|
999
|
+
<DropdownMenuTrigger asChild>
|
|
1000
|
+
<Button
|
|
1001
|
+
size="sm"
|
|
1002
|
+
variant="ghost"
|
|
1003
|
+
className="h-7 gap-1.5 px-2 text-xs"
|
|
1004
|
+
title="Copy path, wiki link, or URL"
|
|
1005
|
+
>
|
|
1006
|
+
<Copy className="h-3.5 w-3.5" />
|
|
1007
|
+
Copy
|
|
1008
|
+
<ChevronDown className="h-3 w-3 opacity-60" />
|
|
1009
|
+
</Button>
|
|
1010
|
+
</DropdownMenuTrigger>
|
|
1011
|
+
<DropdownMenuContent align="end" className="w-44">
|
|
1012
|
+
<DropdownMenuItem onClick={() => copyPath(node.path)}>
|
|
1013
|
+
<Copy className="mr-2 h-3.5 w-3.5" />
|
|
1014
|
+
Copy path
|
|
1015
|
+
</DropdownMenuItem>
|
|
1016
|
+
{isMarkdown(node.name) && (
|
|
1017
|
+
<DropdownMenuItem onClick={() => copyWikiLink(node.name)}>
|
|
1018
|
+
<FileText className="mr-2 h-3.5 w-3.5" />
|
|
1019
|
+
Copy wiki link
|
|
1020
|
+
</DropdownMenuItem>
|
|
1021
|
+
)}
|
|
1022
|
+
<DropdownMenuSeparator />
|
|
1023
|
+
<DropdownMenuItem onClick={() => copyUrl(node.path)}>
|
|
1024
|
+
<Link className="mr-2 h-3.5 w-3.5" />
|
|
1025
|
+
Copy URL
|
|
1026
|
+
</DropdownMenuItem>
|
|
1027
|
+
{isText(node.name) && (
|
|
1028
|
+
<>
|
|
1029
|
+
<DropdownMenuSeparator />
|
|
1030
|
+
<DropdownMenuItem onClick={() => void copyRawContent(node.path)}>
|
|
1031
|
+
<FileText className="mr-2 h-3.5 w-3.5" />
|
|
1032
|
+
Copy raw content
|
|
1033
|
+
</DropdownMenuItem>
|
|
1034
|
+
<DropdownMenuItem onClick={() => void copyFormattedContent(node.path, node.name)}>
|
|
1035
|
+
<FileText className="mr-2 h-3.5 w-3.5" />
|
|
1036
|
+
Copy formatted content
|
|
1037
|
+
</DropdownMenuItem>
|
|
1038
|
+
</>
|
|
1039
|
+
)}
|
|
1040
|
+
</DropdownMenuContent>
|
|
1041
|
+
</DropdownMenu>
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
840
1045
|
async function handleDelete() {
|
|
841
1046
|
if (!deletingPath) return;
|
|
842
1047
|
await wsFetch("/api/wiki", {
|
|
@@ -931,149 +1136,241 @@ export default function Page() {
|
|
|
931
1136
|
const contentWidthClass = widthAwareViewer ? VIEW_WIDTH_CLASS[viewWidth] : "";
|
|
932
1137
|
|
|
933
1138
|
function renderNodes(nodes: TreeNode[], depth = 0): React.ReactNode {
|
|
934
|
-
return nodes.map((node) =>
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
variant="ghost"
|
|
1019
|
-
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
|
1020
|
-
title="New file here (default .md)"
|
|
1021
|
-
onClick={async () => {
|
|
1022
|
-
if (!node.expanded) await toggleFolder(node);
|
|
1023
|
-
setNewFileParent(node.path);
|
|
1024
|
-
setNewFileName("");
|
|
1025
|
-
setFileCreateError(null);
|
|
1026
|
-
}}
|
|
1027
|
-
>
|
|
1028
|
-
<FilePlus className="h-3 w-3" />
|
|
1029
|
-
</Button>
|
|
1030
|
-
<Button
|
|
1031
|
-
size="sm"
|
|
1032
|
-
variant="ghost"
|
|
1033
|
-
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
|
1034
|
-
title="Upload here"
|
|
1035
|
-
onClick={() => triggerUpload(node.path)}
|
|
1036
|
-
>
|
|
1037
|
-
<Upload className="h-3 w-3" />
|
|
1038
|
-
</Button>
|
|
1039
|
-
<Button
|
|
1040
|
-
size="sm"
|
|
1041
|
-
variant="ghost"
|
|
1042
|
-
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
|
1043
|
-
title="New subfolder"
|
|
1044
|
-
onClick={() => {
|
|
1045
|
-
setNewFolderParent(node.path);
|
|
1046
|
-
setNewFolderName("");
|
|
1047
|
-
setFolderError(null);
|
|
1048
|
-
}}
|
|
1049
|
-
>
|
|
1050
|
-
<FolderPlus className="h-3 w-3" />
|
|
1051
|
-
</Button>
|
|
1052
|
-
</>
|
|
1053
|
-
)}
|
|
1054
|
-
<Button
|
|
1055
|
-
size="sm"
|
|
1056
|
-
variant="ghost"
|
|
1057
|
-
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
|
1058
|
-
title={node.type === "file" ? "Download" : "Download as zip"}
|
|
1059
|
-
onClick={() => handleDownload(node)}
|
|
1060
|
-
>
|
|
1061
|
-
<Download className="h-3 w-3" />
|
|
1062
|
-
</Button>
|
|
1063
|
-
<Button
|
|
1064
|
-
size="sm"
|
|
1065
|
-
variant="ghost"
|
|
1066
|
-
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
|
1067
|
-
title="Delete"
|
|
1139
|
+
return nodes.map((node) => {
|
|
1140
|
+
const isNodePinned = pins.some((p) => p.path === node.path);
|
|
1141
|
+
return (
|
|
1142
|
+
<ContextMenu key={node.path}>
|
|
1143
|
+
<ContextMenuTrigger asChild>
|
|
1144
|
+
<div>
|
|
1145
|
+
<div
|
|
1146
|
+
role="treeitem"
|
|
1147
|
+
tabIndex={0}
|
|
1148
|
+
draggable
|
|
1149
|
+
onKeyDown={(e) => {
|
|
1150
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
1151
|
+
e.preventDefault();
|
|
1152
|
+
if (node.type === "dir") toggleFolder(node);
|
|
1153
|
+
else if (node.type === "app" || node.type === "node-app") { void openViewer(node); void toggleFolder(node); }
|
|
1154
|
+
else void openViewer(node);
|
|
1155
|
+
} else if (e.key === "ArrowDown") {
|
|
1156
|
+
e.preventDefault();
|
|
1157
|
+
const container = sidebarScrollRef.current;
|
|
1158
|
+
if (!container) return;
|
|
1159
|
+
const items = Array.from(container.querySelectorAll<HTMLElement>('[role="treeitem"]'));
|
|
1160
|
+
const idx = items.indexOf(e.currentTarget as HTMLElement);
|
|
1161
|
+
items[idx + 1]?.focus();
|
|
1162
|
+
} else if (e.key === "ArrowUp") {
|
|
1163
|
+
e.preventDefault();
|
|
1164
|
+
const container = sidebarScrollRef.current;
|
|
1165
|
+
if (!container) return;
|
|
1166
|
+
const items = Array.from(container.querySelectorAll<HTMLElement>('[role="treeitem"]'));
|
|
1167
|
+
const idx = items.indexOf(e.currentTarget as HTMLElement);
|
|
1168
|
+
items[idx - 1]?.focus();
|
|
1169
|
+
} else if (e.key === "ArrowRight") {
|
|
1170
|
+
e.preventDefault();
|
|
1171
|
+
if (node.type === "dir" || node.type === "app" || node.type === "node-app") {
|
|
1172
|
+
if (!node.expanded) {
|
|
1173
|
+
void toggleFolder(node);
|
|
1174
|
+
} else {
|
|
1175
|
+
// Focus first visible child
|
|
1176
|
+
const container = sidebarScrollRef.current;
|
|
1177
|
+
if (!container) return;
|
|
1178
|
+
const items = Array.from(container.querySelectorAll<HTMLElement>('[role="treeitem"]'));
|
|
1179
|
+
const idx = items.indexOf(e.currentTarget as HTMLElement);
|
|
1180
|
+
items[idx + 1]?.focus();
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
} else if (e.key === "ArrowLeft") {
|
|
1184
|
+
e.preventDefault();
|
|
1185
|
+
if ((node.type === "dir" || node.type === "app" || node.type === "node-app") && node.expanded) {
|
|
1186
|
+
void toggleFolder(node);
|
|
1187
|
+
} else if (depth > 0) {
|
|
1188
|
+
// Focus nearest ancestor (smaller paddingLeft)
|
|
1189
|
+
const container = sidebarScrollRef.current;
|
|
1190
|
+
if (!container) return;
|
|
1191
|
+
const items = Array.from(container.querySelectorAll<HTMLElement>('[role="treeitem"]'));
|
|
1192
|
+
const current = e.currentTarget as HTMLElement;
|
|
1193
|
+
const idx = items.indexOf(current);
|
|
1194
|
+
const currentPL = Number.parseInt(current.style.paddingLeft ?? "0", 10);
|
|
1195
|
+
for (let i = idx - 1; i >= 0; i--) {
|
|
1196
|
+
const pl = Number.parseInt(items[i].style.paddingLeft ?? "0", 10);
|
|
1197
|
+
if (pl < currentPL) { items[i].focus(); break; }
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
}}
|
|
1202
|
+
onDragStart={(e) => handleDragStart(e, node)}
|
|
1203
|
+
onDragOver={(e) =>
|
|
1204
|
+
node.type === "dir"
|
|
1205
|
+
? handleDragOver(e, node.path, "dir")
|
|
1206
|
+
: e.preventDefault()
|
|
1207
|
+
}
|
|
1208
|
+
onDragLeave={() => setDragOverPath(null)}
|
|
1209
|
+
onDrop={(e) =>
|
|
1210
|
+
node.type === "dir"
|
|
1211
|
+
? handleDropOnFolder(e, node.path)
|
|
1212
|
+
: e.preventDefault()
|
|
1213
|
+
}
|
|
1214
|
+
className={cn(
|
|
1215
|
+
"flex items-center gap-1.5 rounded-sm px-2 py-1 text-sm cursor-pointer group transition-colors select-none",
|
|
1216
|
+
openFile?.path === node.path
|
|
1217
|
+
? "bg-accent-soft text-foreground font-medium"
|
|
1218
|
+
: "hover:bg-muted",
|
|
1219
|
+
dragOverPath === node.path && "ring-2 ring-primary bg-primary-soft",
|
|
1220
|
+
node.name.startsWith(".") && "opacity-40",
|
|
1221
|
+
)}
|
|
1222
|
+
style={{ paddingLeft: `${depth * 14 + 8}px` }}
|
|
1068
1223
|
onClick={() => {
|
|
1069
|
-
|
|
1070
|
-
|
|
1224
|
+
if (node.type === "dir") toggleFolder(node);
|
|
1225
|
+
else if (node.type === "app" || node.type === "node-app") { void openViewer(node); void toggleFolder(node); }
|
|
1226
|
+
else void openViewer(node);
|
|
1071
1227
|
}}
|
|
1072
1228
|
>
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1229
|
+
{(node.type === "dir" || node.type === "app" || node.type === "node-app") ? (
|
|
1230
|
+
node.loading ? (
|
|
1231
|
+
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
|
|
1232
|
+
) : node.expanded ? (
|
|
1233
|
+
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
1234
|
+
) : (
|
|
1235
|
+
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
1236
|
+
)
|
|
1237
|
+
) : (
|
|
1238
|
+
<span className="w-3.5 shrink-0" />
|
|
1239
|
+
)}
|
|
1240
|
+
|
|
1241
|
+
{node.type === "dir" ? (
|
|
1242
|
+
node.expanded ? (
|
|
1243
|
+
<FolderOpen className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-warning")} />
|
|
1244
|
+
) : (
|
|
1245
|
+
<Folder className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-warning")} />
|
|
1246
|
+
)
|
|
1247
|
+
) : node.type === "app" ? (
|
|
1248
|
+
<Globe className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-foreground/70")} />
|
|
1249
|
+
) : node.type === "node-app" ? (
|
|
1250
|
+
<Terminal className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-emerald-500")} />
|
|
1251
|
+
) : isHtmlFile(node.name) ? (
|
|
1252
|
+
<Globe className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-foreground/60")} />
|
|
1253
|
+
) : isImage(node.name) ? (
|
|
1254
|
+
<ImageIcon className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-sunshine-700")} />
|
|
1255
|
+
) : isText(node.name) ? (
|
|
1256
|
+
<FileText className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-foreground/70")} />
|
|
1257
|
+
) : (
|
|
1258
|
+
<File className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-foreground/60")} />
|
|
1259
|
+
)}
|
|
1260
|
+
|
|
1261
|
+
<span className="min-w-0 flex-1 truncate">{node.name}</span>
|
|
1262
|
+
|
|
1263
|
+
{/* Agent presence dot */}
|
|
1264
|
+
{activePaths.has(node.path) && (
|
|
1265
|
+
<span
|
|
1266
|
+
className="ml-0.5 h-1.5 w-1.5 rounded-full bg-emerald-400 shrink-0 animate-pulse"
|
|
1267
|
+
title="Agent recently active"
|
|
1268
|
+
/>
|
|
1269
|
+
)}
|
|
1270
|
+
|
|
1271
|
+
<div
|
|
1272
|
+
className="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"
|
|
1273
|
+
onClick={(e) => e.stopPropagation()}
|
|
1274
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
1275
|
+
>
|
|
1276
|
+
<DropdownMenu>
|
|
1277
|
+
<DropdownMenuTrigger asChild>
|
|
1278
|
+
<Button
|
|
1279
|
+
size="sm"
|
|
1280
|
+
variant="ghost"
|
|
1281
|
+
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
|
1282
|
+
title="File actions"
|
|
1283
|
+
>
|
|
1284
|
+
<MoreHorizontal className="h-3.5 w-3.5" />
|
|
1285
|
+
</Button>
|
|
1286
|
+
</DropdownMenuTrigger>
|
|
1287
|
+
<DropdownMenuContent align="end" className="w-48">
|
|
1288
|
+
<DropdownMenuItem onClick={() => copyPath(node.path)}>
|
|
1289
|
+
<Copy className="mr-2 h-3.5 w-3.5" />
|
|
1290
|
+
Copy path
|
|
1291
|
+
</DropdownMenuItem>
|
|
1292
|
+
{isMarkdown(node.name) && (
|
|
1293
|
+
<DropdownMenuItem onClick={() => copyWikiLink(node.name)}>
|
|
1294
|
+
<FileText className="mr-2 h-3.5 w-3.5" />
|
|
1295
|
+
Copy wiki link
|
|
1296
|
+
</DropdownMenuItem>
|
|
1297
|
+
)}
|
|
1298
|
+
<DropdownMenuItem onClick={() => copyUrl(node.path)}>
|
|
1299
|
+
<Link className="mr-2 h-3.5 w-3.5" />
|
|
1300
|
+
Copy URL
|
|
1301
|
+
</DropdownMenuItem>
|
|
1302
|
+
{node.type === "file" && isText(node.name) && (
|
|
1303
|
+
<>
|
|
1304
|
+
<DropdownMenuItem onClick={() => void copyRawContent(node.path)}>
|
|
1305
|
+
<FileText className="mr-2 h-3.5 w-3.5" />
|
|
1306
|
+
Copy raw content
|
|
1307
|
+
</DropdownMenuItem>
|
|
1308
|
+
<DropdownMenuItem onClick={() => void copyFormattedContent(node.path, node.name)}>
|
|
1309
|
+
<FileText className="mr-2 h-3.5 w-3.5" />
|
|
1310
|
+
Copy formatted content
|
|
1311
|
+
</DropdownMenuItem>
|
|
1312
|
+
</>
|
|
1313
|
+
)}
|
|
1314
|
+
<DropdownMenuSeparator />
|
|
1315
|
+
{node.type === "dir" && (
|
|
1316
|
+
<>
|
|
1317
|
+
<DropdownMenuItem
|
|
1318
|
+
onClick={async () => {
|
|
1319
|
+
if (!node.expanded) await toggleFolder(node);
|
|
1320
|
+
setNewFileParent(node.path);
|
|
1321
|
+
setNewFileName("");
|
|
1322
|
+
setFileCreateError(null);
|
|
1323
|
+
}}
|
|
1324
|
+
>
|
|
1325
|
+
<FilePlus className="mr-2 h-3.5 w-3.5" />
|
|
1326
|
+
New file here
|
|
1327
|
+
</DropdownMenuItem>
|
|
1328
|
+
<DropdownMenuItem onClick={() => triggerUpload(node.path)}>
|
|
1329
|
+
<Upload className="mr-2 h-3.5 w-3.5" />
|
|
1330
|
+
Upload here
|
|
1331
|
+
</DropdownMenuItem>
|
|
1332
|
+
<DropdownMenuItem
|
|
1333
|
+
onClick={() => {
|
|
1334
|
+
setNewFolderParent(node.path);
|
|
1335
|
+
setNewFolderName("");
|
|
1336
|
+
setFolderError(null);
|
|
1337
|
+
}}
|
|
1338
|
+
>
|
|
1339
|
+
<FolderPlus className="mr-2 h-3.5 w-3.5" />
|
|
1340
|
+
New subfolder
|
|
1341
|
+
</DropdownMenuItem>
|
|
1342
|
+
<DropdownMenuSeparator />
|
|
1343
|
+
</>
|
|
1344
|
+
)}
|
|
1345
|
+
<DropdownMenuItem onClick={() => handleDownload(node)}>
|
|
1346
|
+
<Download className="mr-2 h-3.5 w-3.5" />
|
|
1347
|
+
{node.type === "file" ? "Download" : "Download as zip"}
|
|
1348
|
+
</DropdownMenuItem>
|
|
1349
|
+
<DropdownMenuItem
|
|
1350
|
+
onClick={() =>
|
|
1351
|
+
usePinStore
|
|
1352
|
+
.getState()
|
|
1353
|
+
.toggle({ path: node.path, name: node.name, type: (node.type === "dir" ? "file" : node.type) as "file" | "app" | "node-app" }, activeWorkspaceId)
|
|
1354
|
+
}
|
|
1355
|
+
>
|
|
1356
|
+
<Star className={cn("mr-2 h-3.5 w-3.5", isNodePinned && "fill-current text-amber-400")} />
|
|
1357
|
+
{isNodePinned ? "Unpin" : "Pin to top"}
|
|
1358
|
+
</DropdownMenuItem>
|
|
1359
|
+
<DropdownMenuSeparator />
|
|
1360
|
+
<DropdownMenuItem
|
|
1361
|
+
className="text-destructive focus:text-destructive"
|
|
1362
|
+
onClick={() => {
|
|
1363
|
+
setDeletingPath(node.path);
|
|
1364
|
+
setDeletingIsDir(node.type !== "file");
|
|
1365
|
+
}}
|
|
1366
|
+
>
|
|
1367
|
+
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
|
1368
|
+
Delete
|
|
1369
|
+
</DropdownMenuItem>
|
|
1370
|
+
</DropdownMenuContent>
|
|
1371
|
+
</DropdownMenu>
|
|
1372
|
+
</div>
|
|
1373
|
+
</div>
|
|
1077
1374
|
|
|
1078
1375
|
{newFolderParent === node.path && node.type === "dir" && (
|
|
1079
1376
|
<div
|
|
@@ -1166,12 +1463,12 @@ export default function Page() {
|
|
|
1166
1463
|
</div>
|
|
1167
1464
|
)}
|
|
1168
1465
|
|
|
1169
|
-
{(node.type === "dir" || node.type === "app") &&
|
|
1466
|
+
{(node.type === "dir" || node.type === "app" || node.type === "node-app") &&
|
|
1170
1467
|
node.expanded &&
|
|
1171
1468
|
node.children &&
|
|
1172
1469
|
node.children.length > 0 &&
|
|
1173
1470
|
renderNodes(node.children, depth + 1)}
|
|
1174
|
-
{(node.type === "dir" || node.type === "app") &&
|
|
1471
|
+
{(node.type === "dir" || node.type === "app" || node.type === "node-app") &&
|
|
1175
1472
|
node.expanded &&
|
|
1176
1473
|
node.children?.length === 0 && (
|
|
1177
1474
|
<div
|
|
@@ -1183,8 +1480,36 @@ export default function Page() {
|
|
|
1183
1480
|
Empty
|
|
1184
1481
|
</div>
|
|
1185
1482
|
)}
|
|
1186
|
-
|
|
1187
|
-
|
|
1483
|
+
</div>
|
|
1484
|
+
</ContextMenuTrigger>
|
|
1485
|
+
<ContextMenuContent>
|
|
1486
|
+
<ContextMenuItem onSelect={() => copyPath(node.path)}>
|
|
1487
|
+
Copy path
|
|
1488
|
+
</ContextMenuItem>
|
|
1489
|
+
{isMarkdown(node.name) && (
|
|
1490
|
+
<ContextMenuItem onSelect={() => copyWikiLink(node.name)}>
|
|
1491
|
+
Copy wiki link
|
|
1492
|
+
</ContextMenuItem>
|
|
1493
|
+
)}
|
|
1494
|
+
<ContextMenuSeparator />
|
|
1495
|
+
<ContextMenuItem onSelect={() => copyUrl(node.path)}>
|
|
1496
|
+
Copy URL
|
|
1497
|
+
</ContextMenuItem>
|
|
1498
|
+
{node.type === "file" && isText(node.name) && (
|
|
1499
|
+
<>
|
|
1500
|
+
<ContextMenuSeparator />
|
|
1501
|
+
<ContextMenuItem onSelect={() => void copyRawContent(node.path)}>
|
|
1502
|
+
Copy raw content
|
|
1503
|
+
</ContextMenuItem>
|
|
1504
|
+
<ContextMenuItem onSelect={() => void copyFormattedContent(node.path, node.name)}>
|
|
1505
|
+
Copy formatted content
|
|
1506
|
+
</ContextMenuItem>
|
|
1507
|
+
</>
|
|
1508
|
+
)}
|
|
1509
|
+
</ContextMenuContent>
|
|
1510
|
+
</ContextMenu>
|
|
1511
|
+
);
|
|
1512
|
+
});
|
|
1188
1513
|
}
|
|
1189
1514
|
|
|
1190
1515
|
return (
|
|
@@ -1222,7 +1547,14 @@ export default function Page() {
|
|
|
1222
1547
|
</div>
|
|
1223
1548
|
)}
|
|
1224
1549
|
{rootConfigured === true && !addingWorkspace && <>
|
|
1225
|
-
<SearchCommandDialog
|
|
1550
|
+
<SearchCommandDialog
|
|
1551
|
+
onOpenFile={openFromSearch}
|
|
1552
|
+
onToggleSidebar={() => setSidebarCollapsed((v) => !v)}
|
|
1553
|
+
onNewFile={() => setNewFileParent("")}
|
|
1554
|
+
onCopyPath={() => {
|
|
1555
|
+
if (openFile) void navigator.clipboard.writeText(openFile.path);
|
|
1556
|
+
}}
|
|
1557
|
+
/>
|
|
1226
1558
|
{/* Tree sidebar */}
|
|
1227
1559
|
{!sidebarCollapsed && (
|
|
1228
1560
|
<Card className="flex flex-col w-72 shrink-0 overflow-hidden rounded-none border-r border-l-0 border-t-0 border-b-0">
|
|
@@ -1427,6 +1759,7 @@ export default function Page() {
|
|
|
1427
1759
|
</div>
|
|
1428
1760
|
|
|
1429
1761
|
<div
|
|
1762
|
+
ref={sidebarScrollRef}
|
|
1430
1763
|
className={cn(
|
|
1431
1764
|
"flex-1 overflow-auto py-1",
|
|
1432
1765
|
dragOverPath === "" &&
|
|
@@ -1439,6 +1772,82 @@ export default function Page() {
|
|
|
1439
1772
|
}}
|
|
1440
1773
|
onDrop={(e) => handleDropOnFolder(e, "")}
|
|
1441
1774
|
>
|
|
1775
|
+
{/* Pinned section */}
|
|
1776
|
+
{pins.length > 0 && (
|
|
1777
|
+
<div className="border-b mb-1">
|
|
1778
|
+
<button
|
|
1779
|
+
type="button"
|
|
1780
|
+
className="flex w-full items-center gap-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
|
|
1781
|
+
onClick={() => setPinnedCollapsed((c) => !c)}
|
|
1782
|
+
>
|
|
1783
|
+
{pinnedCollapsed ? <ChevronRight className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
|
1784
|
+
<Pin className="h-3 w-3" />
|
|
1785
|
+
Pinned
|
|
1786
|
+
<span className="ml-auto text-[9px] tabular-nums opacity-60">{pins.length}</span>
|
|
1787
|
+
</button>
|
|
1788
|
+
{!pinnedCollapsed && pins.map((p) => (
|
|
1789
|
+
<div
|
|
1790
|
+
key={p.path}
|
|
1791
|
+
role="button"
|
|
1792
|
+
tabIndex={0}
|
|
1793
|
+
className={cn(
|
|
1794
|
+
"group flex items-center gap-1.5 rounded-sm px-2 py-1 text-sm cursor-pointer transition-colors select-none",
|
|
1795
|
+
openFile?.path === p.path ? "bg-accent-soft text-foreground font-medium" : "hover:bg-muted",
|
|
1796
|
+
)}
|
|
1797
|
+
onClick={() => void openViewer({ path: p.path, name: p.name, type: (p.type ?? "file") as TreeNode["type"], modifiedAt: "" } as TreeNode)}
|
|
1798
|
+
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); void openViewer({ path: p.path, name: p.name, type: (p.type ?? "file") as TreeNode["type"], modifiedAt: "" } as TreeNode); } }}
|
|
1799
|
+
>
|
|
1800
|
+
<Star className="h-3 w-3 shrink-0 fill-current text-amber-400" />
|
|
1801
|
+
<span className="min-w-0 flex-1 truncate text-xs">{p.name}</span>
|
|
1802
|
+
<span className="max-w-[80px] truncate text-[10px] text-muted-foreground/60">{p.path.split("/").slice(0, -1).join("/")}</span>
|
|
1803
|
+
<button
|
|
1804
|
+
type="button"
|
|
1805
|
+
className="shrink-0 rounded p-0.5 text-muted-foreground/50 opacity-0 transition-colors hover:bg-muted hover:text-amber-400 group-hover:opacity-100 focus:opacity-100"
|
|
1806
|
+
title="Remove from pinned"
|
|
1807
|
+
onClick={(e) => {
|
|
1808
|
+
e.stopPropagation();
|
|
1809
|
+
usePinStore
|
|
1810
|
+
.getState()
|
|
1811
|
+
.toggle({ path: p.path, name: p.name }, activeWorkspaceId);
|
|
1812
|
+
}}
|
|
1813
|
+
>
|
|
1814
|
+
<X className="h-3 w-3" />
|
|
1815
|
+
</button>
|
|
1816
|
+
</div>
|
|
1817
|
+
))}
|
|
1818
|
+
</div>
|
|
1819
|
+
)}
|
|
1820
|
+
{/* Recent files section */}
|
|
1821
|
+
{recents.length > 0 && (
|
|
1822
|
+
<div className="border-b mb-1">
|
|
1823
|
+
<button
|
|
1824
|
+
type="button"
|
|
1825
|
+
className="flex w-full items-center gap-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
|
|
1826
|
+
onClick={() => setRecentCollapsed((c) => !c)}
|
|
1827
|
+
>
|
|
1828
|
+
{recentCollapsed ? <ChevronRight className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
|
1829
|
+
Recent
|
|
1830
|
+
<span className="ml-auto text-[9px] tabular-nums opacity-60">{recents.length}</span>
|
|
1831
|
+
</button>
|
|
1832
|
+
{!recentCollapsed && recents.slice(0, 8).map((r) => (
|
|
1833
|
+
<div
|
|
1834
|
+
key={r.path}
|
|
1835
|
+
role="button"
|
|
1836
|
+
tabIndex={0}
|
|
1837
|
+
className={cn(
|
|
1838
|
+
"flex items-center gap-1.5 rounded-sm px-2 py-1 text-sm cursor-pointer transition-colors select-none",
|
|
1839
|
+
openFile?.path === r.path ? "bg-accent-soft text-foreground font-medium" : "hover:bg-muted",
|
|
1840
|
+
)}
|
|
1841
|
+
onClick={() => void openViewer({ path: r.path, name: r.name, type: (r.type ?? "file") as TreeNode["type"], modifiedAt: "" } as TreeNode)}
|
|
1842
|
+
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); void openViewer({ path: r.path, name: r.name, type: (r.type ?? "file") as TreeNode["type"], modifiedAt: "" } as TreeNode); } }}
|
|
1843
|
+
>
|
|
1844
|
+
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
1845
|
+
<span className="flex-1 truncate text-xs">{r.name}</span>
|
|
1846
|
+
<span className="text-[10px] text-muted-foreground/60 truncate max-w-[80px]">{r.path.split("/").slice(0, -1).join("/")}</span>
|
|
1847
|
+
</div>
|
|
1848
|
+
))}
|
|
1849
|
+
</div>
|
|
1850
|
+
)}
|
|
1442
1851
|
{rootLoading ? (
|
|
1443
1852
|
<div className="flex justify-center py-6">
|
|
1444
1853
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
@@ -1582,6 +1991,7 @@ export default function Page() {
|
|
|
1582
1991
|
</span>
|
|
1583
1992
|
</div>
|
|
1584
1993
|
<div className="flex items-center gap-1 shrink-0">
|
|
1994
|
+
{renderCopyMenu(openFile)}
|
|
1585
1995
|
{openFileViewerKind === "html" &&
|
|
1586
1996
|
!editing &&
|
|
1587
1997
|
fileContent !== null && (
|
|
@@ -1697,16 +2107,17 @@ export default function Page() {
|
|
|
1697
2107
|
</span>
|
|
1698
2108
|
</div>
|
|
1699
2109
|
<div className="flex items-center gap-1 shrink-0">
|
|
2110
|
+
{renderCopyMenu(openFile)}
|
|
1700
2111
|
{isText(openFile.name) &&
|
|
1701
2112
|
!editing &&
|
|
1702
|
-
fileContent !== null && (
|
|
2113
|
+
(fileContent !== null || isMarkdown(openFile.name)) && (
|
|
1703
2114
|
<Button
|
|
1704
2115
|
size="sm"
|
|
1705
2116
|
variant="ghost"
|
|
1706
2117
|
className="h-7 w-7 p-0"
|
|
1707
2118
|
onClick={() => {
|
|
1708
2119
|
setEditing(true);
|
|
1709
|
-
setEditContent(fileContent);
|
|
2120
|
+
setEditContent(fileContent ?? "");
|
|
1710
2121
|
setSaveError(null);
|
|
1711
2122
|
if (isMarkdown(openFile.name)) {
|
|
1712
2123
|
void useEditorStore
|
|
@@ -1720,7 +2131,7 @@ export default function Page() {
|
|
|
1720
2131
|
)}
|
|
1721
2132
|
{isText(openFile.name) &&
|
|
1722
2133
|
!editing &&
|
|
1723
|
-
fileContent !== null && (
|
|
2134
|
+
(fileContent !== null || isMarkdown(openFile.name)) && (
|
|
1724
2135
|
<Button
|
|
1725
2136
|
size="sm"
|
|
1726
2137
|
variant="ghost"
|
|
@@ -1765,6 +2176,16 @@ export default function Page() {
|
|
|
1765
2176
|
/>
|
|
1766
2177
|
)}
|
|
1767
2178
|
</div>
|
|
2179
|
+
) : isMarkdown(openFile.name) ? (
|
|
2180
|
+
<div className="flex-1 flex flex-col overflow-hidden min-h-0">
|
|
2181
|
+
{fileLoading ? (
|
|
2182
|
+
<div className="flex justify-center py-8">
|
|
2183
|
+
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
2184
|
+
</div>
|
|
2185
|
+
) : (
|
|
2186
|
+
<KBEditor mode="viewing" />
|
|
2187
|
+
)}
|
|
2188
|
+
</div>
|
|
1768
2189
|
) : (
|
|
1769
2190
|
<div className="flex-1 overflow-auto p-4 min-h-0">
|
|
1770
2191
|
<div className={cn("mx-auto w-full", contentWidthClass)}>
|
|
@@ -1817,40 +2238,9 @@ export default function Page() {
|
|
|
1817
2238
|
title={openFile.name}
|
|
1818
2239
|
/>
|
|
1819
2240
|
) : fileContent !== null ? (
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
return (
|
|
1824
|
-
<>
|
|
1825
|
-
<FrontmatterHeader
|
|
1826
|
-
data={data as Record<string, never>}
|
|
1827
|
-
/>
|
|
1828
|
-
<MarkdownPreview
|
|
1829
|
-
markdown={body}
|
|
1830
|
-
pagePath={openFile.path}
|
|
1831
|
-
onNavigate={(targetPath, anchor) => {
|
|
1832
|
-
void openViewer({
|
|
1833
|
-
path: targetPath,
|
|
1834
|
-
name: targetPath.split('/').pop() ?? targetPath,
|
|
1835
|
-
type: 'file',
|
|
1836
|
-
} as TreeNode);
|
|
1837
|
-
if (anchor) {
|
|
1838
|
-
setTimeout(() => {
|
|
1839
|
-
document
|
|
1840
|
-
.getElementById(anchor)
|
|
1841
|
-
?.scrollIntoView({ behavior: 'smooth' });
|
|
1842
|
-
}, 200);
|
|
1843
|
-
}
|
|
1844
|
-
}}
|
|
1845
|
-
/>
|
|
1846
|
-
</>
|
|
1847
|
-
);
|
|
1848
|
-
})()
|
|
1849
|
-
) : (
|
|
1850
|
-
<pre className="text-xs font-mono whitespace-pre-wrap break-words leading-relaxed">
|
|
1851
|
-
{fileContent}
|
|
1852
|
-
</pre>
|
|
1853
|
-
)
|
|
2241
|
+
<pre className="text-xs font-mono whitespace-pre-wrap break-words leading-relaxed">
|
|
2242
|
+
{fileContent}
|
|
2243
|
+
</pre>
|
|
1854
2244
|
) : isText(openFile.name) ? (
|
|
1855
2245
|
<p className="text-sm text-muted-foreground">
|
|
1856
2246
|
Could not load file.
|
|
@@ -1864,7 +2254,7 @@ export default function Page() {
|
|
|
1864
2254
|
</div>
|
|
1865
2255
|
)}
|
|
1866
2256
|
|
|
1867
|
-
{editing && (
|
|
2257
|
+
{editing && !isMarkdown(openFile.name) && (
|
|
1868
2258
|
<div className="border-t px-4 py-2 flex items-center justify-end gap-2 bg-muted shrink-0">
|
|
1869
2259
|
{saveError && (
|
|
1870
2260
|
<span className="text-xs text-destructive mr-auto">
|