From 4534754617ba2f2b6ae28b884cb0705dd8279a80 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 20 Jun 2026 11:37:05 +0700 Subject: [PATCH] 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. --- packages/app/e2e/diff-row-alignment.spec.ts | 10 +- .../app/e2e/empty-project-persists.spec.ts | 92 +- packages/app/e2e/helpers/archive-tab.ts | 14 +- packages/app/e2e/helpers/composer.ts | 22 +- packages/app/e2e/helpers/new-workspace.ts | 16 +- packages/app/e2e/helpers/rewind-flow.ts | 14 +- packages/app/e2e/helpers/seed-client.ts | 41 +- packages/app/e2e/helpers/terminal-dsl.ts | 4 +- packages/app/e2e/helpers/with-workspace.ts | 15 +- packages/app/e2e/helpers/workspace-setup.ts | 14 +- packages/app/e2e/helpers/workspace-ui.ts | 8 +- .../e2e/import-session.opencode.real.spec.ts | 18 +- .../src/components/sidebar-workspace-list.tsx | 1069 +++-------------- .../sidebar/sidebar-status-list.tsx | 516 +------- .../sidebar/sidebar-workspace-row-content.tsx | 7 +- .../sidebar/sidebar-workspace-row.tsx | 641 ++++++++++ .../use-long-press-drag-interaction.ts | 223 ++++ .../src/components/workspace-setup-dialog.tsx | 4 +- packages/app/src/contexts/session-context.tsx | 2 +- packages/app/src/hooks/open-project.ts | 46 +- .../app/src/hooks/use-open-project.test.ts | 139 +-- packages/app/src/hooks/use-open-project.ts | 28 +- .../session-store-hooks/selectors.test.ts | 2 +- packages/app/src/stores/session-store.ts | 4 +- packages/client/src/daemon-client.test.ts | 56 + packages/client/src/daemon-client.ts | 14 + .../src/messages.checkout-pr-schema.test.ts | 14 + packages/protocol/src/messages.ts | 35 +- .../protocol/src/messages.workspaces.test.ts | 44 + .../empty-project-persists.e2e.test.ts | 138 ++- packages/server/src/server/session.ts | 103 +- .../server/src/server/websocket-server.ts | 2 + .../src/server/workspace-archive-service.ts | 5 +- .../src/server/workspace-directory.test.ts | 2 +- .../server/src/server/workspace-directory.ts | 10 +- .../workspace-reconciliation-service.ts | 4 +- 36 files changed, 1653 insertions(+), 1723 deletions(-) create mode 100644 packages/app/src/components/sidebar/sidebar-workspace-row.tsx create mode 100644 packages/app/src/components/sidebar/use-long-press-drag-interaction.ts diff --git a/packages/app/e2e/diff-row-alignment.spec.ts b/packages/app/e2e/diff-row-alignment.spec.ts index 61a8ecbfd..62d37053d 100644 --- a/packages/app/e2e/diff-row-alignment.spec.ts +++ b/packages/app/e2e/diff-row-alignment.spec.ts @@ -362,11 +362,13 @@ async function createWorkspaceWithMountedTabDiff(): Promise { }); await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER); - const opened = await client.openProject(repo.path); - if (!opened.workspace) { - throw new Error(opened.error ?? `Failed to open project ${repo.path}`); + const createdWorkspace = await client.createWorkspace({ + source: { kind: "directory", path: 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 { diff --git a/packages/app/e2e/empty-project-persists.spec.ts b/packages/app/e2e/empty-project-persists.spec.ts index 2c8443c22..262fcdbb1 100644 --- a/packages/app/e2e/empty-project-persists.spec.ts +++ b/packages/app/e2e/empty-project-persists.spec.ts @@ -1,8 +1,10 @@ +import path from "node:path"; import { test, expect, type Page } from "./fixtures"; 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 { waitForSidebarHydration } from "./helpers/workspace-ui"; +import { createTempGitRepo } from "./helpers/workspace"; +import { waitForNoProjectsInSidebar, waitForSidebarHydration } from "./helpers/workspace-ui"; function workspaceRowTestId(workspaceId: string): string { return `sidebar-workspace-row-${getServerId()}:${workspaceId}`; @@ -45,11 +47,58 @@ async function removeProjectFromSidebar(page: Page, projectId: string): Promise< await removeItem.click(); } -// Model B makes the project a first-class parent: archiving its last workspace -// must not delete the project. The per-project "+ New workspace" row is gone; -// 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. -test.describe("Empty project persists", () => { +async function addProjectFromPicker(page: Page, projectPath: string): Promise { + await page.getByTestId("sidebar-add-project").click(); + + const input = page.getByPlaceholder("Type a directory path..."); + 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 ({ page, }) => { @@ -57,8 +106,8 @@ test.describe("Empty project persists", () => { try { const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`); - const projectNewWorktreeIcon = page.getByTestId( - `sidebar-project-new-worktree-${workspace.projectId}`, + const newWorkspaceRow = page.getByTestId( + `sidebar-project-new-workspace-row-${workspace.projectId}`, ); const globalNewWorkspace = page.getByTestId("sidebar-global-new-workspace"); @@ -71,24 +120,21 @@ test.describe("Empty project persists", () => { await hideWorkspaceFromSidebar(page, workspace.workspaceId); - // The workspace row goes away, but its project parent stays as an empty - // project row. Creation is still reachable: the project row keeps its own - // new-worktree icon (revealed on hover) and the global button persists. + // The workspace row goes away, but its project parent stays and exposes a + // child row for creating the next workspace. await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, { 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 projectRow.hover(); - await expect(projectNewWorktreeIcon).toBeVisible({ timeout: 30_000 }); - // The empty project survives a reload — it is persisted, not a transient - // artifact of the just-archived workspace still lingering in memory. + // The project survives a reload after its last workspace is archived. await page.reload(); await waitForSidebarHydration(page); await expect(projectRow).toBeVisible({ timeout: 30_000 }); - await projectRow.hover(); - await expect(projectNewWorktreeIcon).toBeVisible({ timeout: 30_000 }); + await expect(newWorkspaceRow).toBeVisible({ timeout: 30_000 }); } finally { await workspace.cleanup(); } @@ -120,16 +166,20 @@ test.describe("Project remove", () => { await waitForSidebarHydration(page); await expect(projectRow).toHaveCount(0, { timeout: 30_000 }); - const reopened = await workspace.client.openProject(workspace.repoPath); - expect(reopened.error).toBeNull(); - expect(reopened.workspace?.projectDisplayName).toBe(workspace.projectDisplayName); + const readded = await workspace.client.addProject(workspace.repoPath); + expect(readded.error).toBeNull(); + expect(readded.project?.projectDisplayName).toBe(workspace.projectDisplayName); await page.reload(); await waitForSidebarHydration(page); await expect(projectRow).toBeVisible({ timeout: 30_000 }); await expect(projectRow).toContainText(workspace.projectDisplayName); await expect(projectRow).not.toContainText(workspace.repoPath); + await expect( + page.getByTestId(`sidebar-project-new-workspace-row-${workspace.projectId}`), + ).toBeVisible({ timeout: 30_000 }); } finally { + await removeProjectFromSidebar(page, workspace.projectId).catch(() => undefined); await workspace.cleanup(); } }); diff --git a/packages/app/e2e/helpers/archive-tab.ts b/packages/app/e2e/helpers/archive-tab.ts index f7553d7e9..c62d3c90f 100644 --- a/packages/app/e2e/helpers/archive-tab.ts +++ b/packages/app/e2e/helpers/archive-tab.ts @@ -35,7 +35,7 @@ function buildSeededStoragePayload() { * idle agent from the same client it uses for everything else. */ export interface IdleAgentSeedClient { - openProject(cwd: string): Promise<{ + createWorkspace(input: { source: { kind: "directory"; path: string } }): Promise<{ workspace: { id: string } | null; error: string | null; }>; @@ -58,16 +58,18 @@ export async function createIdleAgent( client: IdleAgentSeedClient, input: { cwd: string; title: string }, ): Promise { - const opened = await client.openProject(input.cwd); - if (!opened.workspace) { - throw new Error(opened.error ?? `Failed to open project ${input.cwd}`); + const createdWorkspace = await client.createWorkspace({ + source: { kind: "directory", path: input.cwd }, + }); + if (!createdWorkspace.workspace) { + throw new Error(createdWorkspace.error ?? `Failed to create workspace ${input.cwd}`); } const created = await client.createAgent({ provider: "opencode", model: "opencode/gpt-5-nano", modeId: "bypassPermissions", cwd: input.cwd, - workspaceId: opened.workspace.id, + workspaceId: createdWorkspace.workspace.id, title: input.title, }); const snapshot = await client.waitForAgentUpsert( @@ -82,7 +84,7 @@ export async function createIdleAgent( id: created.id, title: input.title, cwd: input.cwd, - workspaceId: opened.workspace.id, + workspaceId: createdWorkspace.workspace.id, }; } diff --git a/packages/app/e2e/helpers/composer.ts b/packages/app/e2e/helpers/composer.ts index 4a0aaaef9..c88f5610b 100644 --- a/packages/app/e2e/helpers/composer.ts +++ b/packages/app/e2e/helpers/composer.ts @@ -160,15 +160,19 @@ export async function startRunningMockAgent( const repo = await createTempGitRepo(opts.prefix); const client = await connectSeedClient(); - const opened = await client.openProject(repo.path); - if (!opened.workspace) throw new Error(opened.error ?? "Failed to open project"); + const createdWorkspace = await client.createWorkspace({ + source: { kind: "directory", path: repo.path }, + }); + if (!createdWorkspace.workspace) { + throw new Error(createdWorkspace.error ?? "Failed to create workspace"); + } const agent = await client.createAgent({ provider: "mock", cwd: repo.path, - workspaceId: opened.workspace.id, + workspaceId: createdWorkspace.workspace.id, 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 expectComposerVisible(page); await client.sendAgentMessage(agent.id, opts.prompt); @@ -188,10 +192,14 @@ export async function openGithubWorkspace( repoPath: string, ): Promise { const client = await connectWorkspaceSetupClient(); - const opened = await client.openProject(repoPath); - if (!opened.workspace) throw new Error(opened.error ?? `Failed to open project ${repoPath}`); + const createdWorkspace = await client.createWorkspace({ + source: { kind: "directory", path: repoPath }, + }); + if (!createdWorkspace.workspace) { + throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repoPath}`); + } await gotoAppShell(page); - await selectWorkspaceInSidebar(page, opened.workspace.id); + await selectWorkspaceInSidebar(page, createdWorkspace.workspace.id); await waitForTabBar(page); return { cleanup: () => client.close().catch(() => undefined) }; } diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/helpers/new-workspace.ts index 59ef0c4f5..27e5eaa39 100644 --- a/packages/app/e2e/helpers/new-workspace.ts +++ b/packages/app/e2e/helpers/new-workspace.ts @@ -13,16 +13,16 @@ type NewWorkspaceDaemonClient = Pick< | "close" | "connect" | "createPaseoWorktree" + | "createWorkspace" | "fetchWorkspaces" | "getPaseoWorktreeList" | "getDaemonConfig" - | "openProject" | "patchDaemonConfig" >; -type OpenProjectPayload = Awaited>; -type WorkspacePayload = Pick; -type WorkspaceDescriptor = NonNullable; +type CreateWorkspacePayload = Awaited>; +type WorkspacePayload = Pick; +type WorkspaceDescriptor = NonNullable; export interface OpenedProject { workspaceId: string; @@ -37,7 +37,7 @@ function requireWorkspace(payload: WorkspacePayload) { throw new Error(payload.error); } if (!payload.workspace) { - throw new Error("openProject returned no workspace."); + throw new Error("workspace.create returned no workspace."); } return payload.workspace; } @@ -96,7 +96,11 @@ export async function openProjectViaDaemon( client: NewWorkspaceDaemonClient, repoPath: string, ): Promise { - const workspace = requireWorkspace(await client.openProject(repoPath)); + const workspace = requireWorkspace( + await client.createWorkspace({ + source: { kind: "directory", path: repoPath }, + }), + ); return openedProjectFromWorkspace(workspace); } diff --git a/packages/app/e2e/helpers/rewind-flow.ts b/packages/app/e2e/helpers/rewind-flow.ts index 46aa7c61d..70bb4b2a2 100644 --- a/packages/app/e2e/helpers/rewind-flow.ts +++ b/packages/app/e2e/helpers/rewind-flow.ts @@ -171,25 +171,27 @@ export async function launchAgent(input: { execFileSync("git", ["add", "README.md"], { cwd: input.cwd, stdio: "ignore" }); execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: input.cwd, stdio: "ignore" }); const client = await connectSeedClient(); - const opened = await client.openProject(input.cwd); - if (!opened.workspace) { - throw new Error(opened.error ?? `Failed to open project ${input.cwd}`); + const createdWorkspace = await client.createWorkspace({ + source: { kind: "directory", path: input.cwd }, + }); + if (!createdWorkspace.workspace) { + throw new Error(createdWorkspace.error ?? `Failed to create workspace ${input.cwd}`); } const agent = await client.createAgent({ ...fullAccessConfig(input.provider), cwd: input.cwd, - workspaceId: opened.workspace.id, + workspaceId: createdWorkspace.workspace.id, title: `rewind-flow-${input.provider}-${randomUUID()}`, }); const handle = { page: input.page, client, agentId: agent.id, - workspaceId: opened.workspace.id, + workspaceId: createdWorkspace.workspace.id, cwd: input.cwd, 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; } diff --git a/packages/app/e2e/helpers/seed-client.ts b/packages/app/e2e/helpers/seed-client.ts index c95217f0e..5c91ff228 100644 --- a/packages/app/e2e/helpers/seed-client.ts +++ b/packages/app/e2e/helpers/seed-client.ts @@ -4,6 +4,15 @@ import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; import { connectDaemonClient } from "./daemon-client-loader"; 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 * band (workspaces, agents, terminals) while the UI is exercised through the @@ -13,17 +22,17 @@ import { createTempDirectory, createTempGitRepo } from "./workspace"; export interface SeedDaemonClient { connect(): Promise; close(): Promise; - openProject(cwd: string): Promise<{ - workspace: { - id: string; - name: string; + addProject(cwd: string): Promise<{ + project: { projectId: string; projectDisplayName: string; projectRootPath: string; - workspaceDirectory: string; } | null; error: string | null; }>; + fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{ + entries: SeedWorkspaceDescriptor[]; + }>; createWorkspace(input: { source: | { kind: "directory"; path: string; projectId?: string } @@ -39,7 +48,7 @@ export interface SeedDaemonClient { }; title?: string; }): Promise<{ - workspace: { id: string; name: string } | null; + workspace: SeedWorkspaceDescriptor | null; error: string | null; }>; /** @@ -126,7 +135,7 @@ export interface SeedDaemonClient { agentId: string, ): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>; getLastServerInfoMessage(): { - features?: { worktreeRestore?: boolean } | null; + features?: { projectAdd?: boolean; worktreeRestore?: boolean } | null; } | null; fetchAgentHistory(options?: { page?: { limit: number }; @@ -184,18 +193,20 @@ export async function seedWorkspace(options: { : await createTempGitRepo(options.repoPrefix, options.repo); const client = await connectSeedClient(); try { - const opened = await client.openProject(project.path); - if (!opened.workspace) { - throw new Error(opened.error ?? `Failed to open project ${project.path}`); + const created = await client.createWorkspace({ + source: { kind: "directory", path: project.path }, + }); + if (!created.workspace) { + throw new Error(created.error ?? `Failed to create workspace ${project.path}`); } return { client, repoPath: project.path, - workspaceId: opened.workspace.id, - workspaceName: opened.workspace.name, - workspaceDirectory: opened.workspace.workspaceDirectory, - projectId: opened.workspace.projectId, - projectDisplayName: opened.workspace.projectDisplayName, + workspaceId: created.workspace.id, + workspaceName: created.workspace.name, + workspaceDirectory: created.workspace.workspaceDirectory, + projectId: created.workspace.projectId, + projectDisplayName: created.workspace.projectDisplayName, cleanup: async () => { await client.close().catch(() => undefined); await project.cleanup().catch(() => undefined); diff --git a/packages/app/e2e/helpers/terminal-dsl.ts b/packages/app/e2e/helpers/terminal-dsl.ts index 78ae2ada3..d74946ade 100644 --- a/packages/app/e2e/helpers/terminal-dsl.ts +++ b/packages/app/e2e/helpers/terminal-dsl.ts @@ -43,7 +43,9 @@ export class TerminalE2EHarness { static async create(input: { tempPrefix: string }): Promise { const tempRepo = await createTempGitRepo(input.tempPrefix); 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) { await client.close().catch(() => {}); await tempRepo.cleanup().catch(() => {}); diff --git a/packages/app/e2e/helpers/with-workspace.ts b/packages/app/e2e/helpers/with-workspace.ts index 86ce84ae9..b13cf7a66 100644 --- a/packages/app/e2e/helpers/with-workspace.ts +++ b/packages/app/e2e/helpers/with-workspace.ts @@ -60,14 +60,19 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle { ); worktrees.push({ repoPath: repo.path, worktreePath: workspacePath }); // 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); - if (!opened.workspace) { - throw new Error(opened.error ?? `Failed to open project ${workspacePath}`); + const created = await client.createWorkspace({ + source: { kind: "directory", path: 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 { workspaceId, diff --git a/packages/app/e2e/helpers/workspace-setup.ts b/packages/app/e2e/helpers/workspace-setup.ts index 4ba3177c1..84758d411 100644 --- a/packages/app/e2e/helpers/workspace-setup.ts +++ b/packages/app/e2e/helpers/workspace-setup.ts @@ -11,13 +11,14 @@ import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; type WorkspaceSetupDaemonClient = Pick< InternalDaemonClient, | "close" + | "addProject" | "connect" | "createPaseoWorktree" + | "createWorkspace" | "fetchAgent" | "fetchAgents" | "fetchWorkspaces" | "listTerminals" - | "openProject" | "subscribeRawMessages" >; @@ -36,9 +37,11 @@ export async function openProjectViaDaemon( client: WorkspaceSetupDaemonClient, repoPath: 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) { - throw new Error(result.error ?? `Failed to open project ${repoPath}`); + throw new Error(result.error ?? `Failed to create workspace ${repoPath}`); } return { id: result.workspace.id, @@ -51,7 +54,10 @@ export async function seedProjectForWorkspaceSetup( client: WorkspaceSetupDaemonClient, repoPath: string, ): Promise { - 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 { diff --git a/packages/app/e2e/helpers/workspace-ui.ts b/packages/app/e2e/helpers/workspace-ui.ts index 62bb8af56..b03d50056 100644 --- a/packages/app/e2e/helpers/workspace-ui.ts +++ b/packages/app/e2e/helpers/workspace-ui.ts @@ -8,8 +8,8 @@ export async function openNewAgentComposer(page: Page): Promise { } /** - * Wait for the sidebar to show at least one project row, indicating that the - * WebSocket connection is up and workspace hydration has completed. + * Wait for the sidebar to show at least one project row. Use this after a spec + * seeds a workspace/project; zero-project flows need their own assertion. */ export async function waitForSidebarHydration(page: Page, timeout = 60_000): Promise { await page @@ -18,6 +18,10 @@ export async function waitForSidebarHydration(page: Page, timeout = 60_000): Pro .waitFor({ state: "visible", timeout }); } +export async function waitForNoProjectsInSidebar(page: Page, timeout = 60_000): Promise { + await page.getByTestId("sidebar-project-empty-state").waitFor({ state: "visible", timeout }); +} + function workspaceRowLocator(page: Page, serverId: string, workspaceId: string) { return page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).first(); } diff --git a/packages/app/e2e/import-session.opencode.real.spec.ts b/packages/app/e2e/import-session.opencode.real.spec.ts index ae000c663..9e1e909f5 100644 --- a/packages/app/e2e/import-session.opencode.real.spec.ts +++ b/packages/app/e2e/import-session.opencode.real.spec.ts @@ -58,9 +58,11 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise { await client.close().catch(() => undefined); }, diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 131b81e57..8c433cade 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -2,18 +2,13 @@ import { View, Text, Pressable, - Platform, ActivityIndicator, - StatusBar, ScrollView, type GestureResponderEvent, type PressableStateCallbackType, type ViewStyle, } from "react-native"; -import * as Haptics from "expo-haptics"; -import { useMutation } from "@tanstack/react-query"; import { ProjectIconView } from "@/components/project-icon-view"; -import { AdaptiveRenameModal } from "@/components/rename-modal"; import { memo, useCallback, @@ -35,20 +30,14 @@ import { import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { Theme } from "@/styles/theme"; import { type GestureType } from "react-native-gesture-handler"; -import * as Clipboard from "expo-clipboard"; -import { DiffStat } from "@/components/diff-stat"; import { - Archive, CircleAlert, - CircleCheck, ChevronDown, ChevronRight, - Copy, ExternalLink, GitPullRequest, Settings, MoreVertical, - Pencil, Plus, Trash2, } from "lucide-react-native"; @@ -80,24 +69,16 @@ import { } from "@/components/ui/dropdown-menu"; import { SyncedLoader } from "@/components/synced-loader"; import { useToast } from "@/contexts/toast-context"; -import { useCheckoutGitActionsStore } from "@/git/actions-store"; -import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning"; import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder"; -import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration"; +import { SidebarWorkspaceRow } from "@/components/sidebar/sidebar-workspace-row"; +import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction"; import { confirmDialog } from "@/utils/confirm-dialog"; import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-list"; -import { - SidebarWorkspaceRowFrame, - SidebarWorkspaceRowContent, - SidebarWorkspaceShortcutBadge, - SidebarWorkspaceTrailingActionBase, - SidebarWorkspaceTrailingActionOverlay, - SidebarWorkspaceTrailingActionSlot, -} from "@/components/sidebar/sidebar-workspace-row-content"; +import { SidebarWorkspaceShortcutBadge } from "@/components/sidebar/sidebar-workspace-row-content"; import { useProjectNamesMap, useStatusModeWorkspaceEntries, @@ -105,16 +86,10 @@ import { import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; 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 type { PrHint } from "@/git/use-pr-status-query"; import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model"; -import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; import { openExternalUrl } from "@/utils/open-external-url"; -import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory"; -import { useWorkspaceArchive } from "@/workspace/use-workspace-archive"; import { isWeb as platformIsWeb, isNative as platformIsNative, @@ -126,7 +101,6 @@ const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.wo const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey; -const WORKSPACE_STATUS_DOT_WIDTH = 14; const DEFAULT_STATUS_DOT_SIZE = 7; const EMPHASIZED_STATUS_DOT_SIZE = 9; const DEFAULT_STATUS_DOT_OFFSET = 0; @@ -135,15 +109,11 @@ const ThemedExternalLink = withUnistyles(ExternalLink); const ThemedGitPullRequest = withUnistyles(GitPullRequest); const ThemedActivityIndicator = withUnistyles(ActivityIndicator); const ThemedCircleAlert = withUnistyles(CircleAlert); -const ThemedCircleCheck = withUnistyles(CircleCheck); const ThemedSyncedLoader = withUnistyles(SyncedLoader); const ThemedPlus = withUnistyles(Plus); const ThemedMoreVertical = withUnistyles(MoreVertical); const ThemedTrash2 = withUnistyles(Trash2); const ThemedSettings = withUnistyles(Settings); -const ThemedCopy = withUnistyles(Copy); -const ThemedArchive = withUnistyles(Archive); -const ThemedPencil = withUnistyles(Pencil); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground, @@ -209,15 +179,26 @@ function isProjectSelectedByRoute(input: { ); } -function activeWorkspaceSelectionKey(selection: ActiveWorkspaceSelection | null): string { - return selection ? `${selection.serverId}:${selection.workspaceId}` : ""; +function navigateToNewWorkspaceForProject(input: { + serverId: string | null; + project: SidebarProjectEntry; + displayName: string; + onWorkspacePress?: () => void; +}) { + if (!input.serverId) { + return; + } + input.onWorkspacePress?.(); + router.navigate( + buildHostNewWorkspaceRoute(input.serverId, input.project.iconWorkingDir, { + displayName: input.displayName, + projectId: input.project.projectKey, + }) as Href, + ); } -function selectionForSelectedWorkspace( - selected: boolean, - workspace: SidebarWorkspaceEntry, -): ActiveWorkspaceSelection | null { - return selected ? { serverId: workspace.serverId, workspaceId: workspace.workspaceId } : null; +function activeWorkspaceSelectionKey(selection: ActiveWorkspaceSelection | null): string { + return selection ? `${selection.serverId}:${selection.workspaceId}` : ""; } interface SidebarWorkspaceListProps { @@ -260,39 +241,6 @@ interface ProjectHeaderRowProps { dragHandleProps?: DraggableListDragHandleProps; } -interface WorkspaceRowInnerProps { - workspace: SidebarWorkspaceEntry; - selected: boolean; - shortcutNumber: number | null; - showShortcutBadge: boolean; - onPress: () => void; - drag: () => void; - isDragging: boolean; - isArchiving: boolean; - isCreating?: boolean; - dragHandleProps?: DraggableListDragHandleProps; - menuController: ReturnType | null; - archiveLabel?: string; - archiveStatus?: "idle" | "pending" | "success"; - archivePendingLabel?: string; - onArchive?: () => void; - onCopyBranchName?: () => void; - onCopyPath?: () => void; - onRename?: () => void; - onMarkAsRead?: () => void; - archiveShortcutKeys?: ShortcutKey[][] | null; -} - -function getWorkspaceArchiveStatus( - isWorktree: boolean, - archiveStatus: "idle" | "pending" | "success", - isArchivingWorkspace: boolean, -): "idle" | "pending" | "success" { - if (isWorktree) return archiveStatus; - if (isArchivingWorkspace) return "pending"; - return "idle"; -} - export function PrBadge({ hint }: { hint: PrHint }) { const { t } = useTranslation(); const [isHovered, setIsHovered] = useState(false); @@ -350,29 +298,6 @@ function projectKebabStyle({ return [styles.projectKebabButton, hovered && styles.projectKebabButtonHovered]; } -function workspaceKebabStyle({ - hovered = false, -}: PressableStateCallbackType & { hovered?: boolean }) { - return [styles.kebabButton, hovered && styles.kebabButtonHovered]; -} - -function getProjectWorkspaceRowStyle({ - isDragging, - selected, - isHovered, -}: { - isDragging: boolean; - selected: boolean; - isHovered: boolean; -}) { - return [ - styles.workspaceRow, - isDragging && styles.workspaceRowDragging, - selected && styles.sidebarRowSelected, - isHovered && styles.workspaceRowHovered, - ]; -} - function noop() {} const prBadgeStyles = StyleSheet.create((theme) => ({ @@ -532,12 +457,6 @@ function ProjectRowTrailingActions({ const trash2LeadingIcon = ; const settingsLeadingIcon = ; -const copyLeadingIcon = ; -const markAsReadLeadingIcon = ( - -); -const archiveLeadingIcon = ; -const renameLeadingIcon = ; const openInNewWindowLeadingIcon = ( ); @@ -627,175 +546,6 @@ function ProjectKebabMenu({ ); } -function WorkspaceRowRightGroup({ - 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 ? ( - {t("sidebar.workspace.status.creating")} - ) : null} - {shouldRenderActionSlot ? ( - - - {workspace.diffStat ? ( - - ) : null} - - - {onArchive ? ( - - ) : null} - - - ) : 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 ? : null), - [archiveShortcutKeys], - ); - return ( - - - {renderKebabTriggerIcon} - - - {onCopyPath ? ( - - {t("sidebar.workspace.actions.copyPath")} - - ) : null} - {onCopyBranchName ? ( - - {t("sidebar.workspace.actions.copyBranchName")} - - ) : null} - {onRename ? ( - - {t("sidebar.workspace.actions.rename")} - - ) : null} - {onMarkAsRead ? ( - - Mark as read - - ) : null} - - {archiveLabel ?? t("sidebar.workspace.actions.archive")} - - - - ); -} - function ProjectIcon({ iconDataUri, placeholderInitial, @@ -971,223 +721,6 @@ function NewWorktreeButton({ ); } -function useLongPressDragInteraction(input: { - drag: () => void; - menuController: ReturnType | 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 | null>(null); - const contextMenuTimerRef = useRef | 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, - }; -} - function ProjectHeaderRow({ project, displayName, @@ -1214,17 +747,8 @@ function ProjectHeaderRow({ const [isHovered, setIsHovered] = useState(false); const isMobileBreakpoint = useIsCompactFormFactor(); const handleBeginWorkspaceSetup = useCallback(() => { - if (!serverId) { - return; - } - onWorkspacePress?.(); - router.navigate( - buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, { - displayName, - projectId: project.projectKey, - }) as Href, - ); - }, [displayName, onWorkspacePress, project.iconWorkingDir, project.projectKey, serverId]); + navigateToNewWorkspaceForProject({ serverId, project, displayName, onWorkspacePress }); + }, [displayName, onWorkspacePress, project, serverId]); const interaction = useLongPressDragInteraction({ drag, menuController, @@ -1344,304 +868,6 @@ function ProjectHeaderRow({ ); } -function WorkspaceRowInner({ - workspace, - selected, - shortcutNumber, - showShortcutBadge, - onPress, - drag, - isDragging, - isArchiving, - isCreating = false, - dragHandleProps, - menuController, - archiveLabel, - archiveStatus = "idle", - archivePendingLabel, - onArchive, - onCopyBranchName, - onCopyPath, - onRename, - archiveShortcutKeys, -}: WorkspaceRowInnerProps) { - const _isCompact = useIsCompactFormFactor(); - const isTouchPlatform = platformIsNative; - const interaction = useLongPressDragInteraction({ - drag, - menuController, - }); - 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 ( - - {({ 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 = getProjectWorkspaceRowStyle({ - isDragging, - selected, - isHovered, - }); - return ( - } - style={styles.workspaceRowContainer} - {...hoverHandlers} - > - - - - - - - ); - }} - - ); -} - -function WorkspaceRowWithMenu({ - workspace, - selected, - shortcutNumber, - showShortcutBadge, - onPress, - drag, - isDragging, - dragHandleProps, - canCopyBranchName, - isCreating = false, -}: { - workspace: SidebarWorkspaceEntry; - selected: boolean; - shortcutNumber: number | null; - showShortcutBadge: boolean; - onPress: () => void; - drag: () => void; - isDragging: boolean; - dragHandleProps?: DraggableListDragHandleProps; - canCopyBranchName: boolean; - isCreating?: boolean; -}) { - 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: selectionForSelectedWorkspace(selected, workspace), - }); - }, [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; - }, - }); - - return ( - <> - - - - ); -} - interface WorkspaceRowItemProps { workspace: SidebarWorkspaceEntry; shortcutNumber: number | null; @@ -1763,7 +989,7 @@ function WorkspaceRow({ } return ( - void; +}) { + const { t } = useTranslation(); + const handlePress = useCallback(() => { + navigateToNewWorkspaceForProject({ serverId, project, displayName, onWorkspacePress }); + }, [displayName, onWorkspacePress, project, serverId]); + const rowStyle = useCallback( + ({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.newWorkspaceGhostRow, + (Boolean(hovered) || pressed) && styles.newWorkspaceGhostRowHovered, + ], + [], + ); + + return ( + + {({ hovered, pressed }) => ( + <> + + + + + {t("sidebar.workspace.actions.newWorkspace")} + + + )} + + ); +} + function ProjectBlock({ project, collapsed, @@ -1947,6 +1230,36 @@ function ProjectBlock({ onToggleCollapsed(project.projectKey); }, [onToggleCollapsed, project.projectKey]); + let projectChildren = null; + if (!collapsed) { + if (project.workspaces.length > 0) { + projectChildren = ( + + ); + } else { + projectChildren = ( + + ); + } + } + return ( - {!collapsed && project.workspaces.length > 0 ? ( - - ) : null} + {projectChildren} ); } @@ -2351,7 +1650,7 @@ function ProjectModeList({ const content = ( <> {projects.length === 0 ? ( - + {t("sidebar.project.empty.title")} {t("sidebar.project.empty.description")}