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 src/main/db.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const projects = sqliteTable("projects", {
scripts: text("scripts"), // JSON
searchSettings: text("search_settings"), // JSON
mcpServers: text("mcp_servers"), // JSON
workspaceId: text("workspace_id"),
disabled: integer("disabled", { mode: "boolean" }).notNull().default(false),
sortOrder: integer("sort_order").notNull().default(0),
createdAt: text("created_at").notNull(),
Expand Down
6 changes: 6 additions & 0 deletions src/main/db/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,12 @@ export function initDatabase(dbPath: string) {
}

if (storedVersion < 28) {
// Workspace a project belongs to. Left NULL on upgrade; the renderer's
// first-run bootstrap files existing projects into the default workspace.
const cols = sqlite.prepare("PRAGMA table_info(projects)").all() as { name: string }[];
if (!cols.some((c) => c.name === "workspace_id")) {
sqlite.exec("ALTER TABLE projects ADD COLUMN workspace_id TEXT");
}
sqlite.exec(`
CREATE TABLE IF NOT EXISTS pr_watches (
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
Expand Down
42 changes: 42 additions & 0 deletions src/main/db/projectsThreads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,48 @@ describe("projectsThreads (real sqlite round-trip)", () => {
});
});

it("round-trips the project workspace through the projects table", () => {
const project = {
id: "project-1",
name: "Test project",
location: { kind: "posix" as const, path: "/tmp/project" },
createdAt: "2026-01-01T00:00:00.000Z",
};

// Unfiled by default — a project created before workspaces existed must not
// come back pinned to some arbitrary group.
expect(dbGetProject("project-1")?.workspaceId).toBeUndefined();

dbUpsertProject({ ...project, workspaceId: "ws-work" }, 0);
expect(dbGetProject("project-1")?.workspaceId).toBe("ws-work");

dbUpsertProject({ ...project, workspaceId: "ws-side" }, 0);
expect(dbGetProject("project-1")?.workspaceId).toBe("ws-side");

// Unfiling clears the column rather than leaving the previous value behind.
dbUpsertProject(project, 0);
expect(dbGetProject("project-1")?.workspaceId).toBeUndefined();
});

it("round-trips the project workspace through the bulk renderer sync", () => {
const project = {
id: "project-bulk",
name: "Bulk project",
location: { kind: "posix" as const, path: "/tmp/project-bulk" },
createdAt: "2026-01-01T00:00:00.000Z",
};
const viewJson = JSON.stringify({ kind: "home" });

dbSyncAll([{ ...project, workspaceId: "ws-work" }], [], viewJson);
expect(dbGetProject(project.id)?.workspaceId).toBe("ws-work");

dbSyncAll([{ ...project, workspaceId: "ws-side" }], [], viewJson);
expect(dbGetProject(project.id)?.workspaceId).toBe("ws-side");

dbSyncAll([project], [], viewJson);
expect(dbGetProject(project.id)?.workspaceId).toBeUndefined();
});

it("persists candidate threads and the experiment record atomically", () => {
const existing = testThread({ id: "candidate-existing" });
dbPersistExperimentState({
Expand Down
2 changes: 2 additions & 0 deletions src/main/db/projectsThreads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export function dbUpsertProject(project: Project, sortOrder: number): void {
scripts: project.scripts ? JSON.stringify(project.scripts) : null,
searchSettings: project.searchSettings ? JSON.stringify(project.searchSettings) : null,
mcpServers: project.mcpServers ? JSON.stringify(project.mcpServers) : null,
workspaceId: project.workspaceId ?? null,
disabled: !!project.disabled,
sortOrder,
createdAt: project.createdAt,
Expand All @@ -77,6 +78,7 @@ export function dbUpsertProject(project: Project, sortOrder: number): void {
scripts: project.scripts ? JSON.stringify(project.scripts) : null,
searchSettings: project.searchSettings ? JSON.stringify(project.searchSettings) : null,
mcpServers: project.mcpServers ? JSON.stringify(project.mcpServers) : null,
workspaceId: project.workspaceId ?? null,
disabled: !!project.disabled,
sortOrder,
},
Expand Down
1 change: 1 addition & 0 deletions src/main/db/rowMappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export function rowToProject(row: typeof schema.projects.$inferSelect): Project
...(row.scripts ? { scripts: JSON.parse(row.scripts) } : {}),
...(row.searchSettings ? { searchSettings: JSON.parse(row.searchSettings) } : {}),
...(row.mcpServers ? { mcpServers: JSON.parse(row.mcpServers) } : {}),
...(row.workspaceId ? { workspaceId: row.workspaceId } : {}),
...(row.disabled ? { disabled: true } : {}),
createdAt: row.createdAt,
};
Expand Down
10 changes: 6 additions & 4 deletions src/main/db/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,12 @@ function prepareProjectSyncStatement(sqlite: InstanceType<typeof Database>): Sql
return sqlite.prepare(`
INSERT INTO projects (
id, name, location_kind, location_path, location_distro, location_linux_path,
location_unc_path, last_draft_config, scripts, search_settings, mcp_servers, disabled,
sort_order, created_at
location_unc_path, last_draft_config, scripts, search_settings, mcp_servers, workspace_id,
disabled, sort_order, created_at
) VALUES (
@id, @name, @locationKind, @locationPath, @locationDistro, @locationLinuxPath,
@locationUncPath, @lastDraftConfig, @scripts, @searchSettings, @mcpServers, @disabled,
@sortOrder, @createdAt
@locationUncPath, @lastDraftConfig, @scripts, @searchSettings, @mcpServers, @workspaceId,
@disabled, @sortOrder, @createdAt
)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
Expand All @@ -110,6 +110,7 @@ function prepareProjectSyncStatement(sqlite: InstanceType<typeof Database>): Sql
scripts = excluded.scripts,
search_settings = excluded.search_settings,
mcp_servers = excluded.mcp_servers,
workspace_id = excluded.workspace_id,
disabled = excluded.disabled,
sort_order = excluded.sort_order
`);
Expand All @@ -124,6 +125,7 @@ function runProjectSync(stmt: SqliteStatement, project: Project, sortOrder: numb
scripts: project.scripts ? JSON.stringify(project.scripts) : null,
searchSettings: project.searchSettings ? JSON.stringify(project.searchSettings) : null,
mcpServers: project.mcpServers ? JSON.stringify(project.mcpServers) : null,
workspaceId: project.workspaceId ?? null,
disabled: project.disabled ? 1 : 0,
sortOrder,
createdAt: project.createdAt,
Expand Down
2 changes: 2 additions & 0 deletions src/main/sharedSettingsFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ describe("sharedSettingsFile", () => {
notifyL2Cli: true,
remotePushEnabled: true,
remotePushRedactContent: false,
workspaces: [],
favoriteModels: [],
recentModels: [],
agentHookSupport: {},
Expand Down Expand Up @@ -247,6 +248,7 @@ describe("sharedSettingsFile", () => {
notifyL2Cli: true,
remotePushEnabled: true,
remotePushRedactContent: false,
workspaces: [],
favoriteModels: [],
recentModels: [],
agentHookSupport: {},
Expand Down
41 changes: 32 additions & 9 deletions src/renderer/actions/createProjectActions.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { beforeEach, describe, expect, test, vi } from "vitest";

const ACTIVE_WORKSPACE_ID = "ws-active";

const mocks = vi.hoisted(() => ({
createProjectDirectory: vi.fn<(p: unknown) => Promise<{ path: string }>>(),
cloneRepo: vi.fn<(p: unknown) => Promise<{ path: string }>>(),
pickFolder: vi.fn<(d?: string) => Promise<string | null>>(),
addProject: vi.fn<(location: unknown, name?: string) => unknown>((location, name) => ({
id: "p1",
name: name ?? "x",
location,
createdAt: "t",
})),
addProject: vi.fn<(location: unknown, name?: string, workspaceId?: string) => unknown>(
(location, name) => ({
id: "p1",
name: name ?? "x",
location,
createdAt: "t",
}),
),
openDraft: vi.fn<(id: string) => void>(),
setLastUsedProjectDir: vi.fn<(key: string, dir: string) => void>(),
autoDetectSetupScript: vi.fn<(project: unknown) => void>(),
Expand All @@ -33,6 +37,10 @@ vi.mock("@/renderer/actions/projectActions", () => ({
vi.mock("@/renderer/state/appStore", () => ({
useAppStore: { getState: () => ({ addProject: mocks.addProject, openDraft: mocks.openDraft }) },
}));
// New projects inherit whichever workspace the user is currently viewing.
vi.mock("@/renderer/state/workspaceStore", () => ({
getActiveWorkspaceId: () => ACTIVE_WORKSPACE_ID,
}));
vi.mock("@/renderer/state/sharedSettingsStore", () => ({
useSharedSettings: {
getState: () => ({
Expand Down Expand Up @@ -67,7 +75,11 @@ describe("commitCreateProject", () => {
});

expect(createProjectDirectory).not.toHaveBeenCalled();
expect(addProject).toHaveBeenCalledWith({ kind: "posix", path: "/Users/me/code/app" }, "app");
expect(addProject).toHaveBeenCalledWith(
{ kind: "posix", path: "/Users/me/code/app" },
"app",
ACTIVE_WORKSPACE_ID,
);
expect(setLastUsedProjectDir).toHaveBeenCalledWith("native", "/Users/me/code");
expect(openDraft).toHaveBeenCalledWith("p1");
});
Expand All @@ -87,7 +99,11 @@ describe("commitCreateProject", () => {
name: "new",
kind: "posix",
});
expect(addProject).toHaveBeenCalledWith({ kind: "posix", path: "/Users/me/code/new" }, "new");
expect(addProject).toHaveBeenCalledWith(
{ kind: "posix", path: "/Users/me/code/new" },
"new",
ACTIVE_WORKSPACE_ID,
);
// scratch records the parent the user browsed, not the new folder.
expect(setLastUsedProjectDir).toHaveBeenCalledWith("native", "/Users/me/code");
});
Expand Down Expand Up @@ -142,6 +158,7 @@ describe("commitCloneProject", () => {
expect(addProject).toHaveBeenCalledWith(
{ kind: "posix", path: "/Users/me/code/poracode" },
"poracode",
ACTIVE_WORKSPACE_ID,
);
// Records the parent the user cloned into, not the new folder.
expect(setLastUsedProjectDir).toHaveBeenCalledWith("native", "/Users/me/code");
Expand All @@ -163,7 +180,11 @@ describe("commitCloneProject", () => {
name: "repo",
source: { kind: "url", url: "https://github.com/owner/repo.git" },
});
expect(addProject).toHaveBeenCalledWith({ kind: "posix", path: "/Users/me/code/repo" }, "repo");
expect(addProject).toHaveBeenCalledWith(
{ kind: "posix", path: "/Users/me/code/repo" },
"repo",
ACTIVE_WORKSPACE_ID,
);
});

test("clone failure propagates and does not add a project", async () => {
Expand Down Expand Up @@ -199,6 +220,7 @@ describe("addExistingProject", () => {
expect(addProject).toHaveBeenCalledWith(
{ kind: "posix", path: "/Users/me/code/app" },
undefined,
ACTIVE_WORKSPACE_ID,
);
expect(setLastUsedProjectDir).toHaveBeenCalledWith("native", "/Users/me/code");
expect(createProjectDirectory).not.toHaveBeenCalled();
Expand Down Expand Up @@ -227,6 +249,7 @@ describe("addExistingProject", () => {
uncPath: "\\\\wsl.localhost\\Ubuntu\\home\\demo\\app",
},
undefined,
ACTIVE_WORKSPACE_ID,
);
expect(setLastUsedProjectDir).toHaveBeenCalledWith(
"Ubuntu",
Expand Down
7 changes: 6 additions & 1 deletion src/renderer/actions/createProjectActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { readBridge } from "@/renderer/bridge";
import { loadHomeScopeLocation } from "@/renderer/actions/projectActions";
import { useAppStore } from "@/renderer/state/appStore";
import { useSharedSettings } from "@/renderer/state/sharedSettingsStore";
import { getActiveWorkspaceId } from "@/renderer/state/workspaceStore";
import { autoDetectSetupScript } from "@/renderer/utils/gitHelpers";

/** Whether a project is being created from scratch or opened from an existing folder. */
Expand All @@ -35,7 +36,11 @@ function registerNewProject(location: ProjectLocation, name: string, lastUsedDir
useSharedSettings.getState().setLastUsedProjectDir(runtimeKeyForLocation(location), lastUsedDir);

startTransition(() => {
const project = useAppStore.getState().addProject(location, name || undefined);
// New projects join the workspace the user is currently looking at,
// otherwise they'd land unfiled and show up in every workspace.
const project = useAppStore
.getState()
.addProject(location, name || undefined, getActiveWorkspaceId() ?? undefined);
autoDetectSetupScript(project);
useAppStore.getState().openDraft(project.id);
});
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/actions/panelActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ export function openChangelogSettings(): void {
usePanelStore.getState().openSettingsSection("changelog");
}

export function openWorkspaceSettings(): void {
usePanelStore.getState().openSettingsSection("workspaces");
}

/** Open the docked usage panel, or close all right-side panels if it is already active. */
export function openUsagePanel(): void {
const panelStore = usePanelStore.getState();
Expand Down
95 changes: 95 additions & 0 deletions src/renderer/actions/workspaceActions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useAppStore } from "@/renderer/state/appStore";
import { useSharedSettings } from "@/renderer/state/sharedSettingsStore";
import { useWorkspaceStore } from "@/renderer/state/workspaceStore";
import { createWorkspace, deleteWorkspace, renameWorkspace } from "./workspaceActions";

vi.mock("@heroui/react", () => ({
toast: {
info: vi.fn<(message: string) => void>(),
warning: vi.fn<(message: string) => void>(),
danger: vi.fn<(message: string) => void>(),
},
}));

function addProject(name: string, workspaceId?: string) {
return useAppStore
.getState()
.addProject({ kind: "posix", path: `/repos/${name}` }, name, workspaceId);
}

describe("workspaceActions", () => {
beforeEach(() => {
localStorage.clear();
useSharedSettings.setState({ workspaces: [] });
useWorkspaceStore.setState({ activeWorkspaceId: null });
useAppStore.setState((state) => ({ ...state, projects: [], threads: [] }));
});

it("creates a workspace and switches to it", () => {
const first = createWorkspace("Work");
const second = createWorkspace("Side Hustle");

expect(useSharedSettings.getState().workspaces.map((w) => w.name)).toEqual([
"Work",
"Side Hustle",
]);
expect(useSharedSettings.getState().workspaces.map((w) => w.icon)).toEqual([
"briefcase",
"rocket",
]);
expect(useWorkspaceStore.getState().activeWorkspaceId).toBe(second!.id);
expect(first!.id).not.toBe(second!.id);
});

it("refuses a blank name", () => {
expect(createWorkspace(" ")).toBeNull();
expect(useSharedSettings.getState().workspaces).toHaveLength(0);
});

it("trims names on create and rename", () => {
const workspace = createWorkspace(" Work ")!;
expect(workspace.name).toBe("Work");

renameWorkspace(workspace.id, " Client ");
expect(useSharedSettings.getState().workspaces[0]!.name).toBe("Client");
});

it("moves projects to another workspace when one is deleted", () => {
const work = createWorkspace("Work")!;
const side = createWorkspace("Side Hustle")!;
const alpha = addProject("alpha", work.id);
const beta = addProject("beta", side.id);

deleteWorkspace(work.id);

const projects = useAppStore.getState().projects;
// Deleting a grouping must never delete the work inside it.
expect(projects).toHaveLength(2);
expect(projects.find((p) => p.id === alpha.id)?.workspaceId).toBe(side.id);
expect(projects.find((p) => p.id === beta.id)?.workspaceId).toBe(side.id);
expect(useSharedSettings.getState().workspaces.map((w) => w.id)).toEqual([side.id]);
});

it("moves the active workspace off a deleted one", () => {
const work = createWorkspace("Work")!;
const side = createWorkspace("Side Hustle")!;
useWorkspaceStore.setState({ activeWorkspaceId: side.id });

deleteWorkspace(side.id);

expect(useWorkspaceStore.getState().activeWorkspaceId).toBe(work.id);
});

it("refuses to delete the last workspace", () => {
const only = createWorkspace("Work")!;
const alpha = addProject("alpha", only.id);

deleteWorkspace(only.id);

expect(useSharedSettings.getState().workspaces).toHaveLength(1);
expect(useAppStore.getState().projects.find((p) => p.id === alpha.id)?.workspaceId).toBe(
only.id,
);
});
});
Loading