85187dfc78
The Memory tab could only show this machine's user memory and the
dashboard's own project file. Setting up a project you had not run
through the dashboard meant opening a terminal — `PUT /file`'s
"project" scope always resolves against the server's cwd, on purpose.
Add one narrow route per tool that writes into the directory you name:
CLAUDE.md for Claude Code, AGENTS.md for Codex, both through the same
`project-memory.js`. Being the only write path that honours a
caller-supplied directory, it pays for that:
- the directory must be absolute and already exist — nothing is
created for you;
- the filename comes from the route, never from the body;
- an existing file, symlink included, is a 409 — this can only
create, never overwrite;
- it sits behind the loopback-Origin guard /api/run uses, so a page
in the user's browser cannot plant agent instructions in a project
directory. That guard moves to lib/same-origin.js to be shared.
The modal takes the dashboard's known project dirs as suggestions on a
free-text input rather than a closed list: a project it has never seen
is exactly the case this exists for.
Mutation-checked: dropping the origin guard, and swapping lstat for
stat, each fail a test.
181 lines
6.7 KiB
TypeScript
181 lines
6.7 KiB
TypeScript
/**
|
|
* @file ProjectMemoryModal.tsx
|
|
* @description Create a project's memory file — CLAUDE.md or AGENTS.md — in a
|
|
* directory that is not the dashboard's own. Shared by the Claude Code and
|
|
* Codex config pages; the caller supplies its i18n namespace and the create
|
|
* call, so this component knows about neither tool.
|
|
*
|
|
* The directory list is `GET /api/run/cwds` (the dashboard's known projects),
|
|
* offered as suggestions on a plain text input rather than a closed dropdown:
|
|
* a project you have never run through the dashboard is exactly the case this
|
|
* modal exists for, so it must stay typeable.
|
|
*
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useCallback, useEffect, useId, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { BookOpen, X, Save, AlertCircle } from "lucide-react";
|
|
import { api, type CwdSuggestion } from "../../lib/api";
|
|
|
|
interface ProjectMemoryModalProps {
|
|
/** i18n namespace of the calling page — "ccConfig" or "codexConfig". */
|
|
ns: string;
|
|
/** The file this will create, e.g. "CLAUDE.md". Shown, never sent. */
|
|
filename: string;
|
|
/** Starting body text, so the user edits a skeleton instead of a blank box. */
|
|
template: string;
|
|
onClose: () => void;
|
|
/** Resolves with the path actually written (the server realpaths the dir). */
|
|
onCreate: (args: { dir: string; content: string }) => Promise<string>;
|
|
}
|
|
|
|
export function ProjectMemoryModal({
|
|
ns,
|
|
filename,
|
|
template,
|
|
onClose,
|
|
onCreate,
|
|
}: ProjectMemoryModalProps) {
|
|
// Every key lives under `projectMemory.*` in BOTH namespaces, so this
|
|
// component never depends on the calling page's other key groups lining up.
|
|
const { t } = useTranslation(ns);
|
|
const listId = useId();
|
|
const [dir, setDir] = useState("");
|
|
const [content, setContent] = useState(template);
|
|
const [suggestions, setSuggestions] = useState<CwdSuggestion[]>([]);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
api.run
|
|
.cwds()
|
|
.then((r) => setSuggestions(r.items))
|
|
// Suggestions are a convenience; the input still works without them.
|
|
.catch(() => undefined);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") onClose();
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [onClose]);
|
|
|
|
const handleCreate = useCallback(async () => {
|
|
const target = dir.trim();
|
|
if (!target) {
|
|
setError(t("projectMemory.dirRequired"));
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
await onCreate({ dir: target, content });
|
|
} catch (err: unknown) {
|
|
setError(err instanceof Error ? err.message : "unknown");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}, [dir, content, onCreate, t]);
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
role="dialog"
|
|
aria-label={t("projectMemory.title", { file: filename })}
|
|
className="relative w-full max-w-3xl max-h-[90vh] rounded-xl border border-border bg-surface-1 flex flex-col"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
|
<BookOpen className="w-4 h-4 text-fg-muted" />
|
|
<span className="text-sm font-medium text-fg-primary flex-1 truncate">
|
|
{t("projectMemory.title", { file: filename })}
|
|
</span>
|
|
<button
|
|
onClick={onClose}
|
|
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
|
|
aria-label={t("projectMemory.close")}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="overflow-auto p-4 space-y-3">
|
|
<div>
|
|
<label
|
|
htmlFor={`${listId}-dir`}
|
|
className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
|
>
|
|
{t("projectMemory.dirLabel")}
|
|
</label>
|
|
<input
|
|
id={`${listId}-dir`}
|
|
list={listId}
|
|
value={dir}
|
|
onChange={(e) => setDir(e.target.value)}
|
|
placeholder="/home/you/projects/my-app"
|
|
spellCheck={false}
|
|
className="w-full bg-surface-2 border border-border rounded-md px-3 py-1.5 text-sm font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
|
|
/>
|
|
<datalist id={listId}>
|
|
{suggestions.map((s) => (
|
|
<option key={s.path} value={s.path}>
|
|
{s.label}
|
|
</option>
|
|
))}
|
|
</datalist>
|
|
<p className="mt-1 text-[10px] text-fg-muted">
|
|
{t("projectMemory.dirHelp", { file: filename })}
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
htmlFor={`${listId}-content`}
|
|
className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
|
>
|
|
{t("projectMemory.contentLabel")}
|
|
</label>
|
|
<textarea
|
|
id={`${listId}-content`}
|
|
value={content}
|
|
onChange={(e) => setContent(e.target.value)}
|
|
spellCheck={false}
|
|
className="w-full h-64 bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:border-accent/50 resize-y"
|
|
/>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger inline-flex items-center gap-2">
|
|
<AlertCircle className="w-3.5 h-3.5" />
|
|
{error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
|
|
<button
|
|
onClick={onClose}
|
|
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary"
|
|
>
|
|
{t("projectMemory.cancel")}
|
|
</button>
|
|
<button
|
|
onClick={handleCreate}
|
|
disabled={saving}
|
|
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent inline-flex items-center gap-1.5 disabled:opacity-60"
|
|
>
|
|
<Save className="w-3.5 h-3.5" />
|
|
{saving ? t("projectMemory.saving") : t("projectMemory.createButton")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|