feat(settings): data-safe editors, deep-linked search, Data/About/Cost pages

- EditorModal loads once per path (a parent re-render no longer reloads the
  file over the user's typing), refuses Save after a failed load, opens a
  truncated (>256 KB) file read-only, and asks before overwriting a file
  that changed on disk or could not be re-read.
- Settings/Hooks editors unset only keys from the snapshot taken at Edit,
  warn when the file changed underneath, keep a renamed key's value, and
  refuse a new key named like a redacted one (env) or a read-only hook
  event, which used to replace the secret/hooks.
- Project scope shows its resolved folder and is disabled inside the
  plugin cache. Account-profile, pricing-row and pet deletes confirm;
  two-click actions disarm after 3 s; unsaved drafts ask before closing.
- Search indexes every Claude Code / Codex sub-tab, opens it via ?tab= and
  scrolls to + highlights the row; the active config tab is in the URL.
- General splits into General / Data / About; Pricing moves to a Cost
  group with one shared load; config tabs group as Content / Extensions /
  Config with arrow-key roving; Promise.allSettled per tab.
- Data: browser-preferences export/import (allowlisted keys), per-section
  reset, housekeeping dry-run first, clear-all behind a typed phrase.
  Server: purge days must be a whole number >= 1 (INVALID_PURGE_DAYS) and
  dry_run returns exactly what the real run would delete.
- Alerts can be edited; webhooks/alerts/remote-source failures toast;
  copies go through copyText; modals get dialog roles and useDialog.
- Sagi: Ctrl/Cmd+B only when shown and not typing, default spot clear of
  row controls, arrow keys move it, reduced motion honoured; the dead
  /run launch link goes to the workspace.
This commit is contained in:
2026-10-05 13:22:08 +07:00
parent 03f1673fce
commit 6715fbd971
68 changed files with 4817 additions and 1374 deletions
+61 -102
View File
@@ -1,19 +1,17 @@
/**
* @file ConfigTabBar.tsx
* @description The tab strip both config explorers wear: one horizontally
* scrollable row with fade edges and chevrons that appear only when there is
* something to scroll to.
* @description The tab strip both config explorers wear: tabs WRAP instead of
* scrolling behind a hidden scrollbar, an optional `group` per tab draws a
* labelled separator between groups (Content · Extensions · Config), and the
* strip is a real ARIA tablist — roving tabindex, ←/→/Home/End move and
* select, each tab `aria-controls` the panel.
*
* Shared rather than copied because the two pages must look the same. Codex
* originally wrapped its tabs onto a second row — fine at eight tabs, but it
* read as a different component sitting next to Claude Code's twelve-tab
* scroller. One row that scrolls is the same shape at any tab count.
* Shared rather than copied because the two pages must look the same.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState, type ComponentType } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Fragment, useRef, type ComponentType, type KeyboardEvent } from "react";
export interface ConfigTab<K extends string> {
key: K;
@@ -21,15 +19,19 @@ export interface ConfigTab<K extends string> {
label: string;
/** Badge number, or null for tabs that count nothing (e.g. Overview). */
count: number | null;
/** Group label; a change of group between neighbours draws a separator. */
group?: string;
}
interface ConfigTabBarProps<K extends string> {
tabs: ConfigTab<K>[];
current: K;
onSelect: (key: K) => void;
/** `data-testid` becomes `<prefix>-<key>`, e.g. "cc-tab-plugins". */
/** `data-testid` (and the tab's id) becomes `<prefix>-<key>`, e.g. "cc-tab-plugins". */
testIdPrefix: string;
ariaLabel: string;
/** The id of the tabpanel element every tab controls. */
panelId?: string;
}
export function ConfigTabBar<K extends string>({
@@ -38,90 +40,63 @@ export function ConfigTabBar<K extends string>({
onSelect,
testIdPrefix,
ariaLabel,
panelId,
}: ConfigTabBarProps<K>) {
const scrollRef = useRef<HTMLDivElement | null>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const listRef = useRef<HTMLDivElement | null>(null);
// Update scroll affordances when content size or scroll position changes.
const updateAffordances = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
setCanScrollLeft(el.scrollLeft > 1);
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
}, []);
useEffect(() => {
updateAffordances();
const el = scrollRef.current;
if (!el) return;
el.addEventListener("scroll", updateAffordances, { passive: true });
const ro = new ResizeObserver(updateAffordances);
ro.observe(el);
return () => {
el.removeEventListener("scroll", updateAffordances);
ro.disconnect();
};
}, [updateAffordances]);
// Scroll the active tab into view when it changes (e.g. user picks a tab
// that's offscreen, or window resize hides the active one).
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const active = el.querySelector<HTMLElement>('[data-tab-active="true"]');
if (!active) return;
const elRect = el.getBoundingClientRect();
const activeRect = active.getBoundingClientRect();
if (activeRect.left < elRect.left + 8) {
el.scrollBy({ left: activeRect.left - elRect.left - 16, behavior: "smooth" });
} else if (activeRect.right > elRect.right - 8) {
el.scrollBy({ left: activeRect.right - elRect.right + 16, behavior: "smooth" });
}
}, [current]);
const scrollByButton = (dir: 1 | -1) => {
const el = scrollRef.current;
if (!el) return;
el.scrollBy({ left: dir * Math.max(200, el.clientWidth * 0.6), behavior: "smooth" });
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const i = tabs.findIndex((t) => t.key === current);
const next =
e.key === "ArrowRight"
? (i + 1) % tabs.length
: e.key === "ArrowLeft"
? (i - 1 + tabs.length) % tabs.length
: e.key === "Home"
? 0
: e.key === "End"
? tabs.length - 1
: -1;
if (next < 0) return;
e.preventDefault();
const key = tabs[next]!.key;
onSelect(key);
listRef.current?.querySelector<HTMLElement>(`[data-testid="${testIdPrefix}-${key}"]`)?.focus();
};
return (
<div className="relative rounded-xl border border-border bg-surface-1">
{/* Left edge gradient + chevron */}
<div
className={`pointer-events-none absolute left-0 top-0 bottom-0 w-12 rounded-l-xl bg-gradient-to-r from-surface-1 to-transparent transition-opacity z-10 ${
canScrollLeft ? "opacity-100" : "opacity-0"
}`}
/>
{canScrollLeft && (
<button
onClick={() => scrollByButton(-1)}
aria-label="scroll tabs left"
className="absolute left-1 top-1/2 -translate-y-1/2 z-20 rounded-md w-7 h-7 flex items-center justify-center bg-surface-2 border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
>
<ChevronLeft className="w-4 h-4" />
</button>
)}
<div
ref={scrollRef}
role="tablist"
aria-label={ariaLabel}
className="flex gap-1 p-1 overflow-x-auto scroll-smooth [&::-webkit-scrollbar]:hidden"
style={{ scrollbarWidth: "none" }}
>
{tabs.map(({ key, icon: Icon, label, count }) => {
const active = current === key;
return (
<div
ref={listRef}
role="tablist"
aria-label={ariaLabel}
onKeyDown={onKeyDown}
className="flex flex-wrap items-center gap-1 rounded-xl border border-border bg-surface-1 p-1"
>
{tabs.map(({ key, icon: Icon, label, count, group }, i) => {
const active = current === key;
const newGroup = group && group !== tabs[i - 1]?.group;
return (
<Fragment key={key}>
{newGroup && (
<span
aria-hidden
className={`flex items-center gap-1 px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted ${
i > 0 ? "ml-1 border-l border-border pl-2" : ""
}`}
>
{group}
</span>
)}
<button
key={key}
type="button"
id={`${testIdPrefix}-${key}`}
data-testid={`${testIdPrefix}-${key}`}
data-tab-active={active ? "true" : undefined}
role="tab"
aria-selected={active}
aria-controls={panelId}
tabIndex={active ? 0 : -1}
onClick={() => onSelect(key)}
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap ${
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors whitespace-nowrap focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 ${
active
? "bg-accent/15 text-accent border border-accent/30"
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
@@ -139,25 +114,9 @@ export function ConfigTabBar<K extends string>({
</span>
)}
</button>
);
})}
</div>
{/* Right edge gradient + chevron */}
<div
className={`pointer-events-none absolute right-0 top-0 bottom-0 w-12 rounded-r-xl bg-gradient-to-l from-surface-1 to-transparent transition-opacity z-10 ${
canScrollRight ? "opacity-100" : "opacity-0"
}`}
/>
{canScrollRight && (
<button
onClick={() => scrollByButton(1)}
aria-label="scroll tabs right"
className="absolute right-1 top-1/2 -translate-y-1/2 z-20 rounded-md w-7 h-7 flex items-center justify-center bg-surface-2 border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
>
<ChevronRight className="w-4 h-4" />
</button>
)}
</Fragment>
);
})}
</div>
);
}
@@ -6,7 +6,8 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { useDialog } from "../../hooks/useDialog";
import { useTranslation } from "react-i18next";
import { ShieldAlert, Trash2 } from "lucide-react";
import type { CcArtifactType } from "../../lib/api";
@@ -41,13 +42,10 @@ export function ConfirmDeleteModal<
const { t } = useTranslation("ccConfig");
const [busy, setBusy] = useState(false);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
const panelRef = useRef<HTMLDivElement>(null);
const cancelRef = useRef<HTMLButtonElement>(null);
// Focus starts on Cancel — the safe answer — and Escape cancels.
useDialog(true, panelRef, busy ? undefined : onCancel, cancelRef);
const handleConfirm = useCallback(async () => {
setBusy(true);
@@ -60,27 +58,36 @@ export function ConfirmDeleteModal<
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onCancel}
className="fixed inset-0 bg-surface-0/70 z-50 flex items-center justify-center p-4"
onClick={busy ? undefined : onCancel}
>
<div
ref={panelRef}
role="alertdialog"
aria-modal="true"
aria-labelledby="cc-confirm-delete-title"
aria-describedby="cc-confirm-delete-body"
className="relative w-full max-w-lg rounded-xl border border-status-danger/40 bg-surface-1"
onClick={(e) => e.stopPropagation()}
>
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2">
<ShieldAlert className="w-4 h-4 text-status-danger" />
<span className="text-sm font-medium text-fg-primary flex-1">
<span id="cc-confirm-delete-title" className="text-sm font-medium text-fg-primary flex-1">
{t("edit.confirmDelete")}
</span>
</div>
<div className="p-4 space-y-3">
<p className="text-xs text-fg-secondary leading-relaxed">{t("edit.confirmDeleteBody")}</p>
<p id="cc-confirm-delete-body" className="text-xs text-fg-secondary leading-relaxed">
{t("edit.confirmDeleteBody")}
</p>
<div className="rounded-md border border-border bg-surface-2 px-3 py-2 font-mono text-[11px] text-fg-secondary break-all">
{t("edit.confirmDeletePath", { path: state.path })}
</div>
</div>
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
<button
ref={cancelRef}
type="button"
onClick={onCancel}
disabled={busy}
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 disabled:opacity-60"
@@ -88,6 +95,7 @@ export function ConfirmDeleteModal<
{t("edit.cancel")}
</button>
<button
type="button"
onClick={handleConfirm}
disabled={busy}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-status-danger/50 bg-status-danger/15 hover:bg-status-danger/25 text-status-danger inline-flex items-center gap-1.5 disabled:opacity-60"
+144 -38
View File
@@ -3,14 +3,27 @@
* @description Create/edit modal for text-file config artifacts (skills,
* agents, commands, output styles, memory). Tool-agnostic: it takes a
* `fetchContent` callback instead of reaching for any particular tool's API,
* so the same modal serves both the Claude Code and Codex config pages.
* so the same modal serves both the Claude Code and Codex config pages. It
* loads a file ONCE per path (never over the user's typing), refuses to save
* over a failed or truncated read, asks before discarding edits or overwriting
* a file changed on disk, and is a real dialog (focus trap, Escape).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pencil, X, Save, AlertCircle } from "lucide-react";
import type { CcArtifactType } from "../../lib/api";
import { confirmDialog, confirmDiscard } from "../../lib/confirm";
import { useDialog } from "../../hooks/useDialog";
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
/** What a fetcher may answer: the text, or the API's `{text, truncated}`. */
type Fetched = string | { text: string; truncated?: boolean };
const textOf = (r: Fetched) => (typeof r === "string" ? r : r.text);
/** Same rule the server applies to an artifact name. */
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
/** Claude Code's own scope union, kept as the default so existing callers
* (CcConfig.tsx) don't need to name it. Codex has no `auto-memory` scope —
@@ -48,7 +61,7 @@ interface EditorModalProps<
TScope extends string = DefaultScope,
> {
state: NonNullable<EditorState<TType, TScope>>;
fetchContent: (filePath: string) => Promise<string>;
fetchContent: (filePath: string) => Promise<Fetched>;
onClose: () => void;
onSave: (args: {
type: TType;
@@ -60,6 +73,8 @@ interface EditorModalProps<
/** Which `state.type` values are nameless singleton files. Defaults to
* Claude Code's own `["memory"]` so existing callers are unaffected. */
namelessTypes?: readonly string[];
/** Refuse the Project target (its folder is the plugin cache, wiped on update). */
projectDisabled?: boolean;
}
export function EditorModal<
@@ -71,6 +86,7 @@ export function EditorModal<
onClose,
onSave,
namelessTypes = DEFAULT_NAMELESS_TYPES,
projectDisabled = false,
}: EditorModalProps<TType, TScope>) {
const { t } = useTranslation("ccConfig");
const isCreate = state.mode === "create";
@@ -88,41 +104,110 @@ export function EditorModal<
const [loading, setLoading] = useState(!isCreate);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// "ok" once an edit's text is in; a failed or truncated load never saves —
// Save over a failed load wrote an EMPTY file, over a truncated one a cut file.
const [loadState, setLoadState] = useState<"ok" | "failed" | "truncated">(
isCreate ? "ok" : "failed"
);
// The text as opened; `dirty` and the changed-on-disk check compare to it.
const original = useRef<string>(isCreate ? state.template : "");
const panelRef = useRef<HTMLDivElement>(null);
const titleId = "cc-editor-title";
const dirty = !loading && content !== original.current;
useUnsavedGuard(dirty);
// For edit mode, fetch the actual file content
useEffect(() => {
if (state.mode === "edit") {
setLoading(true);
fetchContent(state.filePath)
.then((text) => {
setContent(text);
setLoading(false);
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : "unknown";
setError(msg);
setLoading(false);
});
}
}, [state, fetchContent]);
// Read through a ref: callers pass an inline fetcher, and a dependency on it
// reloaded the file on every parent render — overwriting the user's typing.
const fetchRef = useRef(fetchContent);
fetchRef.current = fetchContent;
const editPath = state.mode === "edit" ? state.filePath : null;
// Esc to close
// `t` through a ref too: a language switch must not re-run the load below.
const tRef = useRef(t);
tRef.current = t;
// For edit mode, fetch the file once per path.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (editPath === null) return;
let live = true;
setLoading(true);
fetchRef
.current(editPath)
.then((r) => {
if (!live) return;
const text = textOf(r);
original.current = text;
setContent(text);
const cut = typeof r !== "string" && !!r.truncated;
setLoadState(cut ? "truncated" : "ok");
if (cut) setError(tRef.current("edit.truncatedReadOnly"));
setLoading(false);
})
.catch((err: unknown) => {
if (!live) return;
const msg = err instanceof Error ? err.message : "unknown";
setLoadState("failed");
setError(tRef.current("edit.loadError", { message: msg }));
setLoading(false);
});
return () => {
live = false;
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
}, [editPath]);
const requestClose = useCallback(async () => {
if (dirty && !(await confirmDiscard())) return;
onClose();
}, [dirty, onClose]);
// Escape closes — but not while typing in the textarea, where it only leaves
// the field (an editor's Escape must never cost a draft).
useDialog(true, panelRef, () => {
const el = document.activeElement;
if (el instanceof HTMLTextAreaElement) {
el.blur();
return;
}
void requestClose();
});
const handleSave = useCallback(async () => {
setSaving(true);
setError(null);
try {
if (!isNameless && state.mode === "create" && !name) {
setError(t("edit.nameLabel"));
setSaving(false);
return;
if (!isNameless && state.mode === "create") {
if (!name.trim()) {
setError(t("edit.nameRequired"));
return;
}
if (!isAutoMemory && !NAME_RE.test(name)) {
setError(t("edit.nameInvalid"));
return;
}
}
// The file changed on disk since it was opened (a running session, a
// terminal): say so instead of silently overwriting that change.
if (state.mode === "edit") {
const fresh = await fetchRef
.current(state.filePath)
.then(textOf)
.catch(() => null);
// Could not re-read it: say so rather than saving blind.
const ask =
fresh === null
? {
title: t("edit.unverifiedTitle"),
message: t("edit.unverifiedBody"),
confirmLabel: t("edit.saveAnyway"),
}
: fresh !== original.current
? {
title: t("edit.changedOnDiskTitle"),
message: t("edit.changedOnDiskBody"),
confirmLabel: t("edit.overwrite"),
}
: null;
if (ask && !(await confirmDialog(ask))) return;
}
// For auto-memory creates, append a .md extension when the user omits it.
const createName = isAutoMemory && !/\.md$/i.test(name) ? `${name}.md` : name;
@@ -140,13 +225,14 @@ export function EditorModal<
content,
project: state.project,
});
original.current = content;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "unknown";
setError(t("edit.writeError", { message: msg }));
} finally {
setSaving(false);
}
}, [state, targetScope, name, content, isAutoMemory, onSave, t]);
}, [state, targetScope, name, content, isAutoMemory, isNameless, onSave, t]);
const titleText = isCreate
? isAutoMemory
@@ -156,18 +242,25 @@ export function EditorModal<
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onClose}
className="fixed inset-0 bg-surface-0/70 z-50 flex items-center justify-center p-4"
onClick={() => void requestClose()}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="relative w-full max-w-4xl 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">
<Pencil className="w-4 h-4 text-fg-muted" />
<span className="text-sm font-medium text-fg-primary flex-1 truncate">{titleText}</span>
<span id={titleId} className="text-sm font-medium text-fg-primary flex-1 truncate">
{titleText}
</span>
<button
onClick={onClose}
type="button"
onClick={() => void requestClose()}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("common.close")}
>
@@ -214,8 +307,14 @@ export function EditorModal<
{(["user", "project"] as const).map((s) => (
<button
key={s}
type="button"
aria-pressed={targetScope === s}
disabled={s === "project" && projectDisabled}
title={
s === "project" && projectDisabled ? t("scope.projectInCache") : undefined
}
onClick={() => setTargetScope(s)}
className={`px-3 py-1 text-[11px] font-medium rounded ${
className={`px-3 py-1 text-[11px] font-medium rounded disabled:opacity-40 disabled:cursor-not-allowed ${
targetScope === s
? "bg-accent/20 text-accent border border-accent/30"
: "text-fg-secondary hover:text-fg-primary"
@@ -240,6 +339,8 @@ export function EditorModal<
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
readOnly={loadState !== "ok"}
aria-label={t("edit.contentLabel")}
spellCheck={false}
className="w-full h-72 bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-y"
/>
@@ -247,7 +348,10 @@ export function EditorModal<
</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">
<div
role="alert"
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>
@@ -256,14 +360,16 @@ export function EditorModal<
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
<button
onClick={onClose}
type="button"
onClick={() => void requestClose()}
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("edit.cancel")}
</button>
<button
type="button"
onClick={handleSave}
disabled={saving || loading}
disabled={saving || loading || loadState !== "ok"}
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" />
+14 -10
View File
@@ -8,7 +8,8 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useRef, useState } from "react";
import { useDialog } from "../../hooks/useDialog";
import { useTranslation } from "react-i18next";
import { FileText, X, AlertCircle, Eye, Code2 } from "lucide-react";
import type { CcFileResponse } from "../../lib/api";
@@ -25,25 +26,27 @@ export function FileViewer({
const { t } = useTranslation("ccConfig");
const [preview, setPreview] = useState(false);
const canPreview = isMarkdown(state.path) && !!state.data;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const panelRef = useRef<HTMLDivElement>(null);
useDialog(true, panelRef, onClose);
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
className="fixed inset-0 bg-surface-0/70 z-50 flex items-center justify-center p-4"
onClick={onClose}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby="cc-file-viewer-title"
className="relative w-full max-w-4xl max-h-[85vh] 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">
<FileText className="w-4 h-4 text-fg-muted" />
<span className="font-mono text-[12px] text-fg-secondary truncate flex-1">
<span
id="cc-file-viewer-title"
className="font-mono text-[12px] text-fg-secondary truncate flex-1"
>
{state.path}
</span>
{canPreview && (
@@ -59,6 +62,7 @@ export function FileViewer({
)}
<CopyButton value={state.path} />
<button
type="button"
onClick={onClose}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("common.close")}
+2 -4
View File
@@ -13,6 +13,7 @@ import { useMemo, type MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import { ExternalLink, Pencil, Trash2 } from "lucide-react";
import type { CcArtifactType, CcMdItem } from "../../lib/api";
import { copyText } from "../../lib/clipboard";
import {
ContextMenu,
MenuItem,
@@ -83,10 +84,7 @@ export function FileItemMenu({
<MenuItem testId="config-item-menu-edit" onSelect={pick(onEdit)}>
{l.edit}
</MenuItem>
<MenuItem
testId="config-item-menu-copy"
onSelect={pick(() => void navigator.clipboard.writeText(path))}
>
<MenuItem testId="config-item-menu-copy" onSelect={pick(() => void copyText(path))}>
{l.copyPath}
</MenuItem>
<MenuSeparator />
@@ -8,15 +8,19 @@
* 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.
* modal exists for, so it must stay typeable. Typed text is a draft: closing
* asks first, and it is a real dialog (focus trap, Escape).
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useId, useState } from "react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { BookOpen, X, Save, AlertCircle } from "lucide-react";
import { api, type CwdSuggestion } from "../../lib/api";
import { confirmDiscard } from "../../lib/confirm";
import { useDialog } from "../../hooks/useDialog";
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
interface ProjectMemoryModalProps {
/** i18n namespace of the calling page — "ccConfig" or "codexConfig". */
@@ -55,13 +59,23 @@ export function ProjectMemoryModal({
.catch(() => undefined);
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
// Typed text is a draft: closing (X, Cancel, backdrop, Escape) asks first,
// and Escape inside the textarea only leaves the field.
const dirty = dir.trim() !== "" || content !== template;
useUnsavedGuard(dirty);
const panelRef = useRef<HTMLDivElement>(null);
const requestClose = useCallback(async () => {
if (dirty && !(await confirmDiscard())) return;
onClose();
}, [dirty, onClose]);
useDialog(true, panelRef, () => {
const el = document.activeElement;
if (el instanceof HTMLTextAreaElement) {
el.blur();
return;
}
void requestClose();
});
const handleCreate = useCallback(async () => {
const target = dir.trim();
@@ -82,22 +96,28 @@ export function ProjectMemoryModal({
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onClose}
className="fixed inset-0 bg-surface-0/70 z-50 flex items-center justify-center p-4"
onClick={() => void requestClose()}
>
<div
ref={panelRef}
role="dialog"
aria-label={t("projectMemory.title", { file: filename })}
aria-modal="true"
aria-labelledby={`${listId}-title`}
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">
<span
id={`${listId}-title`}
className="text-sm font-medium text-fg-primary flex-1 truncate"
>
{t("projectMemory.title", { file: filename })}
</span>
<button
onClick={onClose}
type="button"
onClick={() => void requestClose()}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("projectMemory.close")}
>
@@ -151,7 +171,10 @@ export function ProjectMemoryModal({
</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">
<div
role="alert"
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>
@@ -160,12 +183,14 @@ export function ProjectMemoryModal({
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
<button
onClick={onClose}
type="button"
onClick={() => void requestClose()}
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
type="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"
+9 -37
View File
@@ -1,46 +1,18 @@
/**
* @file ToastNotice.tsx
* @description Bottom-right success/error toast for the config explorer.
* Tool-agnostic — the page owns the 5s auto-dismiss timer and passes the
* message down.
* @description The config explorers' `{kind, message}` toast shape and
* `showToast`, which hands it to the app-wide toast queue (`lib/toast.ts`): a
* success fades on its own, an error stays until dismissed. The panels keep
* taking an `onToast(Toast)` prop, so this is the one place it is rendered.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { AlertCircle, Check, X } from "lucide-react";
import { toast } from "../../lib/toast";
export type Toast = { kind: "success" | "error"; message: string } | null;
export function ToastNotice({
toast,
onDismiss,
}: {
toast: NonNullable<Toast>;
onDismiss: () => void;
}) {
const isErr = toast.kind === "error";
return (
<div className="fixed bottom-6 right-6 z-50 max-w-md">
<div
className={`rounded-lg border px-3 py-2 shadow-lg flex items-start gap-2 ${
isErr
? "border-status-danger/50 bg-status-danger/15 text-status-danger"
: "border-status-success/40 bg-status-success/10 text-status-success"
}`}
>
{isErr ? (
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
) : (
<Check className="w-4 h-4 flex-shrink-0 mt-0.5" />
)}
<span className="text-xs leading-relaxed flex-1 break-all">{toast.message}</span>
<button
onClick={onDismiss}
className="text-current/70 hover:text-current p-0.5"
aria-label="dismiss"
>
<X className="w-3 h-3" />
</button>
</div>
</div>
);
/** Show a config-page toast through the global queue. */
export function showToast(t: NonNullable<Toast>): void {
if (t.kind === "error") toast.error(t.message);
else toast.success(t.message);
}
@@ -7,9 +7,11 @@
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, act } from "@testing-library/react";
import i18n from "../../../i18n";
import userEvent from "@testing-library/user-event";
import { EditorModal } from "../EditorModal";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../../lib/confirm";
describe("EditorModal", () => {
it("loads the existing body through the injected fetcher, not a hardcoded API", async () => {
@@ -46,3 +48,156 @@ describe("EditorModal", () => {
expect(onSave).not.toHaveBeenCalled();
});
});
describe("EditorModal — data safety", () => {
const edit = {
mode: "edit" as const,
type: "agents" as const,
scope: "user" as const,
name: "a",
filePath: "/r/a.md",
};
it("loads once per file: a parent re-render with a new inline fetcher never overwrites typing", async () => {
let calls = 0;
const mk = () => async () => {
calls++;
return "disk";
};
const { rerender } = render(
<EditorModal
state={{ ...edit }}
fetchContent={mk()}
onClose={() => {}}
onSave={async () => {}}
/>
);
const box = await screen.findByDisplayValue("disk");
await userEvent.type(box, " mine");
rerender(
<EditorModal
state={{ ...edit }}
fetchContent={mk()}
onClose={() => {}}
onSave={async () => {}}
/>
);
await new Promise((r) => setTimeout(r, 20));
expect(calls).toBe(1);
expect(screen.getByDisplayValue("disk mine")).toBeInTheDocument();
});
it("disables Save after a failed load, so it can never write an empty file", async () => {
const onSave = vi.fn();
render(
<EditorModal
state={edit}
fetchContent={async () => {
throw new Error("nope");
}}
onClose={() => {}}
onSave={onSave}
/>
);
expect(await screen.findByText(/nope/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /save/i })).toBeDisabled();
});
it("opens a truncated file read-only with Save disabled", async () => {
render(
<EditorModal
state={edit}
fetchContent={async () => ({ text: "partial", truncated: true })}
onClose={() => {}}
onSave={async () => {}}
/>
);
const box = await screen.findByDisplayValue("partial");
expect(box).toHaveAttribute("readonly");
expect(screen.getByRole("button", { name: /save/i })).toBeDisabled();
});
it("Escape inside the textarea does not close the editor", async () => {
const onClose = vi.fn();
render(
<EditorModal
state={edit}
fetchContent={async () => "x"}
onClose={onClose}
onSave={async () => {}}
/>
);
const box = await screen.findByDisplayValue("x");
box.focus();
await userEvent.keyboard("{Escape}");
expect(onClose).not.toHaveBeenCalled();
});
it("closing with unsaved edits asks first", async () => {
const onClose = vi.fn();
render(
<EditorModal
state={edit}
fetchContent={async () => "x"}
onClose={onClose}
onSave={async () => {}}
/>
);
await userEvent.type(await screen.findByDisplayValue("x"), "y");
await userEvent.click(screen.getByRole("button", { name: /cancel/i }));
expect(onClose).not.toHaveBeenCalled();
let pending: PendingConfirm | null = null;
const off = subscribeConfirm((p) => (pending = p));
expect(pending).not.toBeNull();
settleConfirm(true);
off();
await vi.waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("asks before saving when the on-disk check cannot be read", async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
let n = 0;
render(
<EditorModal
state={edit}
fetchContent={async () => {
n++;
if (n > 1) throw new Error("offline");
return "x";
}}
onClose={() => {}}
onSave={onSave}
/>
);
await userEvent.type(await screen.findByDisplayValue("x"), "y");
let pending: PendingConfirm | null = null;
const off = subscribeConfirm((p) => (pending = p));
await userEvent.click(screen.getByRole("button", { name: /save/i }));
await vi.waitFor(() => expect(pending).not.toBeNull());
expect(onSave).not.toHaveBeenCalled();
settleConfirm(true);
off();
await vi.waitFor(() => expect(onSave).toHaveBeenCalled());
});
it("a language switch mid-edit does not reload the file over typing", async () => {
const fetchContent = vi.fn().mockResolvedValue("disk");
render(
<EditorModal
state={edit}
fetchContent={fetchContent}
onClose={() => {}}
onSave={async () => {}}
/>
);
await userEvent.type(await screen.findByDisplayValue("disk"), " mine");
await act(async () => {
await i18n.changeLanguage("vi");
});
await act(async () => {
await i18n.changeLanguage("en");
});
expect(fetchContent).toHaveBeenCalledTimes(1);
expect(screen.getByDisplayValue("disk mine")).toBeInTheDocument();
});
});
@@ -10,23 +10,23 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Copy, Check } from "lucide-react";
import { copyText } from "../../lib/clipboard";
export function CopyButton({ value }: { value: string }) {
const { t } = useTranslation("ccConfig");
const [copied, setCopied] = useState(false);
return (
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(value);
if (await copyText(value, true)) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* clipboard unavailable */
}
}}
className="text-[10px] font-medium px-1.5 py-1 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1 flex-shrink-0"
title={t("common.copyPath")}
aria-label={t("common.copyPath")}
>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
</button>