feat(settings): one page per section with a grouped nav and search
Settings moves from a tab bar to /settings/<section>: a left nav grouped General / Agents / Routing / Git / Projects (a select on narrow screens), a search box over every section (Ctrl/Cmd+F, Enter opens the first match), and row primitives. Each AI tool has its page under agents/<id>, the AI Tools modal becomes the Agents page, Pricing shows every vendor, Git → Worktrees sets which untracked worktrees the sidebar lists, and each project gets a page with its own worktree override and Forget all. ?tool= and #ai-tools links redirect to the path form.
This commit is contained in:
@@ -1,16 +1,13 @@
|
||||
/**
|
||||
* @file AiToolsSettings.test.tsx
|
||||
* @description The AI Tools modal: the Settings tab bar's button and the
|
||||
* `#ai-tools` deep link open it, Escape closes it; installed rows carry an
|
||||
* @description The AI Tools page: installed rows carry an
|
||||
* enable switch and Set default, available rows fold behind a disclosure with
|
||||
* a homepage link, Refresh re-detects, a disabled tool's Settings tab
|
||||
* disappears from the hub, every updater gets Update without a version
|
||||
* a homepage link, Refresh re-detects, every updater gets Update without a version
|
||||
* lookup, and Install/Update confirm the exact command, then show the polled
|
||||
* job log, folded once it is done.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -244,73 +241,6 @@ describe("AiToolsSettings — install and update", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsHub — AI tool tabs", () => {
|
||||
it("hides the Codex tab while Codex is disabled", async () => {
|
||||
vi.mocked(api.aiTools.list).mockResolvedValue({
|
||||
default: "claude",
|
||||
tools: [tool("claude", "Claude Code"), tool("codex", "Codex", { enabled: false })],
|
||||
});
|
||||
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
|
||||
const { SettingsHub } = await import("../../../pages/SettingsHub");
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsHub />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(await screen.findByRole("tab", { name: /Claude Code/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: /^Codex/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("gives every installed, enabled catalog tool its own tab, and none to the rest", async () => {
|
||||
vi.mocked(api.aiTools.list).mockResolvedValue({
|
||||
default: "claude",
|
||||
tools: [
|
||||
tool("claude", "Claude Code", { launcher: "claude" }),
|
||||
tool("gemini", "Gemini"),
|
||||
tool("qwen-code", "Qwen Code", { enabled: false }),
|
||||
tool("aider", "Aider", { installed: false }),
|
||||
],
|
||||
});
|
||||
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
|
||||
const { SettingsHub } = await import("../../../pages/SettingsHub");
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsHub />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(await screen.findByRole("tab", { name: /Gemini/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: /Qwen Code/ })).toBeNull();
|
||||
expect(screen.queryByRole("tab", { name: /Aider/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("opens AI Tools as a modal from the tab bar, and closes it on Escape", async () => {
|
||||
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
|
||||
const { SettingsHub } = await import("../../../pages/SettingsHub");
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsHub />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "AI Tools" }));
|
||||
const dialog = screen.getByRole("dialog", { name: "AI Tools" });
|
||||
expect(await within(dialog).findByText("Installed (3 detected)")).toBeInTheDocument();
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the modal for the /settings#ai-tools deep link", async () => {
|
||||
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
|
||||
const { SettingsHub } = await import("../../../pages/SettingsHub");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/settings#ai-tools"]}>
|
||||
<SettingsHub />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(await screen.findByRole("dialog", { name: "AI Tools" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AiToolIcon", () => {
|
||||
it("retries the icon when the same instance shows another tool", async () => {
|
||||
const { AiToolIcon } = await import("../../AiToolIcon");
|
||||
|
||||
@@ -24,7 +24,8 @@ describe("AppearanceSettings", () => {
|
||||
const pressed = screen
|
||||
.getAllByRole("button", { pressed: true })
|
||||
.map((b) => b.getAttribute("aria-label") ?? b.textContent);
|
||||
expect(pressed).toHaveLength(3); // mode "Dark" + both Default cards
|
||||
expect(pressed).toHaveLength(2); // both Default cards
|
||||
expect(screen.getByRole("radio", { name: /Dark/ })).toHaveAttribute("aria-checked", "true");
|
||||
for (const name of ["Darcula", "Apple Dark", "Apple Light", "IntelliJ Light", "Canvas"]) {
|
||||
expect(screen.getByText(name)).toBeInTheDocument();
|
||||
}
|
||||
@@ -37,17 +38,17 @@ describe("AppearanceSettings", () => {
|
||||
expect(root.dataset.theme).toBe("darcula");
|
||||
await user.click(screen.getByRole("button", { name: /Canvas/ }));
|
||||
expect(root.dataset.theme).toBe("darcula");
|
||||
await user.click(screen.getByRole("button", { name: /^Light$/ }));
|
||||
await user.click(screen.getByRole("radio", { name: /^Light$/ }));
|
||||
expect(root.classList.contains("dark")).toBe(false);
|
||||
expect(root.dataset.theme).toBe("canvas");
|
||||
expect(screen.getByRole("button", { name: /Canvas/ })).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("the System mode button is pressed after choosing it", async () => {
|
||||
it("the System mode is checked after choosing it", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppearanceSettings />);
|
||||
await user.click(screen.getByRole("button", { name: /System/ }));
|
||||
expect(screen.getByRole("button", { name: /System/ })).toHaveAttribute("aria-pressed", "true");
|
||||
await user.click(screen.getByRole("radio", { name: /System/ }));
|
||||
expect(screen.getByRole("radio", { name: /System/ })).toHaveAttribute("aria-checked", "true");
|
||||
expect(localStorage.getItem("theme")).toBe("system");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @file WorktreeVisibilitySettings.test.tsx
|
||||
* @description Settings → Worktrees writes the sidebar tree's own prefs key
|
||||
* (`agenthub.sidebar.projects`), disables the source rows while untracked
|
||||
* worktrees are hidden, and a project's override (`hiddenSourcesFor`) replaces
|
||||
* the global list for that project only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { WorktreeVisibilitySettings } from "../WorktreeVisibilitySettings";
|
||||
import { hiddenSourcesFor, readProjectTreePrefs } from "../../../lib/projectTreePrefs";
|
||||
|
||||
const stored = () => JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!);
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
describe("WorktreeVisibilitySettings", () => {
|
||||
it("writes the sidebar's prefs key", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<WorktreeVisibilitySettings />);
|
||||
await user.click(screen.getByRole("switch", { name: "Claude Code worktrees" }));
|
||||
expect(stored().hideSources).toEqual(["claude"]);
|
||||
await user.click(screen.getByRole("switch", { name: "Claude Code worktrees" }));
|
||||
expect(stored().hideSources).toEqual([]);
|
||||
await user.click(screen.getByRole("switch", { name: "Show untracked worktrees" }));
|
||||
expect(stored().showUntracked).toBe(false);
|
||||
expect(screen.getByRole("switch", { name: "AgentHub-managed worktrees" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("lets a project's own list replace the global one", () => {
|
||||
localStorage.setItem(
|
||||
"agenthub.sidebar.projects",
|
||||
JSON.stringify({ hideSources: ["claude"], projectHideSources: { "/r": [] } })
|
||||
);
|
||||
const prefs = readProjectTreePrefs();
|
||||
expect(hiddenSourcesFor(prefs, "/r")).toEqual([]);
|
||||
expect(hiddenSourcesFor(prefs, "/other")).toEqual(["claude"]);
|
||||
expect(hiddenSourcesFor(prefs, null)).toEqual(["claude"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user