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
+55 -6
View File
@@ -6,17 +6,31 @@
* uploading an image or importing a `.codex-pet.zip`. Everything here is the
* same state and calls as the mascot's right-click menu (`Sagi/usePets.ts`,
* `Sagi/prefs.ts`): the choice and size are per browser, the pets live on the
* server (`/api/pets`).
* server (`/api/pets`). Previews follow the OS reduced-motion preference; size
* and bubbles are disabled while the pet is hidden; a delete asks first; and
* **Reset to defaults** puts show / bubbles / size / pet back.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useRef, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Check, Download, ExternalLink, Loader2, PackageOpen, Trash2, Upload } from "lucide-react";
import {
Check,
Download,
ExternalLink,
Loader2,
PackageOpen,
RotateCcw,
Trash2,
Upload,
} from "lucide-react";
import { SettingsRow, SettingsSwitchRow } from "./SettingsRow";
import { SagiAvatar } from "../Sagi/SagiAvatar";
import { PetSprite } from "../Sagi/PetSprite";
import { PET_SIZE, sagiPrefs } from "../Sagi/prefs";
import { usePrefersReducedMotion } from "../Sagi/Sagi";
import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast";
import { usePets, useWornPet } from "../Sagi/usePets";
import type { PetView } from "../../lib/api";
@@ -78,7 +92,9 @@ function PetCard({
disabled={disabled}
aria-label={deleteLabel}
title={deleteLabel}
className="absolute right-1.5 top-1.5 rounded p-1 text-fg-muted opacity-0 transition-opacity hover:bg-surface-4 hover:text-status-danger focus:opacity-100 group-hover:opacity-100"
className={`absolute right-1.5 top-1.5 rounded p-1 text-fg-muted transition-opacity hover:bg-surface-4 hover:text-status-danger focus:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100 ${
selected ? "opacity-100" : "opacity-0"
}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
@@ -88,13 +104,14 @@ function PetCard({
}
function Preview({ pet, size }: { pet: PetView; size: number }) {
const reducedMotion = usePrefersReducedMotion();
return (
<PetSprite
id={pet.id}
kind={pet.kind}
mood="idle"
size={size}
reducedMotion={false}
reducedMotion={reducedMotion}
onBroken={() => {}}
/>
);
@@ -108,6 +125,22 @@ export function PetSettings() {
const bundleInput = useRef<HTMLInputElement>(null);
const wornPet = list?.pets.find((p) => p.id === worn.pet.id);
const sizePreview = Math.min(worn.size, 120);
const reducedMotion = usePrefersReducedMotion();
const resetDefaults = async () => {
const ok = await confirmDialog({
title: t("sagi.pet.reset"),
message: t("sagi.pet.resetMessage"),
confirmLabel: t("sagi.pet.reset"),
destructive: false,
});
if (!ok) return;
sagiPrefs.setEnabled(true);
sagiPrefs.setMuted(false);
sagiPrefs.setSize(PET_SIZE.default);
sagiPrefs.setPet({ id: "sagi" });
toast.success(t("sagi.pet.resetDone"));
};
return (
<div className="space-y-6">
@@ -123,6 +156,7 @@ export function PetSettings() {
onChange={(v) => sagiPrefs.setMuted(!v)}
label={t("sagi.bubbles")}
description={t("sagi.bubblesDesc")}
disabled={!worn.shown}
/>
<SettingsRow
label={t("sagi.pet.size")}
@@ -136,8 +170,9 @@ export function PetSettings() {
min={PET_SIZE.min}
max={PET_SIZE.max}
value={worn.size}
disabled={!worn.shown}
onChange={(e) => sagiPrefs.setSize(Number(e.target.value))}
className="w-40 accent-[rgb(var(--accent))]"
className="w-40 accent-[rgb(var(--accent))] disabled:opacity-50"
/>
<span className="w-12 text-right text-xs tabular-nums text-fg-muted">
{worn.size}px
@@ -152,9 +187,23 @@ export function PetSettings() {
{wornPet ? (
<Preview pet={wornPet} size={sizePreview} />
) : (
<SagiAvatar mood="idle" reducedMotion={false} size={sizePreview} />
<SagiAvatar mood="idle" reducedMotion={reducedMotion} size={sizePreview} />
)}
</div>
{worn.size > 120 && (
<p className="mt-1.5 text-[11px] text-fg-muted">
{t("sagi.pet.previewCapped", { size: worn.size })}
</p>
)}
{!worn.shown && (
<p className="mt-1.5 text-[11px] text-fg-muted">{t("sagi.pet.hiddenNote")}</p>
)}
<div className="mt-3 flex justify-end">
<button type="button" className="btn-ghost text-xs" onClick={() => void resetDefaults()}>
<RotateCcw className="h-3.5 w-3.5" />
{t("sagi.pet.reset")}
</button>
</div>
</div>
<div className="card p-5">