@agentprojectcontext/apx 1.57.0 → 1.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/core/agent/skills/index.js +9 -0
- package/src/core/agent/skills/inspector.js +7 -2
- package/src/core/agent/skills/policy.js +128 -0
- package/src/core/agent/super-agent.js +8 -1
- package/src/core/agent/tools/handlers/list-skills.js +6 -3
- package/src/core/agent/tools/handlers/load-skill.js +9 -2
- package/src/core/apc/paths.js +7 -0
- package/src/core/stores/organization.js +152 -0
- package/src/core/stores/project-files.js +199 -0
- package/src/core/stores/tasks.js +36 -3
- package/src/host/daemon/api/agents.js +22 -2
- package/src/host/daemon/api/files-project.js +99 -0
- package/src/host/daemon/api/organization.js +88 -0
- package/src/host/daemon/api/shared.js +7 -0
- package/src/host/daemon/api/skills.js +301 -17
- package/src/host/daemon/api/tasks.js +14 -0
- package/src/host/daemon/api.js +4 -0
- package/src/interfaces/cli/commands/org.js +77 -0
- package/src/interfaces/cli/commands/skills.js +3 -2
- package/src/interfaces/cli/index.js +48 -0
- package/src/interfaces/web/dist/assets/index-CnQb4N6C.js +731 -0
- package/src/interfaces/web/dist/assets/index-CnQb4N6C.js.map +1 -0
- package/src/interfaces/web/dist/assets/index-Dv3X-zpx.css +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/src/components/agents/AgentFormFields.tsx +123 -0
- package/src/interfaces/web/src/components/common/ConfirmDialog.tsx +51 -0
- package/src/interfaces/web/src/components/files/FileBrowser.tsx +138 -0
- package/src/interfaces/web/src/components/files/FileTree.tsx +133 -0
- package/src/interfaces/web/src/components/files/FileViewer.tsx +167 -0
- package/src/interfaces/web/src/components/files/MarkdownEditor.tsx +48 -0
- package/src/interfaces/web/src/components/files/MarkdownPreview.tsx +146 -0
- package/src/interfaces/web/src/components/files/NewFileDialog.tsx +66 -0
- package/src/interfaces/web/src/components/settings/SkillsManager.tsx +465 -0
- package/src/interfaces/web/src/components/settings/SkillsSettings.tsx +49 -0
- package/src/interfaces/web/src/components/structure/StructureDialogs.tsx +172 -0
- package/src/interfaces/web/src/components/tasks/TaskDetailPanel.tsx +142 -0
- package/src/interfaces/web/src/components/tasks/taskStatus.tsx +57 -0
- package/src/interfaces/web/src/i18n/en.ts +171 -0
- package/src/interfaces/web/src/i18n/es.ts +171 -0
- package/src/interfaces/web/src/lib/api/organization.ts +18 -0
- package/src/interfaces/web/src/lib/api/projectFiles.ts +19 -0
- package/src/interfaces/web/src/lib/api/skills.ts +79 -8
- package/src/interfaces/web/src/lib/api/tasks.ts +16 -1
- package/src/interfaces/web/src/lib/api.ts +2 -0
- package/src/interfaces/web/src/lib/slug.ts +11 -0
- package/src/interfaces/web/src/screens/ProjectScreen.tsx +25 -2
- package/src/interfaces/web/src/screens/SettingsScreen.tsx +3 -3
- package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +31 -11
- package/src/interfaces/web/src/screens/project/AgentsTab.tsx +24 -7
- package/src/interfaces/web/src/screens/project/DocsTab.tsx +13 -0
- package/src/interfaces/web/src/screens/project/FilesTab.tsx +12 -0
- package/src/interfaces/web/src/screens/project/Overview.tsx +122 -10
- package/src/interfaces/web/src/screens/project/SkillsTab.tsx +13 -0
- package/src/interfaces/web/src/screens/project/StructureTab.tsx +147 -0
- package/src/interfaces/web/src/screens/project/TasksTab.tsx +101 -62
- package/src/interfaces/web/src/types/daemon.ts +63 -0
- package/src/interfaces/web/dist/assets/index-CEI8DfVg.css +0 -1
- package/src/interfaces/web/dist/assets/index-DJ-ocXOR.js +0 -651
- package/src/interfaces/web/dist/assets/index-DJ-ocXOR.js.map +0 -1
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Dialog, Button } from "../ui";
|
|
3
|
+
import { t } from "../../i18n";
|
|
4
|
+
|
|
5
|
+
// Reusable confirm dialog for destructive/executing actions (project rule:
|
|
6
|
+
// never native confirm()). Shows a loading state while the action runs.
|
|
7
|
+
export function ConfirmDialog({
|
|
8
|
+
open,
|
|
9
|
+
onClose,
|
|
10
|
+
onConfirm,
|
|
11
|
+
title,
|
|
12
|
+
description,
|
|
13
|
+
confirmLabel,
|
|
14
|
+
destructive = true,
|
|
15
|
+
}: {
|
|
16
|
+
open: boolean;
|
|
17
|
+
onClose: () => void;
|
|
18
|
+
onConfirm: () => Promise<void> | void;
|
|
19
|
+
title: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
confirmLabel?: string;
|
|
22
|
+
destructive?: boolean;
|
|
23
|
+
}) {
|
|
24
|
+
const [busy, setBusy] = useState(false);
|
|
25
|
+
const run = async () => {
|
|
26
|
+
setBusy(true);
|
|
27
|
+
try {
|
|
28
|
+
await onConfirm();
|
|
29
|
+
onClose();
|
|
30
|
+
} finally {
|
|
31
|
+
setBusy(false);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
return (
|
|
35
|
+
<Dialog
|
|
36
|
+
open={open}
|
|
37
|
+
onClose={onClose}
|
|
38
|
+
title={title}
|
|
39
|
+
footer={
|
|
40
|
+
<>
|
|
41
|
+
<Button variant="ghost" onClick={onClose} disabled={busy}>{t("common.cancel")}</Button>
|
|
42
|
+
<Button variant={destructive ? "destructive" : "primary"} onClick={() => void run()} loading={busy}>
|
|
43
|
+
{confirmLabel ?? t("common.confirm")}
|
|
44
|
+
</Button>
|
|
45
|
+
</>
|
|
46
|
+
}
|
|
47
|
+
>
|
|
48
|
+
<p className="text-sm text-muted-foreground">{description}</p>
|
|
49
|
+
</Dialog>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import useSWR from "swr";
|
|
3
|
+
import { RefreshCw, FilePlus2, FolderOpen } from "lucide-react";
|
|
4
|
+
import { ProjectFiles, type FileScope } from "../../lib/api/projectFiles";
|
|
5
|
+
import type { FileNode, FileContent } from "../../types/daemon";
|
|
6
|
+
import { Spinner, Button, Empty } from "../ui";
|
|
7
|
+
import { useToast } from "../Toast";
|
|
8
|
+
import { t } from "../../i18n";
|
|
9
|
+
import { FileTree } from "./FileTree";
|
|
10
|
+
import { FileViewer } from "./FileViewer";
|
|
11
|
+
import { NewFileDialog } from "./NewFileDialog";
|
|
12
|
+
|
|
13
|
+
// Shared file browser used by both /files (scope=project) and /docs
|
|
14
|
+
// (scope=docs). One component, two roots — the docs surface is just the same
|
|
15
|
+
// browser with `editable` on and a "new document" affordance.
|
|
16
|
+
export function FileBrowser({
|
|
17
|
+
pid,
|
|
18
|
+
scope,
|
|
19
|
+
editable = false,
|
|
20
|
+
emptyHint,
|
|
21
|
+
}: {
|
|
22
|
+
pid: string;
|
|
23
|
+
scope: FileScope;
|
|
24
|
+
/** Allow editing text/markdown + creating/deleting files (docs surface). */
|
|
25
|
+
editable?: boolean;
|
|
26
|
+
emptyHint?: string;
|
|
27
|
+
}) {
|
|
28
|
+
const toast = useToast();
|
|
29
|
+
const [selected, setSelected] = useState<string | null>(null);
|
|
30
|
+
const [newOpen, setNewOpen] = useState(false);
|
|
31
|
+
|
|
32
|
+
const treeKey = `/projects/${pid}/fs/tree?scope=${scope}`;
|
|
33
|
+
const tree = useSWR(treeKey, () => ProjectFiles.tree(pid, scope));
|
|
34
|
+
|
|
35
|
+
const fileKey = selected ? `/projects/${pid}/fs/file?scope=${scope}&path=${selected}` : null;
|
|
36
|
+
const file = useSWR<FileContent | null>(fileKey, () => (selected ? ProjectFiles.read(pid, selected, scope) : null));
|
|
37
|
+
|
|
38
|
+
const onSelect = (node: FileNode) => setSelected(node.path);
|
|
39
|
+
|
|
40
|
+
const onSave = editable
|
|
41
|
+
? async (content: string) => {
|
|
42
|
+
if (!selected) return;
|
|
43
|
+
await ProjectFiles.write(pid, selected, content, scope);
|
|
44
|
+
toast.success(t("files.saved"));
|
|
45
|
+
void file.mutate();
|
|
46
|
+
}
|
|
47
|
+
: undefined;
|
|
48
|
+
|
|
49
|
+
const onDelete = editable
|
|
50
|
+
? async (node: FileNode) => {
|
|
51
|
+
await ProjectFiles.remove(pid, node.path, scope);
|
|
52
|
+
if (selected === node.path) setSelected(null);
|
|
53
|
+
toast.success(t("files.deleted"));
|
|
54
|
+
void tree.mutate();
|
|
55
|
+
}
|
|
56
|
+
: undefined;
|
|
57
|
+
|
|
58
|
+
const onCreated = (path: string) => {
|
|
59
|
+
setNewOpen(false);
|
|
60
|
+
setSelected(path);
|
|
61
|
+
void tree.mutate();
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const nodes = tree.data?.tree ?? [];
|
|
65
|
+
const isEmpty = !tree.isLoading && nodes.length === 0;
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="flex h-full min-h-0 overflow-hidden rounded-xl border border-border bg-card">
|
|
69
|
+
{/* Sidebar: tree */}
|
|
70
|
+
<div className="flex w-64 shrink-0 flex-col border-r border-border">
|
|
71
|
+
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2">
|
|
72
|
+
<FolderOpen className="size-4 text-muted-foreground" />
|
|
73
|
+
<span className="flex-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
74
|
+
{scope === "docs" ? t("files.docs_label") : t("files.files_label")}
|
|
75
|
+
</span>
|
|
76
|
+
{editable && (
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
data-testid="docs-new"
|
|
80
|
+
onClick={() => setNewOpen(true)}
|
|
81
|
+
className="text-muted-foreground hover:text-foreground"
|
|
82
|
+
aria-label={t("files.new_doc")}
|
|
83
|
+
title={t("files.new_doc")}
|
|
84
|
+
>
|
|
85
|
+
<FilePlus2 className="size-4" />
|
|
86
|
+
</button>
|
|
87
|
+
)}
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
onClick={() => void tree.mutate()}
|
|
91
|
+
className="text-muted-foreground hover:text-foreground"
|
|
92
|
+
aria-label={t("common.refresh")}
|
|
93
|
+
>
|
|
94
|
+
<RefreshCw className={tree.isValidating ? "size-3.5 animate-spin" : "size-3.5"} />
|
|
95
|
+
</button>
|
|
96
|
+
</div>
|
|
97
|
+
<div className="min-h-0 flex-1 overflow-y-auto p-1.5">
|
|
98
|
+
{tree.isLoading ? (
|
|
99
|
+
<div className="flex justify-center py-6"><Spinner size={14} /></div>
|
|
100
|
+
) : isEmpty ? (
|
|
101
|
+
<div className="p-3">
|
|
102
|
+
<Empty>
|
|
103
|
+
<div className="space-y-2">
|
|
104
|
+
<p>{emptyHint ?? t("files.empty")}</p>
|
|
105
|
+
{editable && (
|
|
106
|
+
<Button size="sm" variant="secondary" onClick={() => setNewOpen(true)}>
|
|
107
|
+
<FilePlus2 className="size-3.5" />{t("files.new_doc")}
|
|
108
|
+
</Button>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
</Empty>
|
|
112
|
+
</div>
|
|
113
|
+
) : (
|
|
114
|
+
<FileTree nodes={nodes} selectedPath={selected} onSelect={onSelect} onDelete={onDelete} />
|
|
115
|
+
)}
|
|
116
|
+
{tree.data?.truncated && (
|
|
117
|
+
<p className="px-2 py-1 text-[10px] text-muted-foreground/60">{t("files.truncated")}</p>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
{/* Main: viewer */}
|
|
123
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
124
|
+
<FileViewer file={file.data ?? null} loading={!!fileKey && file.isLoading} onSave={onSave} />
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
{editable && (
|
|
128
|
+
<NewFileDialog
|
|
129
|
+
open={newOpen}
|
|
130
|
+
onClose={() => setNewOpen(false)}
|
|
131
|
+
pid={pid}
|
|
132
|
+
scope={scope}
|
|
133
|
+
onCreated={onCreated}
|
|
134
|
+
/>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import {
|
|
3
|
+
ChevronRight, ChevronDown, Folder, FolderOpen,
|
|
4
|
+
FileText, FileCode, Image as ImageIcon, File, Trash2,
|
|
5
|
+
} from "lucide-react";
|
|
6
|
+
import { cn } from "../../lib/cn";
|
|
7
|
+
import type { FileNode, FileKind } from "../../types/daemon";
|
|
8
|
+
|
|
9
|
+
function kindIcon(kind?: FileKind) {
|
|
10
|
+
switch (kind) {
|
|
11
|
+
case "markdown": return { Icon: FileText, color: "text-sky-500" };
|
|
12
|
+
case "text": return { Icon: FileCode, color: "text-amber-500" };
|
|
13
|
+
case "image": return { Icon: ImageIcon, color: "text-pink-500" };
|
|
14
|
+
default: return { Icon: File, color: "text-muted-foreground" };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Ancestor dir paths of a file path, so the tree can auto-expand to a selection.
|
|
19
|
+
function ancestors(path: string): string[] {
|
|
20
|
+
const parts = path.split("/");
|
|
21
|
+
const out: string[] = [];
|
|
22
|
+
for (let i = 1; i < parts.length; i++) out.push(parts.slice(0, i).join("/"));
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function Row({
|
|
27
|
+
node, depth, selectedPath, expanded, toggle, onSelect, onDelete,
|
|
28
|
+
}: {
|
|
29
|
+
node: FileNode;
|
|
30
|
+
depth: number;
|
|
31
|
+
selectedPath: string | null;
|
|
32
|
+
expanded: Set<string>;
|
|
33
|
+
toggle: (p: string) => void;
|
|
34
|
+
onSelect: (node: FileNode) => void;
|
|
35
|
+
onDelete?: (node: FileNode) => void;
|
|
36
|
+
}) {
|
|
37
|
+
const isDir = node.type === "dir";
|
|
38
|
+
const open = expanded.has(node.path);
|
|
39
|
+
const selected = selectedPath === node.path;
|
|
40
|
+
const { Icon, color } = isDir
|
|
41
|
+
? { Icon: open ? FolderOpen : Folder, color: "text-muted-foreground" }
|
|
42
|
+
: kindIcon(node.kind);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div>
|
|
46
|
+
<div
|
|
47
|
+
className={cn(
|
|
48
|
+
"group flex items-center gap-1 rounded px-1.5 py-1 text-[13px] cursor-pointer",
|
|
49
|
+
selected ? "bg-primary/15 text-foreground" : "hover:bg-accent/40 text-foreground/80",
|
|
50
|
+
)}
|
|
51
|
+
style={{ paddingLeft: depth * 12 + 6 }}
|
|
52
|
+
onClick={() => (isDir ? toggle(node.path) : onSelect(node))}
|
|
53
|
+
>
|
|
54
|
+
{isDir ? (
|
|
55
|
+
open ? <ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
|
|
56
|
+
: <ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
|
|
57
|
+
) : (
|
|
58
|
+
<span className="w-3.5 shrink-0" />
|
|
59
|
+
)}
|
|
60
|
+
<Icon className={cn("size-3.5 shrink-0", color)} />
|
|
61
|
+
<span className="min-w-0 flex-1 truncate">{node.name}</span>
|
|
62
|
+
{onDelete && !isDir && (
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
onClick={(e) => { e.stopPropagation(); onDelete(node); }}
|
|
66
|
+
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-red-500"
|
|
67
|
+
aria-label={`delete ${node.name}`}
|
|
68
|
+
>
|
|
69
|
+
<Trash2 className="size-3.5" />
|
|
70
|
+
</button>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
{isDir && open && node.children?.map((child) => (
|
|
74
|
+
<Row
|
|
75
|
+
key={child.path}
|
|
76
|
+
node={child}
|
|
77
|
+
depth={depth + 1}
|
|
78
|
+
selectedPath={selectedPath}
|
|
79
|
+
expanded={expanded}
|
|
80
|
+
toggle={toggle}
|
|
81
|
+
onSelect={onSelect}
|
|
82
|
+
onDelete={onDelete}
|
|
83
|
+
/>
|
|
84
|
+
))}
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function FileTree({
|
|
90
|
+
nodes, selectedPath, onSelect, onDelete, className,
|
|
91
|
+
}: {
|
|
92
|
+
nodes: FileNode[];
|
|
93
|
+
selectedPath: string | null;
|
|
94
|
+
onSelect: (node: FileNode) => void;
|
|
95
|
+
onDelete?: (node: FileNode) => void;
|
|
96
|
+
className?: string;
|
|
97
|
+
}) {
|
|
98
|
+
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
|
99
|
+
|
|
100
|
+
// Auto-expand ancestors of the current selection so it's always visible.
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (!selectedPath) return;
|
|
103
|
+
setExpanded((prev) => {
|
|
104
|
+
const next = new Set(prev);
|
|
105
|
+
for (const a of ancestors(selectedPath)) next.add(a);
|
|
106
|
+
return next;
|
|
107
|
+
});
|
|
108
|
+
}, [selectedPath]);
|
|
109
|
+
|
|
110
|
+
const toggle = (p: string) =>
|
|
111
|
+
setExpanded((prev) => {
|
|
112
|
+
const next = new Set(prev);
|
|
113
|
+
next.has(p) ? next.delete(p) : next.add(p);
|
|
114
|
+
return next;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div className={cn("select-none", className)}>
|
|
119
|
+
{nodes.map((node) => (
|
|
120
|
+
<Row
|
|
121
|
+
key={node.path}
|
|
122
|
+
node={node}
|
|
123
|
+
depth={0}
|
|
124
|
+
selectedPath={selectedPath}
|
|
125
|
+
expanded={expanded}
|
|
126
|
+
toggle={toggle}
|
|
127
|
+
onSelect={onSelect}
|
|
128
|
+
onDelete={onDelete}
|
|
129
|
+
/>
|
|
130
|
+
))}
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { Save, RotateCcw, Pencil, Eye, Columns2, FileQuestion, Download } from "lucide-react";
|
|
3
|
+
import { cn } from "../../lib/cn";
|
|
4
|
+
import { Spinner } from "../ui";
|
|
5
|
+
import { t } from "../../i18n";
|
|
6
|
+
import type { FileContent } from "../../types/daemon";
|
|
7
|
+
import { MarkdownPreview } from "./MarkdownPreview";
|
|
8
|
+
import { MarkdownEditor } from "./MarkdownEditor";
|
|
9
|
+
|
|
10
|
+
function ToolbarButton({
|
|
11
|
+
onClick, active, disabled, children,
|
|
12
|
+
}: { onClick: () => void; active?: boolean; disabled?: boolean; children: React.ReactNode }) {
|
|
13
|
+
return (
|
|
14
|
+
<button
|
|
15
|
+
type="button"
|
|
16
|
+
onClick={onClick}
|
|
17
|
+
disabled={disabled}
|
|
18
|
+
className={cn(
|
|
19
|
+
"inline-flex items-center gap-1 rounded px-2 py-0.5 text-[11px] font-medium transition-colors disabled:opacity-40",
|
|
20
|
+
active ? "bg-primary/15 text-foreground" : "text-muted-foreground hover:bg-accent hover:text-foreground",
|
|
21
|
+
)}
|
|
22
|
+
>
|
|
23
|
+
{children}
|
|
24
|
+
</button>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function CodeView({ content }: { content: string }) {
|
|
29
|
+
return (
|
|
30
|
+
<div className="min-h-0 flex-1 overflow-auto">
|
|
31
|
+
<table className="w-full border-collapse font-mono text-[12px] leading-[1.6]">
|
|
32
|
+
<tbody>
|
|
33
|
+
{content.split("\n").map((line, i) => (
|
|
34
|
+
<tr key={i} className="hover:bg-accent/20">
|
|
35
|
+
<td className="w-12 select-none border-r border-border/30 px-3 text-right align-top text-[10px] text-muted-foreground/40" aria-hidden="true">
|
|
36
|
+
{i + 1}
|
|
37
|
+
</td>
|
|
38
|
+
<td className="whitespace-pre px-4 align-top text-foreground/90">{line || " "}</td>
|
|
39
|
+
</tr>
|
|
40
|
+
))}
|
|
41
|
+
</tbody>
|
|
42
|
+
</table>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Type-aware file view/editor. Markdown gets a preview + optional split editor;
|
|
48
|
+
// text/code get a line-numbered view + optional textarea; images render inline;
|
|
49
|
+
// binaries show metadata only. `onSave` (docs, editable text) turns on editing.
|
|
50
|
+
export function FileViewer({
|
|
51
|
+
file, loading, onSave,
|
|
52
|
+
}: {
|
|
53
|
+
file: FileContent | null;
|
|
54
|
+
loading?: boolean;
|
|
55
|
+
onSave?: (content: string) => Promise<void> | void;
|
|
56
|
+
}) {
|
|
57
|
+
const editable = typeof onSave === "function";
|
|
58
|
+
const [draft, setDraft] = useState("");
|
|
59
|
+
const [editing, setEditing] = useState(false);
|
|
60
|
+
const [showPreview, setShowPreview] = useState(true);
|
|
61
|
+
const [saving, setSaving] = useState(false);
|
|
62
|
+
|
|
63
|
+
// Reset local state whenever a different file loads.
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
setDraft(file?.content ?? "");
|
|
66
|
+
setEditing(false);
|
|
67
|
+
setShowPreview(true);
|
|
68
|
+
}, [file?.path, file?.content]);
|
|
69
|
+
|
|
70
|
+
if (loading) {
|
|
71
|
+
return <div className="flex flex-1 items-center justify-center"><Spinner size={16} /></div>;
|
|
72
|
+
}
|
|
73
|
+
if (!file) {
|
|
74
|
+
return (
|
|
75
|
+
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
|
76
|
+
{t("files.select_prompt")}
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const isMarkdown = file.kind === "markdown";
|
|
82
|
+
const isText = file.kind === "text" || file.kind === "markdown";
|
|
83
|
+
const dirty = editing && draft !== (file.content ?? "");
|
|
84
|
+
|
|
85
|
+
const save = async () => {
|
|
86
|
+
if (!onSave || !dirty) return;
|
|
87
|
+
setSaving(true);
|
|
88
|
+
try {
|
|
89
|
+
await onSave(draft);
|
|
90
|
+
setEditing(false);
|
|
91
|
+
} finally {
|
|
92
|
+
setSaving(false);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div className="flex h-full min-h-0 flex-col bg-card/40" data-testid="file-viewer">
|
|
98
|
+
{/* Header / toolbar */}
|
|
99
|
+
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5">
|
|
100
|
+
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground">
|
|
101
|
+
{file.path}
|
|
102
|
+
{dirty && <span className="ml-1 text-amber-400">•</span>}
|
|
103
|
+
</span>
|
|
104
|
+
|
|
105
|
+
{isMarkdown && editing && (
|
|
106
|
+
<ToolbarButton onClick={() => setShowPreview((v) => !v)} active={showPreview}>
|
|
107
|
+
<Columns2 className="size-3" />{t("files.preview")}
|
|
108
|
+
</ToolbarButton>
|
|
109
|
+
)}
|
|
110
|
+
|
|
111
|
+
{editable && isText && (
|
|
112
|
+
editing ? (
|
|
113
|
+
<>
|
|
114
|
+
<ToolbarButton onClick={() => { setDraft(file.content ?? ""); setEditing(false); }} disabled={saving}>
|
|
115
|
+
<RotateCcw className="size-3" />{t("files.discard")}
|
|
116
|
+
</ToolbarButton>
|
|
117
|
+
<ToolbarButton onClick={() => void save()} disabled={!dirty || saving} active={dirty}>
|
|
118
|
+
{saving ? <Spinner size={10} /> : <Save className="size-3" />}{t("files.save")}
|
|
119
|
+
</ToolbarButton>
|
|
120
|
+
</>
|
|
121
|
+
) : (
|
|
122
|
+
<ToolbarButton onClick={() => setEditing(true)}>
|
|
123
|
+
<Pencil className="size-3" />{t("files.edit")}
|
|
124
|
+
</ToolbarButton>
|
|
125
|
+
)
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
{/* Body */}
|
|
130
|
+
{isMarkdown ? (
|
|
131
|
+
editing ? (
|
|
132
|
+
<MarkdownEditor value={draft} onChange={setDraft} showPreview={showPreview} onSave={() => void save()} />
|
|
133
|
+
) : (
|
|
134
|
+
<div className="min-h-0 flex-1 overflow-y-auto p-4"><MarkdownPreview content={file.content ?? ""} /></div>
|
|
135
|
+
)
|
|
136
|
+
) : file.kind === "text" ? (
|
|
137
|
+
editing ? (
|
|
138
|
+
<textarea
|
|
139
|
+
value={draft}
|
|
140
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
141
|
+
onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === "s") { e.preventDefault(); void save(); } }}
|
|
142
|
+
spellCheck={false}
|
|
143
|
+
className="min-h-0 flex-1 resize-none bg-transparent p-4 font-mono text-[12px] leading-[1.6] text-foreground/90 outline-none"
|
|
144
|
+
/>
|
|
145
|
+
) : (
|
|
146
|
+
<CodeView content={file.content ?? ""} />
|
|
147
|
+
)
|
|
148
|
+
) : file.kind === "image" && file.encoding === "base64" ? (
|
|
149
|
+
<div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4">
|
|
150
|
+
<img
|
|
151
|
+
src={`data:${file.mime};base64,${file.content}`}
|
|
152
|
+
alt={file.name}
|
|
153
|
+
className="max-h-full max-w-full rounded object-contain"
|
|
154
|
+
/>
|
|
155
|
+
</div>
|
|
156
|
+
) : (
|
|
157
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
|
|
158
|
+
<FileQuestion className="size-8 opacity-50" />
|
|
159
|
+
<span>{file.too_large ? t("files.too_large") : t("files.no_preview")}</span>
|
|
160
|
+
<span className="flex items-center gap-1 text-xs opacity-70">
|
|
161
|
+
<Download className="size-3" />{(file.size / 1024).toFixed(1)} KB
|
|
162
|
+
</span>
|
|
163
|
+
</div>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { cn } from "../../lib/cn";
|
|
2
|
+
import { MarkdownPreview } from "./MarkdownPreview";
|
|
3
|
+
|
|
4
|
+
// A plain-textarea markdown editor with an optional live side-by-side preview.
|
|
5
|
+
// Deliberately not a heavyweight editor (Monaco/CodeMirror) — a styled textarea
|
|
6
|
+
// is fast, dependency-free, and enough for docs/specs. The parent owns the
|
|
7
|
+
// toolbar (save / preview toggle) and passes `showPreview`.
|
|
8
|
+
export function MarkdownEditor({
|
|
9
|
+
value,
|
|
10
|
+
onChange,
|
|
11
|
+
showPreview = false,
|
|
12
|
+
placeholder,
|
|
13
|
+
onSave,
|
|
14
|
+
className,
|
|
15
|
+
}: {
|
|
16
|
+
value: string;
|
|
17
|
+
onChange: (v: string) => void;
|
|
18
|
+
showPreview?: boolean;
|
|
19
|
+
placeholder?: string;
|
|
20
|
+
onSave?: () => void;
|
|
21
|
+
className?: string;
|
|
22
|
+
}) {
|
|
23
|
+
return (
|
|
24
|
+
<div className={cn("flex min-h-0 flex-1", className)}>
|
|
25
|
+
<textarea
|
|
26
|
+
value={value}
|
|
27
|
+
onChange={(e) => onChange(e.target.value)}
|
|
28
|
+
onKeyDown={(e) => {
|
|
29
|
+
if (onSave && (e.metaKey || e.ctrlKey) && e.key === "s") {
|
|
30
|
+
e.preventDefault();
|
|
31
|
+
onSave();
|
|
32
|
+
}
|
|
33
|
+
}}
|
|
34
|
+
placeholder={placeholder}
|
|
35
|
+
spellCheck={false}
|
|
36
|
+
className={cn(
|
|
37
|
+
"min-h-0 resize-none bg-transparent p-4 font-mono text-[13px] leading-[1.7] text-foreground/90 outline-none",
|
|
38
|
+
showPreview ? "w-1/2 border-r border-border" : "w-full",
|
|
39
|
+
)}
|
|
40
|
+
/>
|
|
41
|
+
{showPreview && (
|
|
42
|
+
<div className="min-h-0 w-1/2 overflow-y-auto p-4">
|
|
43
|
+
<MarkdownPreview content={value} />
|
|
44
|
+
</div>
|
|
45
|
+
)}
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { Fragment, type ReactNode } from "react";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
|
|
4
|
+
// A small, dependency-free markdown renderer. We deliberately avoid
|
|
5
|
+
// react-markdown/marked (no installers per project rules) and never use
|
|
6
|
+
// dangerouslySetInnerHTML — every node is a real React element, so untrusted
|
|
7
|
+
// document content can't inject markup. Coverage is the common subset:
|
|
8
|
+
// headings, fenced/inline code, bold/italic/links, blockquotes, hr, and
|
|
9
|
+
// ordered/unordered lists. Anything else renders as plain paragraphs.
|
|
10
|
+
|
|
11
|
+
// ── Inline: bold, italic, code, links ──────────────────────────────────────
|
|
12
|
+
function renderInline(text: string, keyBase: string): ReactNode[] {
|
|
13
|
+
const out: ReactNode[] = [];
|
|
14
|
+
// One regex, alternation ordered so `**` beats `*`. Groups capture the inner.
|
|
15
|
+
const re = /(\*\*([^*]+)\*\*)|(\*([^*]+)\*)|(`([^`]+)`)|(\[([^\]]+)\]\(([^)]+)\))/g;
|
|
16
|
+
let last = 0;
|
|
17
|
+
let m: RegExpExecArray | null;
|
|
18
|
+
let i = 0;
|
|
19
|
+
while ((m = re.exec(text))) {
|
|
20
|
+
if (m.index > last) out.push(<Fragment key={`${keyBase}-t${i}`}>{text.slice(last, m.index)}</Fragment>);
|
|
21
|
+
if (m[2] !== undefined) out.push(<strong key={`${keyBase}-b${i}`}>{m[2]}</strong>);
|
|
22
|
+
else if (m[4] !== undefined) out.push(<em key={`${keyBase}-i${i}`}>{m[4]}</em>);
|
|
23
|
+
else if (m[6] !== undefined)
|
|
24
|
+
out.push(<code key={`${keyBase}-c${i}`} className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]">{m[6]}</code>);
|
|
25
|
+
else if (m[8] !== undefined)
|
|
26
|
+
out.push(
|
|
27
|
+
<a key={`${keyBase}-l${i}`} href={m[9]} target="_blank" rel="noreferrer" className="text-sky-500 underline underline-offset-2 hover:text-sky-400">
|
|
28
|
+
{m[8]}
|
|
29
|
+
</a>,
|
|
30
|
+
);
|
|
31
|
+
last = re.lastIndex;
|
|
32
|
+
i += 1;
|
|
33
|
+
}
|
|
34
|
+
if (last < text.length) out.push(<Fragment key={`${keyBase}-tend`}>{text.slice(last)}</Fragment>);
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Block-level ─────────────────────────────────────────────────────────────
|
|
39
|
+
export function MarkdownPreview({ content, className }: { content: string; className?: string }) {
|
|
40
|
+
const lines = content.replace(/\r\n/g, "\n").split("\n");
|
|
41
|
+
const blocks: ReactNode[] = [];
|
|
42
|
+
let i = 0;
|
|
43
|
+
let key = 0;
|
|
44
|
+
|
|
45
|
+
const flushList = (items: string[], ordered: boolean) => {
|
|
46
|
+
const Tag = ordered ? "ol" : "ul";
|
|
47
|
+
blocks.push(
|
|
48
|
+
<Tag key={`k${key++}`} className={cn("my-2 space-y-1 pl-5", ordered ? "list-decimal" : "list-disc")}>
|
|
49
|
+
{items.map((it, idx) => (
|
|
50
|
+
<li key={idx}>{renderInline(it, `li${key}-${idx}`)}</li>
|
|
51
|
+
))}
|
|
52
|
+
</Tag>,
|
|
53
|
+
);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
while (i < lines.length) {
|
|
57
|
+
const line = lines[i];
|
|
58
|
+
|
|
59
|
+
// Fenced code block.
|
|
60
|
+
if (/^```/.test(line.trim())) {
|
|
61
|
+
const buf: string[] = [];
|
|
62
|
+
i += 1;
|
|
63
|
+
while (i < lines.length && !/^```/.test(lines[i].trim())) buf.push(lines[i++]);
|
|
64
|
+
i += 1; // closing fence
|
|
65
|
+
blocks.push(
|
|
66
|
+
<pre key={`k${key++}`} className="my-2 overflow-x-auto rounded-lg bg-muted/60 p-3 font-mono text-[12px] leading-[1.6]">
|
|
67
|
+
<code>{buf.join("\n")}</code>
|
|
68
|
+
</pre>,
|
|
69
|
+
);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Blank line.
|
|
74
|
+
if (line.trim() === "") { i += 1; continue; }
|
|
75
|
+
|
|
76
|
+
// Headings.
|
|
77
|
+
const h = line.match(/^(#{1,6})\s+(.*)$/);
|
|
78
|
+
if (h) {
|
|
79
|
+
const level = h[1].length;
|
|
80
|
+
const sizes = ["text-2xl", "text-xl", "text-lg", "text-base", "text-sm", "text-sm"];
|
|
81
|
+
blocks.push(
|
|
82
|
+
<div key={`k${key++}`} className={cn("mt-3 mb-1 font-semibold text-foreground", sizes[level - 1])}>
|
|
83
|
+
{renderInline(h[2], `h${key}`)}
|
|
84
|
+
</div>,
|
|
85
|
+
);
|
|
86
|
+
i += 1;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Horizontal rule.
|
|
91
|
+
if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) {
|
|
92
|
+
blocks.push(<hr key={`k${key++}`} className="my-3 border-border" />);
|
|
93
|
+
i += 1;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Blockquote.
|
|
98
|
+
if (/^>\s?/.test(line)) {
|
|
99
|
+
const buf: string[] = [];
|
|
100
|
+
while (i < lines.length && /^>\s?/.test(lines[i])) buf.push(lines[i++].replace(/^>\s?/, ""));
|
|
101
|
+
blocks.push(
|
|
102
|
+
<blockquote key={`k${key++}`} className="my-2 border-l-2 border-border pl-3 text-muted-foreground">
|
|
103
|
+
{renderInline(buf.join(" "), `q${key}`)}
|
|
104
|
+
</blockquote>,
|
|
105
|
+
);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Unordered list.
|
|
110
|
+
if (/^\s*[-*+]\s+/.test(line)) {
|
|
111
|
+
const items: string[] = [];
|
|
112
|
+
while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) items.push(lines[i++].replace(/^\s*[-*+]\s+/, ""));
|
|
113
|
+
flushList(items, false);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Ordered list.
|
|
118
|
+
if (/^\s*\d+\.\s+/.test(line)) {
|
|
119
|
+
const items: string[] = [];
|
|
120
|
+
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) items.push(lines[i++].replace(/^\s*\d+\.\s+/, ""));
|
|
121
|
+
flushList(items, true);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Paragraph (gather consecutive non-blank, non-structural lines).
|
|
126
|
+
const para: string[] = [];
|
|
127
|
+
while (
|
|
128
|
+
i < lines.length &&
|
|
129
|
+
lines[i].trim() !== "" &&
|
|
130
|
+
!/^(#{1,6})\s/.test(lines[i]) &&
|
|
131
|
+
!/^```/.test(lines[i].trim()) &&
|
|
132
|
+
!/^>\s?/.test(lines[i]) &&
|
|
133
|
+
!/^\s*[-*+]\s+/.test(lines[i]) &&
|
|
134
|
+
!/^\s*\d+\.\s+/.test(lines[i])
|
|
135
|
+
) {
|
|
136
|
+
para.push(lines[i++]);
|
|
137
|
+
}
|
|
138
|
+
blocks.push(
|
|
139
|
+
<p key={`k${key++}`} className="my-2 leading-relaxed">
|
|
140
|
+
{renderInline(para.join(" "), `p${key}`)}
|
|
141
|
+
</p>,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return <div className={cn("text-sm text-foreground/90", className)}>{blocks}</div>;
|
|
146
|
+
}
|