@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.
Files changed (60) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/skills/index.js +9 -0
  3. package/src/core/agent/skills/inspector.js +7 -2
  4. package/src/core/agent/skills/policy.js +128 -0
  5. package/src/core/agent/super-agent.js +8 -1
  6. package/src/core/agent/tools/handlers/list-skills.js +6 -3
  7. package/src/core/agent/tools/handlers/load-skill.js +9 -2
  8. package/src/core/apc/paths.js +7 -0
  9. package/src/core/stores/organization.js +152 -0
  10. package/src/core/stores/project-files.js +199 -0
  11. package/src/core/stores/tasks.js +36 -3
  12. package/src/host/daemon/api/agents.js +22 -2
  13. package/src/host/daemon/api/files-project.js +99 -0
  14. package/src/host/daemon/api/organization.js +88 -0
  15. package/src/host/daemon/api/shared.js +7 -0
  16. package/src/host/daemon/api/skills.js +301 -17
  17. package/src/host/daemon/api/tasks.js +14 -0
  18. package/src/host/daemon/api.js +4 -0
  19. package/src/interfaces/cli/commands/org.js +77 -0
  20. package/src/interfaces/cli/commands/skills.js +3 -2
  21. package/src/interfaces/cli/index.js +48 -0
  22. package/src/interfaces/web/dist/assets/index-CnQb4N6C.js +731 -0
  23. package/src/interfaces/web/dist/assets/index-CnQb4N6C.js.map +1 -0
  24. package/src/interfaces/web/dist/assets/index-Dv3X-zpx.css +1 -0
  25. package/src/interfaces/web/dist/index.html +2 -2
  26. package/src/interfaces/web/src/components/agents/AgentFormFields.tsx +123 -0
  27. package/src/interfaces/web/src/components/common/ConfirmDialog.tsx +51 -0
  28. package/src/interfaces/web/src/components/files/FileBrowser.tsx +138 -0
  29. package/src/interfaces/web/src/components/files/FileTree.tsx +133 -0
  30. package/src/interfaces/web/src/components/files/FileViewer.tsx +167 -0
  31. package/src/interfaces/web/src/components/files/MarkdownEditor.tsx +48 -0
  32. package/src/interfaces/web/src/components/files/MarkdownPreview.tsx +146 -0
  33. package/src/interfaces/web/src/components/files/NewFileDialog.tsx +66 -0
  34. package/src/interfaces/web/src/components/settings/SkillsManager.tsx +465 -0
  35. package/src/interfaces/web/src/components/settings/SkillsSettings.tsx +49 -0
  36. package/src/interfaces/web/src/components/structure/StructureDialogs.tsx +172 -0
  37. package/src/interfaces/web/src/components/tasks/TaskDetailPanel.tsx +142 -0
  38. package/src/interfaces/web/src/components/tasks/taskStatus.tsx +57 -0
  39. package/src/interfaces/web/src/i18n/en.ts +171 -0
  40. package/src/interfaces/web/src/i18n/es.ts +171 -0
  41. package/src/interfaces/web/src/lib/api/organization.ts +18 -0
  42. package/src/interfaces/web/src/lib/api/projectFiles.ts +19 -0
  43. package/src/interfaces/web/src/lib/api/skills.ts +79 -8
  44. package/src/interfaces/web/src/lib/api/tasks.ts +16 -1
  45. package/src/interfaces/web/src/lib/api.ts +2 -0
  46. package/src/interfaces/web/src/lib/slug.ts +11 -0
  47. package/src/interfaces/web/src/screens/ProjectScreen.tsx +25 -2
  48. package/src/interfaces/web/src/screens/SettingsScreen.tsx +3 -3
  49. package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +31 -11
  50. package/src/interfaces/web/src/screens/project/AgentsTab.tsx +24 -7
  51. package/src/interfaces/web/src/screens/project/DocsTab.tsx +13 -0
  52. package/src/interfaces/web/src/screens/project/FilesTab.tsx +12 -0
  53. package/src/interfaces/web/src/screens/project/Overview.tsx +122 -10
  54. package/src/interfaces/web/src/screens/project/SkillsTab.tsx +13 -0
  55. package/src/interfaces/web/src/screens/project/StructureTab.tsx +147 -0
  56. package/src/interfaces/web/src/screens/project/TasksTab.tsx +101 -62
  57. package/src/interfaces/web/src/types/daemon.ts +63 -0
  58. package/src/interfaces/web/dist/assets/index-CEI8DfVg.css +0 -1
  59. package/src/interfaces/web/dist/assets/index-DJ-ocXOR.js +0 -651
  60. 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
+ }