diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eba4a4..4b6bc11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Changed +- Moved Projects and Settings controls into the Projects rail and redesigned Settings as an Orca-inspired full-window page with grouped navigation and search. - Upgraded Git Changes into an Orca-inspired Source Control panel with filtering, collapsible conflict-aware groups, rich status metadata, safe stage/unstage/discard actions, and per-worktree commit drafts. - Refined the workbench with quieter neutral chrome, a narrower Chat reading column, accessible icon tooltips, and a System appearance option that follows the operating system theme. diff --git a/docs/orca-aligned-settings/change.md b/docs/orca-aligned-settings/change.md new file mode 100644 index 0000000..713f72a --- /dev/null +++ b/docs/orca-aligned-settings/change.md @@ -0,0 +1,35 @@ +# Intent + +SpireCode currently puts the Projects-panel toggle and Settings action in the workspace top-right chrome, while Settings opens as a modal over an otherwise active workspace. This makes the left project surface feel disconnected from its own controls and constrains increasingly rich settings content to a small dialog. + +Adopt Orca's useful navigation pattern without importing its product complexity: place the Projects toggle in the Projects panel header, place Settings at the panel footer, and make Settings a dedicated full-window page with its own back action, search, grouped navigation, and spacious content surface. + +# Spec + +- The open Projects panel shows its toggle in the panel's top brand/header row, not in the workspace top-right action cluster. +- When Projects is collapsed, a compact toggle remains available at the former top-left panel edge so the panel can always be reopened. +- The Settings entry sits at the bottom of the Projects panel and is keyboard accessible. +- Settings opens as a full-window application page, replacing workspace chrome rather than using a backdrop, modal dialog, focus trap, or close icon. +- Settings provides: + - an explicit “Back to workspace” action; + - a sidebar search field that filters Settings sections; + - grouped, icon-labelled navigation with `aria-current` on the active section; + - a centered, scrollable content column with section headers and contained setting surfaces; + - existing General, Agent, Skills, Memory, Extensions, Editor, and Terminal behavior without changing persistence or IPC. +- Manual Settings entry opens General. Agent-readiness failures continue to open Agent automatically. +- Theme selection exists only in Settings; the workspace top bar does not expose a theme toggle. +- Returning to the workspace preserves panel widths/collapse state, active project/worktree, editor resources, and right-panel state. +- English and Simplified Chinese copy remain complete. +- No new dependency, Renderer authority, Main IPC, persistence schema, or product capability is introduced. + +# Plan + +Risk classification: standard. This is a cross-module, user-visible navigation and layout change, but it does not alter filesystem/Git safety boundaries, IPC, persistence formats, or irreversible data. + +1. Add failing Workbench, ProjectRail, and Settings tests for relocated controls, full-page navigation, back behavior, grouped search, active-section semantics, and readiness entry. +2. Convert the existing Settings dialog shell into a controlled full-window Settings page while retaining the existing section implementations and data APIs. +3. Move Settings and the open-panel toggle into ProjectRail via explicit callbacks owned by Workbench; add a collapsed-edge reopen control outside the unmounted rail. +4. Replace modal CSS with Orca-inspired page sidebar, search, navigation groups, centered content, contained rows, focus, and responsive behavior. +5. Keep theme selection in General Settings and remove the redundant workspace shortcut. +6. Update localized copy, changelog, and style-contract tests. +7. Run targeted tests, `pnpm check`, `pnpm build`, `git diff --check`, then update the existing feature PR and reinstall the verified macOS bundle. diff --git a/electron/domains/filesystem/service.ts b/electron/domains/filesystem/service.ts index be02541..f307680 100644 --- a/electron/domains/filesystem/service.ts +++ b/electron/domains/filesystem/service.ts @@ -622,8 +622,8 @@ export class FilesystemService { newName: string, ): Promise { assertLeafName(newName); - const parent = path.dirname(relativePath); - const destination = path.join(parent === "." ? "" : parent, newName); + const parent = path.posix.dirname(relativePath); + const destination = path.posix.join(parent === "." ? "" : parent, newName); return this.moveEntry(worktreeId, relativePath, destination); } diff --git a/electron/domains/projects/index.ts b/electron/domains/projects/index.ts index d4a200e..a1c6ee1 100644 --- a/electron/domains/projects/index.ts +++ b/electron/domains/projects/index.ts @@ -457,7 +457,10 @@ async function canonicalDirectory(selected: string): Promise { async function discoverRepositoryRoot(root: string): Promise { try { - return (await gitAbsolute(root, ["rev-parse", "--show-toplevel"])).trim(); + const discovered = ( + await gitAbsolute(root, ["rev-parse", "--show-toplevel"]) + ).trim(); + return await realpath(discovered); } catch (error) { if (!(error instanceof CommandError) || error.code !== "GIT_FAILED") throw error; diff --git a/electron/domains/settings/index.ts b/electron/domains/settings/index.ts index 31678d7..b5dc50b 100644 --- a/electron/domains/settings/index.ts +++ b/electron/domains/settings/index.ts @@ -465,9 +465,11 @@ function extensionName(extensionPath: string): string { function abbreviateHome(value: string): string { const home = homedir(); - return value === home || value.startsWith(home + path.sep) - ? `~${value.slice(home.length)}` - : value; + return portablePath( + value === home || value.startsWith(home + path.sep) + ? `~${value.slice(home.length)}` + : value, + ); } function validateMemoryConfig(value: MemoryConfig): MemoryConfig { diff --git a/electron/domains/settings/settings.test.ts b/electron/domains/settings/settings.test.ts index f784c8b..36fe968 100644 --- a/electron/domains/settings/settings.test.ts +++ b/electron/domains/settings/settings.test.ts @@ -303,7 +303,7 @@ describe("SettingsService", () => { name: "user-skill", description: "user-skill description", kind: "user", - displayPath: "external/user-skill/SKILL.md", + displayPath: expect.stringMatching(/user-skill\/SKILL\.md$/), }), expect.objectContaining({ name: "project-skill", @@ -314,6 +314,7 @@ describe("SettingsService", () => { ); const selected = catalog.find(({ name }) => name === "user-skill")!; + expect(selected.displayPath).not.toContain("\\"); const updated = await service.setSkillEnabled(cwd, selected.id, false); expect(updated.find(({ id }) => id === selected.id)?.enabled).toBe(false); expect(updated.find(({ name }) => name === "project-skill")?.enabled).toBe( diff --git a/src/features/editor/EditorPane.test.tsx b/src/features/editor/EditorPane.test.tsx index cd9054d..6ea695a 100644 --- a/src/features/editor/EditorPane.test.tsx +++ b/src/features/editor/EditorPane.test.tsx @@ -236,6 +236,7 @@ describe("EditorPane resources", () => { render(); const editor = await screen.findByRole("textbox", { name: "File editor" }); fireEvent.change(editor, { target: { value: "first edit" } }); + await screen.findByLabelText("Unsaved changes"); fireEvent.keyDown(window, { key: "s", ctrlKey: true }); await waitFor(() => expect(commands.fsWriteFile).toHaveBeenCalledOnce()); fireEvent.change(editor, { target: { value: "newer edit" } }); @@ -272,6 +273,7 @@ describe("EditorPane resources", () => { await screen.findByRole("textbox", { name: "File editor" }), { target: { value: "after" } }, ); + await screen.findByLabelText("Unsaved changes"); fireEvent.keyDown(window, { key: "s", ctrlKey: true }); expect(await screen.findByText("try again")).toBeInTheDocument(); @@ -347,6 +349,7 @@ describe("EditorPane resources", () => { render(); const editor = await screen.findByRole("textbox", { name: "File editor" }); fireEvent.change(editor, { target: { value: "local edit" } }); + await screen.findByLabelText("Unsaved changes"); fireEvent.keyDown(window, { key: "s", metaKey: true }); expect(await screen.findByText("file changed on disk")).toBeInTheDocument(); @@ -374,6 +377,7 @@ describe("EditorPane resources", () => { render(); const editor = await screen.findByRole("textbox", { name: "File editor" }); fireEvent.change(editor, { target: { value: "local edit" } }); + await screen.findByLabelText("Unsaved changes"); fireEvent.keyDown(window, { key: "s", metaKey: true }); await screen.findByRole("button", { name: "Reload" }); @@ -402,6 +406,7 @@ describe("EditorPane resources", () => { render(); const editor = await screen.findByRole("textbox", { name: "File editor" }); fireEvent.change(editor, { target: { value: "local edit" } }); + await screen.findByLabelText("Unsaved changes"); fireEvent.keyDown(window, { key: "s", metaKey: true }); await screen.findByRole("button", { name: "Compare" }); @@ -437,6 +442,7 @@ describe("EditorPane resources", () => { render(); const editor = await screen.findByRole("textbox", { name: "File editor" }); fireEvent.change(editor, { target: { value: "local edit" } }); + await screen.findByLabelText("Unsaved changes"); fireEvent.keyDown(window, { key: "s", metaKey: true }); await screen.findByRole("button", { name: "Overwrite" }); diff --git a/src/features/projects/ProjectRail.test.tsx b/src/features/projects/ProjectRail.test.tsx index eede9b8..9ddf53d 100644 --- a/src/features/projects/ProjectRail.test.tsx +++ b/src/features/projects/ProjectRail.test.tsx @@ -81,6 +81,33 @@ describe("ProjectRail", () => { expect(screen.getByTestId("spirecode-mark")).toBeInTheDocument(); }); + it("places panel and Settings controls at the top and bottom edges", () => { + const toggleProjects = vi.fn(); + const openSettings = vi.fn(); + render( + , + ); + + const rail = screen.getByRole("complementary", { name: "Projects" }); + const toggle = screen.getByRole("button", { + name: "Toggle projects panel", + }); + const settings = screen.getByRole("button", { name: "Settings" }); + expect(toggle.closest(".brand")).toBeTruthy(); + expect(settings.closest(".project-rail-footer")).toBeTruthy(); + expect(rail.compareDocumentPosition(settings)).toBe( + Node.DOCUMENT_POSITION_CONTAINED_BY | Node.DOCUMENT_POSITION_FOLLOWING, + ); + + fireEvent.click(toggle); + fireEvent.click(settings); + expect(toggleProjects).toHaveBeenCalledOnce(); + expect(openSettings).toHaveBeenCalledOnce(); + }); + it("shows active Pi session loading on the owning project and worktree only", async () => { chatRuntime.ensure("active-feature", "feature", "streaming"); render(); diff --git a/src/features/projects/ProjectRail.tsx b/src/features/projects/ProjectRail.tsx index 7d1841a..47c2db0 100644 --- a/src/features/projects/ProjectRail.tsx +++ b/src/features/projects/ProjectRail.tsx @@ -6,11 +6,14 @@ import { RiFolderOpenLine, RiGitBranchLine, RiGitRepositoryLine, + RiLayoutLeftLine, RiMore2Fill, + RiSettings3Line, } from "@remixicon/react"; import type { WorktreeSummary } from "../../bindings"; import { chatRuntime, useActiveChatWorktrees } from "../chat/chatRuntime"; import { SpireCodeMark } from "../../components/SpireCodeMark"; +import { Tooltip } from "../../components/Tooltip"; import { useTranslation } from "../../i18n"; import { commandError } from "../../lib/errors"; import { projectsApi } from "./projectsApi"; @@ -27,7 +30,13 @@ type DialogState = | { type: "delete"; worktree: WorktreeSummary } | null; -export function ProjectRail() { +export function ProjectRail({ + onToggleProjects = () => undefined, + onOpenSettings = () => undefined, +}: { + onToggleProjects?: () => void; + onOpenSettings?: () => void; +}) { const { t } = useTranslation(); const store = useProjectsStore(); const activeChatWorktrees = useActiveChatWorktrees(); @@ -127,6 +136,16 @@ export function ProjectRail() {
SPIRECODE + + +
{t("projects.heading")} @@ -362,6 +381,12 @@ export function ProjectRail() { {t("projects.empty.action")} )} +
+ +
{dialog?.type === "new" && ( { vi.mocked(settingsApi.setSkillEnabled).mockResolvedValue([]); }); -describe("SettingsDialog", () => { +describe("SettingsPage", () => { it("offers system, light, and dark appearance modes", () => { render( - undefined} + onBack={() => undefined} />, ); @@ -112,7 +112,7 @@ describe("SettingsDialog", () => { }); it("shows only resolved user resources as a read-only list", async () => { - render( undefined} />); + render( undefined} />); expect( await screen.findByText("Configured user resources"), @@ -125,7 +125,7 @@ describe("SettingsDialog", () => { }); it("shows global user resources without an open project", async () => { - render( undefined} />); + render( undefined} />); expect( await screen.findByText("Configured user resources"), @@ -172,7 +172,7 @@ describe("SettingsDialog", () => { { ...skills[1], enabled: false, status: "disabled" }, skills[2], ]); - render( undefined} />); + render( undefined} />); fireEvent.click(screen.getByRole("button", { name: "Skills" })); expect(await screen.findByText("System built-in")).toBeInTheDocument(); @@ -197,7 +197,7 @@ describe("SettingsDialog", () => { }); it("requires a project before loading skills", () => { - render( undefined} />); + render( undefined} />); fireEvent.click(screen.getByRole("button", { name: "Skills" })); expect( @@ -222,7 +222,7 @@ describe("SettingsDialog", () => { defaultModelAvailable: true, resourcesHealthy: true, }); - render( undefined} />); + render( undefined} />); fireEvent.click(screen.getByRole("button", { name: "Agent" })); expect( @@ -235,7 +235,7 @@ describe("SettingsDialog", () => { }); it("opens global memory without requiring a worktree", async () => { - render( undefined} />); + render( undefined} />); fireEvent.click(screen.getByRole("button", { name: "Memory" })); expect( @@ -251,7 +251,7 @@ describe("SettingsDialog", () => { resolveLanguage = resolve; }), ); - render( undefined} />); + render( undefined} />); fireEvent.click(screen.getByRole("button", { name: "General" })); const language = screen.getByRole("combobox", { name: "Language" }); fireEvent.change(language, { target: { value: "zh-CN" } }); @@ -271,7 +271,7 @@ describe("SettingsDialog", () => { }); it("removes diagnostics and keeps feedback as a standalone setting", () => { - render( undefined} />); + render( undefined} />); fireEvent.click(screen.getByRole("button", { name: "General" })); expect(screen.queryByText("Diagnostics")).not.toBeInTheDocument(); @@ -295,7 +295,7 @@ describe("SettingsDialog", () => { code: "SETTINGS_FAILED", message: "write failed", }); - render( undefined} />); + render( undefined} />); fireEvent.click(screen.getByRole("button", { name: "General" })); fireEvent.change(screen.getByRole("combobox", { name: "Language" }), { target: { value: "zh-CN" }, @@ -308,49 +308,44 @@ describe("SettingsDialog", () => { expect(document.documentElement.lang).toBe("en"); }); - it("traps focus and restores it to the opener when closed", () => { - const opener = document.createElement("button"); - document.body.append(opener); - opener.focus(); - const { unmount } = render( - undefined} />, + it("renders as a full page with grouped navigation and an explicit back action", () => { + const back = vi.fn(); + render( + , ); - const dialog = screen.getByRole("dialog", { name: "Settings" }); - const closeButton = screen.getByRole("button", { name: "Close settings" }); - const terminalButton = screen.getByRole("button", { name: "Terminal" }); - expect(closeButton).toHaveFocus(); - - terminalButton.focus(); - fireEvent.keyDown(dialog, { key: "Tab" }); - expect(closeButton).toHaveFocus(); - - closeButton.focus(); - fireEvent.keyDown(dialog, { key: "Tab", shiftKey: true }); - expect(terminalButton).toHaveFocus(); - - unmount(); - expect(opener).toHaveFocus(); - opener.remove(); + expect(screen.getByRole("main", { name: "Settings" })).toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(screen.getByText("CORE")).toBeInTheDocument(); + expect(screen.getByText("CAPABILITIES")).toBeInTheDocument(); + expect(screen.getByText("WORKSPACE")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "General" })).toHaveAttribute( + "aria-current", + "page", + ); + fireEvent.click(screen.getByRole("button", { name: "Back to workspace" })); + expect(back).toHaveBeenCalledOnce(); }); - it("closes from the close button, Escape, and backdrop", () => { - const close = vi.fn(); - const { rerender } = render( - , - ); + it("filters sections from the Settings search field and focuses the first match", async () => { + render( undefined} />); + const search = screen.getByRole("searchbox", { name: "Search settings" }); + expect(search).toHaveFocus(); - fireEvent.click(screen.getByRole("button", { name: "Close settings" })); - expect(close).toHaveBeenCalledOnce(); + fireEvent.change(search, { target: { value: "theme" } }); - close.mockClear(); - fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" }); - expect(close).toHaveBeenCalledOnce(); + expect(screen.queryByRole("button", { name: "Extensions" })).toBeNull(); + expect(screen.getByRole("button", { name: "General" })).toHaveAttribute( + "aria-current", + "page", + ); + expect( + await screen.findByRole("heading", { name: "General" }), + ).toBeInTheDocument(); - close.mockClear(); - const dialog = screen.getByRole("dialog"); - fireEvent.mouseDown(dialog.parentElement!); - expect(close).toHaveBeenCalledOnce(); - rerender(<>); + fireEvent.change(search, { target: { value: "no-setting-matches" } }); + expect( + screen.getByText('No settings found for "no-setting-matches".'), + ).toBeInTheDocument(); }); }); diff --git a/src/features/settings/SettingsDialog.tsx b/src/features/settings/SettingsPage.tsx similarity index 76% rename from src/features/settings/SettingsDialog.tsx rename to src/features/settings/SettingsPage.tsx index 91f1f80..e1e7799 100644 --- a/src/features/settings/SettingsDialog.tsx +++ b/src/features/settings/SettingsPage.tsx @@ -1,14 +1,15 @@ import { + RiArrowLeftLine, RiBookOpenLine, - RiCloseLine, RiComputerLine, RiExternalLinkLine, RiFileList3Line, RiRobot2Line, + RiSearchLine, RiSettings3Line, RiTerminalBoxLine, } from "@remixicon/react"; -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; import { formatNumber, setLanguage, @@ -23,7 +24,7 @@ import { type SkillSetting, } from "../../bindings"; import { commandError } from "../../lib/errors"; -import { useDialogFocus } from "../../lib/useDialogFocus"; +import { SpireCodeMark } from "../../components/SpireCodeMark"; import { useThemeStore, type ThemeMode } from "../theme/themeStore"; import { useSettingsStore } from "./settingsStore"; import { MemorySettings } from "./MemorySettings"; @@ -38,113 +39,190 @@ export type SettingsSection = | "editor" | "terminal"; +type SettingsGroup = "core" | "capabilities" | "workspace"; + const sections: Array<{ id: SettingsSection; label: TranslationKey; + description: TranslationKey; + group: SettingsGroup; + keywords: string[]; icon: ReactNode; }> = [ { id: "general", label: "settings.section.general", + description: "settings.general.description", + group: "core", + keywords: ["appearance", "theme", "language", "feedback"], icon: , }, { id: "agent", label: "settings.section.agent", + description: "settings.agent.description", + group: "core", + keywords: ["model", "provider", "authentication", "configuration"], icon: , }, { id: "skills", label: "settings.section.skills", + description: "settings.skills.description", + group: "capabilities", + keywords: ["skill", "command", "builtin", "project"], icon: , }, { id: "memory", label: "settings.section.memory", + description: "settings.memory.description", + group: "capabilities", + keywords: ["memory", "summary", "instructions", "model"], icon: , }, { id: "extensions", label: "settings.section.extensions", + description: "settings.extensions.description", + group: "capabilities", + keywords: ["extension", "package", "resource", "plugin"], icon: , }, { id: "editor", label: "settings.section.editor", + description: "settings.editor.description", + group: "workspace", + keywords: ["editor", "font", "wrap"], icon: , }, { id: "terminal", label: "settings.section.terminal", + description: "settings.terminal.description", + group: "workspace", + keywords: ["terminal", "font", "scrollback"], icon: , }, ]; -export function SettingsDialog({ +const groups: Array<{ id: SettingsGroup; label: TranslationKey }> = [ + { id: "core", label: "settings.group.core" }, + { id: "capabilities", label: "settings.group.capabilities" }, + { id: "workspace", label: "settings.group.workspace" }, +]; + +export function SettingsPage({ worktreeId, - onClose, + onBack, initialSection = "extensions", }: { worktreeId: string | null; - onClose: () => void; + onBack: () => void; initialSection?: SettingsSection; }) { const [section, setSection] = useState(initialSection); + const [query, setQuery] = useState(""); const { t } = useTranslation(); - const { dialogRef, trapFocus } = useDialogFocus(); + const visibleSections = useMemo(() => { + const normalized = query.trim().toLocaleLowerCase(); + if (!normalized) return sections; + return sections.filter((item) => + [t(item.label), t(item.description), ...item.keywords] + .join(" ") + .toLocaleLowerCase() + .includes(normalized), + ); + }, [query, t]); + useEffect(() => { + if ( + visibleSections.length > 0 && + !visibleSections.some((item) => item.id === section) + ) + setSection(visibleSections[0].id); + }, [section, visibleSections]); return ( -
event.target === event.currentTarget && onClose()} - > -
{ - trapFocus(event); - if (event.key === "Escape") onClose(); - }} - > -
-

{t("settings.title")}

- -
-
+
+
+ + SpireCode + / + {t("settings.title")} +
+
+ +
+
+ {visibleSections.length === 0 ? ( +
+ {t("settings.search.empty", { query: query.trim() })} +
+ ) : ( + <> + {section === "general" && } + {section === "agent" && } + {section === "skills" && ( + + )} + {section === "memory" && ( + + )} + {section === "extensions" && ( + + )} + {section === "editor" && } + {section === "terminal" && } + )} - {section === "editor" && } - {section === "terminal" && } -
-
-
+
+ +
); } diff --git a/src/features/workbench/Workbench.test.tsx b/src/features/workbench/Workbench.test.tsx index 5eec925..283ca6d 100644 --- a/src/features/workbench/Workbench.test.tsx +++ b/src/features/workbench/Workbench.test.tsx @@ -9,7 +9,19 @@ import { settingsApi } from "../settings/settingsApi"; import { resetWorkbenchStore, useWorkbenchStore } from "./workbenchStore"; vi.mock("../projects/ProjectRail", () => ({ - ProjectRail: () => , + ProjectRail: ({ + onToggleProjects, + onOpenSettings, + }: { + onToggleProjects: () => void; + onOpenSettings: () => void; + }) => ( + + ), })); vi.mock("../files/FileTree", () => ({ FileTree: () =>
Files
})); vi.mock("../changes/ChangesPanel", () => ({ @@ -47,18 +59,18 @@ vi.mock("./BreadcrumbSwitcher", () => ({ vi.mock("../settings/settingsApi", () => ({ settingsApi: { getAgentReadiness: vi.fn() }, })); -vi.mock("../settings/SettingsDialog", () => ({ - SettingsDialog: ({ - onClose, +vi.mock("../settings/SettingsPage", () => ({ + SettingsPage: ({ + onBack, initialSection, }: { - onClose: () => void; + onBack: () => void; initialSection?: string; }) => ( -
+
{initialSection} - -
+ + ), })); @@ -248,6 +260,14 @@ describe("Workbench panel handles", () => { ).toBeInTheDocument(); }); + it("keeps theme selection exclusive to Settings", () => { + render(); + + expect( + screen.queryByRole("button", { name: /^Theme:/ }), + ).not.toBeInTheDocument(); + }); + it("uses icon-only controls to switch files and changes", () => { render(); @@ -315,24 +335,27 @@ describe("Workbench panel handles", () => { render(); expect( - await screen.findByRole("dialog", { name: "Settings dialog" }), + await screen.findByRole("main", { name: "Settings page" }), ).toHaveTextContent("agent"); + expect(screen.queryByText("Editor")).not.toBeInTheDocument(); }); - it("opens settings from the top-right action", () => { + it("opens full-page settings from the Projects footer and returns to the workspace", () => { render(); fireEvent.click(screen.getByRole("button", { name: "Settings" })); expect( - screen.getByRole("dialog", { name: "Settings dialog" }), - ).toBeInTheDocument(); + screen.getByRole("main", { name: "Settings page" }), + ).toHaveTextContent("general"); + expect(screen.queryByText("Projects")).not.toBeInTheDocument(); + expect(screen.queryByText("Editor")).not.toBeInTheDocument(); - fireEvent.click( - screen.getByRole("button", { name: "Close mocked settings" }), - ); + fireEvent.click(screen.getByRole("button", { name: "Back to workspace" })); expect( - screen.queryByRole("dialog", { name: "Settings dialog" }), + screen.queryByRole("main", { name: "Settings page" }), ).not.toBeInTheDocument(); + expect(screen.getByText("Projects")).toBeInTheDocument(); + expect(screen.getByText("Editor")).toBeInTheDocument(); }); it("hides the projects panel and separator when collapsed", () => { @@ -347,5 +370,11 @@ describe("Workbench panel handles", () => { screen.queryByRole("separator", { name: "Resize projects panel" }), ).not.toBeInTheDocument(); expect(screen.getByText("Editor")).toBeInTheDocument(); + const reopen = screen.getByRole("button", { + name: "Toggle projects panel", + }); + expect(reopen).toHaveClass("project-panel-reopen"); + fireEvent.click(reopen); + expect(screen.getByText("Projects")).toBeInTheDocument(); }); }); diff --git a/src/features/workbench/Workbench.tsx b/src/features/workbench/Workbench.tsx index 1df36bf..a36c5d9 100644 --- a/src/features/workbench/Workbench.tsx +++ b/src/features/workbench/Workbench.tsx @@ -4,7 +4,6 @@ import { RiGitCommitLine, RiLayoutLeftLine, RiLayoutRightLine, - RiSettings3Line, } from "@remixicon/react"; import { SpireCodeMark } from "../../components/SpireCodeMark"; import { Tooltip } from "../../components/Tooltip"; @@ -18,11 +17,7 @@ import { FileTree } from "../files/FileTree"; import { ProjectRail } from "../projects/ProjectRail"; import { useProjectsStore } from "../projects/projectsStore"; import { settingsApi } from "../settings/settingsApi"; -import { - SettingsDialog, - type SettingsSection, -} from "../settings/SettingsDialog"; -import { ThemeToggle } from "../theme/ThemeToggle"; +import { SettingsPage, type SettingsSection } from "../settings/SettingsPage"; import { BreadcrumbSwitcher } from "./BreadcrumbSwitcher"; import { PanelResizeHandle } from "./PanelResizeHandle"; import { PANEL_LIMITS, useWorkbenchStore } from "./workbenchStore"; @@ -144,6 +139,14 @@ export function Workbench() { "--projects-width": `${visibleProjectsWidth}px`, "--right-panel-width": `${visibleRightWidth}px`, } as CSSProperties; + if (settingsOpen) + return ( + setSettingsOpen(false)} + /> + ); return (
{!workbench.projectsCollapsed && ( <> - + { + setSettingsSection("general"); + setSettingsOpen(true); + }} + /> + {workbench.projectsCollapsed && ( + + + + )} {active ? (
- - - - - - -
)} - {settingsOpen && ( - setSettingsOpen(false)} - /> - )} {error && (
{error.code} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index eab9714..2e7ec32 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -3,6 +3,12 @@ export const en = { "app.error.reload": "Reload", "settings.title": "Settings", "settings.close": "Close settings", + "settings.back": "Back to workspace", + "settings.search": "Search settings", + "settings.search.empty": 'No settings found for "{query}".', + "settings.group.core": "CORE", + "settings.group.capabilities": "CAPABILITIES", + "settings.group.workspace": "WORKSPACE", "settings.sections.label": "Settings sections", "settings.section.general": "General", "settings.section.agent": "Agent", @@ -11,6 +17,8 @@ export const en = { "settings.section.extensions": "Extensions", "settings.section.editor": "Editor", "settings.section.terminal": "Terminal", + "settings.memory.description": + "Inspect and configure the memory context available to Agent sessions.", "settings.general.description": "Application appearance.", "settings.general.theme.label": "Theme", "settings.general.theme.description": "Choose the interface color theme.", diff --git a/src/i18n/zh-CN.ts b/src/i18n/zh-CN.ts index 243cf05..d2c8ea0 100644 --- a/src/i18n/zh-CN.ts +++ b/src/i18n/zh-CN.ts @@ -5,6 +5,12 @@ export const zhCN = { "app.error.reload": "重新加载", "settings.title": "设置", "settings.close": "关闭设置", + "settings.back": "返回工作区", + "settings.search": "搜索设置", + "settings.search.empty": "未找到与“{query}”匹配的设置。", + "settings.group.core": "核心", + "settings.group.capabilities": "能力", + "settings.group.workspace": "工作区", "settings.sections.label": "设置分区", "settings.section.general": "通用", "settings.section.agent": "智能体", @@ -13,6 +19,7 @@ export const zhCN = { "settings.section.extensions": "扩展", "settings.section.editor": "编辑器", "settings.section.terminal": "终端", + "settings.memory.description": "查看并配置提供给智能体会话的记忆上下文。", "settings.general.description": "应用外观。", "settings.general.theme.label": "主题", "settings.general.theme.description": "选择界面颜色主题。", diff --git a/src/styles/index.css b/src/styles/index.css index 3e1474a..30b4199 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -339,6 +339,25 @@ body.panel-resizing-vertical * { flex: 0 0 22px; filter: drop-shadow(0 0 7px var(--accent-glow)); } +.project-panel-toggle { + width: 28px; + height: 28px; + margin-left: auto; + padding: 0; + border: 1px solid transparent; + border-radius: 6px; + display: grid; + place-items: center; + background: transparent; + color: var(--text-subtle); + cursor: pointer; +} +.project-panel-toggle:hover, +.project-panel-toggle:focus-visible { + border-color: var(--border-default); + background: var(--interactive-hover); + color: var(--text-default); +} .project-rail-heading { height: 30px; display: flex; @@ -563,6 +582,32 @@ body.panel-resizing-vertical * { font-size: 10px; color: var(--text-subtle); } +.project-rail-footer { + margin: 8px -8px -8px; + padding: 7px 8px; + border-top: 1px solid var(--border-muted); +} +.project-rail-footer button { + width: 100%; + height: 31px; + padding: 0 9px; + border: 1px solid transparent; + border-radius: 6px; + display: flex; + align-items: center; + gap: 9px; + background: transparent; + color: var(--text-muted); + font-size: 11px; + text-align: left; + cursor: pointer; +} +.project-rail-footer button:hover, +.project-rail-footer button:focus-visible { + border-color: var(--border-default); + background: var(--interactive-hover); + color: var(--text-default); +} .dialog-backdrop { position: fixed; inset: 0; @@ -571,59 +616,138 @@ body.panel-resizing-vertical * { place-items: center; background: var(--modal-overlay); } -.settings-dialog { - width: min(760px, calc(100vw - 48px)); - height: min(680px, calc(100vh - 48px)); +.settings-page { + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; overflow: hidden; - border: 1px solid var(--border-strong); - border-radius: 12px; - background: var(--elevated-bg); - box-shadow: 0 18px 60px var(--shadow); + background: var(--workspace-bg); color: var(--text-default); } -.settings-header { - height: 52px; - padding: 0 16px 0 20px; - border-bottom: 1px solid var(--border-default); +.settings-page-titlebar { + height: 46px; + flex: 0 0 46px; + padding: 0 14px; + border-bottom: 1px solid var(--border-muted); display: flex; align-items: center; - justify-content: space-between; + gap: 7px; + background: var(--header-bg); + color: var(--text-muted); + font-size: 11px; + -webkit-app-region: drag; } -.settings-header h2, -.settings-content h3 { - margin: 0; - font-size: 17px; +.settings-page-mark { + width: 20px; + height: 20px; +} +.settings-page-titlebar strong { + color: var(--text-default); + font-weight: 600; +} +.settings-page-title-separator { + color: var(--text-subtle); +} +.settings-page-body { + min-width: 0; + min-height: 0; + flex: 1; + display: flex; + overflow: hidden; +} +.settings-sidebar { + width: 260px; + min-width: 220px; + flex: 0 0 260px; + display: flex; + flex-direction: column; + border-right: 1px solid var(--border-muted); + background: var(--sidebar-bg); } -.settings-header button { +.settings-page-back { + padding: 10px 12px; + border-bottom: 1px solid var(--border-muted); +} +.settings-page-back button { + width: 100%; + height: 32px; + padding: 0 9px; border: 0; - border-radius: 5px; - padding: 5px; - display: grid; - place-items: center; + border-radius: 6px; + display: flex; + align-items: center; + gap: 8px; background: transparent; - color: var(--text-subtle); + color: var(--text-muted); + font-size: 12px; + text-align: left; cursor: pointer; } -.settings-header button:hover { - background: var(--control-hover); +.settings-page-back button:hover, +.settings-page-back button:focus-visible { + background: var(--interactive-hover); color: var(--text-default); } -.settings-body { - height: calc(100% - 53px); - display: grid; - grid-template-columns: 176px minmax(0, 1fr); +.settings-page-search { + position: relative; + margin: 12px; +} +.settings-page-search > svg { + position: absolute; + top: 50%; + left: 10px; + z-index: 1; + transform: translateY(-50%); + color: var(--text-subtle); + pointer-events: none; +} +.settings-page-search input { + width: 100%; + height: 34px; + padding: 0 10px 0 32px; + border: 1px solid var(--border-default); + border-radius: 7px; + outline: 0; + background: color-mix(in srgb, var(--control-bg) 80%, transparent); + color: var(--text-default); + font: inherit; + font-size: 12px; +} +.settings-page-search input:focus { + border-color: var(--accent-muted); + box-shadow: 0 0 0 3px var(--focus-ring); } .settings-nav { - padding: 12px 8px; - border-right: 1px solid var(--border-default); - background: var(--header-bg); + min-height: 0; + flex: 1; + overflow-y: auto; + padding: 2px 12px 20px; +} +.settings-nav-group { + display: grid; + gap: 3px; + margin-top: 18px; +} +.settings-nav-group:first-child { + margin-top: 4px; +} +.settings-nav-group h2 { + margin: 0 0 4px; + padding: 0 10px; + color: var(--text-subtle); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.14em; } .settings-nav button { width: 100%; - height: 34px; + min-height: 32px; padding: 0 10px; - border: 0; - border-radius: 6px; + border: 1px solid transparent; + border-radius: 7px; display: flex; align-items: center; gap: 9px; @@ -633,22 +757,47 @@ body.panel-resizing-vertical * { text-align: left; cursor: pointer; } -.settings-nav button:hover { - background: var(--control-hover); +.settings-nav button svg { + flex: 0 0 auto; +} +.settings-nav button:hover, +.settings-nav button:focus-visible { + background: var(--interactive-hover); color: var(--text-default); } .settings-nav button.active { - background: var(--control-selected); + border-color: var(--border-default); + background: var(--interactive-selected); color: var(--text-default); + font-weight: 600; } -.settings-content { +.settings-main { min-width: 0; min-height: 0; + flex: 1; overflow-y: auto; - padding: 24px 28px; } -.settings-content:has(.memory-settings) { - overflow: hidden; +.settings-content { + width: min(880px, 100%); + min-height: 100%; + margin: 0 auto; + padding: 46px 48px 88px; +} +.settings-content > section > h3 { + margin: 0; + font-size: 24px; + font-weight: 650; + letter-spacing: -0.02em; +} +.settings-no-results { + min-height: 320px; + border: 1px dashed var(--border-default); + border-radius: 14px; + display: grid; + place-items: center; + background: color-mix(in srgb, var(--elevated-bg) 50%, transparent); + color: var(--text-muted); + font-size: 13px; } .memory-settings { height: 100%; @@ -788,9 +937,6 @@ body.panel-resizing-vertical * { line-height: 1.55; } @media (max-height: 720px) { - .settings-content:has(.memory-settings) { - overflow-y: auto; - } .memory-settings { height: auto; } @@ -801,24 +947,37 @@ body.panel-resizing-vertical * { } .settings-description { - margin: 7px 0 20px; + margin: 8px 0 24px; + padding-bottom: 20px; + border-bottom: 1px solid var(--border-muted); color: var(--text-muted); - font-size: 11px; - line-height: 1.5; + font-size: 13px; + line-height: 1.6; } .setting-row { - padding: 14px 0; - border-top: 1px solid var(--border-default); + min-height: 68px; + margin-top: 10px; + padding: 14px 16px; + border: 1px solid var(--border-default); + border-radius: 10px; display: flex; align-items: center; justify-content: space-between; + gap: 20px; + background: color-mix(in srgb, var(--elevated-bg) 72%, transparent); + box-shadow: 0 1px 2px color-mix(in srgb, var(--shadow) 35%, transparent); } .setting-row > div { display: grid; gap: 4px; } +.setting-row b { + font-size: 12px; +} .setting-row small { color: var(--text-muted); + font-size: 11px; + line-height: 1.45; } .setting-row select { width: 130px; @@ -841,8 +1000,13 @@ body.panel-resizing-vertical * { gap: 24px; } .extension-section { + padding: 18px; + border: 1px solid var(--border-default); + border-radius: 11px; display: grid; gap: 4px; + background: color-mix(in srgb, var(--elevated-bg) 72%, transparent); + box-shadow: 0 1px 2px color-mix(in srgb, var(--shadow) 35%, transparent); } .extension-section h4 { margin: 0; @@ -1137,9 +1301,31 @@ body.panel-resizing-vertical * { .project-crumb { min-width: 0; overflow: visible; + display: flex; + align-items: center; + gap: 6px; font-size: 12px; white-space: nowrap; } +.project-panel-reopen { + width: 28px; + height: 28px; + padding: 0; + border: 1px solid transparent; + border-radius: 6px; + display: grid; + place-items: center; + background: transparent; + color: var(--text-subtle); + cursor: pointer; + -webkit-app-region: no-drag; +} +.project-panel-reopen:hover, +.project-panel-reopen:focus-visible { + border-color: var(--border-default); + background: var(--interactive-hover); + color: var(--text-default); +} .breadcrumb-switcher { min-width: 0; display: flex; @@ -3414,6 +3600,20 @@ details[open] > summary > .chat-disclosure-icon { font: 700 55px Georgia; color: var(--danger); } +@media (max-width: 760px) { + .settings-sidebar { + width: 210px; + min-width: 190px; + flex-basis: 210px; + } + .settings-content { + padding: 32px 24px 64px; + } + .setting-row { + align-items: flex-start; + flex-direction: column; + } +} @keyframes spin { to { transform: rotate(360deg); diff --git a/src/styles/themeTokens.test.ts b/src/styles/themeTokens.test.ts index 171fb67..fe466ef 100644 --- a/src/styles/themeTokens.test.ts +++ b/src/styles/themeTokens.test.ts @@ -96,6 +96,26 @@ describe("theme tokens", () => { expectDeclarations(".branch-options", menuSurface.slice(1)); }); + it("keeps Settings as a full-window navigation surface", () => { + expectDeclarations(".settings-page", [ + "width: 100%", + "height: 100%", + "display: flex", + "overflow: hidden", + "background: var(--workspace-bg)", + ]); + expectDeclarations(".settings-sidebar", [ + "width: 260px", + "border-right: 1px solid var(--border-muted)", + "background: var(--sidebar-bg)", + ]); + expectDeclarations(".settings-content", [ + "width: min(880px, 100%)", + "margin: 0 auto", + "padding: 46px 48px 88px", + ]); + }); + it("aligns Projects dialogs with the Files confirmation surface", () => { const dialogSurface = [ "width: min(360px, calc(100vw - 32px))",