wiki-viewer 1.8.1 → 1.8.3
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/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/0d~o0h0ssgra7.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/package.json +1 -1
- package/.next/standalone/packages/wiki-viewer-mcp/package-lock.json +2 -2
- package/.next/standalone/packages/wiki-viewer-mcp/package.json +1 -1
- package/.next/standalone/src/app/api/wiki/app/route.ts +7 -4
- package/.next/standalone/src/app/page.tsx +631 -193
- 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 +55 -0
- package/.next/standalone/src/stores/recent-store.ts +50 -0
- package/.next/standalone/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/.next/standalone/.next/static/chunks/0c_~zevf2n0r4.css +0 -2
- package/.next/standalone/.next/static/chunks/0l4842ez-0_66.js +0 -129
- package/.next/standalone/src/components/wiki/markdown-preview.tsx +0 -120
- /package/.next/standalone/.next/static/{17VWCQ9_AhwJBT4yjkvWG → Pkj31aCfRVk0Zy2rKA1wf}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{17VWCQ9_AhwJBT4yjkvWG → Pkj31aCfRVk0Zy2rKA1wf}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{17VWCQ9_AhwJBT4yjkvWG → Pkj31aCfRVk0Zy2rKA1wf}/_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([
|
|
@@ -230,6 +252,7 @@ export default function Page() {
|
|
|
230
252
|
const [workspaces, setWorkspaces] = useState<Array<{id:string;name:string;rootDir:string;lastOpenedAt?:string;createdAt:string}>>([]);
|
|
231
253
|
const [isWsAdmin, setIsWsAdmin] = useState(false);
|
|
232
254
|
const [addingWorkspace, setAddingWorkspace] = useState(false);
|
|
255
|
+
const [deletingWorkspaceId, setDeletingWorkspaceId] = useState<string | null>(null);
|
|
233
256
|
|
|
234
257
|
const loadWorkspaces = useCallback(async () => {
|
|
235
258
|
try {
|
|
@@ -266,6 +289,44 @@ export default function Page() {
|
|
|
266
289
|
|
|
267
290
|
const editorCurrentPath = useEditorStore((s) => s.currentPath);
|
|
268
291
|
|
|
292
|
+
// Recent files
|
|
293
|
+
const recents = useRecentStore((s) => s.recents);
|
|
294
|
+
const [pinnedCollapsed, setPinnedCollapsed] = useState(false);
|
|
295
|
+
const [recentCollapsed, setRecentCollapsed] = useState(false);
|
|
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
|
+
|
|
269
330
|
// Path captured from the URL at first render, before any effect can clear it.
|
|
270
331
|
// Restore reads from this ref (never the live URL) so URL sync can't break it.
|
|
271
332
|
const initialUrlPathRef = useRef<string | null>(
|
|
@@ -406,7 +467,7 @@ export default function Page() {
|
|
|
406
467
|
const collectExpandedPaths = useCallback((nodes: TreeNode[]): string[] => {
|
|
407
468
|
const paths: string[] = [];
|
|
408
469
|
for (const n of nodes) {
|
|
409
|
-
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) {
|
|
410
471
|
paths.push(n.path);
|
|
411
472
|
paths.push(...collectExpandedPaths(n.children));
|
|
412
473
|
}
|
|
@@ -437,6 +498,15 @@ export default function Page() {
|
|
|
437
498
|
|
|
438
499
|
const refreshViewer = useCallback(async () => {
|
|
439
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
|
+
}
|
|
440
510
|
const kind = viewerKindFor(openFile.name, openFile.nodeType);
|
|
441
511
|
if (!["editor", "text"].includes(kind) && !isText(openFile.name)) return;
|
|
442
512
|
setFileLoading(true);
|
|
@@ -455,6 +525,12 @@ export default function Page() {
|
|
|
455
525
|
setFileLoading(false);
|
|
456
526
|
}, [openFile]);
|
|
457
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
|
+
|
|
458
534
|
// Keep watcher refs in sync
|
|
459
535
|
useEffect(() => {
|
|
460
536
|
openFileRef.current = openFile;
|
|
@@ -528,7 +604,7 @@ export default function Page() {
|
|
|
528
604
|
}, [rootConfigured, reloadDir]);
|
|
529
605
|
|
|
530
606
|
async function toggleFolder(node: TreeNode) {
|
|
531
|
-
if (node.type !== "dir" && node.type !== "app") return;
|
|
607
|
+
if (node.type !== "dir" && node.type !== "app" && node.type !== "node-app") return;
|
|
532
608
|
if (!node.expanded) {
|
|
533
609
|
if (node.children === undefined) {
|
|
534
610
|
setRoots((prev) =>
|
|
@@ -560,6 +636,13 @@ export default function Page() {
|
|
|
560
636
|
}, []);
|
|
561
637
|
|
|
562
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
|
+
}
|
|
563
646
|
setOpenFile({
|
|
564
647
|
path: node.path,
|
|
565
648
|
name: node.name,
|
|
@@ -574,6 +657,15 @@ export default function Page() {
|
|
|
574
657
|
setSaveError(null);
|
|
575
658
|
setFileContent(null);
|
|
576
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
|
+
}
|
|
577
669
|
const kind = viewerKindFor(node.name, node.type);
|
|
578
670
|
if (!["editor", "text"].includes(kind) && !isText(node.name)) return;
|
|
579
671
|
setFileLoading(true);
|
|
@@ -678,6 +770,33 @@ export default function Page() {
|
|
|
678
770
|
setActiveWorkspaceId(id);
|
|
679
771
|
}, [activeWorkspaceId, workspaces]);
|
|
680
772
|
|
|
773
|
+
const handleDeleteWorkspace = useCallback(async () => {
|
|
774
|
+
if (!deletingWorkspaceId) return;
|
|
775
|
+
try {
|
|
776
|
+
const res = await fetch(`/api/system/workspaces/${deletingWorkspaceId}`, {
|
|
777
|
+
method: "DELETE",
|
|
778
|
+
});
|
|
779
|
+
if (!res.ok) throw new Error("Failed");
|
|
780
|
+
// If deleting the active workspace, switch to first available or picker
|
|
781
|
+
if (deletingWorkspaceId === activeWorkspaceId) {
|
|
782
|
+
const remaining = workspaces.filter((w) => w.id !== deletingWorkspaceId);
|
|
783
|
+
if (remaining.length > 0) {
|
|
784
|
+
const next = [...remaining].sort(
|
|
785
|
+
(a, b) => (b.lastOpenedAt ?? b.createdAt).localeCompare(a.lastOpenedAt ?? a.createdAt)
|
|
786
|
+
)[0];
|
|
787
|
+
await switchWorkspace(next.id);
|
|
788
|
+
} else {
|
|
789
|
+
setRootConfigured(false);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
await loadWorkspaces();
|
|
793
|
+
} catch {
|
|
794
|
+
/* ignore */
|
|
795
|
+
} finally {
|
|
796
|
+
setDeletingWorkspaceId(null);
|
|
797
|
+
}
|
|
798
|
+
}, [deletingWorkspaceId, activeWorkspaceId, workspaces, switchWorkspace, loadWorkspaces]);
|
|
799
|
+
|
|
681
800
|
async function handleSave() {
|
|
682
801
|
if (!openFile) return;
|
|
683
802
|
setSaving(true);
|
|
@@ -809,6 +928,120 @@ export default function Page() {
|
|
|
809
928
|
a.remove();
|
|
810
929
|
}
|
|
811
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
|
+
|
|
812
1045
|
async function handleDelete() {
|
|
813
1046
|
if (!deletingPath) return;
|
|
814
1047
|
await wsFetch("/api/wiki", {
|
|
@@ -903,149 +1136,241 @@ export default function Page() {
|
|
|
903
1136
|
const contentWidthClass = widthAwareViewer ? VIEW_WIDTH_CLASS[viewWidth] : "";
|
|
904
1137
|
|
|
905
1138
|
function renderNodes(nodes: TreeNode[], depth = 0): React.ReactNode {
|
|
906
|
-
return nodes.map((node) =>
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
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
|
-
variant="ghost"
|
|
991
|
-
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
|
992
|
-
title="New file here (default .md)"
|
|
993
|
-
onClick={async () => {
|
|
994
|
-
if (!node.expanded) await toggleFolder(node);
|
|
995
|
-
setNewFileParent(node.path);
|
|
996
|
-
setNewFileName("");
|
|
997
|
-
setFileCreateError(null);
|
|
998
|
-
}}
|
|
999
|
-
>
|
|
1000
|
-
<FilePlus className="h-3 w-3" />
|
|
1001
|
-
</Button>
|
|
1002
|
-
<Button
|
|
1003
|
-
size="sm"
|
|
1004
|
-
variant="ghost"
|
|
1005
|
-
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
|
1006
|
-
title="Upload here"
|
|
1007
|
-
onClick={() => triggerUpload(node.path)}
|
|
1008
|
-
>
|
|
1009
|
-
<Upload className="h-3 w-3" />
|
|
1010
|
-
</Button>
|
|
1011
|
-
<Button
|
|
1012
|
-
size="sm"
|
|
1013
|
-
variant="ghost"
|
|
1014
|
-
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
|
1015
|
-
title="New subfolder"
|
|
1016
|
-
onClick={() => {
|
|
1017
|
-
setNewFolderParent(node.path);
|
|
1018
|
-
setNewFolderName("");
|
|
1019
|
-
setFolderError(null);
|
|
1020
|
-
}}
|
|
1021
|
-
>
|
|
1022
|
-
<FolderPlus className="h-3 w-3" />
|
|
1023
|
-
</Button>
|
|
1024
|
-
</>
|
|
1025
|
-
)}
|
|
1026
|
-
<Button
|
|
1027
|
-
size="sm"
|
|
1028
|
-
variant="ghost"
|
|
1029
|
-
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
|
1030
|
-
title={node.type === "file" ? "Download" : "Download as zip"}
|
|
1031
|
-
onClick={() => handleDownload(node)}
|
|
1032
|
-
>
|
|
1033
|
-
<Download className="h-3 w-3" />
|
|
1034
|
-
</Button>
|
|
1035
|
-
<Button
|
|
1036
|
-
size="sm"
|
|
1037
|
-
variant="ghost"
|
|
1038
|
-
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
|
1039
|
-
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` }}
|
|
1040
1223
|
onClick={() => {
|
|
1041
|
-
|
|
1042
|
-
|
|
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);
|
|
1043
1227
|
}}
|
|
1044
1228
|
>
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
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 }, 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>
|
|
1049
1374
|
|
|
1050
1375
|
{newFolderParent === node.path && node.type === "dir" && (
|
|
1051
1376
|
<div
|
|
@@ -1138,12 +1463,12 @@ export default function Page() {
|
|
|
1138
1463
|
</div>
|
|
1139
1464
|
)}
|
|
1140
1465
|
|
|
1141
|
-
{(node.type === "dir" || node.type === "app") &&
|
|
1466
|
+
{(node.type === "dir" || node.type === "app" || node.type === "node-app") &&
|
|
1142
1467
|
node.expanded &&
|
|
1143
1468
|
node.children &&
|
|
1144
1469
|
node.children.length > 0 &&
|
|
1145
1470
|
renderNodes(node.children, depth + 1)}
|
|
1146
|
-
{(node.type === "dir" || node.type === "app") &&
|
|
1471
|
+
{(node.type === "dir" || node.type === "app" || node.type === "node-app") &&
|
|
1147
1472
|
node.expanded &&
|
|
1148
1473
|
node.children?.length === 0 && (
|
|
1149
1474
|
<div
|
|
@@ -1155,8 +1480,36 @@ export default function Page() {
|
|
|
1155
1480
|
Empty
|
|
1156
1481
|
</div>
|
|
1157
1482
|
)}
|
|
1158
|
-
|
|
1159
|
-
|
|
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
|
+
});
|
|
1160
1513
|
}
|
|
1161
1514
|
|
|
1162
1515
|
return (
|
|
@@ -1194,7 +1547,14 @@ export default function Page() {
|
|
|
1194
1547
|
</div>
|
|
1195
1548
|
)}
|
|
1196
1549
|
{rootConfigured === true && !addingWorkspace && <>
|
|
1197
|
-
<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
|
+
/>
|
|
1198
1558
|
{/* Tree sidebar */}
|
|
1199
1559
|
{!sidebarCollapsed && (
|
|
1200
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">
|
|
@@ -1399,6 +1759,7 @@ export default function Page() {
|
|
|
1399
1759
|
</div>
|
|
1400
1760
|
|
|
1401
1761
|
<div
|
|
1762
|
+
ref={sidebarScrollRef}
|
|
1402
1763
|
className={cn(
|
|
1403
1764
|
"flex-1 overflow-auto py-1",
|
|
1404
1765
|
dragOverPath === "" &&
|
|
@@ -1411,6 +1772,80 @@ export default function Page() {
|
|
|
1411
1772
|
}}
|
|
1412
1773
|
onDrop={(e) => handleDropOnFolder(e, "")}
|
|
1413
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
|
+
</button>
|
|
1787
|
+
{!pinnedCollapsed && pins.map((p) => (
|
|
1788
|
+
<div
|
|
1789
|
+
key={p.path}
|
|
1790
|
+
role="button"
|
|
1791
|
+
tabIndex={0}
|
|
1792
|
+
className={cn(
|
|
1793
|
+
"group flex items-center gap-1.5 rounded-sm px-2 py-1 text-sm cursor-pointer transition-colors select-none",
|
|
1794
|
+
openFile?.path === p.path ? "bg-accent-soft text-foreground font-medium" : "hover:bg-muted",
|
|
1795
|
+
)}
|
|
1796
|
+
onClick={() => void openViewer({ path: p.path, name: p.name, type: "file", modifiedAt: "" } as TreeNode)}
|
|
1797
|
+
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); void openViewer({ path: p.path, name: p.name, type: "file", modifiedAt: "" } as TreeNode); } }}
|
|
1798
|
+
>
|
|
1799
|
+
<Star className="h-3 w-3 shrink-0 fill-current text-amber-400" />
|
|
1800
|
+
<span className="min-w-0 flex-1 truncate text-xs">{p.name}</span>
|
|
1801
|
+
<span className="max-w-[80px] truncate text-[10px] text-muted-foreground/60">{p.path.split("/").slice(0, -1).join("/")}</span>
|
|
1802
|
+
<button
|
|
1803
|
+
type="button"
|
|
1804
|
+
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"
|
|
1805
|
+
title="Remove from pinned"
|
|
1806
|
+
onClick={(e) => {
|
|
1807
|
+
e.stopPropagation();
|
|
1808
|
+
usePinStore
|
|
1809
|
+
.getState()
|
|
1810
|
+
.toggle({ path: p.path, name: p.name }, activeWorkspaceId);
|
|
1811
|
+
}}
|
|
1812
|
+
>
|
|
1813
|
+
<X className="h-3 w-3" />
|
|
1814
|
+
</button>
|
|
1815
|
+
</div>
|
|
1816
|
+
))}
|
|
1817
|
+
</div>
|
|
1818
|
+
)}
|
|
1819
|
+
{/* Recent files section */}
|
|
1820
|
+
{recents.length > 0 && (
|
|
1821
|
+
<div className="border-b mb-1">
|
|
1822
|
+
<button
|
|
1823
|
+
type="button"
|
|
1824
|
+
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"
|
|
1825
|
+
onClick={() => setRecentCollapsed((c) => !c)}
|
|
1826
|
+
>
|
|
1827
|
+
{recentCollapsed ? <ChevronRight className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
|
1828
|
+
Recent
|
|
1829
|
+
</button>
|
|
1830
|
+
{!recentCollapsed && recents.slice(0, 8).map((r) => (
|
|
1831
|
+
<div
|
|
1832
|
+
key={r.path}
|
|
1833
|
+
role="button"
|
|
1834
|
+
tabIndex={0}
|
|
1835
|
+
className={cn(
|
|
1836
|
+
"flex items-center gap-1.5 rounded-sm px-2 py-1 text-sm cursor-pointer transition-colors select-none",
|
|
1837
|
+
openFile?.path === r.path ? "bg-accent-soft text-foreground font-medium" : "hover:bg-muted",
|
|
1838
|
+
)}
|
|
1839
|
+
onClick={() => void openViewer({ path: r.path, name: r.name, type: "file", modifiedAt: "" } as TreeNode)}
|
|
1840
|
+
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); void openViewer({ path: r.path, name: r.name, type: "file", modifiedAt: "" } as TreeNode); } }}
|
|
1841
|
+
>
|
|
1842
|
+
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
1843
|
+
<span className="flex-1 truncate text-xs">{r.name}</span>
|
|
1844
|
+
<span className="text-[10px] text-muted-foreground/60 truncate max-w-[80px]">{r.path.split("/").slice(0, -1).join("/")}</span>
|
|
1845
|
+
</div>
|
|
1846
|
+
))}
|
|
1847
|
+
</div>
|
|
1848
|
+
)}
|
|
1414
1849
|
{rootLoading ? (
|
|
1415
1850
|
<div className="flex justify-center py-6">
|
|
1416
1851
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
@@ -1477,10 +1912,22 @@ export default function Page() {
|
|
|
1477
1912
|
) : (
|
|
1478
1913
|
<span className="w-3.5 shrink-0" />
|
|
1479
1914
|
)}
|
|
1480
|
-
<span className="flex flex-col min-w-0">
|
|
1915
|
+
<span className="flex flex-col min-w-0 flex-1">
|
|
1481
1916
|
<span className="truncate">{w.name}</span>
|
|
1482
1917
|
<span className="truncate text-[10px] text-muted-foreground font-mono">{w.rootDir}</span>
|
|
1483
1918
|
</span>
|
|
1919
|
+
{isWsAdmin && (
|
|
1920
|
+
<button
|
|
1921
|
+
className="ml-auto shrink-0 rounded p-0.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
|
1922
|
+
title="Delete workspace (does not delete files)"
|
|
1923
|
+
onClick={(e) => {
|
|
1924
|
+
e.stopPropagation();
|
|
1925
|
+
setDeletingWorkspaceId(w.id);
|
|
1926
|
+
}}
|
|
1927
|
+
>
|
|
1928
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
1929
|
+
</button>
|
|
1930
|
+
)}
|
|
1484
1931
|
</DropdownMenuItem>
|
|
1485
1932
|
))}
|
|
1486
1933
|
{isWsAdmin && (
|
|
@@ -1542,6 +1989,7 @@ export default function Page() {
|
|
|
1542
1989
|
</span>
|
|
1543
1990
|
</div>
|
|
1544
1991
|
<div className="flex items-center gap-1 shrink-0">
|
|
1992
|
+
{renderCopyMenu(openFile)}
|
|
1545
1993
|
{openFileViewerKind === "html" &&
|
|
1546
1994
|
!editing &&
|
|
1547
1995
|
fileContent !== null && (
|
|
@@ -1657,16 +2105,17 @@ export default function Page() {
|
|
|
1657
2105
|
</span>
|
|
1658
2106
|
</div>
|
|
1659
2107
|
<div className="flex items-center gap-1 shrink-0">
|
|
2108
|
+
{renderCopyMenu(openFile)}
|
|
1660
2109
|
{isText(openFile.name) &&
|
|
1661
2110
|
!editing &&
|
|
1662
|
-
fileContent !== null && (
|
|
2111
|
+
(fileContent !== null || isMarkdown(openFile.name)) && (
|
|
1663
2112
|
<Button
|
|
1664
2113
|
size="sm"
|
|
1665
2114
|
variant="ghost"
|
|
1666
2115
|
className="h-7 w-7 p-0"
|
|
1667
2116
|
onClick={() => {
|
|
1668
2117
|
setEditing(true);
|
|
1669
|
-
setEditContent(fileContent);
|
|
2118
|
+
setEditContent(fileContent ?? "");
|
|
1670
2119
|
setSaveError(null);
|
|
1671
2120
|
if (isMarkdown(openFile.name)) {
|
|
1672
2121
|
void useEditorStore
|
|
@@ -1680,7 +2129,7 @@ export default function Page() {
|
|
|
1680
2129
|
)}
|
|
1681
2130
|
{isText(openFile.name) &&
|
|
1682
2131
|
!editing &&
|
|
1683
|
-
fileContent !== null && (
|
|
2132
|
+
(fileContent !== null || isMarkdown(openFile.name)) && (
|
|
1684
2133
|
<Button
|
|
1685
2134
|
size="sm"
|
|
1686
2135
|
variant="ghost"
|
|
@@ -1725,6 +2174,16 @@ export default function Page() {
|
|
|
1725
2174
|
/>
|
|
1726
2175
|
)}
|
|
1727
2176
|
</div>
|
|
2177
|
+
) : isMarkdown(openFile.name) ? (
|
|
2178
|
+
<div className="flex-1 flex flex-col overflow-hidden min-h-0">
|
|
2179
|
+
{fileLoading ? (
|
|
2180
|
+
<div className="flex justify-center py-8">
|
|
2181
|
+
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
2182
|
+
</div>
|
|
2183
|
+
) : (
|
|
2184
|
+
<KBEditor mode="viewing" />
|
|
2185
|
+
)}
|
|
2186
|
+
</div>
|
|
1728
2187
|
) : (
|
|
1729
2188
|
<div className="flex-1 overflow-auto p-4 min-h-0">
|
|
1730
2189
|
<div className={cn("mx-auto w-full", contentWidthClass)}>
|
|
@@ -1777,40 +2236,9 @@ export default function Page() {
|
|
|
1777
2236
|
title={openFile.name}
|
|
1778
2237
|
/>
|
|
1779
2238
|
) : fileContent !== null ? (
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
return (
|
|
1784
|
-
<>
|
|
1785
|
-
<FrontmatterHeader
|
|
1786
|
-
data={data as Record<string, never>}
|
|
1787
|
-
/>
|
|
1788
|
-
<MarkdownPreview
|
|
1789
|
-
markdown={body}
|
|
1790
|
-
pagePath={openFile.path}
|
|
1791
|
-
onNavigate={(targetPath, anchor) => {
|
|
1792
|
-
void openViewer({
|
|
1793
|
-
path: targetPath,
|
|
1794
|
-
name: targetPath.split('/').pop() ?? targetPath,
|
|
1795
|
-
type: 'file',
|
|
1796
|
-
} as TreeNode);
|
|
1797
|
-
if (anchor) {
|
|
1798
|
-
setTimeout(() => {
|
|
1799
|
-
document
|
|
1800
|
-
.getElementById(anchor)
|
|
1801
|
-
?.scrollIntoView({ behavior: 'smooth' });
|
|
1802
|
-
}, 200);
|
|
1803
|
-
}
|
|
1804
|
-
}}
|
|
1805
|
-
/>
|
|
1806
|
-
</>
|
|
1807
|
-
);
|
|
1808
|
-
})()
|
|
1809
|
-
) : (
|
|
1810
|
-
<pre className="text-xs font-mono whitespace-pre-wrap break-words leading-relaxed">
|
|
1811
|
-
{fileContent}
|
|
1812
|
-
</pre>
|
|
1813
|
-
)
|
|
2239
|
+
<pre className="text-xs font-mono whitespace-pre-wrap break-words leading-relaxed">
|
|
2240
|
+
{fileContent}
|
|
2241
|
+
</pre>
|
|
1814
2242
|
) : isText(openFile.name) ? (
|
|
1815
2243
|
<p className="text-sm text-muted-foreground">
|
|
1816
2244
|
Could not load file.
|
|
@@ -1824,7 +2252,7 @@ export default function Page() {
|
|
|
1824
2252
|
</div>
|
|
1825
2253
|
)}
|
|
1826
2254
|
|
|
1827
|
-
{editing && (
|
|
2255
|
+
{editing && !isMarkdown(openFile.name) && (
|
|
1828
2256
|
<div className="border-t px-4 py-2 flex items-center justify-end gap-2 bg-muted shrink-0">
|
|
1829
2257
|
{saveError && (
|
|
1830
2258
|
<span className="text-xs text-destructive mr-auto">
|
|
@@ -1891,6 +2319,16 @@ export default function Page() {
|
|
|
1891
2319
|
}
|
|
1892
2320
|
onConfirm={handleDelete}
|
|
1893
2321
|
/>
|
|
2322
|
+
|
|
2323
|
+
<ConfirmDialog
|
|
2324
|
+
open={!!deletingWorkspaceId}
|
|
2325
|
+
onOpenChange={(open) => {
|
|
2326
|
+
if (!open) setDeletingWorkspaceId(null);
|
|
2327
|
+
}}
|
|
2328
|
+
title="Delete workspace?"
|
|
2329
|
+
description={`"${workspaces.find((w) => w.id === deletingWorkspaceId)?.name ?? ""}" will be removed from the workspace list. Files on disk are NOT deleted.`}
|
|
2330
|
+
onConfirm={handleDeleteWorkspace}
|
|
2331
|
+
/>
|
|
1894
2332
|
</>}
|
|
1895
2333
|
</div>
|
|
1896
2334
|
);
|