Add projects without creating workspaces
Keep the legacy open-project request creating a workspace for old clients; new clients now add projects and create workspaces through separate flows.
This commit is contained in:
@@ -362,11 +362,13 @@ async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
|
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
|
||||||
const opened = await client.openProject(repo.path);
|
const createdWorkspace = await client.createWorkspace({
|
||||||
if (!opened.workspace) {
|
source: { kind: "directory", path: repo.path },
|
||||||
throw new Error(opened.error ?? `Failed to open project ${repo.path}`);
|
});
|
||||||
|
if (!createdWorkspace.workspace) {
|
||||||
|
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repo.path}`);
|
||||||
}
|
}
|
||||||
return { id: opened.workspace.id };
|
return { id: createdWorkspace.workspace.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import path from "node:path";
|
||||||
import { test, expect, type Page } from "./fixtures";
|
import { test, expect, type Page } from "./fixtures";
|
||||||
import { gotoAppShell } from "./helpers/app";
|
import { gotoAppShell } from "./helpers/app";
|
||||||
import { seedWorkspace } from "./helpers/seed-client";
|
import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
|
||||||
import { getServerId } from "./helpers/server-id";
|
import { getServerId } from "./helpers/server-id";
|
||||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import { waitForNoProjectsInSidebar, waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||||
|
|
||||||
function workspaceRowTestId(workspaceId: string): string {
|
function workspaceRowTestId(workspaceId: string): string {
|
||||||
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||||
@@ -45,11 +47,58 @@ async function removeProjectFromSidebar(page: Page, projectId: string): Promise<
|
|||||||
await removeItem.click();
|
await removeItem.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Model B makes the project a first-class parent: archiving its last workspace
|
async function addProjectFromPicker(page: Page, projectPath: string): Promise<string> {
|
||||||
// must not delete the project. The per-project "+ New workspace" row is gone;
|
await page.getByTestId("sidebar-add-project").click();
|
||||||
// the empty project keeps its parent row, and creation stays reachable from the
|
|
||||||
// project row's own new-worktree icon (git projects) and the global button.
|
const input = page.getByPlaceholder("Type a directory path...");
|
||||||
test.describe("Empty project persists", () => {
|
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||||
|
await input.fill(projectPath);
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
|
||||||
|
const projectRow = page
|
||||||
|
.locator('[data-testid^="sidebar-project-row-"]')
|
||||||
|
.filter({ hasText: path.basename(projectPath) })
|
||||||
|
.first();
|
||||||
|
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
const testId = await projectRow.getAttribute("data-testid");
|
||||||
|
expect(testId).not.toBeNull();
|
||||||
|
return testId!.replace("sidebar-project-row-", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Projects are parents in the sidebar. Archiving the last workspace leaves the
|
||||||
|
// project row in place with a ghost "+ New workspace" child row.
|
||||||
|
test.describe("Project with no workspaces persists", () => {
|
||||||
|
test("adding a project starts with only a new-workspace child row", async ({ page }) => {
|
||||||
|
const repo = await createTempGitRepo("empty-project-add-");
|
||||||
|
const client = await connectSeedClient();
|
||||||
|
let projectId: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForNoProjectsInSidebar(page);
|
||||||
|
|
||||||
|
projectId = await addProjectFromPicker(page, repo.path);
|
||||||
|
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
|
||||||
|
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(projectRow).toContainText(path.basename(repo.path));
|
||||||
|
await expect(page.getByTestId(`sidebar-workspace-list-${projectId}`)).toHaveCount(0);
|
||||||
|
|
||||||
|
const newWorkspaceRow = page.getByTestId(`sidebar-project-new-workspace-row-${projectId}`);
|
||||||
|
await expect(newWorkspaceRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(newWorkspaceRow).toContainText("New workspace");
|
||||||
|
|
||||||
|
const workspaces = await client.fetchWorkspaces({ filter: { projectId } });
|
||||||
|
expect(workspaces.entries).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
if (projectId) {
|
||||||
|
await removeProjectFromSidebar(page, projectId).catch(() => undefined);
|
||||||
|
}
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
await repo.cleanup().catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("archiving the only workspace keeps the project row with creation still reachable", async ({
|
test("archiving the only workspace keeps the project row with creation still reachable", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -57,8 +106,8 @@ test.describe("Empty project persists", () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
|
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
|
||||||
const projectNewWorktreeIcon = page.getByTestId(
|
const newWorkspaceRow = page.getByTestId(
|
||||||
`sidebar-project-new-worktree-${workspace.projectId}`,
|
`sidebar-project-new-workspace-row-${workspace.projectId}`,
|
||||||
);
|
);
|
||||||
const globalNewWorkspace = page.getByTestId("sidebar-global-new-workspace");
|
const globalNewWorkspace = page.getByTestId("sidebar-global-new-workspace");
|
||||||
|
|
||||||
@@ -71,24 +120,21 @@ test.describe("Empty project persists", () => {
|
|||||||
|
|
||||||
await hideWorkspaceFromSidebar(page, workspace.workspaceId);
|
await hideWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||||
|
|
||||||
// The workspace row goes away, but its project parent stays as an empty
|
// The workspace row goes away, but its project parent stays and exposes a
|
||||||
// project row. Creation is still reachable: the project row keeps its own
|
// child row for creating the next workspace.
|
||||||
// new-worktree icon (revealed on hover) and the global button persists.
|
|
||||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
|
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(newWorkspaceRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(newWorkspaceRow).toContainText("New workspace");
|
||||||
await expect(globalNewWorkspace).toBeVisible({ timeout: 30_000 });
|
await expect(globalNewWorkspace).toBeVisible({ timeout: 30_000 });
|
||||||
await projectRow.hover();
|
|
||||||
await expect(projectNewWorktreeIcon).toBeVisible({ timeout: 30_000 });
|
|
||||||
|
|
||||||
// The empty project survives a reload — it is persisted, not a transient
|
// The project survives a reload after its last workspace is archived.
|
||||||
// artifact of the just-archived workspace still lingering in memory.
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||||
await projectRow.hover();
|
await expect(newWorkspaceRow).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(projectNewWorktreeIcon).toBeVisible({ timeout: 30_000 });
|
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
}
|
}
|
||||||
@@ -120,16 +166,20 @@ test.describe("Project remove", () => {
|
|||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
|
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
|
||||||
|
|
||||||
const reopened = await workspace.client.openProject(workspace.repoPath);
|
const readded = await workspace.client.addProject(workspace.repoPath);
|
||||||
expect(reopened.error).toBeNull();
|
expect(readded.error).toBeNull();
|
||||||
expect(reopened.workspace?.projectDisplayName).toBe(workspace.projectDisplayName);
|
expect(readded.project?.projectDisplayName).toBe(workspace.projectDisplayName);
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(projectRow).toContainText(workspace.projectDisplayName);
|
await expect(projectRow).toContainText(workspace.projectDisplayName);
|
||||||
await expect(projectRow).not.toContainText(workspace.repoPath);
|
await expect(projectRow).not.toContainText(workspace.repoPath);
|
||||||
|
await expect(
|
||||||
|
page.getByTestId(`sidebar-project-new-workspace-row-${workspace.projectId}`),
|
||||||
|
).toBeVisible({ timeout: 30_000 });
|
||||||
} finally {
|
} finally {
|
||||||
|
await removeProjectFromSidebar(page, workspace.projectId).catch(() => undefined);
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function buildSeededStoragePayload() {
|
|||||||
* idle agent from the same client it uses for everything else.
|
* idle agent from the same client it uses for everything else.
|
||||||
*/
|
*/
|
||||||
export interface IdleAgentSeedClient {
|
export interface IdleAgentSeedClient {
|
||||||
openProject(cwd: string): Promise<{
|
createWorkspace(input: { source: { kind: "directory"; path: string } }): Promise<{
|
||||||
workspace: { id: string } | null;
|
workspace: { id: string } | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}>;
|
}>;
|
||||||
@@ -58,16 +58,18 @@ export async function createIdleAgent(
|
|||||||
client: IdleAgentSeedClient,
|
client: IdleAgentSeedClient,
|
||||||
input: { cwd: string; title: string },
|
input: { cwd: string; title: string },
|
||||||
): Promise<ArchiveTabAgent> {
|
): Promise<ArchiveTabAgent> {
|
||||||
const opened = await client.openProject(input.cwd);
|
const createdWorkspace = await client.createWorkspace({
|
||||||
if (!opened.workspace) {
|
source: { kind: "directory", path: input.cwd },
|
||||||
throw new Error(opened.error ?? `Failed to open project ${input.cwd}`);
|
});
|
||||||
|
if (!createdWorkspace.workspace) {
|
||||||
|
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${input.cwd}`);
|
||||||
}
|
}
|
||||||
const created = await client.createAgent({
|
const created = await client.createAgent({
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
model: "opencode/gpt-5-nano",
|
model: "opencode/gpt-5-nano",
|
||||||
modeId: "bypassPermissions",
|
modeId: "bypassPermissions",
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
workspaceId: opened.workspace.id,
|
workspaceId: createdWorkspace.workspace.id,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
});
|
});
|
||||||
const snapshot = await client.waitForAgentUpsert(
|
const snapshot = await client.waitForAgentUpsert(
|
||||||
@@ -82,7 +84,7 @@ export async function createIdleAgent(
|
|||||||
id: created.id,
|
id: created.id,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
workspaceId: opened.workspace.id,
|
workspaceId: createdWorkspace.workspace.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -160,15 +160,19 @@ export async function startRunningMockAgent(
|
|||||||
|
|
||||||
const repo = await createTempGitRepo(opts.prefix);
|
const repo = await createTempGitRepo(opts.prefix);
|
||||||
const client = await connectSeedClient();
|
const client = await connectSeedClient();
|
||||||
const opened = await client.openProject(repo.path);
|
const createdWorkspace = await client.createWorkspace({
|
||||||
if (!opened.workspace) throw new Error(opened.error ?? "Failed to open project");
|
source: { kind: "directory", path: repo.path },
|
||||||
|
});
|
||||||
|
if (!createdWorkspace.workspace) {
|
||||||
|
throw new Error(createdWorkspace.error ?? "Failed to create workspace");
|
||||||
|
}
|
||||||
const agent = await client.createAgent({
|
const agent = await client.createAgent({
|
||||||
provider: "mock",
|
provider: "mock",
|
||||||
cwd: repo.path,
|
cwd: repo.path,
|
||||||
workspaceId: opened.workspace.id,
|
workspaceId: createdWorkspace.workspace.id,
|
||||||
model: opts.model,
|
model: opts.model,
|
||||||
});
|
});
|
||||||
const agentUrl = `${buildHostWorkspaceRoute(serverId, opened.workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
|
const agentUrl = `${buildHostWorkspaceRoute(serverId, createdWorkspace.workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
|
||||||
await page.goto(agentUrl);
|
await page.goto(agentUrl);
|
||||||
await expectComposerVisible(page);
|
await expectComposerVisible(page);
|
||||||
await client.sendAgentMessage(agent.id, opts.prompt);
|
await client.sendAgentMessage(agent.id, opts.prompt);
|
||||||
@@ -188,10 +192,14 @@ export async function openGithubWorkspace(
|
|||||||
repoPath: string,
|
repoPath: string,
|
||||||
): Promise<GithubWorkspaceHandle> {
|
): Promise<GithubWorkspaceHandle> {
|
||||||
const client = await connectWorkspaceSetupClient();
|
const client = await connectWorkspaceSetupClient();
|
||||||
const opened = await client.openProject(repoPath);
|
const createdWorkspace = await client.createWorkspace({
|
||||||
if (!opened.workspace) throw new Error(opened.error ?? `Failed to open project ${repoPath}`);
|
source: { kind: "directory", path: repoPath },
|
||||||
|
});
|
||||||
|
if (!createdWorkspace.workspace) {
|
||||||
|
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repoPath}`);
|
||||||
|
}
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await selectWorkspaceInSidebar(page, opened.workspace.id);
|
await selectWorkspaceInSidebar(page, createdWorkspace.workspace.id);
|
||||||
await waitForTabBar(page);
|
await waitForTabBar(page);
|
||||||
return { cleanup: () => client.close().catch(() => undefined) };
|
return { cleanup: () => client.close().catch(() => undefined) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,16 +13,16 @@ type NewWorkspaceDaemonClient = Pick<
|
|||||||
| "close"
|
| "close"
|
||||||
| "connect"
|
| "connect"
|
||||||
| "createPaseoWorktree"
|
| "createPaseoWorktree"
|
||||||
|
| "createWorkspace"
|
||||||
| "fetchWorkspaces"
|
| "fetchWorkspaces"
|
||||||
| "getPaseoWorktreeList"
|
| "getPaseoWorktreeList"
|
||||||
| "getDaemonConfig"
|
| "getDaemonConfig"
|
||||||
| "openProject"
|
|
||||||
| "patchDaemonConfig"
|
| "patchDaemonConfig"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
|
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
|
||||||
type WorkspacePayload = Pick<OpenProjectPayload, "error" | "workspace">;
|
type WorkspacePayload = Pick<CreateWorkspacePayload, "error" | "workspace">;
|
||||||
type WorkspaceDescriptor = NonNullable<OpenProjectPayload["workspace"]>;
|
type WorkspaceDescriptor = NonNullable<CreateWorkspacePayload["workspace"]>;
|
||||||
|
|
||||||
export interface OpenedProject {
|
export interface OpenedProject {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
@@ -37,7 +37,7 @@ function requireWorkspace(payload: WorkspacePayload) {
|
|||||||
throw new Error(payload.error);
|
throw new Error(payload.error);
|
||||||
}
|
}
|
||||||
if (!payload.workspace) {
|
if (!payload.workspace) {
|
||||||
throw new Error("openProject returned no workspace.");
|
throw new Error("workspace.create returned no workspace.");
|
||||||
}
|
}
|
||||||
return payload.workspace;
|
return payload.workspace;
|
||||||
}
|
}
|
||||||
@@ -96,7 +96,11 @@ export async function openProjectViaDaemon(
|
|||||||
client: NewWorkspaceDaemonClient,
|
client: NewWorkspaceDaemonClient,
|
||||||
repoPath: string,
|
repoPath: string,
|
||||||
): Promise<OpenedProject> {
|
): Promise<OpenedProject> {
|
||||||
const workspace = requireWorkspace(await client.openProject(repoPath));
|
const workspace = requireWorkspace(
|
||||||
|
await client.createWorkspace({
|
||||||
|
source: { kind: "directory", path: repoPath },
|
||||||
|
}),
|
||||||
|
);
|
||||||
return openedProjectFromWorkspace(workspace);
|
return openedProjectFromWorkspace(workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -171,25 +171,27 @@ export async function launchAgent(input: {
|
|||||||
execFileSync("git", ["add", "README.md"], { cwd: input.cwd, stdio: "ignore" });
|
execFileSync("git", ["add", "README.md"], { cwd: input.cwd, stdio: "ignore" });
|
||||||
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: input.cwd, stdio: "ignore" });
|
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: input.cwd, stdio: "ignore" });
|
||||||
const client = await connectSeedClient();
|
const client = await connectSeedClient();
|
||||||
const opened = await client.openProject(input.cwd);
|
const createdWorkspace = await client.createWorkspace({
|
||||||
if (!opened.workspace) {
|
source: { kind: "directory", path: input.cwd },
|
||||||
throw new Error(opened.error ?? `Failed to open project ${input.cwd}`);
|
});
|
||||||
|
if (!createdWorkspace.workspace) {
|
||||||
|
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${input.cwd}`);
|
||||||
}
|
}
|
||||||
const agent = await client.createAgent({
|
const agent = await client.createAgent({
|
||||||
...fullAccessConfig(input.provider),
|
...fullAccessConfig(input.provider),
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
workspaceId: opened.workspace.id,
|
workspaceId: createdWorkspace.workspace.id,
|
||||||
title: `rewind-flow-${input.provider}-${randomUUID()}`,
|
title: `rewind-flow-${input.provider}-${randomUUID()}`,
|
||||||
});
|
});
|
||||||
const handle = {
|
const handle = {
|
||||||
page: input.page,
|
page: input.page,
|
||||||
client,
|
client,
|
||||||
agentId: agent.id,
|
agentId: agent.id,
|
||||||
workspaceId: opened.workspace.id,
|
workspaceId: createdWorkspace.workspace.id,
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
provider: input.provider,
|
provider: input.provider,
|
||||||
};
|
};
|
||||||
await openAgent(input.page, { workspaceId: opened.workspace.id, agentId: agent.id });
|
await openAgent(input.page, { workspaceId: createdWorkspace.workspace.id, agentId: agent.id });
|
||||||
return handle;
|
return handle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
|
|||||||
import { connectDaemonClient } from "./daemon-client-loader";
|
import { connectDaemonClient } from "./daemon-client-loader";
|
||||||
import { createTempDirectory, createTempGitRepo } from "./workspace";
|
import { createTempDirectory, createTempGitRepo } from "./workspace";
|
||||||
|
|
||||||
|
export interface SeedWorkspaceDescriptor {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
projectId: string;
|
||||||
|
projectDisplayName: string;
|
||||||
|
projectRootPath: string;
|
||||||
|
workspaceDirectory: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The general-purpose E2E daemon client used to seed and drive state out of
|
* The general-purpose E2E daemon client used to seed and drive state out of
|
||||||
* band (workspaces, agents, terminals) while the UI is exercised through the
|
* band (workspaces, agents, terminals) while the UI is exercised through the
|
||||||
@@ -13,17 +22,17 @@ import { createTempDirectory, createTempGitRepo } from "./workspace";
|
|||||||
export interface SeedDaemonClient {
|
export interface SeedDaemonClient {
|
||||||
connect(): Promise<void>;
|
connect(): Promise<void>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
openProject(cwd: string): Promise<{
|
addProject(cwd: string): Promise<{
|
||||||
workspace: {
|
project: {
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
projectId: string;
|
projectId: string;
|
||||||
projectDisplayName: string;
|
projectDisplayName: string;
|
||||||
projectRootPath: string;
|
projectRootPath: string;
|
||||||
workspaceDirectory: string;
|
|
||||||
} | null;
|
} | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}>;
|
}>;
|
||||||
|
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
|
||||||
|
entries: SeedWorkspaceDescriptor[];
|
||||||
|
}>;
|
||||||
createWorkspace(input: {
|
createWorkspace(input: {
|
||||||
source:
|
source:
|
||||||
| { kind: "directory"; path: string; projectId?: string }
|
| { kind: "directory"; path: string; projectId?: string }
|
||||||
@@ -39,7 +48,7 @@ export interface SeedDaemonClient {
|
|||||||
};
|
};
|
||||||
title?: string;
|
title?: string;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
workspace: { id: string; name: string } | null;
|
workspace: SeedWorkspaceDescriptor | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}>;
|
}>;
|
||||||
/**
|
/**
|
||||||
@@ -126,7 +135,7 @@ export interface SeedDaemonClient {
|
|||||||
agentId: string,
|
agentId: string,
|
||||||
): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>;
|
): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>;
|
||||||
getLastServerInfoMessage(): {
|
getLastServerInfoMessage(): {
|
||||||
features?: { worktreeRestore?: boolean } | null;
|
features?: { projectAdd?: boolean; worktreeRestore?: boolean } | null;
|
||||||
} | null;
|
} | null;
|
||||||
fetchAgentHistory(options?: {
|
fetchAgentHistory(options?: {
|
||||||
page?: { limit: number };
|
page?: { limit: number };
|
||||||
@@ -184,18 +193,20 @@ export async function seedWorkspace(options: {
|
|||||||
: await createTempGitRepo(options.repoPrefix, options.repo);
|
: await createTempGitRepo(options.repoPrefix, options.repo);
|
||||||
const client = await connectSeedClient();
|
const client = await connectSeedClient();
|
||||||
try {
|
try {
|
||||||
const opened = await client.openProject(project.path);
|
const created = await client.createWorkspace({
|
||||||
if (!opened.workspace) {
|
source: { kind: "directory", path: project.path },
|
||||||
throw new Error(opened.error ?? `Failed to open project ${project.path}`);
|
});
|
||||||
|
if (!created.workspace) {
|
||||||
|
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
client,
|
client,
|
||||||
repoPath: project.path,
|
repoPath: project.path,
|
||||||
workspaceId: opened.workspace.id,
|
workspaceId: created.workspace.id,
|
||||||
workspaceName: opened.workspace.name,
|
workspaceName: created.workspace.name,
|
||||||
workspaceDirectory: opened.workspace.workspaceDirectory,
|
workspaceDirectory: created.workspace.workspaceDirectory,
|
||||||
projectId: opened.workspace.projectId,
|
projectId: created.workspace.projectId,
|
||||||
projectDisplayName: opened.workspace.projectDisplayName,
|
projectDisplayName: created.workspace.projectDisplayName,
|
||||||
cleanup: async () => {
|
cleanup: async () => {
|
||||||
await client.close().catch(() => undefined);
|
await client.close().catch(() => undefined);
|
||||||
await project.cleanup().catch(() => undefined);
|
await project.cleanup().catch(() => undefined);
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ export class TerminalE2EHarness {
|
|||||||
static async create(input: { tempPrefix: string }): Promise<TerminalE2EHarness> {
|
static async create(input: { tempPrefix: string }): Promise<TerminalE2EHarness> {
|
||||||
const tempRepo = await createTempGitRepo(input.tempPrefix);
|
const tempRepo = await createTempGitRepo(input.tempPrefix);
|
||||||
const client = await connectSeedClient();
|
const client = await connectSeedClient();
|
||||||
const seedResult = await client.openProject(tempRepo.path);
|
const seedResult = await client.createWorkspace({
|
||||||
|
source: { kind: "directory", path: tempRepo.path },
|
||||||
|
});
|
||||||
if (!seedResult.workspace) {
|
if (!seedResult.workspace) {
|
||||||
await client.close().catch(() => {});
|
await client.close().catch(() => {});
|
||||||
await tempRepo.cleanup().catch(() => {});
|
await tempRepo.cleanup().catch(() => {});
|
||||||
|
|||||||
@@ -60,14 +60,19 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
|
|||||||
);
|
);
|
||||||
worktrees.push({ repoPath: repo.path, worktreePath: workspacePath });
|
worktrees.push({ repoPath: repo.path, worktreePath: workspacePath });
|
||||||
// Register the parent project so the sidebar lists it before we navigate.
|
// Register the parent project so the sidebar lists it before we navigate.
|
||||||
await client.openProject(repo.path);
|
const added = await client.addProject(repo.path);
|
||||||
|
if (!added.project) {
|
||||||
|
throw new Error(added.error ?? `Failed to add project ${repo.path}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const opened = await client.openProject(workspacePath);
|
const created = await client.createWorkspace({
|
||||||
if (!opened.workspace) {
|
source: { kind: "directory", path: workspacePath },
|
||||||
throw new Error(opened.error ?? `Failed to open project ${workspacePath}`);
|
});
|
||||||
|
if (!created.workspace) {
|
||||||
|
throw new Error(created.error ?? `Failed to create workspace ${workspacePath}`);
|
||||||
}
|
}
|
||||||
const workspaceId = opened.workspace.id;
|
const workspaceId = created.workspace.id;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
|
|||||||
@@ -11,13 +11,14 @@ import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
|||||||
type WorkspaceSetupDaemonClient = Pick<
|
type WorkspaceSetupDaemonClient = Pick<
|
||||||
InternalDaemonClient,
|
InternalDaemonClient,
|
||||||
| "close"
|
| "close"
|
||||||
|
| "addProject"
|
||||||
| "connect"
|
| "connect"
|
||||||
| "createPaseoWorktree"
|
| "createPaseoWorktree"
|
||||||
|
| "createWorkspace"
|
||||||
| "fetchAgent"
|
| "fetchAgent"
|
||||||
| "fetchAgents"
|
| "fetchAgents"
|
||||||
| "fetchWorkspaces"
|
| "fetchWorkspaces"
|
||||||
| "listTerminals"
|
| "listTerminals"
|
||||||
| "openProject"
|
|
||||||
| "subscribeRawMessages"
|
| "subscribeRawMessages"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
@@ -36,9 +37,11 @@ export async function openProjectViaDaemon(
|
|||||||
client: WorkspaceSetupDaemonClient,
|
client: WorkspaceSetupDaemonClient,
|
||||||
repoPath: string,
|
repoPath: string,
|
||||||
): Promise<{ id: string; name: string; workspaceDirectory: string }> {
|
): Promise<{ id: string; name: string; workspaceDirectory: string }> {
|
||||||
const result = await client.openProject(repoPath);
|
const result = await client.createWorkspace({
|
||||||
|
source: { kind: "directory", path: repoPath },
|
||||||
|
});
|
||||||
if (!result.workspace || result.error) {
|
if (!result.workspace || result.error) {
|
||||||
throw new Error(result.error ?? `Failed to open project ${repoPath}`);
|
throw new Error(result.error ?? `Failed to create workspace ${repoPath}`);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
id: result.workspace.id,
|
id: result.workspace.id,
|
||||||
@@ -51,7 +54,10 @@ export async function seedProjectForWorkspaceSetup(
|
|||||||
client: WorkspaceSetupDaemonClient,
|
client: WorkspaceSetupDaemonClient,
|
||||||
repoPath: string,
|
repoPath: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await openProjectViaDaemon(client, repoPath);
|
const result = await client.addProject(repoPath);
|
||||||
|
if (!result.project || result.error) {
|
||||||
|
throw new Error(result.error ?? `Failed to add project ${repoPath}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function projectNameFromPath(repoPath: string): string {
|
export function projectNameFromPath(repoPath: string): string {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ export async function openNewAgentComposer(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait for the sidebar to show at least one project row, indicating that the
|
* Wait for the sidebar to show at least one project row. Use this after a spec
|
||||||
* WebSocket connection is up and workspace hydration has completed.
|
* seeds a workspace/project; zero-project flows need their own assertion.
|
||||||
*/
|
*/
|
||||||
export async function waitForSidebarHydration(page: Page, timeout = 60_000): Promise<void> {
|
export async function waitForSidebarHydration(page: Page, timeout = 60_000): Promise<void> {
|
||||||
await page
|
await page
|
||||||
@@ -18,6 +18,10 @@ export async function waitForSidebarHydration(page: Page, timeout = 60_000): Pro
|
|||||||
.waitFor({ state: "visible", timeout });
|
.waitFor({ state: "visible", timeout });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function waitForNoProjectsInSidebar(page: Page, timeout = 60_000): Promise<void> {
|
||||||
|
await page.getByTestId("sidebar-project-empty-state").waitFor({ state: "visible", timeout });
|
||||||
|
}
|
||||||
|
|
||||||
function workspaceRowLocator(page: Page, serverId: string, workspaceId: string) {
|
function workspaceRowLocator(page: Page, serverId: string, workspaceId: string) {
|
||||||
return page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).first();
|
return page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).first();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,9 +58,11 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
|
|||||||
await launchOpenCodeSessionInWorkspace(PASEO_REPO_PATH, prompt);
|
await launchOpenCodeSessionInWorkspace(PASEO_REPO_PATH, prompt);
|
||||||
const client = await connectSeedClient();
|
const client = await connectSeedClient();
|
||||||
try {
|
try {
|
||||||
const opened = await client.openProject(PASEO_REPO_PATH);
|
const createdWorkspace = await client.createWorkspace({
|
||||||
if (!opened.workspace) {
|
source: { kind: "directory", path: PASEO_REPO_PATH },
|
||||||
throw new Error(opened.error ?? `Failed to open project ${PASEO_REPO_PATH}`);
|
});
|
||||||
|
if (!createdWorkspace.workspace) {
|
||||||
|
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${PASEO_REPO_PATH}`);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
prompt,
|
prompt,
|
||||||
@@ -69,11 +71,11 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
|
|||||||
workspace: {
|
workspace: {
|
||||||
client,
|
client,
|
||||||
repoPath: PASEO_REPO_PATH,
|
repoPath: PASEO_REPO_PATH,
|
||||||
workspaceId: opened.workspace.id,
|
workspaceId: createdWorkspace.workspace.id,
|
||||||
workspaceName: opened.workspace.name,
|
workspaceName: createdWorkspace.workspace.name,
|
||||||
workspaceDirectory: opened.workspace.workspaceDirectory,
|
workspaceDirectory: createdWorkspace.workspace.workspaceDirectory,
|
||||||
projectId: opened.workspace.projectId,
|
projectId: createdWorkspace.workspace.projectId,
|
||||||
projectDisplayName: opened.workspace.projectDisplayName,
|
projectDisplayName: createdWorkspace.workspace.projectDisplayName,
|
||||||
cleanup: async () => {
|
cleanup: async () => {
|
||||||
await client.close().catch(() => undefined);
|
await client.close().catch(() => undefined);
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
|||||||
import { memo, useCallback, useMemo, useState } from "react";
|
import { memo, useCallback, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
|
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
|
||||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||||
@@ -21,45 +20,12 @@ import {
|
|||||||
CircleCheck,
|
CircleCheck,
|
||||||
CircleDot,
|
CircleDot,
|
||||||
CircleX,
|
CircleX,
|
||||||
MoreVertical,
|
|
||||||
Copy,
|
|
||||||
Archive,
|
|
||||||
Pencil,
|
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { DiffStat } from "@/components/diff-stat";
|
|
||||||
import { useSidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
import { useSidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||||
import {
|
import { SidebarWorkspaceRow } from "@/components/sidebar/sidebar-workspace-row";
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { useToast } from "@/contexts/toast-context";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
|
||||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
|
||||||
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
|
|
||||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
|
||||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
|
||||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
|
||||||
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
|
||||||
import * as Clipboard from "expo-clipboard";
|
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
|
||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
|
||||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
|
||||||
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
|
|
||||||
import {
|
|
||||||
SidebarWorkspaceRowFrame,
|
|
||||||
SidebarWorkspaceRowContent,
|
|
||||||
SidebarWorkspaceTrailingActionBase,
|
|
||||||
SidebarWorkspaceTrailingActionOverlay,
|
|
||||||
SidebarWorkspaceTrailingActionSlot,
|
|
||||||
} from "@/components/sidebar/sidebar-workspace-row-content";
|
|
||||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||||
|
|
||||||
// Themed icon wrappers
|
// Themed icon wrappers
|
||||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
|
||||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
});
|
});
|
||||||
@@ -74,17 +40,6 @@ const ThemedCircleAlert = withUnistyles(CircleAlert);
|
|||||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||||
const ThemedCircleDot = withUnistyles(CircleDot);
|
const ThemedCircleDot = withUnistyles(CircleDot);
|
||||||
const ThemedCircleX = withUnistyles(CircleX);
|
const ThemedCircleX = withUnistyles(CircleX);
|
||||||
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
|
||||||
const ThemedCopy = withUnistyles(Copy);
|
|
||||||
const ThemedArchive = withUnistyles(Archive);
|
|
||||||
const ThemedPencil = withUnistyles(Pencil);
|
|
||||||
|
|
||||||
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
|
|
||||||
const markAsReadLeadingIcon = (
|
|
||||||
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
|
|
||||||
);
|
|
||||||
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
|
||||||
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
|
||||||
|
|
||||||
interface StatusWorkspaceListProps {
|
interface StatusWorkspaceListProps {
|
||||||
workspaces: SidebarWorkspaceEntry[];
|
workspaces: SidebarWorkspaceEntry[];
|
||||||
@@ -320,448 +275,18 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
|||||||
if (!hydratedWorkspace) return null;
|
if (!hydratedWorkspace) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StatusWorkspaceRowWithMenu
|
<SidebarWorkspaceRow
|
||||||
workspace={hydratedWorkspace}
|
workspace={hydratedWorkspace}
|
||||||
projectName={projectName}
|
|
||||||
selected={selected}
|
selected={selected}
|
||||||
shortcutNumber={shortcutNumber}
|
shortcutNumber={shortcutNumber}
|
||||||
showShortcutBadge={showShortcutBadge}
|
showShortcutBadge={showShortcutBadge}
|
||||||
|
canCopyBranchName={hydratedWorkspace.projectKind === "git"}
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
|
subtitle={projectName}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function StatusWorkspaceRowWithMenu({
|
|
||||||
workspace,
|
|
||||||
projectName,
|
|
||||||
selected,
|
|
||||||
shortcutNumber,
|
|
||||||
showShortcutBadge,
|
|
||||||
onPress,
|
|
||||||
}: {
|
|
||||||
workspace: SidebarWorkspaceEntry;
|
|
||||||
projectName: string;
|
|
||||||
selected: boolean;
|
|
||||||
shortcutNumber: number | null;
|
|
||||||
showShortcutBadge: boolean;
|
|
||||||
onPress: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const toast = useToast();
|
|
||||||
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
|
||||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
|
||||||
const workspaceDirectory = resolveWorkspaceDirectory({
|
|
||||||
workspaceDirectory: workspace.workspaceDirectory,
|
|
||||||
});
|
|
||||||
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
|
|
||||||
workspaceDirectory
|
|
||||||
? state.getStatus({
|
|
||||||
serverId: workspace.serverId,
|
|
||||||
cwd: workspaceDirectory,
|
|
||||||
actionId: "archive-worktree",
|
|
||||||
})
|
|
||||||
: "idle",
|
|
||||||
);
|
|
||||||
const isWorktree = workspace.workspaceKind === "worktree";
|
|
||||||
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
|
|
||||||
|
|
||||||
const redirectAfterArchive = useCallback(() => {
|
|
||||||
redirectIfArchivingActiveWorkspace({
|
|
||||||
serverId: workspace.serverId,
|
|
||||||
workspaceId: workspace.workspaceId,
|
|
||||||
activeWorkspaceSelection: selected
|
|
||||||
? { serverId: workspace.serverId, workspaceId: workspace.workspaceId }
|
|
||||||
: null,
|
|
||||||
});
|
|
||||||
}, [selected, workspace]);
|
|
||||||
|
|
||||||
const archiveController = useWorkspaceArchive({
|
|
||||||
serverId: workspace.serverId,
|
|
||||||
workspaceId: workspace.workspaceId,
|
|
||||||
workspaceDirectory: workspace.workspaceDirectory,
|
|
||||||
workspaceKind: workspace.workspaceKind,
|
|
||||||
name: workspace.name,
|
|
||||||
...toWorktreeArchiveRisk(workspace),
|
|
||||||
onArchiveStarted: redirectAfterArchive,
|
|
||||||
onSetHiding: setIsHidingWorkspace,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleArchive = useCallback(() => {
|
|
||||||
if (isArchiving) return;
|
|
||||||
archiveController.archive();
|
|
||||||
}, [archiveController, isArchiving]);
|
|
||||||
|
|
||||||
const handleCopyPath = useCallback(() => {
|
|
||||||
let copyTargetDirectory: string;
|
|
||||||
try {
|
|
||||||
copyTargetDirectory = requireWorkspaceDirectory({
|
|
||||||
workspaceId: workspace.workspaceId,
|
|
||||||
workspaceDirectory: workspace.workspaceDirectory,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "Workspace path not available");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void Clipboard.setStringAsync(copyTargetDirectory);
|
|
||||||
toast.copied("Path copied");
|
|
||||||
}, [toast, workspace.workspaceDirectory, workspace.workspaceId]);
|
|
||||||
|
|
||||||
const handleCopyBranchName = useCallback(() => {
|
|
||||||
void Clipboard.setStringAsync(workspace.name);
|
|
||||||
toast.copied("Branch name copied");
|
|
||||||
}, [toast, workspace.name]);
|
|
||||||
|
|
||||||
const renameMutation = useMutation({
|
|
||||||
mutationFn: async (title: string) => {
|
|
||||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
|
||||||
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
|
|
||||||
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleOpenRename = useCallback(() => setIsRenameOpen(true), []);
|
|
||||||
const handleCloseRename = useCallback(() => setIsRenameOpen(false), []);
|
|
||||||
const handleSubmitRename = useCallback(
|
|
||||||
async (value: string) => {
|
|
||||||
await renameMutation.mutateAsync(value.trim());
|
|
||||||
},
|
|
||||||
[renameMutation],
|
|
||||||
);
|
|
||||||
|
|
||||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
|
||||||
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
|
||||||
serverId: workspace.serverId,
|
|
||||||
workspaceId: workspace.workspaceId,
|
|
||||||
});
|
|
||||||
const handleMarkAsRead = useCallback(() => {
|
|
||||||
void clearAttention().catch((error) => {
|
|
||||||
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read");
|
|
||||||
});
|
|
||||||
}, [clearAttention, toast]);
|
|
||||||
|
|
||||||
useKeyboardActionHandler({
|
|
||||||
handlerId: `worktree-archive-${workspace.workspaceKey}`,
|
|
||||||
actions: ["worktree.archive"],
|
|
||||||
enabled: selected && !isArchiving,
|
|
||||||
priority: 0,
|
|
||||||
handle: () => {
|
|
||||||
handleArchive();
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let computedArchiveStatus: "idle" | "pending" | "success" = "idle";
|
|
||||||
if (isWorktree) {
|
|
||||||
computedArchiveStatus = worktreeArchiveStatus;
|
|
||||||
} else if (isHidingWorkspace) {
|
|
||||||
computedArchiveStatus = "pending";
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<StatusWorkspaceRowInner
|
|
||||||
workspace={workspace}
|
|
||||||
projectName={projectName}
|
|
||||||
selected={selected}
|
|
||||||
shortcutNumber={shortcutNumber}
|
|
||||||
showShortcutBadge={showShortcutBadge}
|
|
||||||
onPress={onPress}
|
|
||||||
isArchiving={isArchiving}
|
|
||||||
archiveLabel={t("sidebar.workspace.actions.archive")}
|
|
||||||
archiveStatus={computedArchiveStatus}
|
|
||||||
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
|
|
||||||
onArchive={handleArchive}
|
|
||||||
onCopyBranchName={workspace.projectKind === "git" ? handleCopyBranchName : undefined}
|
|
||||||
onCopyPath={handleCopyPath}
|
|
||||||
onRename={handleOpenRename}
|
|
||||||
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
|
|
||||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
|
||||||
/>
|
|
||||||
<AdaptiveRenameModal
|
|
||||||
visible={isRenameOpen}
|
|
||||||
title="Rename workspace"
|
|
||||||
initialValue={workspace.title ?? workspace.name}
|
|
||||||
placeholder={workspace.name}
|
|
||||||
submitLabel="Rename"
|
|
||||||
onClose={handleCloseRename}
|
|
||||||
onSubmit={handleSubmitRename}
|
|
||||||
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusWorkspaceRowInner({
|
|
||||||
workspace,
|
|
||||||
projectName,
|
|
||||||
selected,
|
|
||||||
shortcutNumber,
|
|
||||||
showShortcutBadge,
|
|
||||||
onPress,
|
|
||||||
isArchiving,
|
|
||||||
archiveLabel,
|
|
||||||
archiveStatus = "idle",
|
|
||||||
archivePendingLabel,
|
|
||||||
onArchive,
|
|
||||||
onCopyBranchName,
|
|
||||||
onCopyPath,
|
|
||||||
onRename,
|
|
||||||
onMarkAsRead,
|
|
||||||
archiveShortcutKeys,
|
|
||||||
}: {
|
|
||||||
workspace: SidebarWorkspaceEntry;
|
|
||||||
projectName: string;
|
|
||||||
selected: boolean;
|
|
||||||
shortcutNumber: number | null;
|
|
||||||
showShortcutBadge: boolean;
|
|
||||||
onPress: () => void;
|
|
||||||
isArchiving: boolean;
|
|
||||||
archiveLabel?: string;
|
|
||||||
archiveStatus?: "idle" | "pending" | "success";
|
|
||||||
archivePendingLabel?: string;
|
|
||||||
onArchive?: () => void;
|
|
||||||
onCopyBranchName?: () => void;
|
|
||||||
onCopyPath?: () => void;
|
|
||||||
onRename?: () => void;
|
|
||||||
onMarkAsRead?: () => void;
|
|
||||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
|
||||||
}) {
|
|
||||||
const isTouchPlatform = platformIsNative;
|
|
||||||
|
|
||||||
const isDesktop = !isTouchPlatform;
|
|
||||||
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
|
|
||||||
const hasRunningService = workspace.scripts.some(
|
|
||||||
(s) => s.lifecycle === "running" && (s.type ?? "service") === "service",
|
|
||||||
);
|
|
||||||
let scriptIconKind: "service" | "command" | null = null;
|
|
||||||
if (showScriptsIcon) {
|
|
||||||
scriptIconKind = hasRunningService ? "service" : "command";
|
|
||||||
}
|
|
||||||
|
|
||||||
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarWorkspaceRowFrame workspace={workspace}>
|
|
||||||
{({ isHovered, hoverHandlers }) => {
|
|
||||||
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
|
||||||
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
|
|
||||||
const showKebabInSlot = showKebab && !showShortcut;
|
|
||||||
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
|
|
||||||
const workspaceRowStyle = getStatusWorkspaceRowStyle({ selected, isHovered });
|
|
||||||
return (
|
|
||||||
<View style={styles.workspaceRowContainer} {...hoverHandlers}>
|
|
||||||
<Pressable
|
|
||||||
disabled={isArchiving}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityState={accessibilityState}
|
|
||||||
style={workspaceRowStyle}
|
|
||||||
onPress={onPress}
|
|
||||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
|
||||||
>
|
|
||||||
<SidebarWorkspaceRowContent
|
|
||||||
workspace={workspace}
|
|
||||||
subtitle={projectName}
|
|
||||||
scriptIconKind={scriptIconKind}
|
|
||||||
isHovered={isHovered}
|
|
||||||
isLoading={isArchiving}
|
|
||||||
shortcutNumber={shortcutNumber}
|
|
||||||
showShortcutBadge={showShortcutBadge}
|
|
||||||
>
|
|
||||||
{shouldRenderActionSlot ? (
|
|
||||||
<StatusWorkspaceActionSlot
|
|
||||||
workspace={workspace}
|
|
||||||
showBase={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
|
|
||||||
showOverlay={showKebabInSlot}
|
|
||||||
onCopyPath={onCopyPath}
|
|
||||||
onCopyBranchName={onCopyBranchName}
|
|
||||||
onRename={onRename}
|
|
||||||
onMarkAsRead={onMarkAsRead}
|
|
||||||
onArchive={onArchive}
|
|
||||||
archiveLabel={archiveLabel}
|
|
||||||
archiveStatus={archiveStatus}
|
|
||||||
archivePendingLabel={archivePendingLabel}
|
|
||||||
archiveShortcutKeys={archiveShortcutKeys}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</SidebarWorkspaceRowContent>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</SidebarWorkspaceRowFrame>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusWorkspaceActionSlot({
|
|
||||||
workspace,
|
|
||||||
showBase,
|
|
||||||
showOverlay,
|
|
||||||
onCopyPath,
|
|
||||||
onCopyBranchName,
|
|
||||||
onRename,
|
|
||||||
onMarkAsRead,
|
|
||||||
onArchive,
|
|
||||||
archiveLabel,
|
|
||||||
archiveStatus,
|
|
||||||
archivePendingLabel,
|
|
||||||
archiveShortcutKeys,
|
|
||||||
}: {
|
|
||||||
workspace: SidebarWorkspaceEntry;
|
|
||||||
showBase: boolean;
|
|
||||||
showOverlay: boolean;
|
|
||||||
onCopyPath?: () => void;
|
|
||||||
onCopyBranchName?: () => void;
|
|
||||||
onRename?: () => void;
|
|
||||||
onMarkAsRead?: () => void;
|
|
||||||
onArchive?: () => void;
|
|
||||||
archiveLabel?: string;
|
|
||||||
archiveStatus?: "idle" | "pending" | "success";
|
|
||||||
archivePendingLabel?: string;
|
|
||||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<SidebarWorkspaceTrailingActionSlot>
|
|
||||||
<SidebarWorkspaceTrailingActionBase visible={showBase}>
|
|
||||||
{workspace.diffStat ? (
|
|
||||||
<DiffStat
|
|
||||||
additions={workspace.diffStat.additions}
|
|
||||||
deletions={workspace.diffStat.deletions}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</SidebarWorkspaceTrailingActionBase>
|
|
||||||
<SidebarWorkspaceTrailingActionOverlay visible={showOverlay}>
|
|
||||||
{onArchive ? (
|
|
||||||
<StatusKebabMenu
|
|
||||||
workspaceKey={workspace.workspaceKey}
|
|
||||||
onCopyPath={onCopyPath}
|
|
||||||
onCopyBranchName={onCopyBranchName}
|
|
||||||
onRename={onRename}
|
|
||||||
onMarkAsRead={onMarkAsRead}
|
|
||||||
onArchive={onArchive}
|
|
||||||
archiveLabel={archiveLabel}
|
|
||||||
archiveStatus={archiveStatus}
|
|
||||||
archivePendingLabel={archivePendingLabel}
|
|
||||||
archiveShortcutKeys={archiveShortcutKeys}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</SidebarWorkspaceTrailingActionOverlay>
|
|
||||||
</SidebarWorkspaceTrailingActionSlot>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusKebabMenu({
|
|
||||||
workspaceKey,
|
|
||||||
onCopyPath,
|
|
||||||
onCopyBranchName,
|
|
||||||
onRename,
|
|
||||||
onMarkAsRead,
|
|
||||||
onArchive,
|
|
||||||
archiveLabel,
|
|
||||||
archiveStatus,
|
|
||||||
archivePendingLabel,
|
|
||||||
archiveShortcutKeys,
|
|
||||||
}: {
|
|
||||||
workspaceKey: string;
|
|
||||||
onCopyPath?: () => void;
|
|
||||||
onCopyBranchName?: () => void;
|
|
||||||
onRename?: () => void;
|
|
||||||
onMarkAsRead?: () => void;
|
|
||||||
onArchive: () => void;
|
|
||||||
archiveLabel?: string;
|
|
||||||
archiveStatus?: "idle" | "pending" | "success";
|
|
||||||
archivePendingLabel?: string;
|
|
||||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
|
||||||
}) {
|
|
||||||
const archiveTrailing = useMemo(
|
|
||||||
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
|
|
||||||
[archiveShortcutKeys],
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
hitSlop={8}
|
|
||||||
style={kebabStyle}
|
|
||||||
accessibilityRole={platformIsWeb ? undefined : "button"}
|
|
||||||
accessibilityLabel="Workspace actions"
|
|
||||||
testID={`sidebar-workspace-kebab-${workspaceKey}`}
|
|
||||||
>
|
|
||||||
{({ hovered }: { hovered?: boolean }) => (
|
|
||||||
<ThemedMoreVertical
|
|
||||||
size={14}
|
|
||||||
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" width={260}>
|
|
||||||
{onCopyPath ? (
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
|
|
||||||
leading={copyLeadingIcon}
|
|
||||||
onSelect={onCopyPath}
|
|
||||||
>
|
|
||||||
Copy path
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
{onCopyBranchName ? (
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
|
|
||||||
leading={copyLeadingIcon}
|
|
||||||
onSelect={onCopyBranchName}
|
|
||||||
>
|
|
||||||
Copy branch name
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
{onRename ? (
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
|
|
||||||
leading={renameLeadingIcon}
|
|
||||||
onSelect={onRename}
|
|
||||||
>
|
|
||||||
Rename workspace
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
{onMarkAsRead ? (
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
|
|
||||||
leading={markAsReadLeadingIcon}
|
|
||||||
onSelect={onMarkAsRead}
|
|
||||||
>
|
|
||||||
Mark as read
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
|
||||||
leading={archiveLeadingIcon}
|
|
||||||
trailing={archiveTrailing}
|
|
||||||
status={archiveStatus}
|
|
||||||
pendingLabel={archivePendingLabel}
|
|
||||||
onSelect={onArchive}
|
|
||||||
>
|
|
||||||
{archiveLabel ?? "Archive"}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function kebabStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) {
|
|
||||||
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusWorkspaceRowStyle({
|
|
||||||
selected,
|
|
||||||
isHovered,
|
|
||||||
}: {
|
|
||||||
selected: boolean;
|
|
||||||
isHovered: boolean;
|
|
||||||
}) {
|
|
||||||
return [
|
|
||||||
styles.workspaceRow,
|
|
||||||
selected && styles.sidebarRowSelected,
|
|
||||||
isHovered && styles.workspaceRowHovered,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -825,37 +350,4 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
},
|
},
|
||||||
workspaceRowContainer: {
|
|
||||||
position: "relative",
|
|
||||||
},
|
|
||||||
workspaceRow: {
|
|
||||||
minHeight: 36,
|
|
||||||
marginBottom: theme.spacing[1],
|
|
||||||
paddingVertical: theme.spacing[2],
|
|
||||||
paddingLeft: theme.spacing[3] + theme.spacing[3],
|
|
||||||
paddingRight: theme.spacing[3],
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "stretch",
|
|
||||||
justifyContent: "flex-start",
|
|
||||||
gap: theme.spacing[1],
|
|
||||||
userSelect: "none",
|
|
||||||
},
|
|
||||||
workspaceRowHovered: {
|
|
||||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
|
||||||
},
|
|
||||||
workspaceRowPressed: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
sidebarRowSelected: {
|
|
||||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
|
||||||
},
|
|
||||||
kebabButton: {
|
|
||||||
padding: 2,
|
|
||||||
borderRadius: 4,
|
|
||||||
marginLeft: 2,
|
|
||||||
},
|
|
||||||
kebabButtonHovered: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
|||||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
|
|
||||||
const WORKSPACE_STATUS_DOT_WIDTH = 14;
|
|
||||||
const DEFAULT_STATUS_DOT_SIZE = 7;
|
const DEFAULT_STATUS_DOT_SIZE = 7;
|
||||||
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
||||||
const DEFAULT_STATUS_DOT_OFFSET = 0;
|
const DEFAULT_STATUS_DOT_OFFSET = 0;
|
||||||
@@ -220,7 +219,9 @@ function WorkspaceStatusIndicator({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bucket === "done") return null;
|
if (bucket === "done") {
|
||||||
|
return <View style={styles.workspaceStatusDot} testID="workspace-status-indicator-done" />;
|
||||||
|
}
|
||||||
|
|
||||||
let KindIcon: typeof ThemedMonitor;
|
let KindIcon: typeof ThemedMonitor;
|
||||||
if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor;
|
if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor;
|
||||||
@@ -503,7 +504,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
workspaceStatusDot: {
|
workspaceStatusDot: {
|
||||||
position: "relative",
|
position: "relative",
|
||||||
width: WORKSPACE_STATUS_DOT_WIDTH,
|
width: theme.iconSize.md,
|
||||||
height: 20,
|
height: 20,
|
||||||
borderRadius: theme.borderRadius.full,
|
borderRadius: theme.borderRadius.full,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
|||||||
@@ -0,0 +1,641 @@
|
|||||||
|
import { memo, useCallback, useMemo, useState, type Ref } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
|
||||||
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
import { Archive, CircleCheck, Copy, MoreVertical, Pencil } from "lucide-react-native";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import * as Clipboard from "expo-clipboard";
|
||||||
|
import type { Theme } from "@/styles/theme";
|
||||||
|
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||||
|
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
|
||||||
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
|
import { DiffStat } from "@/components/diff-stat";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
|
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||||
|
import { useToast } from "@/contexts/toast-context";
|
||||||
|
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||||
|
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||||
|
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
||||||
|
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||||
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
|
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
|
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
|
||||||
|
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||||
|
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||||
|
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
||||||
|
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||||
|
import {
|
||||||
|
SidebarWorkspaceRowFrame,
|
||||||
|
SidebarWorkspaceRowContent,
|
||||||
|
SidebarWorkspaceTrailingActionBase,
|
||||||
|
SidebarWorkspaceTrailingActionOverlay,
|
||||||
|
SidebarWorkspaceTrailingActionSlot,
|
||||||
|
} from "@/components/sidebar/sidebar-workspace-row-content";
|
||||||
|
|
||||||
|
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||||
|
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||||
|
|
||||||
|
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
||||||
|
const ThemedCopy = withUnistyles(Copy);
|
||||||
|
const ThemedArchive = withUnistyles(Archive);
|
||||||
|
const ThemedPencil = withUnistyles(Pencil);
|
||||||
|
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||||
|
|
||||||
|
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
|
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
|
const markAsReadLeadingIcon = (
|
||||||
|
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
|
||||||
|
);
|
||||||
|
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
|
|
||||||
|
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||||
|
return (
|
||||||
|
<ThemedMoreVertical
|
||||||
|
size={14}
|
||||||
|
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function noop() {}
|
||||||
|
|
||||||
|
interface SidebarWorkspaceRowProps {
|
||||||
|
workspace: SidebarWorkspaceEntry;
|
||||||
|
selected: boolean;
|
||||||
|
shortcutNumber: number | null;
|
||||||
|
showShortcutBadge: boolean;
|
||||||
|
canCopyBranchName: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
/** Secondary line under the name (status grouping shows the project name). */
|
||||||
|
subtitle?: string | null;
|
||||||
|
/** Project grouping only: shows a transient "creating" affordance. */
|
||||||
|
isCreating?: boolean;
|
||||||
|
/** Project grouping only: drag-to-reorder wiring. Absent → not draggable. */
|
||||||
|
drag?: () => void;
|
||||||
|
isDragging?: boolean;
|
||||||
|
dragHandleProps?: DraggableListDragHandleProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarWorkspaceRow({
|
||||||
|
workspace,
|
||||||
|
selected,
|
||||||
|
shortcutNumber,
|
||||||
|
showShortcutBadge,
|
||||||
|
canCopyBranchName,
|
||||||
|
onPress,
|
||||||
|
subtitle,
|
||||||
|
isCreating = false,
|
||||||
|
drag,
|
||||||
|
isDragging = false,
|
||||||
|
dragHandleProps,
|
||||||
|
}: SidebarWorkspaceRowProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
||||||
|
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||||
|
const workspaceDirectory = resolveWorkspaceDirectory({
|
||||||
|
workspaceDirectory: workspace.workspaceDirectory,
|
||||||
|
});
|
||||||
|
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
|
||||||
|
workspaceDirectory
|
||||||
|
? state.getStatus({
|
||||||
|
serverId: workspace.serverId,
|
||||||
|
cwd: workspaceDirectory,
|
||||||
|
actionId: "archive-worktree",
|
||||||
|
})
|
||||||
|
: "idle",
|
||||||
|
);
|
||||||
|
const isWorktree = workspace.workspaceKind === "worktree";
|
||||||
|
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
|
||||||
|
|
||||||
|
const redirectAfterArchive = useCallback(() => {
|
||||||
|
redirectIfArchivingActiveWorkspace({
|
||||||
|
serverId: workspace.serverId,
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
activeWorkspaceSelection: selected
|
||||||
|
? { serverId: workspace.serverId, workspaceId: workspace.workspaceId }
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
}, [selected, workspace]);
|
||||||
|
|
||||||
|
const archiveController = useWorkspaceArchive({
|
||||||
|
serverId: workspace.serverId,
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
workspaceDirectory: workspace.workspaceDirectory,
|
||||||
|
workspaceKind: workspace.workspaceKind,
|
||||||
|
name: workspace.name,
|
||||||
|
...toWorktreeArchiveRisk(workspace),
|
||||||
|
onArchiveStarted: redirectAfterArchive,
|
||||||
|
onSetHiding: setIsHidingWorkspace,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleArchive = useCallback(() => {
|
||||||
|
if (isArchiving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
archiveController.archive();
|
||||||
|
}, [archiveController, isArchiving]);
|
||||||
|
|
||||||
|
const handleCopyPath = useCallback(() => {
|
||||||
|
let copyTargetDirectory: string;
|
||||||
|
try {
|
||||||
|
copyTargetDirectory = requireWorkspaceDirectory({
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
workspaceDirectory: workspace.workspaceDirectory,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void Clipboard.setStringAsync(copyTargetDirectory);
|
||||||
|
toast.copied(t("sidebar.workspace.toasts.pathCopied"));
|
||||||
|
}, [t, toast, workspace.workspaceDirectory, workspace.workspaceId]);
|
||||||
|
|
||||||
|
const handleCopyBranchName = useCallback(() => {
|
||||||
|
if (!workspace.currentBranch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void Clipboard.setStringAsync(workspace.currentBranch);
|
||||||
|
toast.copied(t("sidebar.workspace.toasts.branchNameCopied"));
|
||||||
|
}, [t, toast, workspace.currentBranch]);
|
||||||
|
|
||||||
|
const renameMutation = useMutation({
|
||||||
|
mutationFn: async (title: string) => {
|
||||||
|
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||||
|
if (!client) {
|
||||||
|
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
|
||||||
|
}
|
||||||
|
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleOpenRename = useCallback(() => {
|
||||||
|
setIsRenameOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseRename = useCallback(() => {
|
||||||
|
setIsRenameOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmitRename = useCallback(
|
||||||
|
async (value: string) => {
|
||||||
|
await renameMutation.mutateAsync(value.trim());
|
||||||
|
},
|
||||||
|
[renameMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||||
|
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
||||||
|
serverId: workspace.serverId,
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
});
|
||||||
|
const handleMarkAsRead = useCallback(() => {
|
||||||
|
void clearAttention().catch((error) => {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read");
|
||||||
|
});
|
||||||
|
}, [clearAttention, toast]);
|
||||||
|
|
||||||
|
useKeyboardActionHandler({
|
||||||
|
handlerId: `worktree-archive-${workspace.workspaceKey}`,
|
||||||
|
actions: ["worktree.archive"],
|
||||||
|
enabled: selected && !isArchiving,
|
||||||
|
priority: 0,
|
||||||
|
handle: () => {
|
||||||
|
handleArchive();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let archiveStatus: "idle" | "pending" | "success" = "idle";
|
||||||
|
if (isWorktree) {
|
||||||
|
archiveStatus = worktreeArchiveStatus;
|
||||||
|
} else if (isHidingWorkspace) {
|
||||||
|
archiveStatus = "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<WorkspaceRowBody
|
||||||
|
workspace={workspace}
|
||||||
|
selected={selected}
|
||||||
|
shortcutNumber={shortcutNumber}
|
||||||
|
showShortcutBadge={showShortcutBadge}
|
||||||
|
subtitle={subtitle}
|
||||||
|
isCreating={isCreating}
|
||||||
|
isArchiving={isArchiving}
|
||||||
|
onPress={onPress}
|
||||||
|
drag={drag}
|
||||||
|
isDragging={isDragging}
|
||||||
|
dragHandleProps={dragHandleProps}
|
||||||
|
archiveLabel={t("sidebar.workspace.actions.archive")}
|
||||||
|
archiveStatus={archiveStatus}
|
||||||
|
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
|
||||||
|
onArchive={handleArchive}
|
||||||
|
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
||||||
|
onCopyPath={handleCopyPath}
|
||||||
|
onRename={handleOpenRename}
|
||||||
|
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
|
||||||
|
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||||
|
/>
|
||||||
|
<AdaptiveRenameModal
|
||||||
|
visible={isRenameOpen}
|
||||||
|
title={t("sidebar.workspace.rename.title")}
|
||||||
|
initialValue={workspace.title ?? workspace.name}
|
||||||
|
placeholder={workspace.name}
|
||||||
|
submitLabel={t("sidebar.workspace.rename.submit")}
|
||||||
|
onClose={handleCloseRename}
|
||||||
|
onSubmit={handleSubmitRename}
|
||||||
|
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceRowBodyProps {
|
||||||
|
workspace: SidebarWorkspaceEntry;
|
||||||
|
selected: boolean;
|
||||||
|
shortcutNumber: number | null;
|
||||||
|
showShortcutBadge: boolean;
|
||||||
|
subtitle?: string | null;
|
||||||
|
isCreating: boolean;
|
||||||
|
isArchiving: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
drag?: () => void;
|
||||||
|
isDragging: boolean;
|
||||||
|
dragHandleProps?: DraggableListDragHandleProps;
|
||||||
|
archiveLabel?: string;
|
||||||
|
archiveStatus?: "idle" | "pending" | "success";
|
||||||
|
archivePendingLabel?: string;
|
||||||
|
onArchive?: () => void;
|
||||||
|
onCopyBranchName?: () => void;
|
||||||
|
onCopyPath?: () => void;
|
||||||
|
onRename?: () => void;
|
||||||
|
onMarkAsRead?: () => void;
|
||||||
|
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkspaceRowBody({
|
||||||
|
workspace,
|
||||||
|
selected,
|
||||||
|
shortcutNumber,
|
||||||
|
showShortcutBadge,
|
||||||
|
subtitle,
|
||||||
|
isCreating,
|
||||||
|
isArchiving,
|
||||||
|
onPress,
|
||||||
|
drag,
|
||||||
|
isDragging,
|
||||||
|
dragHandleProps,
|
||||||
|
archiveLabel,
|
||||||
|
archiveStatus = "idle",
|
||||||
|
archivePendingLabel,
|
||||||
|
onArchive,
|
||||||
|
onCopyBranchName,
|
||||||
|
onCopyPath,
|
||||||
|
onRename,
|
||||||
|
onMarkAsRead,
|
||||||
|
archiveShortcutKeys,
|
||||||
|
}: WorkspaceRowBodyProps) {
|
||||||
|
const isTouchPlatform = platformIsNative;
|
||||||
|
const draggable = Boolean(drag);
|
||||||
|
const interaction = useLongPressDragInteraction({
|
||||||
|
drag: drag ?? noop,
|
||||||
|
menuController: null,
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
role: _dragRole,
|
||||||
|
tabIndex: _dragTabIndex,
|
||||||
|
"aria-roledescription": _dragRoleDescription,
|
||||||
|
...dragAttributes
|
||||||
|
} = dragHandleProps?.attributes ?? {};
|
||||||
|
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
if (interaction.didLongPressRef.current) {
|
||||||
|
interaction.didLongPressRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onPress();
|
||||||
|
}, [interaction.didLongPressRef, onPress]);
|
||||||
|
|
||||||
|
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
|
||||||
|
{({ isHovered, hoverHandlers }) => {
|
||||||
|
const isDesktop = !isTouchPlatform;
|
||||||
|
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
|
||||||
|
const hasRunningService = workspace.scripts.some(
|
||||||
|
(s) => s.lifecycle === "running" && (s.type ?? "service") === "service",
|
||||||
|
);
|
||||||
|
let scriptIconKind: "service" | "command" | null = null;
|
||||||
|
if (showScriptsIcon) {
|
||||||
|
scriptIconKind = hasRunningService ? "service" : "command";
|
||||||
|
}
|
||||||
|
const workspaceRowStyle = getWorkspaceRowStyle({ isDragging, selected, isHovered });
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
{...(draggable ? dragAttributes : {})}
|
||||||
|
{...(draggable ? dragHandleProps?.listeners : {})}
|
||||||
|
ref={
|
||||||
|
draggable ? (dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>) : undefined
|
||||||
|
}
|
||||||
|
style={styles.workspaceRowContainer}
|
||||||
|
{...hoverHandlers}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
disabled={isArchiving}
|
||||||
|
aria-selected={selected}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={accessibilityState}
|
||||||
|
style={workspaceRowStyle}
|
||||||
|
onPressIn={draggable ? interaction.handlePressIn : undefined}
|
||||||
|
onTouchMove={draggable ? interaction.handleTouchMove : undefined}
|
||||||
|
onPressOut={draggable ? interaction.handlePressOut : undefined}
|
||||||
|
onPress={handlePress}
|
||||||
|
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||||
|
>
|
||||||
|
<SidebarWorkspaceRowContent
|
||||||
|
workspace={workspace}
|
||||||
|
subtitle={subtitle}
|
||||||
|
scriptIconKind={scriptIconKind}
|
||||||
|
isHovered={isHovered}
|
||||||
|
isLoading={isArchiving || isCreating}
|
||||||
|
isCreating={isCreating}
|
||||||
|
shortcutNumber={shortcutNumber}
|
||||||
|
showShortcutBadge={showShortcutBadge}
|
||||||
|
>
|
||||||
|
<WorkspaceRowTrailingActions
|
||||||
|
workspace={workspace}
|
||||||
|
isHovered={isHovered}
|
||||||
|
isTouchPlatform={isTouchPlatform}
|
||||||
|
isCreating={isCreating}
|
||||||
|
showShortcutBadge={showShortcutBadge}
|
||||||
|
shortcutNumber={shortcutNumber}
|
||||||
|
archiveLabel={archiveLabel}
|
||||||
|
archiveStatus={archiveStatus}
|
||||||
|
archivePendingLabel={archivePendingLabel}
|
||||||
|
archiveShortcutKeys={archiveShortcutKeys}
|
||||||
|
onArchive={onArchive}
|
||||||
|
onCopyBranchName={onCopyBranchName}
|
||||||
|
onCopyPath={onCopyPath}
|
||||||
|
onRename={onRename}
|
||||||
|
onMarkAsRead={onMarkAsRead}
|
||||||
|
/>
|
||||||
|
</SidebarWorkspaceRowContent>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</SidebarWorkspaceRowFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkspaceRowTrailingActions({
|
||||||
|
workspace,
|
||||||
|
isHovered,
|
||||||
|
isTouchPlatform,
|
||||||
|
isCreating,
|
||||||
|
showShortcutBadge,
|
||||||
|
shortcutNumber,
|
||||||
|
archiveLabel,
|
||||||
|
archiveStatus,
|
||||||
|
archivePendingLabel,
|
||||||
|
archiveShortcutKeys,
|
||||||
|
onArchive,
|
||||||
|
onMarkAsRead,
|
||||||
|
onCopyBranchName,
|
||||||
|
onCopyPath,
|
||||||
|
onRename,
|
||||||
|
}: {
|
||||||
|
workspace: SidebarWorkspaceEntry;
|
||||||
|
isHovered: boolean;
|
||||||
|
isTouchPlatform: boolean;
|
||||||
|
isCreating: boolean;
|
||||||
|
showShortcutBadge: boolean;
|
||||||
|
shortcutNumber: number | null;
|
||||||
|
archiveLabel?: string;
|
||||||
|
archiveStatus?: "idle" | "pending" | "success";
|
||||||
|
archivePendingLabel?: string;
|
||||||
|
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||||
|
onArchive?: () => void;
|
||||||
|
onMarkAsRead?: () => void;
|
||||||
|
onCopyBranchName?: () => void;
|
||||||
|
onCopyPath?: () => void;
|
||||||
|
onRename?: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
||||||
|
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
|
||||||
|
const showKebabInSlot = showKebab && !showShortcut;
|
||||||
|
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isCreating ? (
|
||||||
|
<Text style={styles.workspaceCreatingText}>{t("sidebar.workspace.status.creating")}</Text>
|
||||||
|
) : null}
|
||||||
|
{shouldRenderActionSlot ? (
|
||||||
|
<SidebarWorkspaceTrailingActionSlot>
|
||||||
|
<SidebarWorkspaceTrailingActionBase
|
||||||
|
visible={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
|
||||||
|
>
|
||||||
|
{workspace.diffStat ? (
|
||||||
|
<DiffStat
|
||||||
|
additions={workspace.diffStat.additions}
|
||||||
|
deletions={workspace.diffStat.deletions}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</SidebarWorkspaceTrailingActionBase>
|
||||||
|
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot}>
|
||||||
|
{onArchive ? (
|
||||||
|
<WorkspaceKebabMenu
|
||||||
|
workspaceKey={workspace.workspaceKey}
|
||||||
|
onCopyPath={onCopyPath}
|
||||||
|
onCopyBranchName={onCopyBranchName}
|
||||||
|
onRename={onRename}
|
||||||
|
onMarkAsRead={onMarkAsRead}
|
||||||
|
onArchive={onArchive}
|
||||||
|
archiveLabel={archiveLabel}
|
||||||
|
archiveStatus={archiveStatus}
|
||||||
|
archivePendingLabel={archivePendingLabel}
|
||||||
|
archiveShortcutKeys={archiveShortcutKeys}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</SidebarWorkspaceTrailingActionOverlay>
|
||||||
|
</SidebarWorkspaceTrailingActionSlot>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkspaceKebabMenu({
|
||||||
|
workspaceKey,
|
||||||
|
onCopyPath,
|
||||||
|
onCopyBranchName,
|
||||||
|
onRename,
|
||||||
|
onMarkAsRead,
|
||||||
|
onArchive,
|
||||||
|
archiveLabel,
|
||||||
|
archiveStatus,
|
||||||
|
archivePendingLabel,
|
||||||
|
archiveShortcutKeys,
|
||||||
|
}: {
|
||||||
|
workspaceKey: string;
|
||||||
|
onCopyPath?: () => void;
|
||||||
|
onCopyBranchName?: () => void;
|
||||||
|
onRename?: () => void;
|
||||||
|
onMarkAsRead?: () => void;
|
||||||
|
onArchive: () => void;
|
||||||
|
archiveLabel?: string;
|
||||||
|
archiveStatus?: "idle" | "pending" | "success";
|
||||||
|
archivePendingLabel?: string;
|
||||||
|
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const archiveTrailing = useMemo(
|
||||||
|
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
|
||||||
|
[archiveShortcutKeys],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
hitSlop={8}
|
||||||
|
style={workspaceKebabStyle}
|
||||||
|
accessibilityRole={platformIsWeb ? undefined : "button"}
|
||||||
|
accessibilityLabel={t("sidebar.workspace.actions.menu")}
|
||||||
|
testID={`sidebar-workspace-kebab-${workspaceKey}`}
|
||||||
|
>
|
||||||
|
{renderKebabTriggerIcon}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" width={260}>
|
||||||
|
{onCopyPath ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
|
||||||
|
leading={copyLeadingIcon}
|
||||||
|
onSelect={onCopyPath}
|
||||||
|
>
|
||||||
|
{t("sidebar.workspace.actions.copyPath")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{onCopyBranchName ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
|
||||||
|
leading={copyLeadingIcon}
|
||||||
|
onSelect={onCopyBranchName}
|
||||||
|
>
|
||||||
|
{t("sidebar.workspace.actions.copyBranchName")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{onRename ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
|
||||||
|
leading={renameLeadingIcon}
|
||||||
|
onSelect={onRename}
|
||||||
|
>
|
||||||
|
{t("sidebar.workspace.actions.rename")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{onMarkAsRead ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
|
||||||
|
leading={markAsReadLeadingIcon}
|
||||||
|
onSelect={onMarkAsRead}
|
||||||
|
>
|
||||||
|
Mark as read
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
||||||
|
leading={archiveLeadingIcon}
|
||||||
|
trailing={archiveTrailing}
|
||||||
|
status={archiveStatus}
|
||||||
|
pendingLabel={archivePendingLabel}
|
||||||
|
onSelect={onArchive}
|
||||||
|
>
|
||||||
|
{archiveLabel ?? t("sidebar.workspace.actions.archive")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceKebabStyle({
|
||||||
|
hovered = false,
|
||||||
|
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||||
|
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWorkspaceRowStyle({
|
||||||
|
isDragging,
|
||||||
|
selected,
|
||||||
|
isHovered,
|
||||||
|
}: {
|
||||||
|
isDragging: boolean;
|
||||||
|
selected: boolean;
|
||||||
|
isHovered: boolean;
|
||||||
|
}) {
|
||||||
|
return [
|
||||||
|
styles.workspaceRow,
|
||||||
|
isDragging && styles.workspaceRowDragging,
|
||||||
|
selected && styles.sidebarRowSelected,
|
||||||
|
isHovered && styles.workspaceRowHovered,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MemoSidebarWorkspaceRow = memo(SidebarWorkspaceRow);
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
workspaceRowContainer: {
|
||||||
|
position: "relative",
|
||||||
|
},
|
||||||
|
workspaceRow: {
|
||||||
|
minHeight: 36,
|
||||||
|
marginBottom: theme.spacing[1],
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
paddingLeft: theme.spacing[2],
|
||||||
|
paddingRight: theme.spacing[3],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "stretch",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
userSelect: "none",
|
||||||
|
},
|
||||||
|
workspaceRowHovered: {
|
||||||
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
|
},
|
||||||
|
workspaceRowDragging: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
transform: [{ scale: 1.02 }],
|
||||||
|
zIndex: 3,
|
||||||
|
...theme.shadow.md,
|
||||||
|
},
|
||||||
|
sidebarRowSelected: {
|
||||||
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
|
},
|
||||||
|
workspaceCreatingText: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
kebabButton: {
|
||||||
|
padding: 2,
|
||||||
|
borderRadius: 4,
|
||||||
|
marginLeft: 2,
|
||||||
|
},
|
||||||
|
kebabButtonHovered: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import { Platform, StatusBar, type GestureResponderEvent } from "react-native";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||||
|
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
|
||||||
|
import type { useContextMenu } from "@/components/ui/context-menu";
|
||||||
|
|
||||||
|
export function useLongPressDragInteraction(input: {
|
||||||
|
drag: () => void;
|
||||||
|
menuController: ReturnType<typeof useContextMenu> | null;
|
||||||
|
}) {
|
||||||
|
const didLongPressRef = useRef(false);
|
||||||
|
const dragArmedRef = useRef(false);
|
||||||
|
const dragActivatedRef = useRef(false);
|
||||||
|
const didStartDragRef = useRef(false);
|
||||||
|
const scrollIntentRef = useRef(false);
|
||||||
|
const menuOpenedRef = useRef(false);
|
||||||
|
const touchStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
const touchCurrentRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
const dragArmTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const contextMenuTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const clearTimers = useCallback(() => {
|
||||||
|
if (dragArmTimerRef.current) {
|
||||||
|
clearTimeout(dragArmTimerRef.current);
|
||||||
|
dragArmTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (contextMenuTimerRef.current) {
|
||||||
|
clearTimeout(contextMenuTimerRef.current);
|
||||||
|
contextMenuTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openContextMenuAtStartPoint = useCallback(() => {
|
||||||
|
if (!input.menuController || !touchStartRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
||||||
|
input.menuController.setAnchorRect({
|
||||||
|
x: touchStartRef.current.x,
|
||||||
|
y: touchStartRef.current.y + statusBarHeight,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
});
|
||||||
|
input.menuController.setOpen(true);
|
||||||
|
menuOpenedRef.current = true;
|
||||||
|
didLongPressRef.current = true;
|
||||||
|
}, [input.menuController]);
|
||||||
|
|
||||||
|
const handleLongPress = useCallback(() => {
|
||||||
|
// Manual timers own long-press behavior on mobile.
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearTimers();
|
||||||
|
};
|
||||||
|
}, [clearTimers]);
|
||||||
|
|
||||||
|
const armTimers = useCallback(() => {
|
||||||
|
clearTimers();
|
||||||
|
|
||||||
|
const DRAG_ARM_DELAY_MS = 180;
|
||||||
|
const DRAG_ARM_STATIONARY_SLOP_PX = 4;
|
||||||
|
const CONTEXT_MENU_DELAY_MS = 450;
|
||||||
|
const CONTEXT_MENU_STATIONARY_SLOP_PX = 6;
|
||||||
|
|
||||||
|
dragArmTimerRef.current = setTimeout(() => {
|
||||||
|
if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const start = touchStartRef.current;
|
||||||
|
const current = touchCurrentRef.current ?? start;
|
||||||
|
if (!start || !current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dx = current.x - start.x;
|
||||||
|
const dy = current.y - start.y;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (distance > DRAG_ARM_STATIONARY_SLOP_PX) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dragArmedRef.current = true;
|
||||||
|
dragActivatedRef.current = true;
|
||||||
|
didLongPressRef.current = true;
|
||||||
|
void Haptics.selectionAsync().catch(() => {});
|
||||||
|
input.drag();
|
||||||
|
}, DRAG_ARM_DELAY_MS);
|
||||||
|
|
||||||
|
if (!input.menuController || platformIsWeb) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
contextMenuTimerRef.current = setTimeout(() => {
|
||||||
|
if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const start = touchStartRef.current;
|
||||||
|
const current = touchCurrentRef.current ?? start;
|
||||||
|
if (!start || !current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dx = current.x - start.x;
|
||||||
|
const dy = current.y - start.y;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (distance > CONTEXT_MENU_STATIONARY_SLOP_PX) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void Haptics.selectionAsync().catch(() => {});
|
||||||
|
openContextMenuAtStartPoint();
|
||||||
|
}, CONTEXT_MENU_DELAY_MS);
|
||||||
|
}, [clearTimers, input, openContextMenuAtStartPoint]);
|
||||||
|
|
||||||
|
const handleDragIntent = useCallback(
|
||||||
|
(_details: { dx: number; dy: number; distance: number }) => {
|
||||||
|
if (!dragActivatedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
didStartDragRef.current = true;
|
||||||
|
didLongPressRef.current = true;
|
||||||
|
clearTimers();
|
||||||
|
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
|
||||||
|
},
|
||||||
|
[clearTimers],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleScrollIntent = useCallback(
|
||||||
|
(_details: { dx: number; dy: number; distance: number }) => {
|
||||||
|
scrollIntentRef.current = true;
|
||||||
|
didLongPressRef.current = true;
|
||||||
|
clearTimers();
|
||||||
|
},
|
||||||
|
[clearTimers],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSwipeIntent = useCallback(
|
||||||
|
(_details: { dx: number; dy: number; distance: number }) => {
|
||||||
|
didLongPressRef.current = true;
|
||||||
|
clearTimers();
|
||||||
|
},
|
||||||
|
[clearTimers],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePressIn = useCallback(
|
||||||
|
(event: GestureResponderEvent) => {
|
||||||
|
didLongPressRef.current = false;
|
||||||
|
dragArmedRef.current = false;
|
||||||
|
dragActivatedRef.current = false;
|
||||||
|
didStartDragRef.current = false;
|
||||||
|
scrollIntentRef.current = false;
|
||||||
|
menuOpenedRef.current = false;
|
||||||
|
touchStartRef.current = {
|
||||||
|
x: event.nativeEvent.pageX,
|
||||||
|
y: event.nativeEvent.pageY,
|
||||||
|
};
|
||||||
|
touchCurrentRef.current = {
|
||||||
|
x: event.nativeEvent.pageX,
|
||||||
|
y: event.nativeEvent.pageY,
|
||||||
|
};
|
||||||
|
armTimers();
|
||||||
|
},
|
||||||
|
[armTimers],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTouchMove = useCallback(
|
||||||
|
(event: GestureResponderEvent) => {
|
||||||
|
const start = touchStartRef.current;
|
||||||
|
if (!start || didStartDragRef.current || menuOpenedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const touch = event?.nativeEvent?.touches?.[0] ?? event?.nativeEvent;
|
||||||
|
const x = touch?.pageX;
|
||||||
|
const y = touch?.pageY;
|
||||||
|
if (typeof x !== "number" || typeof y !== "number") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = { x, y };
|
||||||
|
touchCurrentRef.current = current;
|
||||||
|
const dx = current.x - start.x;
|
||||||
|
const dy = current.y - start.y;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
const decision = decideLongPressMove({
|
||||||
|
dragArmed: dragArmedRef.current,
|
||||||
|
didStartDrag: didStartDragRef.current,
|
||||||
|
startPoint: start,
|
||||||
|
currentPoint: current,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (decision === "vertical_scroll") {
|
||||||
|
handleScrollIntent({ dx, dy, distance });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decision === "horizontal_swipe" || decision === "cancel_long_press") {
|
||||||
|
handleSwipeIntent({ dx, dy, distance });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decision === "start_drag") {
|
||||||
|
handleDragIntent({ dx, dy, distance });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleDragIntent, handleScrollIntent, handleSwipeIntent],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePressOut = useCallback(() => {
|
||||||
|
clearTimers();
|
||||||
|
dragArmedRef.current = false;
|
||||||
|
dragActivatedRef.current = false;
|
||||||
|
touchStartRef.current = null;
|
||||||
|
touchCurrentRef.current = null;
|
||||||
|
}, [clearTimers]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
didLongPressRef,
|
||||||
|
handleLongPress,
|
||||||
|
handlePressIn,
|
||||||
|
handleTouchMove,
|
||||||
|
handlePressOut,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -95,7 +95,9 @@ async function callWorkspaceCreation({
|
|||||||
worktreeSlug: createNameId(),
|
worktreeSlug: createNameId(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return connectedClient.openProject(input.cwd);
|
return connectedClient.createWorkspace({
|
||||||
|
source: { kind: "directory", path: input.cwd },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function failureMessageForCreationMethod(
|
function failureMessageForCreationMethod(
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ async function fetchWorkspaceHydrationSnapshot(input: {
|
|||||||
workspaces.set(workspace.id, workspace);
|
workspaces.set(workspace.id, workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Empty project parents only ride on the first page.
|
// Project parents with no active workspaces only ride on the first page.
|
||||||
for (const project of payload.emptyProjects ?? []) {
|
for (const project of payload.emptyProjects ?? []) {
|
||||||
const descriptor = normalizeEmptyProjectDescriptor(project);
|
const descriptor = normalizeEmptyProjectDescriptor(project);
|
||||||
emptyProjects.set(descriptor.projectId, descriptor);
|
emptyProjects.set(descriptor.projectId, descriptor);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import type { OpenProjectResponseMessage } from "@getpaseo/protocol/messages";
|
import type { ProjectAddResponse } from "@getpaseo/protocol/messages";
|
||||||
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store";
|
import {
|
||||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
|
normalizeEmptyProjectDescriptor as normalizeProjectWithoutWorkspacesDescriptor,
|
||||||
|
type EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor,
|
||||||
|
} from "@/stores/session-store";
|
||||||
|
|
||||||
type OpenProjectPayload = OpenProjectResponseMessage["payload"];
|
type OpenProjectPayload = ProjectAddResponse["payload"];
|
||||||
type OpenProjectErrorCode = NonNullable<OpenProjectPayload["errorCode"]>;
|
type OpenProjectErrorCode = NonNullable<OpenProjectPayload["errorCode"]>;
|
||||||
|
|
||||||
export interface OpenProjectSuccess {
|
export interface OpenProjectSuccess {
|
||||||
@@ -22,11 +24,10 @@ export interface OpenProjectDirectlyInput {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
client: Pick<DaemonClient, "openProject"> | null;
|
canAddProject: boolean;
|
||||||
mergeWorkspaces: (serverId: string, workspaces: Iterable<WorkspaceDescriptor>) => void;
|
client: Pick<DaemonClient, "addProject"> | null;
|
||||||
|
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void;
|
||||||
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
|
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
|
||||||
openDraftTab: (workspaceKey: string) => string | null;
|
|
||||||
navigateToWorkspace: (serverId: string, workspaceId: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openProjectDirectly(
|
export async function openProjectDirectly(
|
||||||
@@ -38,8 +39,16 @@ export async function openProjectDirectly(
|
|||||||
return { ok: false, errorCode: null, error: null };
|
return { ok: false, errorCode: null, error: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await input.client.openProject(trimmedPath);
|
if (!input.canAddProject) {
|
||||||
if (payload.error || !payload.workspace) {
|
return {
|
||||||
|
ok: false,
|
||||||
|
errorCode: null,
|
||||||
|
error: "Update the host to add projects without creating a workspace.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await input.client.addProject(trimmedPath);
|
||||||
|
if (payload.error || !payload.project) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
errorCode: payload.errorCode ?? null,
|
errorCode: payload.errorCode ?? null,
|
||||||
@@ -47,19 +56,10 @@ export async function openProjectDirectly(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspace = normalizeWorkspaceDescriptor(payload.workspace);
|
input.addEmptyProject(
|
||||||
input.mergeWorkspaces(normalizedServerId, [workspace]);
|
normalizedServerId,
|
||||||
|
normalizeProjectWithoutWorkspacesDescriptor(payload.project),
|
||||||
|
);
|
||||||
input.setHasHydratedWorkspaces(normalizedServerId, true);
|
input.setHasHydratedWorkspaces(normalizedServerId, true);
|
||||||
|
|
||||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
|
||||||
serverId: normalizedServerId,
|
|
||||||
workspaceId: workspace.id,
|
|
||||||
});
|
|
||||||
if (!workspaceKey) {
|
|
||||||
return { ok: false, errorCode: null, error: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
input.openDraftTab(workspaceKey);
|
|
||||||
input.navigateToWorkspace(normalizedServerId, workspace.id);
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,22 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { openProjectDirectly } from "@/hooks/open-project";
|
import { openProjectDirectly } from "@/hooks/open-project";
|
||||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
import type { EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor } from "@/stores/session-store";
|
||||||
|
|
||||||
const SERVER_ID = "server-1";
|
const SERVER_ID = "server-1";
|
||||||
const PROJECT_PATH = "/repo/project";
|
const PROJECT_PATH = "/repo/project";
|
||||||
|
|
||||||
function buildWorkspacePayload() {
|
function buildProjectPayload() {
|
||||||
return {
|
return {
|
||||||
id: "1",
|
projectId: "project-1",
|
||||||
projectId: "1",
|
|
||||||
projectDisplayName: "project",
|
projectDisplayName: "project",
|
||||||
projectRootPath: PROJECT_PATH,
|
projectRootPath: PROJECT_PATH,
|
||||||
workspaceDirectory: PROJECT_PATH,
|
|
||||||
projectKind: "git" as const,
|
projectKind: "git" as const,
|
||||||
workspaceKind: "checkout" as const,
|
|
||||||
name: "project",
|
|
||||||
archivingAt: null,
|
|
||||||
status: "done" as const,
|
|
||||||
statusEnteredAt: null,
|
|
||||||
activityAt: null,
|
|
||||||
diffStat: null,
|
|
||||||
scripts: [],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RecordedMerge {
|
interface RecordedProject {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaces: WorkspaceDescriptor[];
|
project: ProjectWithoutWorkspacesDescriptor;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RecordedHydrated {
|
interface RecordedHydrated {
|
||||||
@@ -34,23 +24,14 @@ interface RecordedHydrated {
|
|||||||
hydrated: boolean;
|
hydrated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RecordedOpenDraftTab {
|
|
||||||
workspaceKey: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RecordedNavigate {
|
|
||||||
serverId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFakeSession() {
|
function createFakeSession() {
|
||||||
const merges: RecordedMerge[] = [];
|
const projects: RecordedProject[] = [];
|
||||||
const hydrated: RecordedHydrated[] = [];
|
const hydrated: RecordedHydrated[] = [];
|
||||||
return {
|
return {
|
||||||
merges,
|
projects,
|
||||||
hydrated,
|
hydrated,
|
||||||
mergeWorkspaces: (serverId: string, workspaces: Iterable<WorkspaceDescriptor>) => {
|
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => {
|
||||||
merges.push({ serverId, workspaces: Array.from(workspaces) });
|
projects.push({ serverId, project });
|
||||||
},
|
},
|
||||||
setHasHydratedWorkspaces: (serverId: string, value: boolean) => {
|
setHasHydratedWorkspaces: (serverId: string, value: boolean) => {
|
||||||
hydrated.push({ serverId, hydrated: value });
|
hydrated.push({ serverId, hydrated: value });
|
||||||
@@ -58,86 +39,88 @@ function createFakeSession() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFakeWorkspaceLayout() {
|
|
||||||
const openedTabs: RecordedOpenDraftTab[] = [];
|
|
||||||
return {
|
|
||||||
openedTabs,
|
|
||||||
openDraftTab: (workspaceKey: string) => {
|
|
||||||
openedTabs.push({ workspaceKey });
|
|
||||||
return "tab-1";
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFakeNavigator() {
|
|
||||||
const navigations: RecordedNavigate[] = [];
|
|
||||||
return {
|
|
||||||
navigations,
|
|
||||||
navigateToWorkspace: (serverId: string, workspaceId: string) => {
|
|
||||||
navigations.push({ serverId, workspaceId });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("openProjectDirectly", () => {
|
describe("openProjectDirectly", () => {
|
||||||
it("opens the workspace, marks workspaces hydrated, and seeds a draft tab", async () => {
|
it("adds the project and marks workspaces hydrated without opening a workspace", async () => {
|
||||||
const session = createFakeSession();
|
const session = createFakeSession();
|
||||||
const layout = createFakeWorkspaceLayout();
|
const projectPayload = buildProjectPayload();
|
||||||
const navigator = createFakeNavigator();
|
|
||||||
const workspacePayload = buildWorkspacePayload();
|
|
||||||
|
|
||||||
const result = await openProjectDirectly({
|
const result = await openProjectDirectly({
|
||||||
serverId: SERVER_ID,
|
serverId: SERVER_ID,
|
||||||
projectPath: PROJECT_PATH,
|
projectPath: PROJECT_PATH,
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
|
canAddProject: true,
|
||||||
client: {
|
client: {
|
||||||
openProject: async () => ({
|
addProject: async () => ({
|
||||||
requestId: "request-1",
|
requestId: "request-1",
|
||||||
error: null,
|
error: null,
|
||||||
workspace: workspacePayload,
|
project: projectPayload,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
mergeWorkspaces: session.mergeWorkspaces,
|
addEmptyProject: session.addEmptyProject,
|
||||||
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
|
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
|
||||||
openDraftTab: layout.openDraftTab,
|
|
||||||
navigateToWorkspace: navigator.navigateToWorkspace,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({ ok: true });
|
expect(result).toEqual({ ok: true });
|
||||||
expect(session.merges).toHaveLength(1);
|
expect(session.projects).toEqual([
|
||||||
expect(session.merges[0]?.serverId).toBe(SERVER_ID);
|
{
|
||||||
expect(session.merges[0]?.workspaces[0]).toMatchObject({
|
serverId: SERVER_ID,
|
||||||
id: "1",
|
project: {
|
||||||
projectId: "1",
|
projectId: "project-1",
|
||||||
projectRootPath: PROJECT_PATH,
|
projectDisplayName: "project",
|
||||||
workspaceDirectory: PROJECT_PATH,
|
projectCustomName: null,
|
||||||
});
|
projectKind: "git",
|
||||||
|
projectRootPath: PROJECT_PATH,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
expect(session.hydrated).toEqual([{ serverId: SERVER_ID, hydrated: true }]);
|
expect(session.hydrated).toEqual([{ serverId: SERVER_ID, hydrated: true }]);
|
||||||
expect(layout.openedTabs).toEqual([{ workspaceKey: `${SERVER_ID}:1` }]);
|
|
||||||
expect(navigator.navigations).toEqual([{ serverId: SERVER_ID, workspaceId: "1" }]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not navigate or seed tabs when openProject fails", async () => {
|
it("fails before sending when the host does not support adding projects without workspaces", async () => {
|
||||||
|
const session = createFakeSession();
|
||||||
|
const result = await openProjectDirectly({
|
||||||
|
serverId: SERVER_ID,
|
||||||
|
projectPath: PROJECT_PATH,
|
||||||
|
isConnected: true,
|
||||||
|
canAddProject: false,
|
||||||
|
client: {
|
||||||
|
addProject: async () => ({
|
||||||
|
requestId: "request-unsupported",
|
||||||
|
error: null,
|
||||||
|
project: buildProjectPayload(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
addEmptyProject: session.addEmptyProject,
|
||||||
|
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
errorCode: null,
|
||||||
|
error: "Update the host to add projects without creating a workspace.",
|
||||||
|
});
|
||||||
|
expect(session.projects).toEqual([]);
|
||||||
|
expect(session.hydrated).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not add a project when addProject fails", async () => {
|
||||||
const session = createFakeSession();
|
const session = createFakeSession();
|
||||||
const layout = createFakeWorkspaceLayout();
|
|
||||||
const navigator = createFakeNavigator();
|
|
||||||
|
|
||||||
const result = await openProjectDirectly({
|
const result = await openProjectDirectly({
|
||||||
serverId: SERVER_ID,
|
serverId: SERVER_ID,
|
||||||
projectPath: PROJECT_PATH,
|
projectPath: PROJECT_PATH,
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
|
canAddProject: true,
|
||||||
client: {
|
client: {
|
||||||
openProject: async () => ({
|
addProject: async () => ({
|
||||||
requestId: "request-2",
|
requestId: "request-2",
|
||||||
error: "Directory not found: /repo/project",
|
error: "Directory not found: /repo/project",
|
||||||
errorCode: "directory_not_found" as const,
|
errorCode: "directory_not_found" as const,
|
||||||
workspace: null,
|
project: null,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
mergeWorkspaces: session.mergeWorkspaces,
|
addEmptyProject: session.addEmptyProject,
|
||||||
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
|
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
|
||||||
openDraftTab: layout.openDraftTab,
|
|
||||||
navigateToWorkspace: navigator.navigateToWorkspace,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
@@ -145,9 +128,7 @@ describe("openProjectDirectly", () => {
|
|||||||
errorCode: "directory_not_found",
|
errorCode: "directory_not_found",
|
||||||
error: "Directory not found: /repo/project",
|
error: "Directory not found: /repo/project",
|
||||||
});
|
});
|
||||||
expect(session.merges).toEqual([]);
|
expect(session.projects).toEqual([]);
|
||||||
expect(session.hydrated).toEqual([]);
|
expect(session.hydrated).toEqual([]);
|
||||||
expect(layout.openedTabs).toEqual([]);
|
|
||||||
expect(navigator.navigations).toEqual([]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
|
||||||
import { generateDraftId } from "@/stores/draft-keys";
|
|
||||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
|
||||||
import { openProjectDirectly, type OpenProjectResult } from "@/hooks/open-project";
|
import { openProjectDirectly, type OpenProjectResult } from "@/hooks/open-project";
|
||||||
|
|
||||||
export function useOpenProject(
|
export function useOpenProject(
|
||||||
@@ -12,7 +9,12 @@ export function useOpenProject(
|
|||||||
const normalizedServerId = serverId?.trim() ?? "";
|
const normalizedServerId = serverId?.trim() ?? "";
|
||||||
const client = useHostRuntimeClient(normalizedServerId);
|
const client = useHostRuntimeClient(normalizedServerId);
|
||||||
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
||||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
const canAddProject = useSessionStore((state) =>
|
||||||
|
normalizedServerId
|
||||||
|
? state.sessions[normalizedServerId]?.serverInfo?.features?.projectAdd === true
|
||||||
|
: false,
|
||||||
|
);
|
||||||
|
const addEmptyProject = useSessionStore((state) => state.addEmptyProject);
|
||||||
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
|
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
|
||||||
|
|
||||||
return useCallback(
|
return useCallback(
|
||||||
@@ -21,17 +23,19 @@ export function useOpenProject(
|
|||||||
serverId: normalizedServerId,
|
serverId: normalizedServerId,
|
||||||
projectPath: path,
|
projectPath: path,
|
||||||
isConnected,
|
isConnected,
|
||||||
|
canAddProject,
|
||||||
client,
|
client,
|
||||||
mergeWorkspaces,
|
addEmptyProject,
|
||||||
setHasHydratedWorkspaces,
|
setHasHydratedWorkspaces,
|
||||||
openDraftTab: (workspaceKey: string) =>
|
|
||||||
useWorkspaceLayoutStore.getState().openTabFocused(workspaceKey, {
|
|
||||||
kind: "draft",
|
|
||||||
draftId: generateDraftId(),
|
|
||||||
}),
|
|
||||||
navigateToWorkspace,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[client, isConnected, mergeWorkspaces, normalizedServerId, setHasHydratedWorkspaces],
|
[
|
||||||
|
addEmptyProject,
|
||||||
|
canAddProject,
|
||||||
|
client,
|
||||||
|
isConnected,
|
||||||
|
normalizedServerId,
|
||||||
|
setHasHydratedWorkspaces,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ describe("workspace structure composition", () => {
|
|||||||
tracked.stop();
|
tracked.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a project with zero active workspaces as an empty project parent", () => {
|
it("renders a project parent with zero active workspaces", () => {
|
||||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||||
useSessionStore.getState().setWorkspaces(SERVER_ID, new Map());
|
useSessionStore.getState().setWorkspaces(SERVER_ID, new Map());
|
||||||
useSessionStore.getState().setEmptyProjects(SERVER_ID, [
|
useSessionStore.getState().setEmptyProjects(SERVER_ID, [
|
||||||
|
|||||||
@@ -331,8 +331,8 @@ export interface SessionState {
|
|||||||
agents: Map<string, Agent>;
|
agents: Map<string, Agent>;
|
||||||
agentDetails: Map<string, Agent>;
|
agentDetails: Map<string, Agent>;
|
||||||
workspaces: Map<string, WorkspaceDescriptor>;
|
workspaces: Map<string, WorkspaceDescriptor>;
|
||||||
// Project parents with no active workspaces, keyed by projectId. Rendered as
|
// Project parents with no active workspaces, keyed by projectId. The
|
||||||
// empty project rows in the sidebar.
|
// `emptyProjects` name is the existing protocol/store projection.
|
||||||
emptyProjects: Map<string, EmptyProjectDescriptor>;
|
emptyProjects: Map<string, EmptyProjectDescriptor>;
|
||||||
// Transient restore state for archived workspaces, keyed by normalized
|
// Transient restore state for archived workspaces, keyed by normalized
|
||||||
// workspaceId. Cleared in mergeWorkspaces when the descriptor lands.
|
// workspaceId. Cleared in mergeWorkspaces when the descriptor lands.
|
||||||
|
|||||||
@@ -1454,6 +1454,62 @@ test("sends structured first-agent context attachments with create_paseo_worktre
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("sends project.add.request without creating a workspace", async () => {
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const mock = createMockTransport();
|
||||||
|
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
clientId: "clsk_unit_test",
|
||||||
|
logger,
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const addPromise = client.addProject("/tmp/project", "req-add-project");
|
||||||
|
|
||||||
|
expect(mock.sent).toHaveLength(1);
|
||||||
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
||||||
|
type: "project.add.request",
|
||||||
|
requestId: "req-add-project",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.triggerMessage(
|
||||||
|
wrapSessionMessage({
|
||||||
|
type: "project.add.response",
|
||||||
|
payload: {
|
||||||
|
requestId: "req-add-project",
|
||||||
|
project: {
|
||||||
|
projectId: "/tmp/project",
|
||||||
|
projectDisplayName: "project",
|
||||||
|
projectCustomName: null,
|
||||||
|
projectRootPath: "/tmp/project",
|
||||||
|
projectKind: "git",
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(addPromise).resolves.toEqual({
|
||||||
|
requestId: "req-add-project",
|
||||||
|
project: {
|
||||||
|
projectId: "/tmp/project",
|
||||||
|
projectDisplayName: "project",
|
||||||
|
projectCustomName: null,
|
||||||
|
projectRootPath: "/tmp/project",
|
||||||
|
projectKind: "git",
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("sends first-agent prompt context with workspace.create.request", async () => {
|
test("sends first-agent prompt context with workspace.create.request", async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import type {
|
|||||||
PaseoWorktreeListResponse,
|
PaseoWorktreeListResponse,
|
||||||
PaseoWorktreeArchiveResponse,
|
PaseoWorktreeArchiveResponse,
|
||||||
ProjectIconResponse,
|
ProjectIconResponse,
|
||||||
|
ProjectAddResponse,
|
||||||
OpenProjectResponseMessage,
|
OpenProjectResponseMessage,
|
||||||
ArchiveWorkspaceResponseMessage,
|
ArchiveWorkspaceResponseMessage,
|
||||||
WorkspaceSetupStatusResponseMessage,
|
WorkspaceSetupStatusResponseMessage,
|
||||||
@@ -661,6 +662,7 @@ export interface RenameTerminalInput {
|
|||||||
requestId?: string;
|
requestId?: string;
|
||||||
}
|
}
|
||||||
type OpenProjectPayload = OpenProjectResponseMessage["payload"];
|
type OpenProjectPayload = OpenProjectResponseMessage["payload"];
|
||||||
|
type ProjectAddPayload = ProjectAddResponse["payload"];
|
||||||
type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"];
|
type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"];
|
||||||
type WorkspaceSetupStatusPayload = WorkspaceSetupStatusResponseMessage["payload"];
|
type WorkspaceSetupStatusPayload = WorkspaceSetupStatusResponseMessage["payload"];
|
||||||
|
|
||||||
@@ -1884,6 +1886,18 @@ export class DaemonClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addProject(cwd: string, requestId?: string): Promise<ProjectAddPayload> {
|
||||||
|
return this.sendCorrelatedSessionRequest({
|
||||||
|
requestId,
|
||||||
|
message: {
|
||||||
|
type: "project.add.request",
|
||||||
|
cwd,
|
||||||
|
},
|
||||||
|
responseType: "project.add.response",
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async startWorkspaceScript(
|
async startWorkspaceScript(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
scriptName: string,
|
scriptName: string,
|
||||||
|
|||||||
@@ -354,4 +354,18 @@ describe("checkout PR schemas", () => {
|
|||||||
projectRemove: true,
|
projectRemove: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("accepts the project add server_info feature flag", () => {
|
||||||
|
expect(
|
||||||
|
ServerInfoStatusPayloadSchema.parse({
|
||||||
|
status: "server_info",
|
||||||
|
serverId: "srv_test",
|
||||||
|
features: {
|
||||||
|
projectAdd: true,
|
||||||
|
},
|
||||||
|
}).features,
|
||||||
|
).toEqual({
|
||||||
|
projectAdd: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1714,6 +1714,12 @@ export const OpenProjectRequestSchema = z.object({
|
|||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const ProjectAddRequestSchema = z.object({
|
||||||
|
type: z.literal("project.add.request"),
|
||||||
|
cwd: z.string(),
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export const ArchiveWorkspaceRequestSchema = z.object({
|
export const ArchiveWorkspaceRequestSchema = z.object({
|
||||||
type: z.literal("archive_workspace_request"),
|
type: z.literal("archive_workspace_request"),
|
||||||
workspaceId: z.string(),
|
workspaceId: z.string(),
|
||||||
@@ -2080,6 +2086,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
LegacyListAvailableEditorsRequestSchema,
|
LegacyListAvailableEditorsRequestSchema,
|
||||||
LegacyOpenInEditorRequestSchema,
|
LegacyOpenInEditorRequestSchema,
|
||||||
OpenProjectRequestSchema,
|
OpenProjectRequestSchema,
|
||||||
|
ProjectAddRequestSchema,
|
||||||
ArchiveWorkspaceRequestSchema,
|
ArchiveWorkspaceRequestSchema,
|
||||||
WorkspaceCreateRequestSchema,
|
WorkspaceCreateRequestSchema,
|
||||||
WorkspaceClearAttentionRequestSchema,
|
WorkspaceClearAttentionRequestSchema,
|
||||||
@@ -2304,6 +2311,8 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
workspaceMultiplicity: z.boolean().optional(),
|
workspaceMultiplicity: z.boolean().optional(),
|
||||||
// COMPAT(projectRemove): added in v0.1.97, drop the gate when floor >= v0.1.97.
|
// COMPAT(projectRemove): added in v0.1.97, drop the gate when floor >= v0.1.97.
|
||||||
projectRemove: z.boolean().optional(),
|
projectRemove: z.boolean().optional(),
|
||||||
|
// COMPAT(projectAdd): added in v0.1.97, drop the gate when floor >= v0.1.97.
|
||||||
|
projectAdd: z.boolean().optional(),
|
||||||
// COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97
|
// COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97
|
||||||
worktreeRestore: z.boolean().optional(),
|
worktreeRestore: z.boolean().optional(),
|
||||||
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
|
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
|
||||||
@@ -2699,8 +2708,9 @@ export const FetchRecentProviderSessionsResponseMessageSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// COMPAT(workspaceProjects): added in v0.1.97, drop the optional gate when floor >= v0.1.97.
|
// COMPAT(workspaceProjects): added in v0.1.97, drop the optional gate when floor >= v0.1.97.
|
||||||
// A project parent that has zero active workspaces. The sidebar renders it as an
|
// A project parent that has zero active workspaces. The sidebar renders the
|
||||||
// empty project row so projects persist after their last workspace is archived.
|
// project row with a new-workspace child so projects persist after their last
|
||||||
|
// workspace is archived.
|
||||||
export const WorkspaceProjectDescriptorPayloadSchema = z.object({
|
export const WorkspaceProjectDescriptorPayloadSchema = z.object({
|
||||||
projectId: z.string(),
|
projectId: z.string(),
|
||||||
projectDisplayName: z.string(),
|
projectDisplayName: z.string(),
|
||||||
@@ -2739,10 +2749,10 @@ export const WorkspaceUpdateMessageSchema = z.object({
|
|||||||
id: z.string(),
|
id: z.string(),
|
||||||
// COMPAT(workspaceProjects): added in v0.1.97, drop the optional gate when floor >= v0.1.97.
|
// COMPAT(workspaceProjects): added in v0.1.97, drop the optional gate when floor >= v0.1.97.
|
||||||
// When archiving this workspace leaves its project with no active
|
// When archiving this workspace leaves its project with no active
|
||||||
// workspaces, the daemon includes the now-empty project parent so the
|
// workspaces, the daemon includes the project parent so the sidebar keeps
|
||||||
// sidebar keeps rendering it without waiting for a full re-hydration. Old
|
// rendering it without waiting for a full re-hydration. Old daemons omit
|
||||||
// daemons omit it; old clients ignore it and surface the empty project on
|
// it; old clients ignore it and surface the project on their next
|
||||||
// their next workspace fetch instead.
|
// workspace fetch instead.
|
||||||
emptyProject: WorkspaceProjectDescriptorPayloadSchema.optional(),
|
emptyProject: WorkspaceProjectDescriptorPayloadSchema.optional(),
|
||||||
// Project removal is represented on the existing workspace update channel
|
// Project removal is represented on the existing workspace update channel
|
||||||
// so old clients can still parse the message and ignore the extra field.
|
// so old clients can still parse the message and ignore the extra field.
|
||||||
@@ -2795,6 +2805,16 @@ export const OpenProjectResponseMessageSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const ProjectAddResponseSchema = z.object({
|
||||||
|
type: z.literal("project.add.response"),
|
||||||
|
payload: z.object({
|
||||||
|
requestId: z.string(),
|
||||||
|
project: WorkspaceProjectDescriptorPayloadSchema.nullable(),
|
||||||
|
error: z.string().nullable(),
|
||||||
|
errorCode: z.enum(["directory_not_found"]).nullish().catch(null),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
export const StartWorkspaceScriptResponseMessageSchema = z.object({
|
export const StartWorkspaceScriptResponseMessageSchema = z.object({
|
||||||
type: z.literal("start_workspace_script_response"),
|
type: z.literal("start_workspace_script_response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -4081,6 +4101,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
FetchAgentHistoryResponseMessageSchema,
|
FetchAgentHistoryResponseMessageSchema,
|
||||||
FetchRecentProviderSessionsResponseMessageSchema,
|
FetchRecentProviderSessionsResponseMessageSchema,
|
||||||
FetchWorkspacesResponseMessageSchema,
|
FetchWorkspacesResponseMessageSchema,
|
||||||
|
ProjectAddResponseSchema,
|
||||||
OpenProjectResponseMessageSchema,
|
OpenProjectResponseMessageSchema,
|
||||||
StartWorkspaceScriptResponseMessageSchema,
|
StartWorkspaceScriptResponseMessageSchema,
|
||||||
LegacyListAvailableEditorsResponseMessageSchema,
|
LegacyListAvailableEditorsResponseMessageSchema,
|
||||||
@@ -4230,6 +4251,7 @@ export type FetchRecentProviderSessionsResponseMessage = z.infer<
|
|||||||
typeof FetchRecentProviderSessionsResponseMessageSchema
|
typeof FetchRecentProviderSessionsResponseMessageSchema
|
||||||
>;
|
>;
|
||||||
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>;
|
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>;
|
||||||
|
export type ProjectAddResponse = z.infer<typeof ProjectAddResponseSchema>;
|
||||||
export type ScriptStatusUpdateMessage = z.infer<typeof ScriptStatusUpdateMessageSchema>;
|
export type ScriptStatusUpdateMessage = z.infer<typeof ScriptStatusUpdateMessageSchema>;
|
||||||
export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema>;
|
export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema>;
|
||||||
export type StartWorkspaceScriptResponseMessage = z.infer<
|
export type StartWorkspaceScriptResponseMessage = z.infer<
|
||||||
@@ -4473,6 +4495,7 @@ export type LegacyListAvailableEditorsRequest = z.infer<
|
|||||||
>;
|
>;
|
||||||
export type LegacyOpenInEditorRequest = z.infer<typeof LegacyOpenInEditorRequestSchema>;
|
export type LegacyOpenInEditorRequest = z.infer<typeof LegacyOpenInEditorRequestSchema>;
|
||||||
export type OpenProjectRequest = z.infer<typeof OpenProjectRequestSchema>;
|
export type OpenProjectRequest = z.infer<typeof OpenProjectRequestSchema>;
|
||||||
|
export type ProjectAddRequest = z.infer<typeof ProjectAddRequestSchema>;
|
||||||
export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>;
|
export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>;
|
||||||
export type WorkspaceClearAttentionRequest = z.infer<typeof WorkspaceClearAttentionRequestSchema>;
|
export type WorkspaceClearAttentionRequest = z.infer<typeof WorkspaceClearAttentionRequestSchema>;
|
||||||
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
|
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
|
||||||
|
|||||||
@@ -27,6 +27,50 @@ describe("workspace message schemas", () => {
|
|||||||
expect(parsed.type).toBe("fetch_workspaces_request");
|
expect(parsed.type).toBe("fetch_workspaces_request");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("parses project.add request and response", () => {
|
||||||
|
expect(
|
||||||
|
SessionInboundMessageSchema.parse({
|
||||||
|
type: "project.add.request",
|
||||||
|
requestId: "req-add-project",
|
||||||
|
cwd: "/repo",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
type: "project.add.request",
|
||||||
|
requestId: "req-add-project",
|
||||||
|
cwd: "/repo",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
SessionOutboundMessageSchema.parse({
|
||||||
|
type: "project.add.response",
|
||||||
|
payload: {
|
||||||
|
requestId: "req-add-project",
|
||||||
|
project: {
|
||||||
|
projectId: "/repo",
|
||||||
|
projectDisplayName: "repo",
|
||||||
|
projectCustomName: null,
|
||||||
|
projectRootPath: "/repo",
|
||||||
|
projectKind: "git",
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
type: "project.add.response",
|
||||||
|
payload: {
|
||||||
|
requestId: "req-add-project",
|
||||||
|
project: {
|
||||||
|
projectId: "/repo",
|
||||||
|
projectDisplayName: "repo",
|
||||||
|
projectCustomName: null,
|
||||||
|
projectRootPath: "/repo",
|
||||||
|
projectKind: "git",
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("parses active-scoped fetch_agents_request as an optional extension", () => {
|
test("parses active-scoped fetch_agents_request as an optional extension", () => {
|
||||||
const legacy = SessionInboundMessageSchema.parse({
|
const legacy = SessionInboundMessageSchema.parse({
|
||||||
type: "fetch_agents_request",
|
type: "fetch_agents_request",
|
||||||
|
|||||||
@@ -1,22 +1,26 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { mkdtempSync, realpathSync, writeFileSync } from "node:fs";
|
import { mkdtempSync, realpathSync, writeFileSync } from "node:fs";
|
||||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
import { readFile, rm } from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, expect, test } from "vitest";
|
import { afterEach, expect, test } from "vitest";
|
||||||
|
|
||||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||||
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||||
import {
|
import { type PersistedProjectRecord } from "../workspace-registry.js";
|
||||||
createPersistedProjectRecord,
|
|
||||||
createPersistedWorkspaceRecord,
|
|
||||||
type PersistedProjectRecord,
|
|
||||||
} from "../workspace-registry.js";
|
|
||||||
|
|
||||||
const cleanupPaths = new Set<string>();
|
const cleanupPaths = new Set<string>();
|
||||||
const cleanupDaemons = new Set<TestPaseoDaemon>();
|
const cleanupDaemons = new Set<TestPaseoDaemon>();
|
||||||
const cleanupClients = new Set<DaemonClient>();
|
const cleanupClients = new Set<DaemonClient>();
|
||||||
|
|
||||||
|
function restoreEnv(name: string, previous: string | undefined): void {
|
||||||
|
if (previous === undefined) {
|
||||||
|
delete process.env[name];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
process.env[name] = previous;
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await Promise.all(Array.from(cleanupClients, (client) => client.close().catch(() => undefined)));
|
await Promise.all(Array.from(cleanupClients, (client) => client.close().catch(() => undefined)));
|
||||||
cleanupClients.clear();
|
cleanupClients.clear();
|
||||||
@@ -28,9 +32,58 @@ afterEach(async () => {
|
|||||||
cleanupPaths.clear();
|
cleanupPaths.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Unit 2.2: a project persists as a first-class empty project after its last
|
test("project.add creates a project without creating a workspace", async () => {
|
||||||
// workspace is archived, and is exposed to clients via fetch_workspaces.
|
const previousSupervised = process.env.PASEO_SUPERVISED;
|
||||||
test("archiving the last workspace leaves the project as an empty project parent", async () => {
|
process.env.PASEO_SUPERVISED = "0";
|
||||||
|
try {
|
||||||
|
const repoRoot = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-add-project-repo-")));
|
||||||
|
const paseoHomeRoot = realpathSync(
|
||||||
|
mkdtempSync(path.join(os.tmpdir(), "paseo-add-project-home-")),
|
||||||
|
);
|
||||||
|
cleanupPaths.add(repoRoot);
|
||||||
|
cleanupPaths.add(paseoHomeRoot);
|
||||||
|
|
||||||
|
execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" });
|
||||||
|
execSync("git config user.email 'test@getpaseo.dev'", { cwd: repoRoot, stdio: "pipe" });
|
||||||
|
execSync("git config user.name 'Paseo Test'", { cwd: repoRoot, stdio: "pipe" });
|
||||||
|
writeFileSync(path.join(repoRoot, "README.md"), "# repo\n", "utf8");
|
||||||
|
execSync("git add README.md", { cwd: repoRoot, stdio: "pipe" });
|
||||||
|
execSync("git -c commit.gpgSign=false commit -m 'initial'", { cwd: repoRoot, stdio: "pipe" });
|
||||||
|
|
||||||
|
const daemon = await createTestPaseoDaemon({ paseoHomeRoot, cleanup: false });
|
||||||
|
cleanupDaemons.add(daemon);
|
||||||
|
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||||
|
cleanupClients.add(client);
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
const added = await client.addProject(repoRoot);
|
||||||
|
|
||||||
|
expect(added.error).toBeNull();
|
||||||
|
expect(added.project).not.toBeNull();
|
||||||
|
const project = added.project!;
|
||||||
|
expect(project).toMatchObject({
|
||||||
|
projectId: repoRoot,
|
||||||
|
projectRootPath: repoRoot,
|
||||||
|
projectKind: "git",
|
||||||
|
});
|
||||||
|
|
||||||
|
const workspaces = await client.fetchWorkspaces({
|
||||||
|
filter: { projectId: project.projectId },
|
||||||
|
});
|
||||||
|
expect(workspaces.entries).toEqual([]);
|
||||||
|
expect(workspaces.emptyProjects).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
projectId: repoRoot,
|
||||||
|
projectRootPath: repoRoot,
|
||||||
|
projectKind: "git",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
restoreEnv("PASEO_SUPERVISED", previousSupervised);
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
test("archiving the last workspace leaves the project parent with no workspaces", async () => {
|
||||||
const previousSupervised = process.env.PASEO_SUPERVISED;
|
const previousSupervised = process.env.PASEO_SUPERVISED;
|
||||||
process.env.PASEO_SUPERVISED = "0";
|
process.env.PASEO_SUPERVISED = "0";
|
||||||
try {
|
try {
|
||||||
@@ -50,47 +103,6 @@ test("archiving the last workspace leaves the project as an empty project parent
|
|||||||
|
|
||||||
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
||||||
const projectsPath = path.join(paseoHome, "projects", "projects.json");
|
const projectsPath = path.join(paseoHome, "projects", "projects.json");
|
||||||
const workspacesPath = path.join(paseoHome, "projects", "workspaces.json");
|
|
||||||
const timestamp = "2026-04-24T09:46:43.146Z";
|
|
||||||
|
|
||||||
await mkdir(path.dirname(projectsPath), { recursive: true });
|
|
||||||
await writeFile(
|
|
||||||
projectsPath,
|
|
||||||
JSON.stringify(
|
|
||||||
[
|
|
||||||
createPersistedProjectRecord({
|
|
||||||
projectId: repoRoot,
|
|
||||||
rootPath: repoRoot,
|
|
||||||
kind: "git",
|
|
||||||
displayName: "repo",
|
|
||||||
createdAt: timestamp,
|
|
||||||
updatedAt: timestamp,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
await writeFile(
|
|
||||||
workspacesPath,
|
|
||||||
JSON.stringify(
|
|
||||||
[
|
|
||||||
createPersistedWorkspaceRecord({
|
|
||||||
workspaceId: repoRoot,
|
|
||||||
projectId: repoRoot,
|
|
||||||
cwd: repoRoot,
|
|
||||||
kind: "local_checkout",
|
|
||||||
displayName: "main",
|
|
||||||
createdAt: timestamp,
|
|
||||||
updatedAt: timestamp,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
const daemon = await createTestPaseoDaemon({ paseoHomeRoot, cleanup: false });
|
const daemon = await createTestPaseoDaemon({ paseoHomeRoot, cleanup: false });
|
||||||
cleanupDaemons.add(daemon);
|
cleanupDaemons.add(daemon);
|
||||||
@@ -99,23 +111,33 @@ test("archiving the last workspace leaves the project as an empty project parent
|
|||||||
await client.connect();
|
await client.connect();
|
||||||
await client.fetchAgents({ subscribe: { subscriptionId: "empty-project-agents" } });
|
await client.fetchAgents({ subscribe: { subscriptionId: "empty-project-agents" } });
|
||||||
|
|
||||||
const beforeArchive = await client.fetchWorkspaces();
|
const created = await client.createWorkspace({ source: { kind: "directory", path: repoRoot } });
|
||||||
expect(beforeArchive.entries.map((entry) => entry.id)).toContain(repoRoot);
|
expect(created.error).toBeNull();
|
||||||
expect(beforeArchive.emptyProjects.map((project) => project.projectId)).not.toContain(repoRoot);
|
expect(created.workspace).not.toBeNull();
|
||||||
|
const workspaceId = created.workspace!.id;
|
||||||
|
const projectId = created.workspace!.projectId;
|
||||||
|
|
||||||
await client.archiveWorkspace(repoRoot);
|
const beforeArchive = await client.fetchWorkspaces();
|
||||||
|
expect(beforeArchive.entries.map((entry) => entry.id)).toContain(workspaceId);
|
||||||
|
expect(beforeArchive.emptyProjects.map((project) => project.projectId)).not.toContain(
|
||||||
|
projectId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const archiveResponse = await client.archiveWorkspace(workspaceId);
|
||||||
|
expect(archiveResponse.error).toBeNull();
|
||||||
|
expect(archiveResponse.archivedAt).not.toBeNull();
|
||||||
|
|
||||||
const afterArchive = await client.fetchWorkspaces();
|
const afterArchive = await client.fetchWorkspaces();
|
||||||
expect(afterArchive.entries.map((entry) => entry.id)).not.toContain(repoRoot);
|
expect(afterArchive.entries.map((entry) => entry.id)).not.toContain(workspaceId);
|
||||||
expect(afterArchive.emptyProjects.map((project) => project.projectId)).toContain(repoRoot);
|
expect(afterArchive.emptyProjects.map((project) => project.projectId)).toContain(projectId);
|
||||||
|
|
||||||
const persistedProjects = JSON.parse(
|
const persistedProjects = JSON.parse(
|
||||||
await readFile(projectsPath, "utf8"),
|
await readFile(projectsPath, "utf8"),
|
||||||
) as PersistedProjectRecord[];
|
) as PersistedProjectRecord[];
|
||||||
expect(
|
expect(
|
||||||
persistedProjects.find((project) => project.projectId === repoRoot)?.archivedAt,
|
persistedProjects.find((project) => project.projectId === projectId)?.archivedAt,
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
} finally {
|
} finally {
|
||||||
process.env.PASEO_SUPERVISED = previousSupervised;
|
restoreEnv("PASEO_SUPERVISED", previousSupervised);
|
||||||
}
|
}
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|||||||
@@ -2247,6 +2247,8 @@ export class Session {
|
|||||||
return this.handleLegacyOpenInEditorRequest(msg);
|
return this.handleLegacyOpenInEditorRequest(msg);
|
||||||
case "open_project_request":
|
case "open_project_request":
|
||||||
return this.handleOpenProjectRequest(msg);
|
return this.handleOpenProjectRequest(msg);
|
||||||
|
case "project.add.request":
|
||||||
|
return this.handleProjectAddRequest(msg);
|
||||||
case "archive_workspace_request":
|
case "archive_workspace_request":
|
||||||
return this.handleArchiveWorkspaceRequest(msg);
|
return this.handleArchiveWorkspaceRequest(msg);
|
||||||
case "project.remove.request":
|
case "project.remove.request":
|
||||||
@@ -7248,6 +7250,30 @@ export class Session {
|
|||||||
return workspaceRecord;
|
return workspaceRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async findOrCreateProjectForDirectory(cwd: string): Promise<PersistedProjectRecord> {
|
||||||
|
const normalizedCwd = resolve(cwd);
|
||||||
|
const checkout = await this.workspaceGitService.getCheckout(normalizedCwd);
|
||||||
|
const membership = classifyDirectoryForProjectMembership({ cwd: normalizedCwd, checkout });
|
||||||
|
const projectRecord = await this.resolveProjectRecordForPlacement({
|
||||||
|
membership,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
await this.projectRegistry.upsert(projectRecord);
|
||||||
|
return projectRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildProjectDescriptor(
|
||||||
|
project: PersistedProjectRecord,
|
||||||
|
): WorkspaceProjectDescriptorPayload {
|
||||||
|
return {
|
||||||
|
projectId: project.projectId,
|
||||||
|
projectDisplayName: resolveProjectDisplayName(project),
|
||||||
|
projectCustomName: project.customName ?? null,
|
||||||
|
projectRootPath: project.rootPath,
|
||||||
|
projectKind: project.kind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async reclassifyOrUnarchiveWorkspaceForDirectory(input: {
|
private async reclassifyOrUnarchiveWorkspaceForDirectory(input: {
|
||||||
workspace: PersistedWorkspaceRecord;
|
workspace: PersistedWorkspaceRecord;
|
||||||
project: PersistedProjectRecord | null;
|
project: PersistedProjectRecord | null;
|
||||||
@@ -7662,24 +7688,24 @@ export class Session {
|
|||||||
return {
|
return {
|
||||||
kind: "remove",
|
kind: "remove",
|
||||||
id: workspaceId,
|
id: workspaceId,
|
||||||
...(await this.resolveEmptyProjectForArchivedWorkspace(workspaceId)),
|
...(await this.resolveProjectWithoutActiveWorkspacesForArchivedWorkspace(workspaceId)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// When a workspace is archived its project may become empty. Resolve the
|
// When a workspace is archived its project may have no active workspaces left.
|
||||||
// now-empty project parent so the `remove` update can carry it, keeping the
|
// Resolve that project parent so the `remove` update can carry it, keeping the
|
||||||
// sidebar's empty project row in sync without a full re-hydration.
|
// sidebar in sync without a full re-hydration.
|
||||||
private async resolveEmptyProjectForArchivedWorkspace(
|
private async resolveProjectWithoutActiveWorkspacesForArchivedWorkspace(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
): Promise<{ emptyProject: WorkspaceProjectDescriptorPayload } | null> {
|
): Promise<{ emptyProject: WorkspaceProjectDescriptorPayload } | null> {
|
||||||
const archivedWorkspace = await this.workspaceRegistry.get(workspaceId);
|
const archivedWorkspace = await this.workspaceRegistry.get(workspaceId);
|
||||||
if (!archivedWorkspace) {
|
if (!archivedWorkspace) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const emptyProject = (await this.workspaceDirectory.listEmptyProjects()).find(
|
const projectWithoutActiveWorkspaces = (await this.workspaceDirectory.listEmptyProjects()).find(
|
||||||
(project) => project.projectId === archivedWorkspace.projectId,
|
(project) => project.projectId === archivedWorkspace.projectId,
|
||||||
);
|
);
|
||||||
return emptyProject ? { emptyProject } : null;
|
return projectWithoutActiveWorkspaces ? { emptyProject: projectWithoutActiveWorkspaces } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async emitWorkspaceUpdateForTerminalContribution(
|
private async emitWorkspaceUpdateForTerminalContribution(
|
||||||
@@ -8233,6 +8259,69 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleProjectAddRequest(
|
||||||
|
request: Extract<SessionInboundMessage, { type: "project.add.request" }>,
|
||||||
|
): Promise<void> {
|
||||||
|
const requestedCwd = request.cwd;
|
||||||
|
const cwd = expandTilde(requestedCwd);
|
||||||
|
const directoryExists = await this.filesystem.isDirectory(cwd).catch(() => false);
|
||||||
|
if (!directoryExists) {
|
||||||
|
this.sessionLogger.info(
|
||||||
|
{ requestedCwd, resolvedCwd: cwd, reason: "directory_not_found" },
|
||||||
|
"Add project rejected",
|
||||||
|
);
|
||||||
|
this.emit({
|
||||||
|
type: "project.add.response",
|
||||||
|
payload: {
|
||||||
|
requestId: request.requestId,
|
||||||
|
project: null,
|
||||||
|
error: `Directory not found: ${cwd}`,
|
||||||
|
errorCode: "directory_not_found",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const projectsBefore = new Map<string, PersistedProjectRecord>();
|
||||||
|
for (const project of await this.projectRegistry.list()) {
|
||||||
|
projectsBefore.set(project.projectId, project);
|
||||||
|
}
|
||||||
|
const project = await this.findOrCreateProjectForDirectory(cwd);
|
||||||
|
this.sessionLogger.info(
|
||||||
|
{
|
||||||
|
requestedCwd,
|
||||||
|
resolvedCwd: cwd,
|
||||||
|
projectId: project.projectId,
|
||||||
|
projectKind: project.kind,
|
||||||
|
projectTransition: describeRegistryTransition(
|
||||||
|
projectsBefore.get(project.projectId) ?? null,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"Project added",
|
||||||
|
);
|
||||||
|
this.emit({
|
||||||
|
type: "project.add.response",
|
||||||
|
payload: {
|
||||||
|
requestId: request.requestId,
|
||||||
|
project: this.buildProjectDescriptor(project),
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to add project";
|
||||||
|
this.sessionLogger.error({ err: error, cwd }, "Failed to add project");
|
||||||
|
this.emit({
|
||||||
|
type: "project.add.response",
|
||||||
|
payload: {
|
||||||
|
requestId: request.requestId,
|
||||||
|
project: null,
|
||||||
|
error: message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private buildWorkspaceScriptPayloadSnapshot(
|
private buildWorkspaceScriptPayloadSnapshot(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
workspaceDirectory: string,
|
workspaceDirectory: string,
|
||||||
|
|||||||
@@ -1166,6 +1166,8 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
workspaceMultiplicity: true,
|
workspaceMultiplicity: true,
|
||||||
// COMPAT(projectRemove): added in v0.1.97, drop the gate when floor >= v0.1.97.
|
// COMPAT(projectRemove): added in v0.1.97, drop the gate when floor >= v0.1.97.
|
||||||
projectRemove: true,
|
projectRemove: true,
|
||||||
|
// COMPAT(projectAdd): added in v0.1.97, drop the gate when floor >= v0.1.97.
|
||||||
|
projectAdd: true,
|
||||||
// COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97
|
// COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97
|
||||||
worktreeRestore: true,
|
worktreeRestore: true,
|
||||||
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
|
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
|
||||||
|
|||||||
@@ -391,9 +391,8 @@ export async function killTerminalsForWorkspace(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Archiving the last workspace of a project leaves the project as a first-class
|
// Archiving the last workspace of a project leaves the project record active.
|
||||||
// empty project — it persists until explicitly removed, so we never archive the
|
// The user removes the project explicitly, so we never archive the parent here.
|
||||||
// parent project here.
|
|
||||||
export async function archivePersistedWorkspaceRecord(input: {
|
export async function archivePersistedWorkspaceRecord(input: {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "archive">;
|
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "archive">;
|
||||||
|
|||||||
@@ -527,7 +527,7 @@ describe("WorkspaceDirectory empty projects", () => {
|
|||||||
} satisfies PersistedProjectRecord;
|
} satisfies PersistedProjectRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
test("surfaces a project with no active workspaces as an empty project", async () => {
|
test("surfaces a project with no active workspaces through the compatibility projection", async () => {
|
||||||
const directory = makeDirectory({
|
const directory = makeDirectory({
|
||||||
projects: [project({ projectId: "empty", customName: "Renamed" })],
|
projects: [project({ projectId: "empty", customName: "Renamed" })],
|
||||||
workspaces: [],
|
workspaces: [],
|
||||||
|
|||||||
@@ -475,9 +475,9 @@ export class WorkspaceDirectory {
|
|||||||
return candidates.at(-1) ?? null;
|
return candidates.at(-1) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Project parents that have no active workspaces. These persist as first-class
|
// Project parents that have no active workspaces. The wire field is the
|
||||||
// empty projects so the sidebar can render an empty project row with a
|
// sidebar projection bucket for projects whose workspace list is currently
|
||||||
// "+ New workspace" affordance.
|
// empty; it is not a separate domain record.
|
||||||
async listEmptyProjects(): Promise<WorkspaceProjectDescriptor[]> {
|
async listEmptyProjects(): Promise<WorkspaceProjectDescriptor[]> {
|
||||||
const [persistedWorkspaces, persistedProjects] = await Promise.all([
|
const [persistedWorkspaces, persistedProjects] = await Promise.all([
|
||||||
this.deps.workspaceRegistry.list(),
|
this.deps.workspaceRegistry.list(),
|
||||||
@@ -566,8 +566,8 @@ export class WorkspaceDirectory {
|
|||||||
? this.pager.encode(pagedEntries[pagedEntries.length - 1], sort)
|
? this.pager.encode(pagedEntries[pagedEntries.length - 1], sort)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Empty project parents ride only on the first page so the sidebar can render
|
// Project parents with no active workspaces ride only on the first page so
|
||||||
// them without them being duplicated across pagination.
|
// the sidebar can render them without duplicating them across pagination.
|
||||||
const projectIdFilter = filter?.projectId?.trim();
|
const projectIdFilter = filter?.projectId?.trim();
|
||||||
const emptyProjects = cursorToken
|
const emptyProjects = cursorToken
|
||||||
? []
|
? []
|
||||||
|
|||||||
@@ -160,8 +160,8 @@ export class WorkspaceReconciliationService {
|
|||||||
await this.mergeDuplicateProjectsByRoot(activeProjects, workspacesByProject, changes);
|
await this.mergeDuplicateProjectsByRoot(activeProjects, workspacesByProject, changes);
|
||||||
|
|
||||||
// 3. Reconcile git metadata for active projects whose directories still exist.
|
// 3. Reconcile git metadata for active projects whose directories still exist.
|
||||||
// A project with zero active workspaces is a first-class empty project — it
|
// Projects persist until explicitly removed, even when they currently have
|
||||||
// persists until explicitly removed and still reconciles its own metadata.
|
// zero active workspaces, so they still reconcile their own metadata.
|
||||||
// Skip projects archived earlier in this pass (e.g. merged duplicates) so we
|
// Skip projects archived earlier in this pass (e.g. merged duplicates) so we
|
||||||
// don't resurrect them by upserting a stale, non-archived copy.
|
// don't resurrect them by upserting a stale, non-archived copy.
|
||||||
const archivedProjectIds = new Set(
|
const archivedProjectIds = new Set(
|
||||||
|
|||||||
Reference in New Issue
Block a user