Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
35 changes: 35 additions & 0 deletions docs/orca-aligned-settings/change.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions electron/domains/filesystem/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -622,8 +622,8 @@ export class FilesystemService {
newName: string,
): Promise<FilesystemMutationResult> {
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);
}

Expand Down
5 changes: 4 additions & 1 deletion electron/domains/projects/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,10 @@ async function canonicalDirectory(selected: string): Promise<string> {

async function discoverRepositoryRoot(root: string): Promise<string | null> {
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;
Expand Down
8 changes: 5 additions & 3 deletions electron/domains/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion electron/domains/settings/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions src/features/editor/EditorPane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ describe("EditorPane resources", () => {
render(<EditorPane worktreeId="p1" />);
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" } });
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -347,6 +349,7 @@ describe("EditorPane resources", () => {
render(<EditorPane worktreeId="p1" />);
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();
Expand Down Expand Up @@ -374,6 +377,7 @@ describe("EditorPane resources", () => {
render(<EditorPane worktreeId="p1" />);
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" });

Expand Down Expand Up @@ -402,6 +406,7 @@ describe("EditorPane resources", () => {
render(<EditorPane worktreeId="p1" />);
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" });

Expand Down Expand Up @@ -437,6 +442,7 @@ describe("EditorPane resources", () => {
render(<EditorPane worktreeId="p1" />);
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" });

Expand Down
27 changes: 27 additions & 0 deletions src/features/projects/ProjectRail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ProjectRail
onToggleProjects={toggleProjects}
onOpenSettings={openSettings}
/>,
);

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(<ProjectRail />);
Expand Down
27 changes: 26 additions & 1 deletion src/features/projects/ProjectRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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();
Expand Down Expand Up @@ -127,6 +136,16 @@ export function ProjectRail() {
<div className="brand">
<SpireCodeMark className="brand-mark" />
<span>SPIRECODE</span>
<Tooltip label={t("workbench.toggleProjects")} align="end">
<button
className="project-panel-toggle"
type="button"
aria-label={t("workbench.toggleProjects")}
onClick={onToggleProjects}
>
<RiLayoutLeftLine size={16} />
</button>
</Tooltip>
</div>
<div className="project-rail-heading">
<span>{t("projects.heading")}</span>
Expand Down Expand Up @@ -362,6 +381,12 @@ export function ProjectRail() {
<small>{t("projects.empty.action")}</small>
</button>
)}
<div className="project-rail-footer">
<button type="button" onClick={onOpenSettings}>
<RiSettings3Line size={15} />
<span>{t("workbench.settings")}</span>
</button>
</div>
{dialog?.type === "new" && (
<NewWorktreeDialog
projectId={dialog.projectId}
Expand Down
Loading
Loading