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:
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user