diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/helpers/new-workspace.ts index 9b52b27cf..24fce38ea 100644 --- a/packages/app/e2e/helpers/new-workspace.ts +++ b/packages/app/e2e/helpers/new-workspace.ts @@ -124,14 +124,30 @@ export async function openNewWorkspaceComposer( }); } -export async function clickNewWorkspaceButton( +export async function openGlobalNewWorkspaceComposer(page: Page): Promise { + await page.getByTestId("sidebar-new-workspace").click(); + + await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, { + timeout: 30_000, + }); +} + +export async function expectNewWorkspaceProjectSelected( page: Page, - input: { projectKey: string; projectDisplayName: string; prompt?: string }, + projectDisplayName: string, +): Promise { + const projectPicker = page.getByRole("button", { name: "Workspace project" }); + await expect(projectPicker).toBeVisible({ timeout: 30_000 }); + await expect(projectPicker).toContainText(projectDisplayName); +} + +export async function submitNewWorkspacePrompt( + page: Page, + prompt = "Hello from e2e", ): Promise { - await openNewWorkspaceComposer(page, input); const composer = page.getByRole("textbox", { name: "Message agent..." }); await expect(composer).toBeVisible({ timeout: 30_000 }); - await composer.fill(input.prompt ?? "Hello from e2e"); + await composer.fill(prompt); const createButton = page .getByTestId("message-input-root") .getByRole("button", { name: "Create" }); @@ -139,6 +155,14 @@ export async function clickNewWorkspaceButton( await createButton.click(); } +export async function clickNewWorkspaceButton( + page: Page, + input: { projectKey: string; projectDisplayName: string; prompt?: string }, +): Promise { + await openNewWorkspaceComposer(page, input); + await submitNewWorkspacePrompt(page, input.prompt); +} + export async function openStartingRefPicker(page: Page): Promise { const trigger = page.getByTestId("new-workspace-ref-picker-trigger"); await expect(trigger).toBeVisible({ timeout: 30_000 }); @@ -177,7 +201,9 @@ export async function selectPickerOptionByKeyboard(page: Page, label: string): P const searchInput = page.getByPlaceholder("Search branches and PRs"); await expect(searchInput).toBeVisible({ timeout: 30_000 }); await page.keyboard.type(label); - await page.keyboard.press("ArrowDown"); + await expect(page.getByTestId(`new-workspace-ref-picker-branch-${label}`)).toBeVisible({ + timeout: 10_000, + }); await page.keyboard.press("Enter"); } diff --git a/packages/app/e2e/new-workspace.spec.ts b/packages/app/e2e/new-workspace.spec.ts index ac2247c6f..f5388a3be 100644 --- a/packages/app/e2e/new-workspace.spec.ts +++ b/packages/app/e2e/new-workspace.spec.ts @@ -7,16 +7,17 @@ import { archiveWorkspaceFromDaemon, archiveLocalWorkspaceFromDaemon, assertNewWorkspaceSidebarAndHeader, - clickNewWorkspaceButton, closeBranchPicker, connectNewWorkspaceDaemonClient, createWorktreeViaDaemon, delayBrowserAgentCreatedStatus, expectComposerGithubAttachmentPill, + expectNewWorkspaceProjectSelected, expectPickerClosed, expectPickerOpen, expectPickerSelected, expectStartingRefPickerTriggerPr, + openGlobalNewWorkspaceComposer, openBranchPicker, openNewWorkspaceComposer, openProjectViaDaemon, @@ -24,6 +25,7 @@ import { selectBranchInPicker, selectGitHubPrInPicker, selectPickerOptionByKeyboard, + submitNewWorkspacePrompt, } from "./helpers/new-workspace"; import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace"; import { getServerId } from "./helpers/server-id"; @@ -193,7 +195,7 @@ test.describe("New workspace flow", () => { } }); - test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one agent tab", async ({ + test("global new workspace uses the last active project and creates one agent tab", async ({ page, }) => { const serverId = getServerId(); @@ -217,10 +219,9 @@ test.describe("New workspace flow", () => { subtitle: openedProject.projectDisplayName, }); - await clickNewWorkspaceButton(page, { - projectKey: openedProject.projectKey, - projectDisplayName: openedProject.projectDisplayName, - }); + await openGlobalNewWorkspaceComposer(page); + await expectNewWorkspaceProjectSelected(page, openedProject.projectDisplayName); + await submitNewWorkspacePrompt(page); const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, { serverId, @@ -400,7 +401,7 @@ test.describe("New workspace flow", () => { } }); - test("branch picker opens via keyboard, navigates options, and selects on Enter", async ({ + test("branch picker opens via keyboard and selects the filtered option on Enter", async ({ page, }) => { const tempRepo = await createTempGitRepo("picker-keyboard-", { branches: ["main", "dev"] }); diff --git a/packages/app/src/app/h/[serverId]/new.tsx b/packages/app/src/app/h/[serverId]/new.tsx index 901fad6f7..e88504b11 100644 --- a/packages/app/src/app/h/[serverId]/new.tsx +++ b/packages/app/src/app/h/[serverId]/new.tsx @@ -9,14 +9,10 @@ export default function HostNewWorkspaceRoute() { projectId?: string; }>(); const serverId = typeof params.serverId === "string" ? params.serverId : ""; - const sourceDirectory = typeof params.dir === "string" ? params.dir : ""; + const sourceDirectory = typeof params.dir === "string" ? params.dir : undefined; const displayName = typeof params.name === "string" ? params.name : undefined; const projectId = typeof params.projectId === "string" ? params.projectId : undefined; - if (!sourceDirectory) { - return null; - } - return ( state.setCommandCenterOpen); const commandCenterKeys = useShortcutKeys("toggle-command-center"); const handleSearchPress = useCallback(() => setCommandCenterOpen(true), [setCommandCenterOpen]); + const handleNewWorkspacePress = useCallback(() => { + if (!serverId) { + return; + } + router.push(buildHostNewWorkspaceRoute(serverId)); + }, [serverId]); const searchButtonStyle = useCallback( ({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.workspacesHeaderIconButton, @@ -974,6 +981,29 @@ function WorkspacesSectionHeader({ serverId }: { serverId: string | null }) { Workspaces + + + + {({ hovered, pressed }) => ( + + )} + + + + + + (null); - if (stableRef.current?.signature !== signature) { - stableRef.current = { signature, data }; - } - return stableRef.current.data; -} - function isWorkspaceSelected(input: { selection: ActiveWorkspaceSelection | null; serverId: string | null; @@ -2463,74 +2452,8 @@ function ProjectModeList({ : undefined, [parentGestureRef], ); - const projectIconRequests = useMemo(() => { - if (!serverId) { - return []; - } - const unique = new Map(); - for (const project of projects) { - const cwd = project.iconWorkingDir.trim(); - if (!cwd) { - continue; - } - unique.set(`${serverId}:${cwd}`, { serverId, cwd }); - } - return Array.from(unique.values()); - }, [projects, serverId]); - const projectIconQueries = useQueries({ - queries: projectIconRequests.map((request) => ({ - queryKey: projectIconQueryKey(request.serverId, request.cwd), - queryFn: async () => { - const client = getHostRuntimeStore().getClient(request.serverId); - if (!client) { - return null; - } - const result = await client.requestProjectIcon(request.cwd); - return result.icon; - }, - select: projectIconToDataUri, - enabled: Boolean( - getHostRuntimeStore().getClient(request.serverId) && - isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)) && - request.cwd, - ), - staleTime: Infinity, - gcTime: 1000 * 60 * 60, - refetchOnMount: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - })), - }); - - const projectIconSignature = projectIconQueries.map((query) => query.data ?? "").join("\u0000"); - const projectIconData = useStableProjectIconData( - projectIconQueries.map((query) => query.data ?? null), - projectIconSignature, - ); - - const projectIconByProjectKey = useMemo(() => { - const iconByServerAndCwd = new Map(); - for (let index = 0; index < projectIconRequests.length; index += 1) { - const request = projectIconRequests[index]; - if (!request) { - continue; - } - iconByServerAndCwd.set(`${request.serverId}:${request.cwd}`, projectIconData[index] ?? null); - } - - const byProject = new Map(); - for (const project of projects) { - const cwd = project.iconWorkingDir.trim(); - if (!cwd || !serverId) { - byProject.set(project.projectKey, null); - continue; - } - byProject.set(project.projectKey, iconByServerAndCwd.get(`${serverId}:${cwd}`) ?? null); - } - - return byProject; - }, [projectIconData, projectIconRequests, projects, serverId]); + const projectIconByProjectKey = useProjectIconDataByProjectKey({ serverId, projects }); useEffect(() => { const timeouts = creatingWorkspaceTimeoutsRef.current; diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts index 1963e0dd4..5532c9b6b 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { WorkspaceStructureProject } from "@/stores/session-store-hooks"; +import type { WorkspaceStructureProject } from "@/projects/workspace-structure"; import { appendMissingOrderKeys, applyStoredOrdering, diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index 6fe179c4a..3bac1196d 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -1,6 +1,10 @@ import type { PrHint } from "@/git/use-pr-status-query"; +import { + canCreateWorktreeForProjectKind, + type HostProjectListItem, +} from "@/projects/host-project-model"; import type { WorkspaceDescriptor } from "@/stores/session-store"; -import type { WorkspaceStructureProject } from "@/stores/session-store-hooks"; +import type { WorkspaceStructureProject } from "@/projects/workspace-structure"; const EMPTY_PROJECTS: SidebarProjectEntry[] = []; @@ -32,12 +36,13 @@ export interface SidebarProjectEntry { projectName: string; projectKind: WorkspaceDescriptor["projectKind"]; iconWorkingDir: string; + canCreateWorktree: boolean; workspaces: SidebarWorkspaceEntry[]; } function createStructuralWorkspaceEntry(input: { serverId: string; - project: WorkspaceStructureProject; + project: HostProjectListItem; workspaceId: string; }): SidebarWorkspaceEntry { return { @@ -65,6 +70,22 @@ function createStructuralWorkspaceEntry(input: { export function buildSidebarProjectsFromStructure(input: { serverId: string; projects: WorkspaceStructureProject[]; +}): SidebarProjectEntry[] { + return buildSidebarProjectsFromHostProjects({ + projects: input.projects.map((project) => ({ + serverId: input.serverId, + projectKey: project.projectKey, + projectName: project.projectName, + projectKind: project.projectKind, + iconWorkingDir: project.iconWorkingDir, + workspaceKeys: project.workspaceKeys, + canCreateWorktree: canCreateWorktreeForProjectKind(project.projectKind), + })), + }); +} + +export function buildSidebarProjectsFromHostProjects(input: { + projects: readonly HostProjectListItem[]; }): SidebarProjectEntry[] { if (input.projects.length === 0) { return EMPTY_PROJECTS; @@ -75,9 +96,10 @@ export function buildSidebarProjectsFromStructure(input: { projectName: project.projectName, projectKind: project.projectKind, iconWorkingDir: project.iconWorkingDir, + canCreateWorktree: project.canCreateWorktree, workspaces: project.workspaceKeys.map((workspaceId) => createStructuralWorkspaceEntry({ - serverId: input.serverId, + serverId: project.serverId, project, workspaceId, }), diff --git a/packages/app/src/hooks/use-projects.test.ts b/packages/app/src/hooks/use-projects.test.ts index 658baad03..e6bc3de44 100644 --- a/packages/app/src/hooks/use-projects.test.ts +++ b/packages/app/src/hooks/use-projects.test.ts @@ -6,7 +6,7 @@ import { type ProjectsHostInput, type ProjectsRuntime, type ProjectsRuntimeSnapshot, -} from "./use-projects"; +} from "@/projects/aggregated-projects"; type FetchWorkspaces = DaemonClient["fetchWorkspaces"]; type FetchWorkspacesResult = Awaited>; diff --git a/packages/app/src/hooks/use-projects.ts b/packages/app/src/hooks/use-projects.ts index e131f0927..7c25d7bfe 100644 --- a/packages/app/src/hooks/use-projects.ts +++ b/packages/app/src/hooks/use-projects.ts @@ -1,18 +1,21 @@ import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime"; -import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store"; -import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects"; +import type { ProjectSummary } from "@/utils/projects"; +import { + fetchAggregatedProjects, + type ProjectHostError, + type ProjectsHostInput, +} from "@/projects/aggregated-projects"; + +export type { + ProjectHostError, + ProjectsHostInput, + ProjectsRuntime, +} from "@/projects/aggregated-projects"; export const projectsQueryKey = ["projects"] as const; -export interface ProjectHostError { - serverId: string; - serverName: string; - message: string; -} - export interface UseProjectsResult { projects: ProjectSummary[]; hostErrors: ProjectHostError[]; @@ -21,116 +24,6 @@ export interface UseProjectsResult { refetch: () => void; } -export interface ProjectsRuntimeSnapshot { - connectionStatus: string; -} - -export interface ProjectsRuntime { - getClient(serverId: string): Pick | null; - getSnapshot(serverId: string): ProjectsRuntimeSnapshot | null | undefined; -} - -export interface ProjectsHostInput { - serverId: string; - serverName: string; -} - -export interface FetchAggregatedProjectsInput { - hosts: ProjectsHostInput[]; - runtime: ProjectsRuntime; -} - -export interface FetchAggregatedProjectsResult { - projects: ProjectSummary[]; - hostErrors: ProjectHostError[]; -} - -interface HostWorkspacesResult { - host: ProjectHost; - error: ProjectHostError | null; -} - -function toErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -async function fetchAllWorkspaceDescriptors( - client: Pick, -): Promise { - const entries: WorkspaceDescriptor[] = []; - let cursor: string | null = null; - - while (true) { - const payload = await client.fetchWorkspaces({ - sort: [{ key: "name", direction: "asc" }], - page: cursor ? { limit: 200, cursor } : { limit: 200 }, - }); - entries.push(...payload.entries.map((entry) => normalizeWorkspaceDescriptor(entry))); - if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) { - break; - } - cursor = payload.pageInfo.nextCursor; - } - - return entries; -} - -export async function fetchAggregatedProjects( - input: FetchAggregatedProjectsInput, -): Promise { - const results = await Promise.all( - input.hosts.map(async (host): Promise => { - const snapshot = input.runtime.getSnapshot(host.serverId); - const isOnline = snapshot?.connectionStatus === "online"; - const client = input.runtime.getClient(host.serverId); - - if (!client || !isOnline) { - return { - host: { - serverId: host.serverId, - serverName: host.serverName, - isOnline, - workspaces: [], - }, - error: null, - }; - } - - try { - return { - host: { - serverId: host.serverId, - serverName: host.serverName, - isOnline, - workspaces: await fetchAllWorkspaceDescriptors(client), - }, - error: null, - }; - } catch (error) { - return { - host: { - serverId: host.serverId, - serverName: host.serverName, - isOnline, - workspaces: [], - }, - error: { - serverId: host.serverId, - serverName: host.serverName, - message: toErrorMessage(error), - }, - }; - } - }), - ); - - const hostErrors = results.flatMap((result) => (result.error ? [result.error] : [])); - return { - ...buildProjects({ hosts: results.map((result) => result.host) }), - hostErrors, - }; -} - export function useProjects(): UseProjectsResult { const hosts = useHosts(); const runtime = getHostRuntimeStore(); diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index 0b469f380..43b1274fb 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -1,17 +1,13 @@ import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; -import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages"; -import { - normalizeWorkspaceDescriptor, - useSessionStore, - type WorkspaceDescriptor, -} from "@/stores/session-store"; +import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { selectPrHintFromStatus } from "@/git/use-pr-status-query"; -import { useWorkspaceStructure } from "@/stores/session-store-hooks"; +import { useHostProjects } from "@/projects/host-projects"; +import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-workspace-upserts"; import { - buildSidebarProjectsFromStructure, + buildSidebarProjectsFromHostProjects, computeSidebarOrderUpdates, deriveSidebarLoadingState, type SidebarProjectEntry, @@ -21,6 +17,7 @@ import { export { appendMissingOrderKeys, applyStoredOrdering, + buildSidebarProjectsFromHostProjects, buildSidebarProjectsFromStructure, computeSidebarOrderUpdates, deriveSidebarLoadingState, @@ -68,10 +65,6 @@ export interface SidebarWorkspacesListResult { refreshAll: () => void; } -function toWorkspaceDescriptor(payload: WorkspaceDescriptorPayload): WorkspaceDescriptor { - return normalizeWorkspaceDescriptor(payload); -} - export function useSidebarWorkspacesList(options?: { serverId?: string | null; enabled?: boolean; @@ -89,7 +82,7 @@ export function useSidebarWorkspacesList(options?: { const hasHydratedWorkspaces = useSessionStore((state) => isActive && serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false, ); - const workspaceStructure = useWorkspaceStructure(isActive ? serverId : null); + const hostProjects = useHostProjects(isActive ? serverId : null); const connectionStatus = useSyncExternalStore( (onStoreChange) => @@ -111,14 +104,13 @@ export function useSidebarWorkspacesList(options?: { ); const projects = useMemo(() => { - if (!serverId || workspaceStructure.projects.length === 0) { + if (!serverId || hostProjects.length === 0) { return EMPTY_PROJECTS; } - return buildSidebarProjectsFromStructure({ - serverId, - projects: workspaceStructure.projects, + return buildSidebarProjectsFromHostProjects({ + projects: hostProjects, }); - }, [serverId, workspaceStructure]); + }, [hostProjects, serverId]); useEffect(() => { if (!serverId) { @@ -156,24 +148,16 @@ export function useSidebarWorkspacesList(options?: { } void (async () => { const next = new Map(); - let cursor: string | null = null; try { - while (true) { - const payload = await client.fetchWorkspaces({ - sort: [{ key: "activity_at", direction: "desc" }], - page: cursor ? { limit: 200, cursor } : { limit: 200 }, - }); - for (const entry of payload.entries) { - const workspace = toWorkspaceDescriptor(entry); - if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) { - continue; - } - next.set(workspace.id, workspace); + const workspaces = await fetchAllWorkspaceDescriptors({ + client, + sort: [{ key: "activity_at", direction: "desc" }], + }); + for (const workspace of workspaces) { + if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) { + continue; } - if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) { - break; - } - cursor = payload.pageInfo.nextCursor; + next.set(workspace.id, workspace); } const store = useSessionStore.getState(); store.setWorkspaces(serverId, next); @@ -181,7 +165,6 @@ export function useSidebarWorkspacesList(options?: { } catch (error) { console.error("[WorkspaceFetch][sidebar-refresh] failed", { serverId, - cursor, error, }); // ignore explicit refresh failures; hook keeps existing data diff --git a/packages/app/src/projects/aggregated-projects.ts b/packages/app/src/projects/aggregated-projects.ts new file mode 100644 index 000000000..32ef18900 --- /dev/null +++ b/packages/app/src/projects/aggregated-projects.ts @@ -0,0 +1,103 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching"; +import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects"; + +export interface ProjectHostError { + serverId: string; + serverName: string; + message: string; +} + +export interface ProjectsRuntimeSnapshot { + connectionStatus: string; +} + +export interface ProjectsRuntime { + getClient(serverId: string): Pick | null; + getSnapshot(serverId: string): ProjectsRuntimeSnapshot | null | undefined; +} + +export interface ProjectsHostInput { + serverId: string; + serverName: string; +} + +export interface FetchAggregatedProjectsInput { + hosts: ProjectsHostInput[]; + runtime: ProjectsRuntime; +} + +export interface FetchAggregatedProjectsResult { + projects: ProjectSummary[]; + hostErrors: ProjectHostError[]; +} + +interface HostWorkspacesResult { + host: ProjectHost; + error: ProjectHostError | null; +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export async function fetchAggregatedProjects( + input: FetchAggregatedProjectsInput, +): Promise { + const results = await Promise.all( + input.hosts.map(async (host): Promise => { + const snapshot = input.runtime.getSnapshot(host.serverId); + const isOnline = snapshot?.connectionStatus === "online"; + const client = input.runtime.getClient(host.serverId); + + if (!client || !isOnline) { + return { + host: { + serverId: host.serverId, + serverName: host.serverName, + isOnline, + workspaces: [], + }, + error: null, + }; + } + + try { + const workspaces: WorkspaceDescriptor[] = await fetchAllWorkspaceDescriptors({ + client, + sort: [{ key: "name", direction: "asc" }], + }); + return { + host: { + serverId: host.serverId, + serverName: host.serverName, + isOnline, + workspaces, + }, + error: null, + }; + } catch (error) { + return { + host: { + serverId: host.serverId, + serverName: host.serverName, + isOnline, + workspaces: [], + }, + error: { + serverId: host.serverId, + serverName: host.serverName, + message: toErrorMessage(error), + }, + }; + } + }), + ); + + const hostErrors = results.flatMap((result) => (result.error ? [result.error] : [])); + return { + ...buildProjects({ hosts: results.map((result) => result.host) }), + hostErrors, + }; +} diff --git a/packages/app/src/projects/host-project-model.ts b/packages/app/src/projects/host-project-model.ts new file mode 100644 index 000000000..6b79d2b99 --- /dev/null +++ b/packages/app/src/projects/host-project-model.ts @@ -0,0 +1,117 @@ +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import type { WorkspaceStructureProject } from "@/projects/workspace-structure"; + +export interface HostProjectListItem { + serverId: string; + projectKey: string; + projectName: string; + projectKind: WorkspaceDescriptor["projectKind"]; + iconWorkingDir: string; + workspaceKeys: string[]; + canCreateWorktree: boolean; +} + +export interface HostProjectRouteContext { + serverId: string; + projectId?: string; + displayName?: string; + sourceDirectory?: string; +} + +function trimOptional(value: string | undefined): string | undefined { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : undefined; +} + +export function canCreateWorktreeForProjectKind( + projectKind: WorkspaceDescriptor["projectKind"], +): boolean { + return projectKind === "git"; +} + +export function buildHostProjectList(input: { + serverId: string; + projects: readonly WorkspaceStructureProject[]; +}): HostProjectListItem[] { + return input.projects.map((project) => ({ + serverId: input.serverId, + projectKey: project.projectKey, + projectName: project.projectName, + projectKind: project.projectKind, + iconWorkingDir: project.iconWorkingDir, + workspaceKeys: project.workspaceKeys, + canCreateWorktree: canCreateWorktreeForProjectKind(project.projectKind), + })); +} + +export function hostProjectFromRoute(route: HostProjectRouteContext): HostProjectListItem | null { + const projectKey = trimOptional(route.projectId); + const iconWorkingDir = trimOptional(route.sourceDirectory); + if (!projectKey || !iconWorkingDir) { + return null; + } + return { + serverId: route.serverId, + projectKey, + projectName: trimOptional(route.displayName) ?? projectKey, + projectKind: "git", + iconWorkingDir, + workspaceKeys: [], + canCreateWorktree: true, + }; +} + +export function hostProjectFromWorkspace(input: { + serverId: string; + workspace: WorkspaceDescriptor | null; +}): HostProjectListItem | null { + if (!input.workspace) { + return null; + } + const projectKey = input.workspace.projectId.trim(); + const iconWorkingDir = input.workspace.projectRootPath.trim(); + if (!projectKey || !iconWorkingDir) { + return null; + } + return { + serverId: input.serverId, + projectKey, + projectName: input.workspace.projectDisplayName || projectKey, + projectKind: input.workspace.projectKind, + iconWorkingDir, + workspaceKeys: [input.workspace.id], + canCreateWorktree: canCreateWorktreeForProjectKind(input.workspace.projectKind), + }; +} + +export function resolveInitialWorktreeProject(input: { + routeProject: HostProjectListItem | null; + lastActiveProject: HostProjectListItem | null; + projects: readonly HostProjectListItem[]; +}): HostProjectListItem | null { + if (input.routeProject?.canCreateWorktree) { + return input.routeProject; + } + if (input.lastActiveProject?.canCreateWorktree) { + return input.lastActiveProject; + } + return input.projects.find((project) => project.canCreateWorktree) ?? null; +} + +export function resolveSelectedHostProject(input: { + selectedProjectKey: string | null; + projects: readonly HostProjectListItem[]; + routeProject: HostProjectListItem | null; + lastActiveProject: HostProjectListItem | null; +}): HostProjectListItem | null { + const selectedProjectKey = input.selectedProjectKey?.trim() ?? ""; + if (!selectedProjectKey) { + return null; + } + + return ( + input.projects.find((project) => project.projectKey === selectedProjectKey) ?? + (input.routeProject?.projectKey === selectedProjectKey ? input.routeProject : null) ?? + (input.lastActiveProject?.projectKey === selectedProjectKey ? input.lastActiveProject : null) + ); +} diff --git a/packages/app/src/projects/host-projects.test.ts b/packages/app/src/projects/host-projects.test.ts new file mode 100644 index 000000000..0281ebea2 --- /dev/null +++ b/packages/app/src/projects/host-projects.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from "vitest"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import type { WorkspaceStructureProject } from "@/projects/workspace-structure"; +import { + buildHostProjectList, + canCreateWorktreeForProjectKind, + hostProjectFromRoute, + hostProjectFromWorkspace, + resolveInitialWorktreeProject, + resolveSelectedHostProject, + type HostProjectListItem, +} from "./host-project-model"; + +function structureProject(input: Partial): WorkspaceStructureProject { + return { + projectKey: input.projectKey ?? "project-a", + projectName: input.projectName ?? "Project A", + projectKind: input.projectKind ?? "git", + iconWorkingDir: input.iconWorkingDir ?? "/repo/a", + workspaceKeys: input.workspaceKeys ?? ["workspace-a"], + }; +} + +function hostProject(input: Partial): HostProjectListItem { + return { + serverId: input.serverId ?? "host-a", + projectKey: input.projectKey ?? "project-a", + projectName: input.projectName ?? "Project A", + projectKind: input.projectKind ?? "git", + iconWorkingDir: input.iconWorkingDir ?? "/repo/a", + workspaceKeys: input.workspaceKeys ?? ["workspace-a"], + canCreateWorktree: input.canCreateWorktree ?? true, + }; +} + +function workspace(input: Partial): WorkspaceDescriptor { + return { + id: input.id ?? "workspace-a", + projectId: input.projectId ?? "project-a", + projectDisplayName: input.projectDisplayName ?? "Project A", + projectRootPath: input.projectRootPath ?? "/repo/a", + workspaceDirectory: input.workspaceDirectory ?? "/repo/a", + projectKind: input.projectKind ?? "git", + workspaceKind: input.workspaceKind ?? "local_checkout", + name: input.name ?? "main", + status: input.status ?? "done", + statusEnteredAt: input.statusEnteredAt ?? null, + archivingAt: input.archivingAt ?? null, + diffStat: input.diffStat ?? null, + scripts: input.scripts ?? [], + }; +} + +const routeProject = hostProject({ + projectKey: "route-project", + projectName: "Route Project", + iconWorkingDir: "/repo/route", +}); +const lastActiveProject = hostProject({ + projectKey: "last-project", + projectName: "Last Project", + iconWorkingDir: "/repo/last", +}); +const firstProject = hostProject({ + projectKey: "first-project", + projectName: "First Project", + iconWorkingDir: "/repo/first", +}); + +describe("host project list", () => { + it("preserves workspace-structure order and project metadata", () => { + expect( + buildHostProjectList({ + serverId: "host-a", + projects: [ + structureProject({ + projectKey: "project-b", + projectName: "Project B", + projectKind: "directory", + iconWorkingDir: "/repo/b", + workspaceKeys: ["workspace-b"], + }), + structureProject({ + projectKey: "project-a", + projectName: "Project A", + projectKind: "git", + iconWorkingDir: "/repo/a", + workspaceKeys: ["workspace-a"], + }), + ], + }), + ).toEqual([ + { + serverId: "host-a", + projectKey: "project-b", + projectName: "Project B", + projectKind: "directory", + iconWorkingDir: "/repo/b", + workspaceKeys: ["workspace-b"], + canCreateWorktree: false, + }, + { + serverId: "host-a", + projectKey: "project-a", + projectName: "Project A", + projectKind: "git", + iconWorkingDir: "/repo/a", + workspaceKeys: ["workspace-a"], + canCreateWorktree: true, + }, + ]); + }); + + it("keeps worktree capability separate from project listability", () => { + expect(canCreateWorktreeForProjectKind("git")).toBe(true); + expect(canCreateWorktreeForProjectKind("directory")).toBe(false); + }); + + it("uses route project before last active project when it can create worktrees", () => { + expect( + resolveInitialWorktreeProject({ + routeProject, + lastActiveProject, + projects: [firstProject], + }), + ).toEqual(routeProject); + }); + + it("skips non-worktree route and last-active projects", () => { + expect( + resolveInitialWorktreeProject({ + routeProject: { ...routeProject, projectKind: "directory", canCreateWorktree: false }, + lastActiveProject: { + ...lastActiveProject, + projectKind: "directory", + canCreateWorktree: false, + }, + projects: [ + { ...firstProject, projectKind: "directory", canCreateWorktree: false }, + hostProject({ projectKey: "git-project", projectName: "Git Project" }), + ], + }), + ).toMatchObject({ projectKey: "git-project" }); + }); + + it("leaves the project empty when no worktree-capable project is available", () => { + expect( + resolveInitialWorktreeProject({ + routeProject: null, + lastActiveProject: null, + projects: [{ ...firstProject, projectKind: "directory", canCreateWorktree: false }], + }), + ).toBeNull(); + }); + + it("keeps a selected route project available before project hydration", () => { + expect( + resolveSelectedHostProject({ + selectedProjectKey: routeProject.projectKey, + projects: [], + routeProject, + lastActiveProject: null, + }), + ).toEqual(routeProject); + }); + + it("converts route project only when it has a key and source directory", () => { + expect( + hostProjectFromRoute({ + serverId: "host-a", + projectId: "project-a", + displayName: "Project A", + sourceDirectory: "/repo/a", + }), + ).toEqual({ + serverId: "host-a", + projectKey: "project-a", + projectName: "Project A", + projectKind: "git", + iconWorkingDir: "/repo/a", + workspaceKeys: [], + canCreateWorktree: true, + }); + expect(hostProjectFromRoute({ serverId: "host-a", projectId: "project-a" })).toBeNull(); + }); + + it("converts last active workspaces with matching worktree capability", () => { + expect(hostProjectFromWorkspace({ serverId: "host-a", workspace: workspace({}) })).toEqual({ + serverId: "host-a", + projectKey: "project-a", + projectName: "Project A", + projectKind: "git", + iconWorkingDir: "/repo/a", + workspaceKeys: ["workspace-a"], + canCreateWorktree: true, + }); + + expect( + hostProjectFromWorkspace({ + serverId: "host-a", + workspace: workspace({ projectKind: "directory" }), + }), + ).toMatchObject({ projectKind: "directory", canCreateWorktree: false }); + }); +}); diff --git a/packages/app/src/projects/host-projects.ts b/packages/app/src/projects/host-projects.ts new file mode 100644 index 000000000..b6b6cf7dd --- /dev/null +++ b/packages/app/src/projects/host-projects.ts @@ -0,0 +1,24 @@ +import { useMemo } from "react"; +import { useWorkspaceStructure } from "@/stores/session-store-hooks"; +import { buildHostProjectList, type HostProjectListItem } from "@/projects/host-project-model"; + +export { + buildHostProjectList, + canCreateWorktreeForProjectKind, + hostProjectFromRoute, + hostProjectFromWorkspace, + resolveInitialWorktreeProject, + resolveSelectedHostProject, + type HostProjectListItem, + type HostProjectRouteContext, +} from "@/projects/host-project-model"; + +export function useHostProjects(serverId: string | null): HostProjectListItem[] { + const workspaceStructure = useWorkspaceStructure(serverId); + return useMemo(() => { + if (!serverId || workspaceStructure.projects.length === 0) { + return []; + } + return buildHostProjectList({ serverId, projects: workspaceStructure.projects }); + }, [serverId, workspaceStructure.projects]); +} diff --git a/packages/app/src/projects/project-icons.ts b/packages/app/src/projects/project-icons.ts new file mode 100644 index 000000000..1653c3258 --- /dev/null +++ b/packages/app/src/projects/project-icons.ts @@ -0,0 +1,97 @@ +import { useMemo, useRef } from "react"; +import { useQueries } from "@tanstack/react-query"; +import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime"; +import { projectIconQueryKey, projectIconToDataUri } from "@/hooks/use-project-icon-query"; + +export interface ProjectIconRequestTarget { + serverId?: string | null; + projectKey: string; + iconWorkingDir: string; +} + +function useStableProjectIconData( + data: (string | null)[], + signature: string, +): readonly (string | null)[] { + const stableRef = useRef<{ signature: string; data: (string | null)[] } | null>(null); + if (stableRef.current?.signature !== signature) { + stableRef.current = { signature, data }; + } + return stableRef.current.data; +} + +export function useProjectIconDataByProjectKey(input: { + serverId: string | null; + projects: readonly ProjectIconRequestTarget[]; +}): Map { + const projectIconRequests = useMemo(() => { + const unique = new Map(); + for (const project of input.projects) { + const serverId = project.serverId || input.serverId; + if (!serverId) { + continue; + } + const cwd = project.iconWorkingDir.trim(); + if (!cwd) { + continue; + } + unique.set(`${serverId}:${cwd}`, { serverId, cwd }); + } + return Array.from(unique.values()); + }, [input.projects, input.serverId]); + + const projectIconQueries = useQueries({ + queries: projectIconRequests.map((request) => ({ + queryKey: projectIconQueryKey(request.serverId, request.cwd), + queryFn: async () => { + const client = getHostRuntimeStore().getClient(request.serverId); + if (!client) { + return null; + } + const result = await client.requestProjectIcon(request.cwd); + return result.icon; + }, + select: projectIconToDataUri, + enabled: Boolean( + getHostRuntimeStore().getClient(request.serverId) && + isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)) && + request.cwd, + ), + staleTime: Infinity, + gcTime: 1000 * 60 * 60, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + })), + }); + + const projectIconSignature = projectIconQueries.map((query) => query.data ?? "").join("\u0000"); + const projectIconData = useStableProjectIconData( + projectIconQueries.map((query) => query.data ?? null), + projectIconSignature, + ); + + return useMemo(() => { + const iconByServerAndCwd = new Map(); + for (let index = 0; index < projectIconRequests.length; index += 1) { + const request = projectIconRequests[index]; + if (!request) { + continue; + } + iconByServerAndCwd.set(`${request.serverId}:${request.cwd}`, projectIconData[index] ?? null); + } + + const byProject = new Map(); + for (const project of input.projects) { + const serverId = project.serverId || input.serverId; + const cwd = project.iconWorkingDir.trim(); + if (!cwd || !serverId) { + byProject.set(project.projectKey, null); + continue; + } + byProject.set(project.projectKey, iconByServerAndCwd.get(`${serverId}:${cwd}`) ?? null); + } + + return byProject; + }, [input.projects, input.serverId, projectIconData, projectIconRequests]); +} diff --git a/packages/app/src/projects/workspace-fetching.ts b/packages/app/src/projects/workspace-fetching.ts new file mode 100644 index 000000000..d83369e56 --- /dev/null +++ b/packages/app/src/projects/workspace-fetching.ts @@ -0,0 +1,29 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store"; + +export type FetchWorkspacesClient = Pick; +export type FetchWorkspacesSort = NonNullable< + Parameters[0] +>["sort"]; + +export async function fetchAllWorkspaceDescriptors(input: { + client: FetchWorkspacesClient; + sort: FetchWorkspacesSort; +}): Promise { + const entries: WorkspaceDescriptor[] = []; + let cursor: string | null = null; + + while (true) { + const payload = await input.client.fetchWorkspaces({ + sort: input.sort, + page: cursor ? { limit: 200, cursor } : { limit: 200 }, + }); + entries.push(...payload.entries.map((entry) => normalizeWorkspaceDescriptor(entry))); + if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) { + break; + } + cursor = payload.pageInfo.nextCursor; + } + + return entries; +} diff --git a/packages/app/src/projects/workspace-structure.ts b/packages/app/src/projects/workspace-structure.ts new file mode 100644 index 000000000..eb0232293 --- /dev/null +++ b/packages/app/src/projects/workspace-structure.ts @@ -0,0 +1,96 @@ +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { projectDisplayNameFromProjectId } from "@/utils/project-display-name"; + +export interface WorkspaceStructureProject { + projectKey: string; + projectName: string; + projectKind: WorkspaceDescriptor["projectKind"]; + iconWorkingDir: string; + workspaceKeys: string[]; +} + +export interface WorkspaceStructure { + projects: WorkspaceStructureProject[]; +} + +const EMPTY_WORKSPACE_STRUCTURE: WorkspaceStructure = { projects: [] }; + +function compareWorkspaceStructureItems( + left: { workspaceId: string; workspaceName: string }, + right: { workspaceId: string; workspaceName: string }, +): number { + const nameDelta = left.workspaceName.localeCompare(right.workspaceName, undefined, { + numeric: true, + sensitivity: "base", + }); + if (nameDelta !== 0) { + return nameDelta; + } + + return left.workspaceId.localeCompare(right.workspaceId, undefined, { + sensitivity: "base", + }); +} + +function compareWorkspaceStructureProjects( + left: WorkspaceStructureProject, + right: WorkspaceStructureProject, +): number { + return left.projectName.localeCompare(right.projectName, undefined, { + numeric: true, + sensitivity: "base", + }); +} + +export function buildWorkspaceStructureProjects(input: { + serverId: string; + workspaces: Iterable; +}): WorkspaceStructureProject[] { + const workspaceList = Array.from(input.workspaces); + if (workspaceList.length === 0) { + return EMPTY_WORKSPACE_STRUCTURE.projects; + } + + const byProject = new Map< + string, + WorkspaceStructureProject & { + workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>; + } + >(); + + for (const workspace of workspaceList) { + const project = + byProject.get(workspace.projectId) ?? + ({ + projectKey: workspace.projectId, + projectName: + workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId), + projectKind: workspace.projectKind, + iconWorkingDir: workspace.projectRootPath, + workspaceKeys: [], + workspaces: [], + } satisfies WorkspaceStructureProject & { + workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>; + }); + + project.workspaces.push({ + workspaceId: workspace.id, + workspaceName: workspace.name, + workspaceKey: `${input.serverId}:${workspace.id}`, + }); + byProject.set(workspace.projectId, project); + } + + const projects = Array.from(byProject.values()).map( + ({ workspaces: projectWorkspaces, ...project }) => { + const sortedWorkspaces = [...projectWorkspaces].sort(compareWorkspaceStructureItems); + + return Object.assign({}, project, { + workspaceKeys: sortedWorkspaces.map((workspace) => workspace.workspaceId), + }); + }, + ); + + projects.sort(compareWorkspaceStructureProjects); + return projects; +} diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index cc0971dd7..dc28c2823 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -5,11 +5,12 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { createNameId } from "mnemonic-id"; import { useQuery } from "@tanstack/react-query"; -import { Check, ChevronDown, GitBranch, GitPullRequest, X } from "lucide-react-native"; +import { Check, ChevronDown, Folder, GitBranch, GitPullRequest, X } from "lucide-react-native"; import { Composer } from "@/composer"; import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control"; import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit"; import { FileDropZone } from "@/components/file-drop-zone"; +import { ProjectIconView } from "@/components/project-icon-view"; import { Combobox, ComboboxItem } from "@/components/ui/combobox"; import type { ComboboxOption as ComboboxOptionType } from "@/components/ui/combobox"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -21,15 +22,29 @@ import { useToast } from "@/contexts/toast-context"; import { useAgentInputDraft } from "@/composer/draft/input-draft"; import { useGithubSearchQuery } from "@/git/use-github-search-query"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; +import { + navigateToWorkspace, + useLastWorkspaceSelection, +} from "@/stores/navigation-active-workspace-store"; import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; +import { useWorkspace } from "@/stores/session-store-hooks"; import { generateDraftId } from "@/stores/draft-keys"; import { useDraftStore } from "@/stores/draft-store"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; import { generateMessageId } from "@/types/stream"; import { toErrorMessage } from "@/utils/error-messages"; +import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; +import { + hostProjectFromRoute, + hostProjectFromWorkspace, + resolveInitialWorktreeProject, + resolveSelectedHostProject, + useHostProjects, + type HostProjectListItem, +} from "@/projects/host-projects"; +import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types"; import type { ImageAttachment, MessagePayload } from "@/composer/types"; import type { AgentAttachment, GitHubSearchItem } from "@getpaseo/protocol/messages"; @@ -58,7 +73,7 @@ function resolveCheckoutRequest( interface NewWorkspaceScreenProps { serverId: string; - sourceDirectory: string; + sourceDirectory?: string; projectId?: string; displayName?: string; } @@ -68,13 +83,38 @@ interface PickerOptionData { itemById: Map; } +interface ProjectOptionData { + options: ComboboxOptionType[]; + projectByOptionId: Map; +} + interface PickerSelection { item: PickerItem; attachedPrNumber: number | null; } +interface NewWorkspaceProjectPickerInput { + serverId: string; + sourceDirectory?: string; + projectId?: string; + displayName?: string; +} + +interface NewWorkspaceProjectPickerState { + projects: HostProjectListItem[]; + selectedProject: HostProjectListItem | null; + selectedSourceDirectory: string | null; + selectedDisplayName: string; + projectPickerOptions: ComboboxOptionType[]; + projectByOptionId: Map; + selectedProjectOptionId: string; + projectTriggerLabel: string; + handleSelectProjectOption: (id: string) => void; +} + const BRANCH_OPTION_PREFIX = "branch:"; const PR_OPTION_PREFIX = "github-pr:"; +const PROJECT_OPTION_PREFIX = "project:"; function RefPickerBadgeContent({ selectedItem, @@ -150,6 +190,68 @@ function RefPickerTrigger({ ); } +function ProjectPickerTrigger({ + pickerAnchorRef, + onPress, + disabled, + badgePressableStyle, + label, + projectKey, + iconDataUri, + iconColor, + iconSize, +}: { + pickerAnchorRef: React.RefObject; + onPress: () => void; + disabled: boolean; + badgePressableStyle: React.ComponentProps["style"]; + label: string; + projectKey: string | null; + iconDataUri: string | null; + iconColor: string; + iconSize: number; +}) { + const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(label); + const placeholderInitial = placeholderLabel.charAt(0).toUpperCase() || "?"; + return ( + + + + + {projectKey ? ( + + ) : ( + + )} + + + {label} + + + + + + Choose project + + + ); +} + function CheckoutHintBadge({ prNumber, onAccept, @@ -239,6 +341,59 @@ function PickerOptionItem({ ); } +function ProjectOptionItem({ + testID, + projectKey, + iconDataUri, + label, + description, + selected, + active, + disabled, + onPress, +}: { + testID: string; + projectKey: string; + iconDataUri: string | null; + label: string; + description: string | undefined; + selected: boolean; + active: boolean; + disabled: boolean; + onPress: () => void; +}) { + const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(label); + const placeholderInitial = placeholderLabel.charAt(0).toUpperCase() || "?"; + const leadingSlot = useMemo( + () => ( + + + + ), + [iconDataUri, placeholderInitial, projectKey], + ); + + return ( + + ); +} + function branchOptionId(name: string): string { return `${BRANCH_OPTION_PREFIX}${name}`; } @@ -247,6 +402,10 @@ function prOptionId(number: number): string { return `${PR_OPTION_PREFIX}${number}`; } +function projectOptionId(projectId: string): string { + return `${PROJECT_OPTION_PREFIX}${projectId}`; +} + function formatPrLabel(item: { number: number; title: string }): string { return `#${item.number} ${item.title}`; } @@ -256,7 +415,7 @@ function pickerItemLabel(item: PickerItem): string { } function pickerItemTriggerLabel(item: PickerItem): string { - return item.kind === "branch" ? item.name : formatPrLabel(item.item); + return pickerItemLabel(item); } function computePickerOptionData( @@ -292,6 +451,108 @@ function computePickerOptionData( return { options: timedOptions.map((t) => t.option), itemById: idMap }; } +function computeProjectOptionData(projects: readonly HostProjectListItem[]): ProjectOptionData { + const projectByOptionId = new Map(); + const options = projects.map((project) => { + const id = projectOptionId(project.projectKey); + projectByOptionId.set(id, project); + return { id, label: project.projectName }; + }); + return { options, projectByOptionId }; +} + +function useNewWorkspaceProjectPicker({ + serverId, + sourceDirectory, + projectId, + displayName: displayNameProp, +}: NewWorkspaceProjectPickerInput): NewWorkspaceProjectPickerState { + const [manualProjectKey, setManualProjectKey] = useState(null); + const displayName = displayNameProp?.trim() ?? ""; + const projects = useHostProjects(serverId || null); + const lastWorkspaceSelection = useLastWorkspaceSelection(); + const lastWorkspaceServerId = lastWorkspaceSelection?.serverId === serverId ? serverId : null; + const lastWorkspaceId = + lastWorkspaceSelection?.serverId === serverId ? lastWorkspaceSelection.workspaceId : null; + const lastWorkspace = useWorkspace(lastWorkspaceServerId, lastWorkspaceId); + const routeProject = useMemo( + () => + hostProjectFromRoute({ + serverId, + projectId, + displayName, + sourceDirectory, + }), + [displayName, projectId, serverId, sourceDirectory], + ); + const lastActiveProject = useMemo( + () => hostProjectFromWorkspace({ serverId, workspace: lastWorkspace }), + [lastWorkspace, serverId], + ); + const initialProject = useMemo( + () => + resolveInitialWorktreeProject({ + routeProject, + lastActiveProject, + projects, + }), + [lastActiveProject, projects, routeProject], + ); + const worktreeProjects = useMemo( + () => projects.filter((project) => project.canCreateWorktree), + [projects], + ); + + const selectedProjectKey = manualProjectKey ?? initialProject?.projectKey ?? null; + + const selectedProject = useMemo( + () => + resolveSelectedHostProject({ + selectedProjectKey, + projects, + routeProject, + lastActiveProject, + }), + [lastActiveProject, projects, routeProject, selectedProjectKey], + ); + const { options: projectPickerOptions, projectByOptionId }: ProjectOptionData = useMemo( + () => computeProjectOptionData(worktreeProjects), + [worktreeProjects], + ); + const handleSelectProjectOption = useCallback( + (id: string) => { + const project = projectByOptionId.get(id); + if (!project?.canCreateWorktree) return; + setManualProjectKey(project.projectKey); + }, + [projectByOptionId], + ); + + return { + projects, + selectedProject, + selectedSourceDirectory: selectedProject?.iconWorkingDir ?? null, + selectedDisplayName: selectedProject?.projectName ?? displayName, + projectPickerOptions, + projectByOptionId, + selectedProjectOptionId: selectedProject ? projectOptionId(selectedProject.projectKey) : "", + projectTriggerLabel: selectedProject?.projectName ?? "Choose project", + handleSelectProjectOption, + }; +} + +function getContentStyle(input: { isCompact: boolean; insetBottom: number }) { + if (input.isCompact) { + return [styles.content, styles.contentCompact, { paddingBottom: input.insetBottom }]; + } + return [styles.content, styles.contentCentered]; +} + +function getSelectedPickerItem(selection: PickerSelection | null): PickerItem | null { + if (!selection) return null; + return selection.item; +} + function normalizeBranchDetails( data: | { branchDetails?: Array<{ name: string; committerDate: number }>; branches?: string[] } @@ -378,32 +639,31 @@ function buildComposerConfig(input: { serverId: string; isConnected: boolean; workspaceDirectory: string | null; - sourceDirectory: string; + sourceDirectory: string | null; }): Parameters[0]["composer"] { const { serverId, isConnected, workspaceDirectory, sourceDirectory } = input; + const workingDir = workspaceDirectory || sourceDirectory || undefined; return { initialServerId: serverId || null, - initialValues: - workspaceDirectory || sourceDirectory - ? { workingDir: workspaceDirectory || sourceDirectory } - : undefined, + initialValues: workingDir ? { workingDir } : undefined, isVisible: true, onlineServerIds: isConnected && serverId ? [serverId] : [], - lockedWorkingDir: workspaceDirectory || sourceDirectory || undefined, + lockedWorkingDir: workingDir, }; } function computeWorkspaceTitle( workspace: ReturnType | null, displayName: string, - sourceDirectory: string, + sourceDirectory: string | null, ): string { + const fallbackDirectoryName = sourceDirectory?.split(/[\\/]/).findLast(Boolean) ?? null; return ( workspace?.name || workspace?.projectDisplayName || displayName || - sourceDirectory.split(/[\\/]/).findLast(Boolean) || - sourceDirectory + fallbackDirectoryName || + "Choose project" ); } @@ -503,7 +763,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void { export function NewWorkspaceScreen({ serverId, - sourceDirectory, + sourceDirectory: sourceDirectoryProp, projectId, displayName: displayNameProp, }: NewWorkspaceScreenProps) { @@ -519,9 +779,11 @@ export function NewWorkspaceScreen({ const [pendingAction, setPendingAction] = useState<"chat" | "empty" | null>(null); const [manualPickerSelection, setManualPickerSelection] = useState(null); const [pickerOpen, setPickerOpen] = useState(false); + const [projectPickerOpen, setProjectPickerOpen] = useState(false); const [pickerSearchQuery, setPickerSearchQuery] = useState(""); const [debouncedPickerSearchQuery, setDebouncedPickerSearchQuery] = useState(""); const pickerAnchorRef = useRef(null); + const projectPickerAnchorRef = useRef(null); useEffect(() => { const trimmed = pickerSearchQuery.trim(); @@ -529,26 +791,42 @@ export function NewWorkspaceScreen({ return () => clearTimeout(timer); }, [pickerSearchQuery]); - const displayName = displayNameProp?.trim() ?? ""; const workspace = createdWorkspace; const isPending = pendingAction !== null; const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); - const draftKey = `new-workspace:${serverId}:${sourceDirectory}`; + const { + projects, + selectedProject, + selectedSourceDirectory, + selectedDisplayName, + projectPickerOptions, + projectByOptionId, + selectedProjectOptionId, + projectTriggerLabel, + handleSelectProjectOption: selectProjectOption, + } = useNewWorkspaceProjectPicker({ + serverId, + sourceDirectory: sourceDirectoryProp, + projectId, + displayName: displayNameProp, + }); + const projectIconDataByProjectKey = useProjectIconDataByProjectKey({ serverId, projects }); + const draftKey = `new-workspace:${serverId}:${selectedSourceDirectory ?? "choose-project"}`; const chatDraft = useAgentInputDraft({ draftKey, composer: buildComposerConfig({ serverId, isConnected, workspaceDirectory: workspace?.workspaceDirectory ?? null, - sourceDirectory, + sourceDirectory: selectedSourceDirectory, }), }); const composerState = chatDraft.composerState; const [dismissedCheckoutHintPrNumbers, setDismissedCheckoutHintPrNumbers] = useCheckoutHintDismissals(chatDraft.attachments); - const selectedItem = manualPickerSelection?.item ?? null; + const selectedItem = getSelectedPickerItem(manualPickerSelection); const withConnectedClient = useCallback(() => { if (!client || !isConnected) { @@ -558,15 +836,19 @@ export function NewWorkspaceScreen({ }, [client, isConnected]); const clientReady = isConnected && Boolean(client); - const pickerQueryEnabled = pickerOpen && clientReady; + const hasSelectedSourceDirectory = selectedSourceDirectory !== null; + const pickerQueryEnabled = pickerOpen && clientReady && hasSelectedSourceDirectory; const checkoutStatusQuery = useQuery({ - queryKey: ["checkout-status", serverId, sourceDirectory], + queryKey: ["checkout-status", serverId, selectedSourceDirectory], queryFn: async () => { + if (!selectedSourceDirectory) { + throw new Error("Choose a project"); + } const connectedClient = withConnectedClient(); - return connectedClient.getCheckoutStatus(sourceDirectory); + return connectedClient.getCheckoutStatus(selectedSourceDirectory); }, - enabled: clientReady, + enabled: clientReady && hasSelectedSourceDirectory, staleTime: Infinity, refetchOnMount: false, refetchOnReconnect: false, @@ -576,11 +858,14 @@ export function NewWorkspaceScreen({ const currentBranch = checkoutStatusQuery.data?.currentBranch ?? null; const branchSuggestionsQuery = useQuery({ - queryKey: ["branch-suggestions", serverId, sourceDirectory, debouncedPickerSearchQuery], + queryKey: ["branch-suggestions", serverId, selectedSourceDirectory, debouncedPickerSearchQuery], queryFn: async () => { + if (!selectedSourceDirectory) { + throw new Error("Choose a project"); + } const connectedClient = withConnectedClient(); return connectedClient.getBranchSuggestions({ - cwd: sourceDirectory, + cwd: selectedSourceDirectory, query: debouncedPickerSearchQuery || undefined, limit: 20, }); @@ -592,7 +877,7 @@ export function NewWorkspaceScreen({ const githubPrSearchQuery = useGithubSearchQuery({ client, serverId, - cwd: sourceDirectory, + cwd: selectedSourceDirectory ?? "", query: debouncedPickerSearchQuery, kinds: ["github-pr"], enabled: pickerQueryEnabled, @@ -612,7 +897,6 @@ export function NewWorkspaceScreen({ () => computePickerOptionData(branchDetails, prItems), [branchDetails, prItems], ); - const triggerLabel = useMemo(() => { if (selectedItem) return pickerItemTriggerLabel(selectedItem); return currentBranch ?? "main"; @@ -624,7 +908,6 @@ export function NewWorkspaceScreen({ ? branchOptionId(selectedItem.name) : prOptionId(selectedItem.item.number); }, [selectedItem]); - const selectPickerItem = useCallback( (item: PickerItem) => { const next = syncPickerPrAttachment({ @@ -654,6 +937,17 @@ export function NewWorkspaceScreen({ [itemById, selectPickerItem], ); + const handleSelectProjectOption = useCallback( + (id: string) => { + const project = projectByOptionId.get(id); + if (!project?.canCreateWorktree) return; + selectProjectOption(id); + setProjectPickerOpen(false); + setManualPickerSelection(null); + }, + [projectByOptionId, selectProjectOption], + ); + const checkoutHintPrAttachment = useMemo( () => findCheckoutHintPrAttachment({ @@ -684,6 +978,10 @@ export function NewWorkspaceScreen({ setPickerOpen(true); }, []); + const openProjectPicker = useCallback(() => { + setProjectPickerOpen(true); + }, []); + const handleClearDraft = useCallback(() => { // No-op: screen navigates away on success, text should stay for retry on error }, []); @@ -705,19 +1003,26 @@ export function NewWorkspaceScreen({ } }, []); + const handleProjectPickerOpenChange = useCallback((nextOpen: boolean) => { + setProjectPickerOpen(nextOpen); + }, []); + const buildCreateWorktreeInput = useCallback( (input: { cwd: string; prompt: string; attachments: AgentAttachment[]; }): CreatePaseoWorktreeInput => { + if (!selectedProject) { + throw new Error("Choose a project"); + } const checkoutRequest = resolveCheckoutRequest(selectedItem, currentBranch); const trimmedPrompt = input.prompt.trim(); const hasFirstAgentContext = trimmedPrompt.length > 0 || input.attachments.length > 0; return { - cwd: input.cwd, - ...(projectId ? { projectId } : {}), + cwd: selectedProject.iconWorkingDir, + projectId: selectedProject.projectKey, worktreeSlug: createNameId(), ...(hasFirstAgentContext ? { @@ -730,7 +1035,7 @@ export function NewWorkspaceScreen({ ...checkoutRequest, }; }, - [currentBranch, projectId, selectedItem], + [currentBranch, selectedItem, selectedProject], ); const ensureWorkspace = useCallback( @@ -783,7 +1088,11 @@ export function NewWorkspaceScreen({ [composerState, draftKey, ensureWorkspace, serverId, toast], ); - const workspaceTitle = computeWorkspaceTitle(workspace, displayName, sourceDirectory); + const workspaceTitle = computeWorkspaceTitle( + workspace, + selectedDisplayName, + selectedSourceDirectory, + ); const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => { @@ -835,12 +1144,40 @@ export function NewWorkspaceScreen({ [isPending, itemById, theme.colors.foregroundMuted, theme.iconSize.sm], ); + const renderProjectOption = useCallback( + ({ + option, + selected, + active, + onPress, + }: { + option: ComboboxOptionType; + selected: boolean; + active: boolean; + onPress: () => void; + }) => { + const project = projectByOptionId.get(option.id); + if (!project) return ; + + return ( + + ); + }, + [isPending, projectByOptionId, projectIconDataByProjectKey], + ); + const contentStyle = useMemo( - () => [ - styles.content, - isCompact ? styles.contentCompact : styles.contentCentered, - isCompact ? { paddingBottom: insets.bottom } : null, - ], + () => getContentStyle({ isCompact, insetBottom: insets.bottom }), [isCompact, insets.bottom], ); @@ -863,11 +1200,42 @@ export function NewWorkspaceScreen({ const composerFooter = useMemo( () => ( + + + + ({ justifyContent: "center", flexShrink: 0, }, + badgeProjectIcon: { + width: theme.iconSize.md, + height: theme.iconSize.md, + borderRadius: theme.borderRadius.sm, + }, + badgeProjectIconFallback: { + width: theme.iconSize.md, + height: theme.iconSize.md, + borderRadius: theme.borderRadius.sm, + alignItems: "center", + justifyContent: "center", + }, + badgeProjectIconFallbackText: { + fontSize: 10, + fontWeight: "600", + }, rowIconBox: { width: theme.iconSize.md, height: theme.iconSize.md, alignItems: "center", justifyContent: "center", }, + projectOptionIcon: { + width: theme.iconSize.md, + height: theme.iconSize.md, + borderRadius: theme.borderRadius.sm, + }, + projectOptionIconFallback: { + width: theme.iconSize.md, + height: theme.iconSize.md, + borderRadius: theme.borderRadius.sm, + alignItems: "center", + justifyContent: "center", + }, + projectOptionIconFallbackText: { + fontSize: 10, + fontWeight: "600", + }, })); diff --git a/packages/app/src/screens/project-settings-screen.tsx b/packages/app/src/screens/project-settings-screen.tsx index 977eeed63..028a552d3 100644 --- a/packages/app/src/screens/project-settings-screen.tsx +++ b/packages/app/src/screens/project-settings-screen.tsx @@ -5,7 +5,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, Check, ChevronDown, MoreVertical, Pencil, Plus, X } from "lucide-react-native"; import { ProjectIconView } from "@/components/project-icon-view"; -import { projectIconToDataUri, useProjectIconQuery } from "@/hooks/use-project-icon-query"; import type { PaseoConfigRaw, PaseoConfigRevision, @@ -29,6 +28,7 @@ import { SettingsGroup } from "@/screens/settings/settings-group"; import { SettingsSection } from "@/screens/settings/settings-section"; import { settingsStyles } from "@/styles/settings"; import { useProjects } from "@/hooks/use-projects"; +import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; import { useHostRuntimeClient, useHostRuntimeSnapshot } from "@/runtime/host-runtime"; import { useToast } from "@/contexts/toast-context"; import { confirmDialog } from "@/utils/confirm-dialog"; @@ -219,6 +219,21 @@ function ProjectSettingsBody({ }); const data = readQuery.data; + const projectIconTargets = useMemo( + () => [ + { + serverId: selectedHost.serverId, + projectKey: project.projectKey, + iconWorkingDir: selectedHost.repoRoot, + }, + ], + [project.projectKey, selectedHost.repoRoot, selectedHost.serverId], + ); + const projectIconDataByKey = useProjectIconDataByProjectKey({ + serverId: null, + projects: projectIconTargets, + }); + const projectIconDataUri = projectIconDataByKey.get(project.projectKey) ?? null; const loadedConfig: PaseoConfigRaw | null = data?.ok ? (data.config ?? {}) : null; const loadedRevision: PaseoConfigRevision | null = data?.ok ? data.revision : null; const readError: ProjectConfigRpcError | null = data && !data.ok ? data.error : null; @@ -236,7 +251,7 @@ function ProjectSettingsBody({ @@ -886,19 +901,18 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) { } function ProjectTitleIcon({ - host, + iconDataUri, projectName, projectKey, }: { - host: ProjectHostEntry; + iconDataUri: string | null; projectName: string; projectKey: string; }) { const initial = projectName.trim().charAt(0).toUpperCase() || "?"; - const { icon } = useProjectIconQuery({ serverId: host.serverId, cwd: host.repoRoot }); return ( ({ useProjects: () => projectsState.current, })); -vi.mock("@/hooks/use-project-icon-query", () => ({ - useProjectIconQuery: () => ({ icon: null, isLoading: false, isError: false }), - projectIconToDataUri: () => null, +vi.mock("@/projects/project-icons", () => ({ + useProjectIconDataByProjectKey: () => new Map(), })); import ProjectsScreen from "./projects-screen"; diff --git a/packages/app/src/screens/projects-screen.tsx b/packages/app/src/screens/projects-screen.tsx index 2223740a8..d736a352d 100644 --- a/packages/app/src/screens/projects-screen.tsx +++ b/packages/app/src/screens/projects-screen.tsx @@ -1,15 +1,15 @@ -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { router } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ChevronRight } from "lucide-react-native"; import { ProjectIconView } from "@/components/project-icon-view"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; -import { projectIconToDataUri, useProjectIconQuery } from "@/hooks/use-project-icon-query"; import { useProjects, type ProjectHostError } from "@/hooks/use-projects"; +import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; import { settingsStyles } from "@/styles/settings"; import { buildProjectSettingsRoute } from "@/utils/host-routes"; -import type { ProjectHostEntry, ProjectSummary } from "@/utils/projects"; +import type { ProjectSummary } from "@/utils/projects"; interface ProjectsScreenProps { view: { kind: "projects" } | { kind: "project"; projectKey: string }; @@ -18,6 +18,25 @@ interface ProjectsScreenProps { export default function ProjectsScreen({ view }: ProjectsScreenProps) { const { projects, hostErrors, isLoading } = useProjects(); const selectedProjectKey = view.kind === "project" ? view.projectKey : null; + const iconTargets = useMemo( + () => + projects.flatMap((project) => { + const host = project.hosts[0]; + if (!host) return []; + return [ + { + serverId: host.serverId, + projectKey: project.projectKey, + iconWorkingDir: host.repoRoot, + }, + ]; + }), + [projects], + ); + const iconDataByProjectKey = useProjectIconDataByProjectKey({ + serverId: null, + projects: iconTargets, + }); if (isLoading && projects.length === 0) { return ( @@ -45,6 +64,7 @@ export default function ProjectsScreen({ view }: ProjectsScreenProps) { project={project} isFirst={index === 0} isSelected={selectedProjectKey === project.projectKey} + iconDataUri={iconDataByProjectKey.get(project.projectKey) ?? null} /> ))} @@ -68,12 +88,12 @@ interface ProjectRowProps { project: ProjectSummary; isFirst: boolean; isSelected: boolean; + iconDataUri: string | null; } -function ProjectRow({ project, isFirst, isSelected }: ProjectRowProps) { +function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowProps) { const { theme } = useUnistyles(); - const { hosts, projectKey, projectName } = project; - const leadingHost = hosts[0]; + const { projectKey, projectName } = project; const handleNavigate = useCallback(() => { router.navigate(buildProjectSettingsRoute(projectKey)); @@ -102,7 +122,11 @@ function ProjectRow({ project, isFirst, isSelected }: ProjectRowProps) { > - + {projectName} @@ -114,22 +138,18 @@ function ProjectRow({ project, isFirst, isSelected }: ProjectRowProps) { } function ProjectRowIcon({ - host, + iconDataUri, projectName, projectKey, }: { - host: ProjectHostEntry | undefined; + iconDataUri: string | null; projectName: string; projectKey: string; }) { const initial = projectName.trim().charAt(0).toUpperCase() || "?"; - const { icon } = useProjectIconQuery({ - serverId: host?.serverId ?? "", - cwd: host?.repoRoot ?? "", - }); return ( }>; @@ -44,33 +37,6 @@ function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string return `${serverId.trim()}::${projectKey.trim()}`; } -function compareWorkspaceStructureItems( - left: { workspaceId: string; workspaceName: string }, - right: { workspaceId: string; workspaceName: string }, -): number { - const nameDelta = left.workspaceName.localeCompare(right.workspaceName, undefined, { - numeric: true, - sensitivity: "base", - }); - if (nameDelta !== 0) { - return nameDelta; - } - - return left.workspaceId.localeCompare(right.workspaceId, undefined, { - sensitivity: "base", - }); -} - -function compareWorkspaceStructureProjects( - left: WorkspaceStructureProject, - right: WorkspaceStructureProject, -): number { - return left.projectName.localeCompare(right.projectName, undefined, { - numeric: true, - sensitivity: "base", - }); -} - function applyStoredOrdering(input: { items: T[]; storedOrder: readonly string[]; @@ -171,48 +137,7 @@ export function selectWorkspaceStructureProjects( return EMPTY_WORKSPACE_STRUCTURE.projects; } - const byProject = new Map< - string, - WorkspaceStructureProject & { - workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>; - } - >(); - - for (const workspace of workspaces.values()) { - const project = - byProject.get(workspace.projectId) ?? - ({ - projectKey: workspace.projectId, - projectName: - workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId), - projectKind: workspace.projectKind, - iconWorkingDir: workspace.projectRootPath, - workspaceKeys: [], - workspaces: [], - } satisfies WorkspaceStructureProject & { - workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>; - }); - - project.workspaces.push({ - workspaceId: workspace.id, - workspaceName: workspace.name, - workspaceKey: `${serverId}:${workspace.id}`, - }); - byProject.set(workspace.projectId, project); - } - - const projects = Array.from(byProject.values()).map( - ({ workspaces: projectWorkspaces, ...project }) => { - const sortedWorkspaces = [...projectWorkspaces].sort(compareWorkspaceStructureItems); - - return Object.assign({}, project, { - workspaceKeys: sortedWorkspaces.map((workspace) => workspace.workspaceId), - }); - }, - ); - - projects.sort(compareWorkspaceStructureProjects); - return projects; + return buildWorkspaceStructureProjects({ serverId, workspaces: workspaces.values() }); } export function selectProjectOrder(state: SidebarOrderSnapshot, serverId: string | null): string[] { diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index f9c23ec47..00fb6f7e5 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { buildHostAgentDetailRoute, + buildHostNewWorkspaceRoute, buildHostRootRoute, buildHostWorkspaceOpenRoute, buildHostWorkspaceRoute, @@ -120,6 +121,19 @@ describe("workspace route parsing", () => { ); }); + it("builds a global new workspace route without a source directory", () => { + expect(buildHostNewWorkspaceRoute("local")).toBe("/h/local/new"); + }); + + it("builds a project shortcut new workspace route with initial project context", () => { + expect( + buildHostNewWorkspaceRoute("local", "/repo/project", { + displayName: "Project", + projectId: "project-1", + }), + ).toBe("/h/local/new?dir=%2Frepo%2Fproject&name=Project&projectId=project-1"); + }); + it("round-trips URL-safe IDs through encode/decode", () => { const ids = ["1", "40", "164", "9999", "workspace-1", "opaque_id.v2~test"]; for (const id of ids) { diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 9d4f2edd5..24c88503d 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -357,7 +357,7 @@ export function buildHostOpenProjectRoute(serverId: string) { export function buildHostNewWorkspaceRoute( serverId: string, - sourceDirectory: string, + sourceDirectory?: string, options?: { displayName?: string; projectId?: string }, ) { const base = buildHostRootRoute(serverId); @@ -365,14 +365,20 @@ export function buildHostNewWorkspaceRoute( return "/" as const; } const params = new URLSearchParams(); - params.set("dir", sourceDirectory); + if (sourceDirectory) { + params.set("dir", sourceDirectory); + } if (options?.displayName) { params.set("name", options.displayName); } if (options?.projectId) { params.set("projectId", options.projectId); } - return `${base}/new?${params.toString()}` as const; + const query = params.toString(); + if (!query) { + return `${base}/new` as const; + } + return `${base}/new?${query}` as const; } export const SETTINGS_SECTION_SLUGS = [ diff --git a/packages/app/src/utils/projects.ts b/packages/app/src/utils/projects.ts index cedbd7afd..a53d55c8a 100644 --- a/packages/app/src/utils/projects.ts +++ b/packages/app/src/utils/projects.ts @@ -1,4 +1,6 @@ import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { buildHostProjectList, type HostProjectListItem } from "@/projects/host-project-model"; +import { buildWorkspaceStructureProjects } from "@/projects/workspace-structure"; export interface WorkspaceSummary { id: string; @@ -61,6 +63,31 @@ interface ProjectGroup { hostsByServerId: Map; } +function findProjectCustomName( + workspaces: WorkspaceDescriptor[], + projectKey: string, +): { customName: string; displayName: string } | null { + for (const workspace of workspaces) { + if (workspace.projectId === projectKey && workspace.projectCustomName) { + return { + customName: workspace.projectCustomName, + displayName: workspace.projectDisplayName, + }; + } + } + return null; +} + +function buildHostProjectEntries(host: ProjectHost): HostProjectListItem[] { + return buildHostProjectList({ + serverId: host.serverId, + projects: buildWorkspaceStructureProjects({ + serverId: host.serverId, + workspaces: host.workspaces, + }), + }); +} + function deriveGithubUrl(projectKey: string): string | undefined { const match = projectKey.match(GITHUB_PROJECT_KEY_PATTERN); if (!match) { @@ -134,33 +161,37 @@ export function buildProjects(input: BuildProjectsInput): BuildProjectsResult { const groups = new Map(); for (const host of input.hosts) { - for (const workspace of host.workspaces) { - const projectKey = workspace.projectId; - - let group = groups.get(projectKey); + const hostProjects = buildHostProjectEntries(host); + for (const hostProject of hostProjects) { + const customName = findProjectCustomName(host.workspaces, hostProject.projectKey); + let group = groups.get(hostProject.projectKey); if (!group) { group = { - projectKey, - projectName: workspace.projectDisplayName, - projectCustomName: workspace.projectCustomName ?? null, + projectKey: hostProject.projectKey, + projectName: customName?.displayName ?? hostProject.projectName, + projectCustomName: customName?.customName ?? null, hostsByServerId: new Map(), }; - groups.set(projectKey, group); - } else if (workspace.projectCustomName && !group.projectCustomName) { - group.projectCustomName = workspace.projectCustomName; - group.projectName = workspace.projectDisplayName; + groups.set(hostProject.projectKey, group); + } else if (customName && !group.projectCustomName) { + group.projectCustomName = customName.customName; + group.projectName = customName.displayName; } - let hostGroup = group.hostsByServerId.get(host.serverId); - if (!hostGroup) { - hostGroup = { + if (!group.hostsByServerId.has(host.serverId)) { + group.hostsByServerId.set(host.serverId, { serverId: host.serverId, serverName: host.serverName, isOnline: host.isOnline, workspaces: [], - }; - group.hostsByServerId.set(host.serverId, hostGroup); + }); } + } + + for (const workspace of host.workspaces) { + const group = groups.get(workspace.projectId); + const hostGroup = group?.hostsByServerId.get(host.serverId); + if (!hostGroup) continue; hostGroup.workspaces.push(workspace); } } diff --git a/packages/app/src/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts index 5ea1405dd..ce1768daa 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -32,11 +32,13 @@ function workspace(overrides: Partial = {}): SidebarWorks } function project(overrides: Partial = {}): SidebarProjectEntry { + const projectKind = overrides.projectKind ?? "git"; return { projectKey: "project-1", projectName: "paseo", - projectKind: "git", + projectKind, iconWorkingDir: "/repo", + canCreateWorktree: overrides.canCreateWorktree ?? projectKind === "git", workspaces: [workspace()], ...overrides, }; diff --git a/packages/app/src/utils/sidebar-project-row-model.ts b/packages/app/src/utils/sidebar-project-row-model.ts index c280d3d11..2a4ffc3d0 100644 --- a/packages/app/src/utils/sidebar-project-row-model.ts +++ b/packages/app/src/utils/sidebar-project-row-model.ts @@ -37,7 +37,7 @@ export function buildSidebarProjectRowModel(input: { kind: "workspace_link", workspace: flattenedWorkspace, chevron: null, - trailingAction: input.project.projectKind === "git" ? "new_worktree" : "none", + trailingAction: input.project.canCreateWorktree ? "new_worktree" : "none", }; } @@ -51,6 +51,6 @@ export function buildSidebarProjectRowModel(input: { return { kind: "project_section", chevron, - trailingAction: input.project.projectKind === "git" ? "new_worktree" : "none", + trailingAction: input.project.canCreateWorktree ? "new_worktree" : "none", }; } diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index cfd6b2b22..bc53acbaa 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -46,6 +46,7 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb projectName: projectKey, projectKind: "git", iconWorkingDir: workspaces[0]?.workspaceDirectory ?? "", + canCreateWorktree: true, workspaces, }; }