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
+32 -17
View File
@@ -86,6 +86,7 @@ import { Checkbox } from "./Checkbox";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
import { WEBHOOK_DOCS } from "./webhookGuides";
import { timeAgo } from "../lib/format";
import { toast } from "../lib/toast";
import type {
AlertRule,
WebhookProvider,
@@ -94,20 +95,21 @@ import type {
WebhookTestResult,
} from "../lib/types";
// Brand-ish accent per provider type; anything unmapped falls back to neutral.
// Brand-ish accent per provider type, on the BORDER only: brand hues fail
// contrast as text on several themes, so the label stays text-fg-primary.
const TYPE_STYLES: Partial<Record<WebhookType, string>> = {
slack: "text-[#E01E5A] bg-[#E01E5A]/10 border-[#E01E5A]/20",
discord: "text-[#5865F2] bg-[#5865F2]/10 border-[#5865F2]/20",
teams: "text-[#6264A7] bg-[#6264A7]/10 border-[#6264A7]/20",
google_chat: "text-[#1A73E8] bg-[#1A73E8]/10 border-[#1A73E8]/20",
mattermost: "text-[#0058CC] bg-[#0058CC]/10 border-[#0058CC]/20",
rocketchat: "text-[#F5455C] bg-[#F5455C]/10 border-[#F5455C]/20",
telegram: "text-[#26A5E4] bg-[#26A5E4]/10 border-[#26A5E4]/20",
pagerduty: "text-[#06AC38] bg-[#06AC38]/10 border-[#06AC38]/20",
opsgenie: "text-[#2684FF] bg-[#2684FF]/10 border-[#2684FF]/20",
splunk_oncall: "text-[#F99D1C] bg-[#F99D1C]/10 border-[#F99D1C]/20",
slack: "border-[#E01E5A]/60",
discord: "border-[#5865F2]/60",
teams: "border-[#6264A7]/60",
google_chat: "border-[#1A73E8]/60",
mattermost: "border-[#0058CC]/60",
rocketchat: "border-[#F5455C]/60",
telegram: "border-[#26A5E4]/60",
pagerduty: "border-[#06AC38]/60",
opsgenie: "border-[#2684FF]/60",
splunk_oncall: "border-[#F99D1C]/60",
};
const NEUTRAL_STYLE = "text-fg-secondary bg-surface-2 border-border";
const NEUTRAL_STYLE = "border-border";
interface HeaderRow {
key: string;
@@ -168,11 +170,13 @@ export function WebhookSettings() {
const res = await api.webhooks.list();
setTargets(res.targets);
} catch (err) {
console.error("Failed to load webhook targets:", err);
toast.error(
t("webhooks.loadFailed", { error: err instanceof Error ? err.message : String(err) })
);
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => {
load();
@@ -252,6 +256,9 @@ export function WebhookSettings() {
const canSubmit = useMemo(() => {
if (!form || !provider) return false;
if (!form.name.trim()) return false;
// An empty rule list means ALL rules server-side — never save that from
// "only selected rules".
if (!form.scopeAll && form.ruleIds.length === 0) return false;
if (isEdit) return true; // server merge - existing values fill the gaps
if (provider.url_required && !form.url.trim()) return false;
for (const f of provider.fields) {
@@ -326,7 +333,9 @@ export function WebhookSettings() {
await api.webhooks.update(target.id, { enabled: !target.enabled });
load();
} catch (err) {
console.error("Failed to toggle webhook:", err);
toast.error(
t("webhooks.toggleFailed", { error: err instanceof Error ? err.message : String(err) })
);
}
};
@@ -336,7 +345,10 @@ export function WebhookSettings() {
setConfirmDelete(null);
load();
} catch (err) {
console.error("Failed to delete webhook:", err);
setConfirmDelete(null);
toast.error(
t("webhooks.deleteFailed", { error: err instanceof Error ? err.message : String(err) })
);
}
};
@@ -402,7 +414,7 @@ export function WebhookSettings() {
>
<div className="flex items-center gap-3 flex-wrap">
<span
className={`inline-flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded border ${
className={`inline-flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded border bg-surface-2 text-fg-primary ${
TYPE_STYLES[target.type] || NEUTRAL_STYLE
}`}
>
@@ -765,6 +777,9 @@ export function WebhookSettings() {
))}
</div>
)}
{!form.scopeAll && form.ruleIds.length === 0 && (
<p className="mt-1.5 text-[11px] text-status-warning">{t("webhooks.pickRule")}</p>
)}
</div>
)}