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:
@@ -9,7 +9,10 @@
|
||||
* filter turns it on rather than vanishing. Reset Defaults reseeds Claude's
|
||||
* rates, so only the Claude tab offers it; the server re-fetches any row the
|
||||
* automatic sync owns (OpenAI's, Gemini's) straight after. A right-click on a
|
||||
* rule row opens its Edit / Delete (plus Copy model pattern) as a menu.
|
||||
* rule row opens its Edit / Delete (plus Copy model pattern) as a menu. A
|
||||
* delete asks first (the sync never re-adds a deleted pattern). Every mounted
|
||||
* table shares ONE list request and reloads together after any change, so the
|
||||
* Pricing page's per-vendor tables never go stale against each other.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -23,6 +26,9 @@ import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
||||
import { getCurrentLocale } from "../../lib/format";
|
||||
import { Skeleton } from "../Skeleton";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { useArmed } from "../../hooks/useArmed";
|
||||
import type { ModelPricing, WSMessage } from "../../lib/types";
|
||||
|
||||
interface EditRow {
|
||||
@@ -185,6 +191,20 @@ function PricingInfoTooltip({ vendor }: { vendor: PricingVendor }) {
|
||||
|
||||
export type PricingVendor = "claude" | "openai" | "google";
|
||||
|
||||
// Every mounted table's `load`: a change in one reloads them all, and loads
|
||||
// fired together share one request.
|
||||
const tables = new Set<() => void>();
|
||||
let inflight: ReturnType<typeof api.pricing.list> | null = null;
|
||||
function listPricing() {
|
||||
inflight ??= api.pricing.list().finally(() => {
|
||||
inflight = null;
|
||||
});
|
||||
return inflight;
|
||||
}
|
||||
function reloadAllTables() {
|
||||
for (const load of tables) load();
|
||||
}
|
||||
|
||||
const VENDOR_PATTERN: Record<PricingVendor, RegExp> = {
|
||||
claude: /^claude-/,
|
||||
openai: /^(gpt-|o\d|codex-)/,
|
||||
@@ -216,7 +236,9 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
// Reset Defaults is a two-click that disarms itself after 3 s.
|
||||
const resetArm = useArmed();
|
||||
const confirmReset = resetArm.isArmed("reset");
|
||||
const ctx = useContextMenu<ModelPricing>();
|
||||
// A menu item: close the menu, then act on the rule it was opened on.
|
||||
const pick = (fn: (rule: ModelPricing) => unknown) => () => {
|
||||
@@ -232,7 +254,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.pricing.list();
|
||||
const res = await listPricing();
|
||||
setPricing(res.pricing);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
@@ -244,6 +266,11 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const reload = () => void load();
|
||||
tables.add(reload);
|
||||
return () => {
|
||||
tables.delete(reload);
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
// A remote source can bring its own pricing rules; reload when one syncs.
|
||||
@@ -332,7 +359,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
|
||||
if (!ours(editRow.model_pattern.trim())) setShowAll(true);
|
||||
setEditingPattern(null);
|
||||
setAdding(false);
|
||||
await load();
|
||||
reloadAllTables();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("messages.failedSave"));
|
||||
} finally {
|
||||
@@ -341,9 +368,17 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
|
||||
};
|
||||
|
||||
const deleteRule = async (pattern: string) => {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("pricing.deleteConfirmTitle", { pattern }),
|
||||
message: t("pricing.deleteConfirmBody"),
|
||||
confirmLabel: t("common:delete"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await api.pricing.delete(pattern);
|
||||
await load();
|
||||
reloadAllTables();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("messages.failedDelete"));
|
||||
}
|
||||
@@ -352,7 +387,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
|
||||
const handleResetPricing = async () => {
|
||||
setResetting(true);
|
||||
setActionResult(null);
|
||||
setConfirmReset(false);
|
||||
resetArm.disarm();
|
||||
try {
|
||||
// Only the Claude tab offers the button, and it resets only Claude's rows.
|
||||
const res = await api.settings.resetPricing("claude");
|
||||
@@ -363,7 +398,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
|
||||
}),
|
||||
isError: false,
|
||||
});
|
||||
await load();
|
||||
reloadAllTables();
|
||||
} catch (err) {
|
||||
setActionResult({
|
||||
key: "reset-pricing",
|
||||
@@ -591,7 +626,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
|
||||
)}
|
||||
{allowReset && (
|
||||
<button
|
||||
onClick={() => (confirmReset ? handleResetPricing() : setConfirmReset(true))}
|
||||
onClick={() => (confirmReset ? handleResetPricing() : resetArm.arm("reset"))}
|
||||
disabled={isEditing || resetting}
|
||||
className={`text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 inline-flex items-center gap-1.5 ${
|
||||
confirmReset
|
||||
@@ -749,7 +784,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
|
||||
<button
|
||||
onClick={() => startEdit(rule)}
|
||||
disabled={isEditing}
|
||||
className="p-1.5 rounded-md text-fg-secondary hover:text-blue-500 hover:bg-blue-600/10 transition-colors disabled:opacity-30"
|
||||
className="p-1.5 rounded-md text-fg-secondary hover:text-accent hover:bg-accent/10 transition-colors disabled:opacity-30"
|
||||
title={t("common:edit")}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
@@ -782,9 +817,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
|
||||
<MenuItem disabled={isEditing} onSelect={pick(startEdit)}>
|
||||
{t("common:edit")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={pick((rule) => void navigator.clipboard.writeText(rule.model_pattern))}
|
||||
>
|
||||
<MenuItem onSelect={pick((rule) => void copyText(rule.model_pattern))}>
|
||||
{t("rowMenu.copyPattern")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
|
||||
Reference in New Issue
Block a user