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:
@@ -13,6 +13,12 @@ import i18n from "i18next";
|
||||
import { ClaudeAgentHubSettings } from "../ClaudeAgentHubSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { ModelPricing } from "../../../lib/types";
|
||||
import { settleConfirm, subscribeConfirm } from "../../../lib/confirm";
|
||||
|
||||
/** Say yes to every confirm (a CLAUDE_HOME change now asks first). */
|
||||
function confirmYes() {
|
||||
return subscribeConfirm((p) => p && queueMicrotask(() => settleConfirm(true)));
|
||||
}
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
@@ -89,8 +95,10 @@ describe("ClaudeAgentHubSettings", () => {
|
||||
await user.clear(input);
|
||||
await user.type(input, "/srv/claude");
|
||||
const home = container.querySelector("section#claude-home") as HTMLElement;
|
||||
const off = confirmYes();
|
||||
await user.click(within(home).getByRole("button"));
|
||||
expect(api.settings.claudeHome.set).toHaveBeenCalledWith("/srv/claude");
|
||||
await waitFor(() => expect(api.settings.claudeHome.set).toHaveBeenCalledWith("/srv/claude"));
|
||||
off();
|
||||
|
||||
const hooks = container.querySelector("section#hooks") as HTMLElement;
|
||||
await user.click(within(hooks).getByRole("button"));
|
||||
@@ -106,8 +114,10 @@ describe("ClaudeAgentHubSettings", () => {
|
||||
await user.type(input, "/srv/claude");
|
||||
const home = container.querySelector("section#claude-home") as HTMLElement;
|
||||
const reads = vi.mocked(api.settings.info).mock.calls.length;
|
||||
const off = confirmYes();
|
||||
await user.click(within(home).getByRole("button"));
|
||||
await waitFor(() => expect(onHomeChanged).toHaveBeenCalledTimes(1));
|
||||
off();
|
||||
expect(vi.mocked(api.settings.info).mock.calls.length).toBeGreaterThan(reads);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { HomeDirSetting, type HomeDirValue } from "../HomeDirSetting";
|
||||
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../../lib/confirm";
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
@@ -80,4 +81,42 @@ describe("HomeDirSetting", () => {
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(await screen.findByText("lane 3 not updated")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves on Enter", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { saveFn } = setup({ value: "/a", envOwned: false }, (p) =>
|
||||
Promise.resolve({ value: p, envOwned: false })
|
||||
);
|
||||
const input = await screen.findByDisplayValue("/a");
|
||||
await user.clear(input);
|
||||
await user.type(input, "/b{Enter}");
|
||||
expect(saveFn).toHaveBeenCalledWith("/b");
|
||||
});
|
||||
|
||||
it("asks first when given a confirm, and saves nothing on cancel", async () => {
|
||||
let pending: PendingConfirm | null = null;
|
||||
const off = subscribeConfirm((p) => (pending = p));
|
||||
const user = userEvent.setup();
|
||||
const saveFn = vi.fn((p: string) => Promise.resolve({ value: p, envOwned: false }));
|
||||
render(
|
||||
<HomeDirSetting
|
||||
id="x-home"
|
||||
title="X_HOME"
|
||||
description="desc"
|
||||
placeholder="/path"
|
||||
confirm={{ title: "Change X_HOME?", message: "sure?" }}
|
||||
load={() => Promise.resolve({ value: "/a", envOwned: false })}
|
||||
save={saveFn}
|
||||
/>
|
||||
);
|
||||
const input = await screen.findByDisplayValue("/a");
|
||||
await user.clear(input);
|
||||
await user.type(input, "/b");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => expect(pending?.title).toBe("Change X_HOME?"));
|
||||
settleConfirm(false);
|
||||
await waitFor(() => expect(pending).toBeNull());
|
||||
expect(saveFn).not.toHaveBeenCalled();
|
||||
off();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,18 @@ import i18n from "i18next";
|
||||
import { PricingSettings } from "../PricingSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { ModelPricing } from "../../../lib/types";
|
||||
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../../lib/confirm";
|
||||
|
||||
/** Answer every confirm dialog with `ok`; returns the asked titles + unsubscribe. */
|
||||
function answerConfirms(ok: boolean) {
|
||||
const asked: PendingConfirm[] = [];
|
||||
const off = subscribeConfirm((p) => {
|
||||
if (!p) return;
|
||||
asked.push(p);
|
||||
queueMicrotask(() => settleConfirm(ok));
|
||||
});
|
||||
return { asked, off };
|
||||
}
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
@@ -113,16 +125,56 @@ describe("PricingSettings", () => {
|
||||
vi.mocked(api.pricing.delete)
|
||||
.mockReset()
|
||||
.mockResolvedValue({} as never);
|
||||
const confirm = answerConfirms(true);
|
||||
const user = userEvent.setup();
|
||||
render(<PricingSettings vendor="openai" />);
|
||||
const row = (await screen.findByText("gpt-5.6-sol%")).closest("tr")!;
|
||||
|
||||
await user.pointer({ keys: "[MouseRight]", target: row });
|
||||
await user.click(screen.getByRole("menuitem", { name: "Delete" }));
|
||||
expect(api.pricing.delete).toHaveBeenCalledWith("gpt-5.6-sol%");
|
||||
await vi.waitFor(() => expect(api.pricing.delete).toHaveBeenCalledWith("gpt-5.6-sol%"));
|
||||
confirm.off();
|
||||
|
||||
await user.pointer({ keys: "[MouseRight]", target: row });
|
||||
await user.click(screen.getByRole("menuitem", { name: "Edit" }));
|
||||
expect(screen.getByDisplayValue("GPT-5.6 Sol")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("asks before deleting a rule, saying the sync will not bring it back", async () => {
|
||||
vi.mocked(api.pricing.delete)
|
||||
.mockReset()
|
||||
.mockResolvedValue({} as never);
|
||||
const confirm = answerConfirms(false);
|
||||
const user = userEvent.setup();
|
||||
render(<PricingSettings vendor="openai" />);
|
||||
await screen.findByText("gpt-5.6-sol%");
|
||||
await user.click(screen.getByTitle("Delete"));
|
||||
await vi.waitFor(() => expect(confirm.asked).toHaveLength(1));
|
||||
expect(confirm.asked[0]!.message).toMatch(/sync/i);
|
||||
expect(api.pricing.delete).not.toHaveBeenCalled();
|
||||
confirm.off();
|
||||
});
|
||||
|
||||
it("refreshes every pricing table on the page after a change in one", async () => {
|
||||
vi.mocked(api.pricing.delete)
|
||||
.mockReset()
|
||||
.mockResolvedValue({} as never);
|
||||
const confirm = answerConfirms(true);
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<>
|
||||
<PricingSettings vendor="claude" id="a" />
|
||||
<PricingSettings vendor="openai" id="b" />
|
||||
</>
|
||||
);
|
||||
await screen.findByText("gpt-5.6-sol%");
|
||||
await screen.findByText("claude-opus-5-5%");
|
||||
vi.mocked(api.pricing.list).mockResolvedValue({
|
||||
pricing: [...ROWS, rule("claude-fable-5%", "Claude Fable 5")],
|
||||
});
|
||||
// The second table (openai) deletes its row; the first must reload too.
|
||||
await user.click(screen.getAllByTitle("Delete")[1]!);
|
||||
expect(await screen.findByText("claude-fable-5%")).toBeInTheDocument();
|
||||
confirm.off();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @file SettingsRow.test.tsx
|
||||
* @description `SettingsSegmented` is a real radio group: only the checked
|
||||
* option is in the tab order, and the arrow keys move (and select) within it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SettingsSegmented } from "../SettingsRow";
|
||||
|
||||
function Group() {
|
||||
const [v, setV] = useState<"a" | "b" | "c">("a");
|
||||
return (
|
||||
<SettingsSegmented
|
||||
ariaLabel="mode"
|
||||
value={v}
|
||||
onChange={setV}
|
||||
options={[
|
||||
{ value: "a", label: "A" },
|
||||
{ value: "b", label: "B" },
|
||||
{ value: "c", label: "C" },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("SettingsSegmented", () => {
|
||||
it("roves the tab stop and moves with the arrow keys", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Group />);
|
||||
const [a, b, c] = screen.getAllByRole("radio");
|
||||
expect(a).toHaveAttribute("tabindex", "0");
|
||||
expect(b).toHaveAttribute("tabindex", "-1");
|
||||
a!.focus();
|
||||
await user.keyboard("{ArrowRight}");
|
||||
expect(b).toHaveAttribute("aria-checked", "true");
|
||||
expect(b).toHaveFocus();
|
||||
await user.keyboard("{ArrowLeft}{ArrowLeft}");
|
||||
expect(c).toHaveAttribute("aria-checked", "true");
|
||||
expect(c).toHaveFocus();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user