From 6ab97c579ee63225e111984945e9d790e3cf2c93 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 19 Mar 2026 23:33:35 +0700 Subject: [PATCH] Simplify workspace creation with inline worktree API Replace the multi-step new-agent route flow with a single create_paseo_worktree endpoint that registers the workspace immediately and creates the git worktree in the background. The sidebar now calls this endpoint directly and shows a creating spinner inline. Also auto-resolves the base branch from origin/HEAD when not explicitly provided, removes the unused Tauri WebSocket transport layer, and normalizes loopback endpoints to localhost. Closes #125 --- packages/app/src/app/_layout.tsx | 1 - .../app/src/app/h/[serverId]/new-agent.tsx | 9 - packages/app/src/components/left-sidebar.tsx | 3 +- .../src/components/sidebar-workspace-list.tsx | 156 ++++++++++- packages/app/src/hooks/use-command-center.ts | 21 +- packages/app/src/runtime/host-runtime.ts | 8 - packages/app/src/types/host-connection.ts | 6 +- packages/app/src/utils/host-routes.test.ts | 11 - packages/app/src/utils/host-routes.ts | 37 --- .../src/utils/tauri-daemon-transport.test.ts | 143 ---------- .../app/src/utils/tauri-daemon-transport.ts | 256 ------------------ .../app/src/utils/test-daemon-connection.ts | 6 +- packages/server/src/client/daemon-client.ts | 20 ++ .../server/src/server/connection-offer.ts | 1 - packages/server/src/server/session.ts | 207 +++++++++++++- .../src/server/session.workspaces.test.ts | 101 +++++-- .../server/src/shared/daemon-endpoints.ts | 11 + packages/server/src/shared/messages.ts | 19 ++ .../server/src/utils/checkout-git.test.ts | 12 + packages/server/src/utils/checkout-git.ts | 6 +- packages/server/src/utils/worktree.ts | 11 +- 21 files changed, 514 insertions(+), 531 deletions(-) delete mode 100644 packages/app/src/app/h/[serverId]/new-agent.tsx delete mode 100644 packages/app/src/utils/tauri-daemon-transport.test.ts delete mode 100644 packages/app/src/utils/tauri-daemon-transport.ts diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 344414d8c..c785066f0 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -620,7 +620,6 @@ export default function RootLayout() { /> - diff --git a/packages/app/src/app/h/[serverId]/new-agent.tsx b/packages/app/src/app/h/[serverId]/new-agent.tsx deleted file mode 100644 index 64bcc2833..000000000 --- a/packages/app/src/app/h/[serverId]/new-agent.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { useLocalSearchParams } from "expo-router"; -import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen"; - -export default function HostNewAgentRoute() { - const params = useLocalSearchParams<{ serverId?: string }>(); - const serverId = typeof params.serverId === "string" ? params.serverId : ""; - - return ; -} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 927be8135..172959e9b 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -618,7 +618,8 @@ const styles = StyleSheet.create((theme) => ({ alignItems: 'center', gap: theme.spacing[2], paddingVertical: theme.spacing[1], - paddingHorizontal: theme.spacing[1], + paddingRight: theme.spacing[1], + paddingLeft: theme.spacing[3], flexShrink: 0, }, newAgentButtonHovered: {}, diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 068f52e1b..09a50706a 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -33,7 +33,7 @@ import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runt import { getIsTauri } from '@/constants/layout' import { projectIconQueryKey } from '@/hooks/use-project-icon-query' import { - buildHostNewAgentRoute, + buildHostWorkspaceRouteWithOpenIntent, parseHostWorkspaceRouteFromPathname, } from '@/utils/host-routes' import { @@ -71,6 +71,8 @@ import { } from '@/components/ui/tooltip' import { buildSidebarProjectRowModel } from '@/utils/sidebar-project-row-model' import { useNavigationActiveWorkspaceSelection } from '@/stores/navigation-active-workspace-store' +import { normalizeWorkspaceDescriptor, useSessionStore } from '@/stores/session-store' +import { createNameId } from 'mnemonic-id' function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { if (!icon) { @@ -126,6 +128,7 @@ interface WorkspaceRowInnerProps { drag: () => void isDragging: boolean isArchiving: boolean + isCreating?: boolean dragHandleProps?: DraggableListDragHandleProps menuController: ReturnType | null archiveLabel?: string @@ -626,6 +629,7 @@ function WorkspaceRowInner({ drag, isDragging, isArchiving, + isCreating = false, dragHandleProps, menuController, archiveLabel, @@ -678,15 +682,23 @@ function WorkspaceRowInner({ ref={dragHandleProps?.setActivatorNodeRef as any} style={styles.workspaceRowLeft} > - + {workspace.name} + {isCreating ? Creating... : null} {onArchive && (isHovered || isMobile) ? ( state.archiveWorktree) @@ -873,6 +887,7 @@ function WorkspaceRowWithMenu({ drag={drag} isDragging={isDragging} isArchiving={isArchiving} + isCreating={isCreating} dragHandleProps={dragHandleProps} menuController={null} archiveLabel={isWorktree ? 'Archive worktree' : 'Hide from sidebar'} @@ -1082,6 +1097,7 @@ function WorkspaceRow({ isDragging, dragHandleProps, canCopyBranchName, + isCreating = false, }: { workspace: SidebarWorkspaceEntry selected: boolean @@ -1092,6 +1108,7 @@ function WorkspaceRow({ isDragging: boolean dragHandleProps?: DraggableListDragHandleProps canCopyBranchName: boolean + isCreating?: boolean }) { return ( ) } @@ -1126,6 +1144,7 @@ function ProjectBlock({ isDragging, dragHandleProps, useNestable, + creatingWorkspaceIds, }: { project: SidebarProjectEntry collapsed: boolean @@ -1144,6 +1163,7 @@ function ProjectBlock({ isDragging: boolean dragHandleProps?: DraggableListDragHandleProps useNestable: boolean + creatingWorkspaceIds: ReadonlySet }) { const rowModel = useMemo( () => @@ -1170,6 +1190,7 @@ function ProjectBlock({ shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null} showShortcutBadge={showShortcutBadges} canCopyBranchName={project.projectKind === 'git'} + isCreating={creatingWorkspaceIds.has(item.workspaceId)} onPress={() => { if (!serverId) { return @@ -1186,6 +1207,7 @@ function ProjectBlock({ [ activeWorkspaceSelection, project.projectKind, + creatingWorkspaceIds, onWorkspacePress, serverId, shortcutIndexByWorkspaceKey, @@ -1300,6 +1322,13 @@ export function SidebarWorkspaceList({ const isNative = Platform.OS !== 'web' const pathname = usePathname() const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection() + const toast = useToast() + const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces) + const [creatingProjectKey, setCreatingProjectKey] = useState(null) + const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState>(() => new Set()) + const creatingWorkspaceTimeoutsRef = useRef>>( + new Map() + ) const isTauri = getIsTauri() const altDown = useKeyboardShortcutsStore((state) => state.altDown) const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown) @@ -1379,6 +1408,51 @@ export function SidebarWorkspaceList({ return byProject }, [projectIconQueries, projectIconRequests, projects, serverId]) + useEffect(() => { + return () => { + for (const timeout of creatingWorkspaceTimeoutsRef.current.values()) { + clearTimeout(timeout) + } + creatingWorkspaceTimeoutsRef.current.clear() + } + }, []) + + useEffect(() => { + if (creatingWorkspaceIds.size === 0) { + return + } + + const visibleWorkspaceIds = new Set() + for (const project of projects) { + for (const workspace of project.workspaces) { + visibleWorkspaceIds.add(workspace.workspaceId) + } + } + + const removedWorkspaceIds = Array.from(creatingWorkspaceIds).filter( + (workspaceId) => !visibleWorkspaceIds.has(workspaceId) + ) + if (removedWorkspaceIds.length === 0) { + return + } + + for (const workspaceId of removedWorkspaceIds) { + const timeout = creatingWorkspaceTimeoutsRef.current.get(workspaceId) + if (timeout) { + clearTimeout(timeout) + creatingWorkspaceTimeoutsRef.current.delete(workspaceId) + } + } + + setCreatingWorkspaceIds((current) => { + const next = new Set(current) + for (const workspaceId of removedWorkspaceIds) { + next.delete(workspaceId) + } + return next + }) + }, [creatingWorkspaceIds, projects]) + const handleProjectDragEnd = useCallback( (reorderedProjects: SidebarProjectEntry[]) => { if (!serverId) { @@ -1437,20 +1511,68 @@ export function SidebarWorkspaceList({ ) const handleCreateWorktree = useCallback( - (project: SidebarProjectEntry) => { + async (project: SidebarProjectEntry) => { if (!serverId || project.projectKind !== 'git') { return } + if (creatingProjectKey) { + return + } onSetProjectCollapsed(project.projectKey, false) - onWorkspacePress?.() - router.push( - buildHostNewAgentRoute(serverId, { - workingDir: project.iconWorkingDir, - worktreeMode: 'create', - }) as any - ) + setCreatingProjectKey(project.projectKey) + try { + const client = getHostRuntimeStore().getClient(serverId) + if (!client || !isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(serverId))) { + throw new Error('Host is not connected') + } + const payload = await client.createPaseoWorktree({ + cwd: project.iconWorkingDir, + worktreeSlug: createNameId(), + }) + if (payload.error || !payload.workspace) { + throw new Error(payload.error ?? 'Failed to create worktree') + } + const workspace = payload.workspace + mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(workspace)]) + setCreatingWorkspaceIds((current) => { + const next = new Set(current) + next.add(workspace.id) + return next + }) + const existingTimeout = creatingWorkspaceTimeoutsRef.current.get(workspace.id) + if (existingTimeout) { + clearTimeout(existingTimeout) + } + creatingWorkspaceTimeoutsRef.current.set( + workspace.id, + setTimeout(() => { + creatingWorkspaceTimeoutsRef.current.delete(workspace.id) + setCreatingWorkspaceIds((current) => { + if (!current.has(workspace.id)) { + return current + } + const next = new Set(current) + next.delete(workspace.id) + return next + }) + }, 3000) + ) + onWorkspacePress?.() + router.replace( + buildHostWorkspaceRouteWithOpenIntent(serverId, workspace.id, { + kind: 'draft', + draftId: 'new', + }) as any + ) + } catch (error) { + toast.error( + error instanceof Error ? error.message : String(error) + ) + } finally { + setCreatingProjectKey((current) => (current === project.projectKey ? null : current)) + } }, - [onSetProjectCollapsed, serverId, onWorkspacePress] + [creatingProjectKey, mergeWorkspaces, onSetProjectCollapsed, onWorkspacePress, serverId, toast] ) const renderProject = useCallback( @@ -1474,6 +1596,7 @@ export function SidebarWorkspaceList({ isDragging={isActive} dragHandleProps={dragHandleProps} useNestable={isNative} + creatingWorkspaceIds={creatingWorkspaceIds} /> ) }, @@ -1490,6 +1613,7 @@ export function SidebarWorkspaceList({ shortcutIndexByWorkspaceKey, showShortcutBadges, isNative, + creatingWorkspaceIds, ] ) @@ -1771,9 +1895,17 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, minWidth: 0, }, + workspaceBranchTextCreating: { + opacity: 0.92, + }, workspaceBranchTextHovered: { opacity: 1, }, + workspaceCreatingText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + flexShrink: 0, + }, diffStatRow: { flexDirection: 'row', alignItems: 'center', diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index 0c8765245..921b192ea 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -12,7 +12,6 @@ import { takeCommandCenterFocusRestoreElement, } from "@/utils/command-center-focus-restore"; import { - buildHostOpenProjectRoute, buildHostWorkspaceAgentRoute, buildHostSettingsRoute, parseHostAgentRouteFromPathname, @@ -55,7 +54,7 @@ type CommandCenterActionDefinition = { icon?: "plus" | "settings"; shortcutKeys?: ShortcutKey[]; keywords: string[]; - buildRoute: (params: { newAgentRoute: Href; settingsRoute: Href }) => Href; + routeKind: "settings" | "none"; }; const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [ @@ -65,14 +64,14 @@ const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [ icon: "plus", shortcutKeys: ["mod", "shift", "O"], keywords: ["open", "project", "folder", "workspace", "repo"], - buildRoute: ({ newAgentRoute }) => newAgentRoute, + routeKind: "none", }, { id: "settings", title: "Settings", icon: "settings", keywords: ["settings", "preferences", "config", "configuration"], - buildRoute: ({ settingsRoute }) => settingsRoute, + routeKind: "settings", }, ]; @@ -93,7 +92,7 @@ export type CommandCenterActionItem = { id: string; title: string; icon?: "plus" | "settings"; - route: Href; + route?: Href; shortcutKeys?: ShortcutKey[]; }; @@ -149,11 +148,6 @@ export function useCommandCenter() { return filtered; }, [agents, open, query]); - const newAgentRoute = useMemo(() => { - const serverIdFromPath = activeServerId; - return serverIdFromPath ? (buildHostOpenProjectRoute(serverIdFromPath) as Href) : "/"; - }, [activeServerId]); - const settingsRoute = useMemo(() => { const serverIdFromPath = activeServerId; return serverIdFromPath ? (buildHostSettingsRoute(serverIdFromPath) as Href) : "/"; @@ -170,10 +164,10 @@ export function useCommandCenter() { id: action.id, title: action.title, icon: action.icon, - route: action.buildRoute({ newAgentRoute, settingsRoute }), + route: action.routeKind === "settings" ? settingsRoute : undefined, shortcutKeys: action.shortcutKeys, })); - }, [newAgentRoute, open, query, settingsRoute]); + }, [open, query, settingsRoute]); const items = useMemo(() => { if (!open) { @@ -227,6 +221,9 @@ export function useCommandCenter() { void openProjectPicker(); return; } + if (!action.route) { + return; + } didNavigateRef.current = true; router.push(action.route); }, [openProjectPicker, setOpen]); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 914cb57d4..fb31126f7 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -34,7 +34,6 @@ import { buildLocalDaemonTransportUrl, createTauriLocalDaemonTransportFactory, } from "@/utils/managed-tauri-daemon-transport"; -import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport"; import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync"; import { useSessionStore, type Agent } from "@/stores/session-store"; @@ -433,7 +432,6 @@ function probeIntervalForConnection( function createDefaultDeps(): HostRuntimeControllerDeps { return { createClient: ({ host, connection, clientId, runtimeGeneration }) => { - const tauriTransportFactory = createTauriWebSocketTransportFactory(); const localTransportFactory = createTauriLocalDaemonTransportFactory(); const base = { suppressSendErrors: true, @@ -454,17 +452,11 @@ function createDefaultDeps(): HostRuntimeControllerDeps { if (connection.type === "directTcp") { return new DaemonClient({ ...base, - ...(tauriTransportFactory - ? { transportFactory: tauriTransportFactory } - : {}), url: buildDaemonWebSocketUrl(connection.endpoint), }); } return new DaemonClient({ ...base, - ...(tauriTransportFactory - ? { transportFactory: tauriTransportFactory } - : {}), url: buildRelayWebSocketUrl({ endpoint: connection.relayEndpoint, serverId: host.serverId, diff --git a/packages/app/src/types/host-connection.ts b/packages/app/src/types/host-connection.ts index ef3b57408..a815c78a8 100644 --- a/packages/app/src/types/host-connection.ts +++ b/packages/app/src/types/host-connection.ts @@ -1,4 +1,4 @@ -import { normalizeHostPort } from '@server/shared/daemon-endpoints' +import { normalizeHostPort, normalizeLoopbackToLocalhost } from '@server/shared/daemon-endpoints' export type DirectTcpHostConnection = { id: string @@ -188,7 +188,7 @@ export function connectionFromListen(listen: string): HostConnection | null { } try { - const endpoint = normalizeHostPort(normalizedListen) + const endpoint = normalizeLoopbackToLocalhost(normalizeHostPort(normalizedListen)) return { id: `direct:${endpoint}`, type: 'directTcp', @@ -207,7 +207,7 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null { const type = typeof record.type === 'string' ? record.type : null if (type === 'directTcp') { try { - const endpoint = normalizeHostPort(String(record.endpoint ?? '')) + const endpoint = normalizeLoopbackToLocalhost(normalizeHostPort(String(record.endpoint ?? ''))) return { id: `direct:${endpoint}`, type: 'directTcp', endpoint } } catch { return null diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index b1c287ac5..70784fa91 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import { buildHostAgentDetailRoute, - buildHostNewAgentRoute, buildHostRootRoute, buildHostWorkspaceAgentRoute, buildHostWorkspaceFileRoute, @@ -66,16 +65,6 @@ describe("workspace route parsing", () => { expect(buildHostRootRoute("local")).toBe("/h/local"); }); - it("builds host new-agent routes", () => { - expect(buildHostNewAgentRoute("local")).toBe("/h/local/new-agent"); - expect( - buildHostNewAgentRoute("local", { - workingDir: "/tmp/repo", - worktreeMode: "create", - }) - ).toBe("/h/local/new-agent?workingDir=%2Ftmp%2Frepo&worktreeMode=create"); - }); - it("builds workspace routes with open intent query", () => { expect(buildHostWorkspaceAgentRoute("local", "/tmp/repo", "agent-1")).toBe( "/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1" diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index e6d076806..f912c67e5 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -399,40 +399,6 @@ export function buildHostAgentsRoute(serverId: string): string { return `${base}/agents`; } -export function buildHostNewAgentRoute( - serverId: string, - options?: { - workingDir?: string; - provider?: string; - modeId?: string; - model?: string; - thinkingOptionId?: string; - worktreeMode?: "create" | "attach"; - } -): string { - const base = buildHostRootRoute(serverId); - if (base === "/") { - return "/"; - } - const searchParams = new URLSearchParams(); - const entries: Array<[string, string | undefined]> = [ - ["workingDir", options?.workingDir], - ["provider", options?.provider], - ["modeId", options?.modeId], - ["model", options?.model], - ["thinkingOptionId", options?.thinkingOptionId], - ["worktreeMode", options?.worktreeMode], - ]; - for (const [key, value] of entries) { - const normalized = trimNonEmpty(value); - if (normalized) { - searchParams.set(key, normalized); - } - } - const search = searchParams.toString(); - return search ? `${base}/new-agent?${search}` : `${base}/new-agent`; -} - export function buildHostOpenProjectRoute(serverId: string): string { const base = buildHostRootRoute(serverId); if (base === "/") { @@ -466,9 +432,6 @@ export function mapPathnameToServer( if (suffix.startsWith("agents")) { return `${base}/agents`; } - if (suffix.startsWith("new-agent")) { - return `${base}/new-agent`; - } if (suffix.startsWith("open-project")) { return `${base}/open-project`; } diff --git a/packages/app/src/utils/tauri-daemon-transport.test.ts b/packages/app/src/utils/tauri-daemon-transport.test.ts deleted file mode 100644 index 4cceafe72..000000000 --- a/packages/app/src/utils/tauri-daemon-transport.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const tauriMock = vi.hoisted(() => { - let listenerCleanup: (() => void) | null = null; - let disconnectCount = 0; - - const connect = vi.fn(async () => { - let listenerActive = false; - - return { - addListener: vi.fn(() => { - listenerActive = true; - listenerCleanup = vi.fn(() => { - if (!listenerActive) { - throw new TypeError( - "undefined is not an object (evaluating 'listeners[eventId].handlerId')" - ); - } - listenerActive = false; - }); - return listenerCleanup; - }), - send: vi.fn(async () => undefined), - disconnect: vi.fn(async () => { - disconnectCount += 1; - listenerCleanup?.(); - }), - }; - }); - - return { - connect, - getListenerCleanup: () => listenerCleanup, - getDisconnectCount: () => disconnectCount, - resetState: () => { - listenerCleanup = null; - disconnectCount = 0; - }, - }; -}); - -vi.mock("./tauri", () => ({ - getTauri: () => ({ - websocket: { - connect: tauriMock.connect, - }, - }), -})); - -describe("tauri-daemon-transport", () => { - beforeEach(() => { - tauriMock.connect.mockClear(); - tauriMock.resetState(); - }); - - it("does not unregister the websocket listener twice during close", async () => { - const mod = await import("./tauri-daemon-transport"); - const transportFactory = mod.createTauriWebSocketTransportFactory(); - expect(transportFactory).not.toBeNull(); - - const transport = transportFactory!({ url: "ws://localhost:6767/ws" }); - await Promise.resolve(); - - expect(() => transport.close()).not.toThrow(); - await Promise.resolve(); - expect(tauriMock.getListenerCleanup()).toHaveBeenCalledTimes(1); - }); - - it("disconnects the websocket only once after the connection is already open", async () => { - const mod = await import("./tauri-daemon-transport"); - const transportFactory = mod.createTauriWebSocketTransportFactory(); - expect(transportFactory).not.toBeNull(); - - const transport = transportFactory!({ url: "ws://localhost:6767/ws" }); - await Promise.resolve(); - await Promise.resolve(); - - expect(() => transport.close()).not.toThrow(); - await Promise.resolve(); - - expect(tauriMock.getDisconnectCount()).toBe(1); - expect(tauriMock.getListenerCleanup()).toHaveBeenCalledTimes(1); - }); - - it("disconnects only once when close happens during async connect cleanup", async () => { - let resolveConnect!: (socket: any) => void; - let resolveSend!: () => void; - - tauriMock.connect.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveConnect = resolve; - }) - ); - - const mod = await import("./tauri-daemon-transport"); - const transportFactory = mod.createTauriWebSocketTransportFactory(); - expect(transportFactory).not.toBeNull(); - - const disconnect = vi.fn(async () => undefined); - const addListener = vi.fn(() => vi.fn()); - const send = vi.fn( - () => - new Promise((resolve) => { - resolveSend = resolve; - }) - ); - - const transport = transportFactory!({ url: "ws://localhost:6767/ws" }); - transport.send("queued-before-connect"); - - resolveConnect({ addListener, send, disconnect }); - await Promise.resolve(); - - transport.close(); - resolveSend(); - await Promise.resolve(); - await Promise.resolve(); - - expect(disconnect).toHaveBeenCalledTimes(1); - }); - - it("swallows the tauri duplicate-listener cleanup error when disconnect throws synchronously", async () => { - tauriMock.connect.mockImplementationOnce(async () => ({ - addListener: vi.fn(() => vi.fn()), - send: vi.fn(async () => undefined), - disconnect: vi.fn(() => { - throw new TypeError( - "undefined is not an object (evaluating 'listeners[eventId].handlerId')" - ); - }), - })); - - const mod = await import("./tauri-daemon-transport"); - const transportFactory = mod.createTauriWebSocketTransportFactory(); - expect(transportFactory).not.toBeNull(); - - const transport = transportFactory!({ url: "ws://localhost:6767/ws" }); - await Promise.resolve(); - - expect(() => transport.close()).not.toThrow(); - }); -}); diff --git a/packages/app/src/utils/tauri-daemon-transport.ts b/packages/app/src/utils/tauri-daemon-transport.ts deleted file mode 100644 index b845620ea..000000000 --- a/packages/app/src/utils/tauri-daemon-transport.ts +++ /dev/null @@ -1,256 +0,0 @@ -import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client"; -import { getTauri } from "@/utils/tauri"; - -type TauriWebSocketMessage = - | { type: "Text"; data: string } - | { type: "Binary"; data: number[] } - | { type: "Ping"; data: number[] } - | { type: "Pong"; data: number[] } - | { type: "Close"; data: { code: number; reason: string } | null }; - -type TauriWebSocketConnection = { - addListener(cb: (msg: TauriWebSocketMessage) => void): () => void; - send(message: string | number[] | TauriWebSocketMessage): Promise; - disconnect(): Promise; -}; - -type TauriWebSocketModule = { - connect(url: string, config?: unknown): Promise; -}; - -function isTauriDuplicateListenerCleanupError(error: unknown): boolean { - return ( - error instanceof TypeError && - error.message.includes("listeners[eventId].handlerId") - ); -} - -function toTauriOutgoingMessage( - data: string | Uint8Array | ArrayBuffer -): string | number[] { - if (typeof data === "string") { - return data; - } - if (data instanceof ArrayBuffer) { - return Array.from(new Uint8Array(data)); - } - return Array.from(data); -} - -function getTauriWebSocketModule(): TauriWebSocketModule | null { - const ws = getTauri()?.websocket; - if (ws && typeof ws.connect === "function") { - return ws as TauriWebSocketModule; - } - return null; -} - -export function createTauriWebSocketTransportFactory(): DaemonTransportFactory | null { - if (getTauri() === null) { - return null; - } - - return ({ url }) => { - let ws: TauriWebSocketConnection | null = null; - let wsListenerCleanup: (() => void) | null = null; - let disposed = false; - let opened = false; - let closeEmitted = false; - let disconnectPromise: Promise | null = null; - - const openHandlers = new Set<() => void>(); - const closeHandlers = new Set<(event?: unknown) => void>(); - const errorHandlers = new Set<(event?: unknown) => void>(); - const messageHandlers = new Set<(data: unknown) => void>(); - - const pendingSends: Array = []; - let closeRequested: { code?: number; reason?: string } | null = null; - - const emitOpen = () => { - if (disposed || opened) return; - opened = true; - for (const handler of openHandlers) { - try { - handler(); - } catch { - // no-op - } - } - }; - - const emitClose = (event?: unknown) => { - if (disposed || closeEmitted) return; - closeEmitted = true; - for (const handler of closeHandlers) { - try { - handler(event); - } catch { - // no-op - } - } - }; - - const emitError = (event?: unknown) => { - if (disposed) return; - for (const handler of errorHandlers) { - try { - handler(event); - } catch { - // no-op - } - } - }; - - const emitMessage = (data: unknown) => { - if (disposed) return; - for (const handler of messageHandlers) { - try { - handler(data); - } catch { - // no-op - } - } - }; - - const disconnectSocket = (socket: TauriWebSocketConnection) => { - if (disconnectPromise) { - return disconnectPromise; - } - disconnectPromise = Promise.resolve() - .then(() => socket.disconnect()) - .catch((error) => { - if (!isTauriDuplicateListenerCleanupError(error)) { - emitError(error); - } - }) - .then(() => undefined); - return disconnectPromise; - }; - - const connect = async () => { - try { - let module: TauriWebSocketModule | null = getTauriWebSocketModule(); - if (!module) { - const start = Date.now(); - while (!module && Date.now() - start < 2000) { - await new Promise((r) => setTimeout(r, 50)); - module = getTauriWebSocketModule(); - } - } - if (!module) { - throw new Error( - "Tauri WebSocket plugin is not available (expected getTauri().websocket). Did you enable tauri-plugin-websocket and websocket:default capability?" - ); - } - - const socket = await module.connect(url); - if (disposed) { - await disconnectSocket(socket); - return; - } - - ws = socket; - - wsListenerCleanup = ws.addListener((msg) => { - if (msg.type === "Text") { - emitMessage({ data: msg.data }); - return; - } - if (msg.type === "Binary") { - emitMessage({ data: new Uint8Array(msg.data) }); - return; - } - if (msg.type === "Close") { - emitClose(msg.data ?? undefined); - return; - } - }); - - emitOpen(); - - while (pendingSends.length > 0) { - const next = pendingSends.shift(); - if (!next) break; - try { - await ws.send(next); - } catch (error) { - emitError(error); - } - } - - if (closeRequested) { - await disconnectSocket(ws); - emitClose(closeRequested); - } - } catch (error) { - emitError(error); - } - }; - - void connect(); - - const transport: DaemonTransport = { - send: (data) => { - if (disposed) return; - const outgoing = toTauriOutgoingMessage(data); - if (!ws) { - pendingSends.push(outgoing); - return; - } - void ws.send(outgoing).catch((error) => emitError(error)); - }, - close: (code?: number, reason?: string) => { - if (disposed) return; - closeRequested = { code, reason }; - if (!ws) return; - void disconnectSocket(ws).finally(() => emitClose(closeRequested)); - }, - onMessage: (handler) => { - messageHandlers.add(handler); - return () => messageHandlers.delete(handler); - }, - onOpen: (handler) => { - openHandlers.add(handler); - if (opened) { - try { - handler(); - } catch { - // no-op - } - } - return () => openHandlers.delete(handler); - }, - onClose: (handler) => { - closeHandlers.add(handler); - if (closeEmitted) { - try { - handler(closeRequested ?? undefined); - } catch { - // no-op - } - } - return () => closeHandlers.delete(handler); - }, - onError: (handler) => { - errorHandlers.add(handler); - return () => errorHandlers.delete(handler); - }, - }; - - return { - ...transport, - close: (code?: number, reason?: string) => { - wsListenerCleanup = null; - try { - transport.close(code, reason); - } catch (error) { - if (!isTauriDuplicateListenerCleanupError(error)) { - throw error; - } - } - disposed = true; - ws = null; - }, - }; - }; -} diff --git a/packages/app/src/utils/test-daemon-connection.ts b/packages/app/src/utils/test-daemon-connection.ts index 5c9589e2a..3d2ae5a79 100644 --- a/packages/app/src/utils/test-daemon-connection.ts +++ b/packages/app/src/utils/test-daemon-connection.ts @@ -7,7 +7,6 @@ import { buildLocalDaemonTransportUrl, createTauriLocalDaemonTransportFactory, } from "./managed-tauri-daemon-transport"; -import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport"; function normalizeNonEmptyString(value: unknown): string | null { if (typeof value !== "string") return null; @@ -50,7 +49,6 @@ export async function buildClientConfig( serverId?: string ): Promise { const clientId = await getOrCreateClientId(); - const tauriTransportFactory = createTauriWebSocketTransportFactory(); const localTransportFactory = createTauriLocalDaemonTransportFactory(); const base = { clientId, @@ -61,9 +59,7 @@ export async function buildClientConfig( ? localTransportFactory ? { transportFactory: localTransportFactory } : {} - : tauriTransportFactory - ? { transportFactory: tauriTransportFactory } - : {}), + : {}), }; if (connection.type === "directSocket" || connection.type === "directPipe") { diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 204c33faf..2fd230f24 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -213,6 +213,10 @@ type BranchSuggestionsPayload = BranchSuggestionsResponse['payload'] type DirectorySuggestionsPayload = DirectorySuggestionsResponse['payload'] type PaseoWorktreeListPayload = PaseoWorktreeListResponse['payload'] type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse['payload'] +type CreatePaseoWorktreePayload = Extract< + SessionOutboundMessage, + { type: 'create_paseo_worktree_response' } +>['payload'] type FileExplorerPayload = FileExplorerResponse['payload'] type FileDownloadTokenPayload = FileDownloadTokenResponse['payload'] type ListProviderModelsPayload = ListProviderModelsResponseMessage['payload'] @@ -2148,6 +2152,22 @@ export class DaemonClient { }) } + async createPaseoWorktree( + input: { cwd: string; worktreeSlug?: string }, + requestId?: string + ): Promise { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: 'create_paseo_worktree_request', + cwd: input.cwd, + worktreeSlug: input.worktreeSlug, + }, + responseType: 'create_paseo_worktree_response', + timeout: 60000, + }) + } + async validateBranch( options: { cwd: string; branchName: string }, requestId?: string diff --git a/packages/server/src/server/connection-offer.ts b/packages/server/src/server/connection-offer.ts index 208c6adf5..259ffc206 100644 --- a/packages/server/src/server/connection-offer.ts +++ b/packages/server/src/server/connection-offer.ts @@ -30,7 +30,6 @@ export function buildOfferEndpoints({ } endpoints.push(`localhost:${port}`); - endpoints.push(`127.0.0.1:${port}`); return dedupePreserveOrder(endpoints); } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 6446ab0dd..0ae5e1c52 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -132,6 +132,9 @@ import { DownloadTokenStore } from './file-download/token-store.js' import { PushTokenStore } from './push/token-store.js' import { type WorktreeConfig, + computeWorktreePath, + getWorktreeSetupCommands, + resolveWorktreeRuntimeEnv, slugify, validateBranchSlug, listPaseoWorktrees, @@ -144,6 +147,7 @@ import { getCheckoutDiff, getCheckoutShortstat, getCheckoutStatus, + getCheckoutStatusLite, listBranchSuggestions, NotGitRepoError, MergeConflictError, @@ -154,6 +158,7 @@ import { pushCurrentBranch, createPullRequest, getPullRequestStatus, + resolveRepositoryDefaultBranch, } from '../utils/checkout-git.js' import { getProjectIcon } from '../utils/project-icon.js' import { expandTilde } from '../utils/path.js' @@ -1596,6 +1601,10 @@ export class Session { await this.handlePaseoWorktreeArchiveRequest(msg) break + case 'create_paseo_worktree_request': + await this.handleCreatePaseoWorktreeRequest(msg) + break + case 'open_project_request': await this.handleOpenProjectRequest(msg) break @@ -2870,19 +2879,21 @@ export class Session { `Creating worktree '${normalized.worktreeSlug ?? targetBranch}' for branch ${targetBranch}` ) + const baseBranch = normalized.baseBranch ?? (await this.resolveGitCreateBaseBranch(cwd)) const createdWorktree = await createAgentWorktree({ branchName: targetBranch, cwd, - baseBranch: normalized.baseBranch!, + baseBranch, worktreeSlug: normalized.worktreeSlug ?? targetBranch, paseoHome: this.paseoHome, }) cwd = createdWorktree.worktreePath worktreeConfig = createdWorktree } else if (normalized.createNewBranch) { + const baseBranch = normalized.baseBranch ?? (await this.resolveGitCreateBaseBranch(cwd)) await this.createBranchFromBase({ cwd, - baseBranch: normalized.baseBranch!, + baseBranch, newBranchName: normalized.newBranchName!, }) } else if (normalized.baseBranch) { @@ -3094,13 +3105,6 @@ export class Session { this.assertSafeGitRef(baseBranch, 'base branch') } - if (createWorktree && !baseBranch) { - throw new Error('Base branch is required when creating a worktree') - } - if (createNewBranch && !baseBranch) { - throw new Error('Base branch is required when creating a new branch') - } - if (createNewBranch) { if (!normalizedBranchName) { throw new Error('New branch name is required') @@ -3133,6 +3137,20 @@ export class Session { } } + private async resolveGitCreateBaseBranch(cwd: string): Promise { + const checkout = await getCheckoutStatusLite(cwd, { paseoHome: this.paseoHome }) + if (!checkout.isGit) { + throw new Error('Cannot create a worktree outside a git repository') + } + + const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : cwd + const baseBranch = await resolveRepositoryDefaultBranch(repoRoot) + if (!baseBranch) { + throw new Error('Unable to resolve repository default branch') + } + return baseBranch + } + private toCheckoutError(error: unknown): CheckoutErrorPayload { if (error instanceof NotGitRepoError) { return { code: 'NOT_GIT_REPO', message: error.message } @@ -5677,6 +5695,54 @@ export class Session { return (await this.reconcileWorkspaceRecord(workspaceId)).workspace } + private async registerPendingWorktreeWorkspace(options: { + repoRoot: string + worktreePath: string + branchName: string + }): Promise { + const workspaceId = normalizePersistedWorkspaceId(options.worktreePath) + const basePlacement = await this.buildProjectPlacement(options.repoRoot) + const placement: ProjectPlacementPayload = { + ...basePlacement, + checkout: { + cwd: workspaceId, + isGit: true, + currentBranch: options.branchName, + remoteUrl: basePlacement.checkout.remoteUrl, + isPaseoOwnedWorktree: true, + mainRepoRoot: options.repoRoot, + }, + } + const now = new Date().toISOString() + const existingWorkspace = await this.workspaceRegistry.get(workspaceId) + const existingProject = await this.projectRegistry.get(placement.projectKey) + const nextProjectRecord = this.buildPersistedProjectRecord({ + workspaceId, + placement, + createdAt: existingProject?.createdAt ?? now, + updatedAt: now, + }) + const nextWorkspaceRecord = this.buildPersistedWorkspaceRecord({ + workspaceId, + placement, + createdAt: existingWorkspace?.createdAt ?? now, + updatedAt: now, + }) + + await this.projectRegistry.upsert(nextProjectRecord) + await this.workspaceRegistry.upsert(nextWorkspaceRecord) + + if ( + existingWorkspace && + !existingWorkspace.archivedAt && + existingWorkspace.projectId !== nextWorkspaceRecord.projectId + ) { + await this.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now) + } + + return nextWorkspaceRecord + } + private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise { const existing = await this.workspaceRegistry.get(workspaceId) if (!existing || existing.archivedAt) { @@ -5910,6 +5976,129 @@ export class Session { } } + private async handleCreatePaseoWorktreeRequest( + request: Extract + ): Promise { + try { + const checkout = await getCheckoutStatusLite(request.cwd, { paseoHome: this.paseoHome }) + if (!checkout.isGit) { + throw new Error('Create worktree requires a git repository') + } + + const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : request.cwd + const baseBranch = await resolveRepositoryDefaultBranch(repoRoot) + if (!baseBranch) { + throw new Error('Unable to resolve repository default branch') + } + + const normalizedSlug = request.worktreeSlug ? slugify(request.worktreeSlug) : uuidv4() + const validation = validateBranchSlug(normalizedSlug) + if (!validation.valid) { + throw new Error(`Invalid worktree name: ${validation.error}`) + } + + const worktreePath = await computeWorktreePath(repoRoot, normalizedSlug, this.paseoHome) + const workspace = await this.registerPendingWorktreeWorkspace({ + repoRoot, + worktreePath, + branchName: normalizedSlug, + }) + const descriptor = await this.describeWorkspaceRecord(workspace) + this.emit({ + type: 'create_paseo_worktree_response', + payload: { + workspace: descriptor, + error: null, + setupTerminalId: null, + requestId: request.requestId, + }, + }) + + void this.createPaseoWorktreeInBackground({ + requestCwd: request.cwd, + repoRoot, + baseBranch, + slug: normalizedSlug, + worktreePath, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to create worktree' + this.sessionLogger.error( + { err: error, cwd: request.cwd, worktreeSlug: request.worktreeSlug }, + 'Failed to create worktree' + ) + this.emit({ + type: 'create_paseo_worktree_response', + payload: { + workspace: null, + error: message, + setupTerminalId: null, + requestId: request.requestId, + }, + }) + } + } + + private async createPaseoWorktreeInBackground(options: { + requestCwd: string + repoRoot: string + baseBranch: string + slug: string + worktreePath: string + }): Promise { + let setupTerminalId: string | null = null + + try { + await createAgentWorktree({ + cwd: options.repoRoot, + branchName: options.slug, + baseBranch: options.baseBranch, + worktreeSlug: options.slug, + paseoHome: this.paseoHome, + }) + + const setupCommands = getWorktreeSetupCommands(options.worktreePath) + if (setupCommands.length > 0 && this.terminalManager) { + const runtimeEnv = await resolveWorktreeRuntimeEnv({ + worktreePath: options.worktreePath, + branchName: options.slug, + repoRootPath: options.repoRoot, + }) + this.terminalManager.registerCwdEnv({ + cwd: options.worktreePath, + env: runtimeEnv, + }) + const terminal = await this.terminalManager.createTerminal({ + cwd: options.worktreePath, + name: `setup-${options.slug}`, + env: runtimeEnv, + }) + setupTerminalId = terminal.id + + for (const command of setupCommands) { + terminal.send({ + type: 'input', + data: `${command}\r`, + }) + } + } + } catch (error) { + this.sessionLogger.error( + { + err: error, + cwd: options.requestCwd, + repoRoot: options.repoRoot, + worktreeSlug: options.slug, + worktreePath: options.worktreePath, + setupTerminalId, + }, + 'Background worktree creation failed' + ) + } finally { + await this.emitWorkspaceUpdateForCwd(options.worktreePath) + } + } + private async handleArchiveWorkspaceRequest( request: Extract ): Promise { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 740d55383..bf44ab246 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -1,3 +1,7 @@ +import { execSync } from 'node:child_process' +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' import { describe, expect, test, vi } from 'vitest' import { Session } from './session.js' import type { AgentSnapshotPayload } from '../shared/messages.js' @@ -335,55 +339,110 @@ describe('workspace aggregation', () => { }) }) + test('create paseo worktree request returns a registered workspace descriptor', async () => { + const emitted: Array<{ type: string; payload: unknown }> = [] + const session = createSessionForWorkspaceTests() as any + const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), 'session-worktree-test-'))) + const repoDir = path.join(tempDir, 'repo') + const paseoHome = path.join(tempDir, 'paseo-home') + execSync(`mkdir -p ${repoDir}`) + execSync('git init -b main', { cwd: repoDir, stdio: 'pipe' }) + execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: 'pipe' }) + execSync("git config user.name 'Test'", { cwd: repoDir, stdio: 'pipe' }) + writeFileSync(path.join(repoDir, 'file.txt'), 'hello\n') + execSync('git add .', { cwd: repoDir, stdio: 'pipe' }) + execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: 'pipe' }) + + const workspaces = new Map() + const projects = new Map() + session.paseoHome = paseoHome + session.workspaceRegistry.get = async (workspaceId: string) => workspaces.get(workspaceId) ?? null + session.workspaceRegistry.list = async () => Array.from(workspaces.values()) + session.workspaceRegistry.upsert = async (record: any) => { + workspaces.set(record.workspaceId, record) + } + session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null + session.projectRegistry.list = async () => Array.from(projects.values()) + session.projectRegistry.upsert = async (record: any) => { + projects.set(record.projectId, record) + } + session.emit = (message: { type: string; payload: unknown }) => { + emitted.push(message) + } + try { + await session.handleCreatePaseoWorktreeRequest({ + type: 'create_paseo_worktree_request', + cwd: repoDir, + worktreeSlug: 'worktree-123', + requestId: 'req-worktree', + }) + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } + + const response = emitted.find((message) => message.type === 'create_paseo_worktree_response') as + | { type: 'create_paseo_worktree_response'; payload: any } + | undefined + + expect(response?.payload.error).toBeNull() + expect(response?.payload.workspace).toMatchObject({ + projectDisplayName: 'repo', + projectKind: 'git', + workspaceKind: 'worktree', + name: 'worktree-123', + status: 'done', + }) + expect(response?.payload.workspace?.id).toContain(path.join('worktree-123')) + expect(workspaces.has(response?.payload.workspace?.id)).toBe(true) + expect(projects.has(response?.payload.workspace?.projectId)).toBe(true) + }) + test('workspace update fanout for multiple cwd values is deduplicated', async () => { const emitted: Array<{ type: string; payload: unknown }> = [] const session = createSessionForWorkspaceTests() as any - session.emit = (message: any) => emitted.push(message) session.workspaceUpdatesSubscription = { - subscriptionId: 'sub-dedupe', + subscriptionId: 'sub-dedup', filter: undefined, isBootstrapping: false, pendingUpdatesByWorkspaceId: new Map(), } - session.reconcileActiveWorkspaceRecords = async () => new Set() + session.reconcileActiveWorkspaceRecords = async () => new Set(['/tmp/repo', '/tmp/repo/worktree']) session.listWorkspaceDescriptorsSnapshot = async () => [ { id: '/tmp/repo', projectId: '/tmp/repo', projectDisplayName: 'repo', projectRootPath: '/tmp/repo', - projectKind: 'non_git', - workspaceKind: 'directory', - name: 'repo', + projectKind: 'git', + workspaceKind: 'local_checkout', + name: 'main', status: 'done', activityAt: null, }, { - id: '/tmp/repo/sub', + id: '/tmp/repo/worktree', projectId: '/tmp/repo', projectDisplayName: 'repo', projectRootPath: '/tmp/repo', - projectKind: 'non_git', - workspaceKind: 'directory', - name: 'sub', - status: 'done', - activityAt: null, + projectKind: 'git', + workspaceKind: 'worktree', + name: 'feature', + status: 'running', + activityAt: '2026-03-01T12:00:00.000Z', }, ] + session.onMessage = (message: { type: string; payload: unknown }) => { + emitted.push(message) + } - await session.emitWorkspaceUpdatesForCwds([ - '/tmp/repo', - '/tmp/repo/', - ' /tmp/repo ', - '/tmp/repo/sub', - '/tmp/repo/sub/', - ]) + await session.emitWorkspaceUpdateForCwd('/tmp/repo/worktree') const workspaceUpdates = emitted.filter((message) => message.type === 'workspace_update') as any[] expect(workspaceUpdates).toHaveLength(2) - expect(workspaceUpdates.map((message) => message.payload.workspace.id)).toEqual([ + expect(workspaceUpdates.map((entry) => entry.payload.kind)).toEqual(['upsert', 'upsert']) + expect(workspaceUpdates.map((entry) => entry.payload.workspace.id).sort()).toEqual([ '/tmp/repo', - '/tmp/repo/sub', + '/tmp/repo/worktree', ]) }) diff --git a/packages/server/src/shared/daemon-endpoints.ts b/packages/server/src/shared/daemon-endpoints.ts index 7dbeb1936..fb9a3bda2 100644 --- a/packages/server/src/shared/daemon-endpoints.ts +++ b/packages/server/src/shared/daemon-endpoints.ts @@ -69,6 +69,17 @@ export function normalizeHostPort(input: string): string { return isIpv6 ? `[${host}]:${port}` : `${host}:${port}`; } +export function normalizeLoopbackToLocalhost(endpoint: string): string { + const { host, port, isIpv6 } = parseHostPort(endpoint); + if (host === "127.0.0.1" || (!isIpv6 && host === "0.0.0.0")) { + return `localhost:${port}`; + } + if (isIpv6 && (host === "::1" || host === "::")) { + return `localhost:${port}`; + } + return endpoint; +} + export function deriveLabelFromEndpoint(endpoint: string): string { try { const { host } = parseHostPort(endpoint); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 68f0d29eb..c85382192 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -936,6 +936,13 @@ export const PaseoWorktreeArchiveRequestSchema = z.object({ requestId: z.string(), }) +export const CreatePaseoWorktreeRequestSchema = z.object({ + type: z.literal('create_paseo_worktree_request'), + cwd: z.string(), + worktreeSlug: z.string().optional(), + requestId: z.string(), +}) + export const OpenProjectRequestSchema = z.object({ type: z.literal('open_project_request'), cwd: z.string(), @@ -1186,6 +1193,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion('type', [ DirectorySuggestionsRequestSchema, PaseoWorktreeListRequestSchema, PaseoWorktreeArchiveRequestSchema, + CreatePaseoWorktreeRequestSchema, OpenProjectRequestSchema, ArchiveWorkspaceRequestSchema, FileExplorerRequestSchema, @@ -1961,6 +1969,16 @@ export const PaseoWorktreeArchiveResponseSchema = z.object({ }), }) +export const CreatePaseoWorktreeResponseSchema = z.object({ + type: z.literal('create_paseo_worktree_response'), + payload: z.object({ + workspace: WorkspaceDescriptorPayloadSchema.nullable(), + error: z.string().nullable(), + setupTerminalId: z.string().nullable(), + requestId: z.string(), + }), +}) + export const FileExplorerResponseSchema = z.object({ type: z.literal('file_explorer_response'), payload: z.object({ @@ -2237,6 +2255,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion('type', [ DirectorySuggestionsResponseSchema, PaseoWorktreeListResponseSchema, PaseoWorktreeArchiveResponseSchema, + CreatePaseoWorktreeResponseSchema, FileExplorerResponseSchema, ProjectIconResponseSchema, FileDownloadTokenResponseSchema, diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 40914e467..8ad3b687b 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -17,6 +17,7 @@ import { MergeFromBaseConflictError, NotGitRepoError, pushCurrentBranch, + resolveRepositoryDefaultBranch, } from "./checkout-git.js"; import { createWorktree } from "./worktree.js"; import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js"; @@ -707,6 +708,17 @@ describe("checkout git utilities", () => { expect(baseDiff.diff).not.toContain("file.txt"); }); + it("resolves the repository default branch from origin HEAD", async () => { + execSync("git checkout -b develop", { cwd: repoDir }); + execSync("git checkout main", { cwd: repoDir }); + execSync("git remote add origin https://github.com/acme/repo.git", { cwd: repoDir }); + execSync("git update-ref refs/remotes/origin/main refs/heads/main", { cwd: repoDir }); + execSync("git update-ref refs/remotes/origin/develop refs/heads/develop", { cwd: repoDir }); + execSync("git symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main", { cwd: repoDir }); + + await expect(resolveRepositoryDefaultBranch(repoDir)).resolves.toBe("main"); + }); + it("merges to stored baseRefName when baseRef is not provided", async () => { // Create a non-default base branch with a unique commit. execSync("git checkout -b develop", { cwd: repoDir }); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 40c2c551a..7cf9e83d2 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -708,7 +708,7 @@ async function hasOriginRemote(cwd: string): Promise { return url !== null; } -async function resolveBaseRef(repoRoot: string): Promise { +export async function resolveRepositoryDefaultBranch(repoRoot: string): Promise { try { const { stdout } = await execAsync("git symbolic-ref --quiet refs/remotes/origin/HEAD", { cwd: repoRoot, @@ -755,6 +755,10 @@ async function resolveBaseRef(repoRoot: string): Promise { return null; } +async function resolveBaseRef(repoRoot: string): Promise { + return resolveRepositoryDefaultBranch(repoRoot); +} + function normalizeLocalBranchRefName(input: string): string { return input.startsWith("origin/") ? input.slice("origin/".length) : input; } diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 7e59aaa3f..10db94532 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -668,12 +668,21 @@ export async function deriveWorktreeProjectHash(cwd: string): Promise { } } -async function getPaseoWorktreesRoot(cwd: string, paseoHome?: string): Promise { +export async function getPaseoWorktreesRoot(cwd: string, paseoHome?: string): Promise { const home = paseoHome ? resolve(paseoHome) : resolvePaseoHome(); const projectHash = await deriveWorktreeProjectHash(cwd); return join(home, "worktrees", projectHash); } +export async function computeWorktreePath( + cwd: string, + slug: string, + paseoHome?: string +): Promise { + const worktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome); + return join(worktreesRoot, slug); +} + function normalizePathForOwnership(input: string): string { try { return realpathSync(input);