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
This commit is contained in:
@@ -620,7 +620,6 @@ export default function RootLayout() {
|
|||||||
/>
|
/>
|
||||||
<Stack.Screen name="h/[serverId]/index" />
|
<Stack.Screen name="h/[serverId]/index" />
|
||||||
<Stack.Screen name="h/[serverId]/agents" />
|
<Stack.Screen name="h/[serverId]/agents" />
|
||||||
<Stack.Screen name="h/[serverId]/new-agent" />
|
|
||||||
<Stack.Screen name="h/[serverId]/open-project" />
|
<Stack.Screen name="h/[serverId]/open-project" />
|
||||||
<Stack.Screen name="h/[serverId]/settings" />
|
<Stack.Screen name="h/[serverId]/settings" />
|
||||||
<Stack.Screen name="pair-scan" />
|
<Stack.Screen name="pair-scan" />
|
||||||
|
|||||||
@@ -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 <DraftAgentScreen forcedServerId={serverId} />;
|
|
||||||
}
|
|
||||||
@@ -618,7 +618,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
paddingHorizontal: theme.spacing[1],
|
paddingRight: theme.spacing[1],
|
||||||
|
paddingLeft: theme.spacing[3],
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
newAgentButtonHovered: {},
|
newAgentButtonHovered: {},
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runt
|
|||||||
import { getIsTauri } from '@/constants/layout'
|
import { getIsTauri } from '@/constants/layout'
|
||||||
import { projectIconQueryKey } from '@/hooks/use-project-icon-query'
|
import { projectIconQueryKey } from '@/hooks/use-project-icon-query'
|
||||||
import {
|
import {
|
||||||
buildHostNewAgentRoute,
|
buildHostWorkspaceRouteWithOpenIntent,
|
||||||
parseHostWorkspaceRouteFromPathname,
|
parseHostWorkspaceRouteFromPathname,
|
||||||
} from '@/utils/host-routes'
|
} from '@/utils/host-routes'
|
||||||
import {
|
import {
|
||||||
@@ -71,6 +71,8 @@ import {
|
|||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
import { buildSidebarProjectRowModel } from '@/utils/sidebar-project-row-model'
|
import { buildSidebarProjectRowModel } from '@/utils/sidebar-project-row-model'
|
||||||
import { useNavigationActiveWorkspaceSelection } from '@/stores/navigation-active-workspace-store'
|
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 {
|
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
|
||||||
if (!icon) {
|
if (!icon) {
|
||||||
@@ -126,6 +128,7 @@ interface WorkspaceRowInnerProps {
|
|||||||
drag: () => void
|
drag: () => void
|
||||||
isDragging: boolean
|
isDragging: boolean
|
||||||
isArchiving: boolean
|
isArchiving: boolean
|
||||||
|
isCreating?: boolean
|
||||||
dragHandleProps?: DraggableListDragHandleProps
|
dragHandleProps?: DraggableListDragHandleProps
|
||||||
menuController: ReturnType<typeof useContextMenu> | null
|
menuController: ReturnType<typeof useContextMenu> | null
|
||||||
archiveLabel?: string
|
archiveLabel?: string
|
||||||
@@ -626,6 +629,7 @@ function WorkspaceRowInner({
|
|||||||
drag,
|
drag,
|
||||||
isDragging,
|
isDragging,
|
||||||
isArchiving,
|
isArchiving,
|
||||||
|
isCreating = false,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
menuController,
|
menuController,
|
||||||
archiveLabel,
|
archiveLabel,
|
||||||
@@ -678,15 +682,23 @@ function WorkspaceRowInner({
|
|||||||
ref={dragHandleProps?.setActivatorNodeRef as any}
|
ref={dragHandleProps?.setActivatorNodeRef as any}
|
||||||
style={styles.workspaceRowLeft}
|
style={styles.workspaceRowLeft}
|
||||||
>
|
>
|
||||||
<WorkspaceStatusIndicator bucket={workspace.statusBucket} loading={isArchiving} />
|
<WorkspaceStatusIndicator
|
||||||
|
bucket={workspace.statusBucket}
|
||||||
|
loading={isArchiving || isCreating}
|
||||||
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={[styles.workspaceBranchText, isHovered && styles.workspaceBranchTextHovered]}
|
style={[
|
||||||
|
styles.workspaceBranchText,
|
||||||
|
isHovered && styles.workspaceBranchTextHovered,
|
||||||
|
isCreating && styles.workspaceBranchTextCreating,
|
||||||
|
]}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{workspace.name}
|
{workspace.name}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.workspaceRowRight}>
|
<View style={styles.workspaceRowRight}>
|
||||||
|
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
|
||||||
{onArchive && (isHovered || isMobile) ? (
|
{onArchive && (isHovered || isMobile) ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
@@ -763,6 +775,7 @@ function WorkspaceRowWithMenu({
|
|||||||
isDragging,
|
isDragging,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
canCopyBranchName,
|
canCopyBranchName,
|
||||||
|
isCreating = false,
|
||||||
}: {
|
}: {
|
||||||
workspace: SidebarWorkspaceEntry
|
workspace: SidebarWorkspaceEntry
|
||||||
selected: boolean
|
selected: boolean
|
||||||
@@ -773,6 +786,7 @@ function WorkspaceRowWithMenu({
|
|||||||
isDragging: boolean
|
isDragging: boolean
|
||||||
dragHandleProps?: DraggableListDragHandleProps
|
dragHandleProps?: DraggableListDragHandleProps
|
||||||
canCopyBranchName: boolean
|
canCopyBranchName: boolean
|
||||||
|
isCreating?: boolean
|
||||||
}) {
|
}) {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree)
|
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree)
|
||||||
@@ -873,6 +887,7 @@ function WorkspaceRowWithMenu({
|
|||||||
drag={drag}
|
drag={drag}
|
||||||
isDragging={isDragging}
|
isDragging={isDragging}
|
||||||
isArchiving={isArchiving}
|
isArchiving={isArchiving}
|
||||||
|
isCreating={isCreating}
|
||||||
dragHandleProps={dragHandleProps}
|
dragHandleProps={dragHandleProps}
|
||||||
menuController={null}
|
menuController={null}
|
||||||
archiveLabel={isWorktree ? 'Archive worktree' : 'Hide from sidebar'}
|
archiveLabel={isWorktree ? 'Archive worktree' : 'Hide from sidebar'}
|
||||||
@@ -1082,6 +1097,7 @@ function WorkspaceRow({
|
|||||||
isDragging,
|
isDragging,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
canCopyBranchName,
|
canCopyBranchName,
|
||||||
|
isCreating = false,
|
||||||
}: {
|
}: {
|
||||||
workspace: SidebarWorkspaceEntry
|
workspace: SidebarWorkspaceEntry
|
||||||
selected: boolean
|
selected: boolean
|
||||||
@@ -1092,6 +1108,7 @@ function WorkspaceRow({
|
|||||||
isDragging: boolean
|
isDragging: boolean
|
||||||
dragHandleProps?: DraggableListDragHandleProps
|
dragHandleProps?: DraggableListDragHandleProps
|
||||||
canCopyBranchName: boolean
|
canCopyBranchName: boolean
|
||||||
|
isCreating?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<WorkspaceRowWithMenu
|
<WorkspaceRowWithMenu
|
||||||
@@ -1104,6 +1121,7 @@ function WorkspaceRow({
|
|||||||
isDragging={isDragging}
|
isDragging={isDragging}
|
||||||
dragHandleProps={dragHandleProps}
|
dragHandleProps={dragHandleProps}
|
||||||
canCopyBranchName={canCopyBranchName}
|
canCopyBranchName={canCopyBranchName}
|
||||||
|
isCreating={isCreating}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1126,6 +1144,7 @@ function ProjectBlock({
|
|||||||
isDragging,
|
isDragging,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
useNestable,
|
useNestable,
|
||||||
|
creatingWorkspaceIds,
|
||||||
}: {
|
}: {
|
||||||
project: SidebarProjectEntry
|
project: SidebarProjectEntry
|
||||||
collapsed: boolean
|
collapsed: boolean
|
||||||
@@ -1144,6 +1163,7 @@ function ProjectBlock({
|
|||||||
isDragging: boolean
|
isDragging: boolean
|
||||||
dragHandleProps?: DraggableListDragHandleProps
|
dragHandleProps?: DraggableListDragHandleProps
|
||||||
useNestable: boolean
|
useNestable: boolean
|
||||||
|
creatingWorkspaceIds: ReadonlySet<string>
|
||||||
}) {
|
}) {
|
||||||
const rowModel = useMemo(
|
const rowModel = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -1170,6 +1190,7 @@ function ProjectBlock({
|
|||||||
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
|
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
|
||||||
showShortcutBadge={showShortcutBadges}
|
showShortcutBadge={showShortcutBadges}
|
||||||
canCopyBranchName={project.projectKind === 'git'}
|
canCopyBranchName={project.projectKind === 'git'}
|
||||||
|
isCreating={creatingWorkspaceIds.has(item.workspaceId)}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
return
|
return
|
||||||
@@ -1186,6 +1207,7 @@ function ProjectBlock({
|
|||||||
[
|
[
|
||||||
activeWorkspaceSelection,
|
activeWorkspaceSelection,
|
||||||
project.projectKind,
|
project.projectKind,
|
||||||
|
creatingWorkspaceIds,
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
serverId,
|
serverId,
|
||||||
shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey,
|
||||||
@@ -1300,6 +1322,13 @@ export function SidebarWorkspaceList({
|
|||||||
const isNative = Platform.OS !== 'web'
|
const isNative = Platform.OS !== 'web'
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection()
|
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection()
|
||||||
|
const toast = useToast()
|
||||||
|
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces)
|
||||||
|
const [creatingProjectKey, setCreatingProjectKey] = useState<string | null>(null)
|
||||||
|
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set())
|
||||||
|
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
||||||
|
new Map()
|
||||||
|
)
|
||||||
const isTauri = getIsTauri()
|
const isTauri = getIsTauri()
|
||||||
const altDown = useKeyboardShortcutsStore((state) => state.altDown)
|
const altDown = useKeyboardShortcutsStore((state) => state.altDown)
|
||||||
const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown)
|
const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown)
|
||||||
@@ -1379,6 +1408,51 @@ export function SidebarWorkspaceList({
|
|||||||
return byProject
|
return byProject
|
||||||
}, [projectIconQueries, projectIconRequests, projects, serverId])
|
}, [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<string>()
|
||||||
|
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(
|
const handleProjectDragEnd = useCallback(
|
||||||
(reorderedProjects: SidebarProjectEntry[]) => {
|
(reorderedProjects: SidebarProjectEntry[]) => {
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
@@ -1437,20 +1511,68 @@ export function SidebarWorkspaceList({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const handleCreateWorktree = useCallback(
|
const handleCreateWorktree = useCallback(
|
||||||
(project: SidebarProjectEntry) => {
|
async (project: SidebarProjectEntry) => {
|
||||||
if (!serverId || project.projectKind !== 'git') {
|
if (!serverId || project.projectKind !== 'git') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (creatingProjectKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
onSetProjectCollapsed(project.projectKey, false)
|
onSetProjectCollapsed(project.projectKey, false)
|
||||||
onWorkspacePress?.()
|
setCreatingProjectKey(project.projectKey)
|
||||||
router.push(
|
try {
|
||||||
buildHostNewAgentRoute(serverId, {
|
const client = getHostRuntimeStore().getClient(serverId)
|
||||||
workingDir: project.iconWorkingDir,
|
if (!client || !isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(serverId))) {
|
||||||
worktreeMode: 'create',
|
throw new Error('Host is not connected')
|
||||||
}) as any
|
}
|
||||||
)
|
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(
|
const renderProject = useCallback(
|
||||||
@@ -1474,6 +1596,7 @@ export function SidebarWorkspaceList({
|
|||||||
isDragging={isActive}
|
isDragging={isActive}
|
||||||
dragHandleProps={dragHandleProps}
|
dragHandleProps={dragHandleProps}
|
||||||
useNestable={isNative}
|
useNestable={isNative}
|
||||||
|
creatingWorkspaceIds={creatingWorkspaceIds}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -1490,6 +1613,7 @@ export function SidebarWorkspaceList({
|
|||||||
shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey,
|
||||||
showShortcutBadges,
|
showShortcutBadges,
|
||||||
isNative,
|
isNative,
|
||||||
|
creatingWorkspaceIds,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1771,9 +1895,17 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
|
workspaceBranchTextCreating: {
|
||||||
|
opacity: 0.92,
|
||||||
|
},
|
||||||
workspaceBranchTextHovered: {
|
workspaceBranchTextHovered: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
},
|
},
|
||||||
|
workspaceCreatingText: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
diffStatRow: {
|
diffStatRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
takeCommandCenterFocusRestoreElement,
|
takeCommandCenterFocusRestoreElement,
|
||||||
} from "@/utils/command-center-focus-restore";
|
} from "@/utils/command-center-focus-restore";
|
||||||
import {
|
import {
|
||||||
buildHostOpenProjectRoute,
|
|
||||||
buildHostWorkspaceAgentRoute,
|
buildHostWorkspaceAgentRoute,
|
||||||
buildHostSettingsRoute,
|
buildHostSettingsRoute,
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
@@ -55,7 +54,7 @@ type CommandCenterActionDefinition = {
|
|||||||
icon?: "plus" | "settings";
|
icon?: "plus" | "settings";
|
||||||
shortcutKeys?: ShortcutKey[];
|
shortcutKeys?: ShortcutKey[];
|
||||||
keywords: string[];
|
keywords: string[];
|
||||||
buildRoute: (params: { newAgentRoute: Href; settingsRoute: Href }) => Href;
|
routeKind: "settings" | "none";
|
||||||
};
|
};
|
||||||
|
|
||||||
const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
|
const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
|
||||||
@@ -65,14 +64,14 @@ const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
|
|||||||
icon: "plus",
|
icon: "plus",
|
||||||
shortcutKeys: ["mod", "shift", "O"],
|
shortcutKeys: ["mod", "shift", "O"],
|
||||||
keywords: ["open", "project", "folder", "workspace", "repo"],
|
keywords: ["open", "project", "folder", "workspace", "repo"],
|
||||||
buildRoute: ({ newAgentRoute }) => newAgentRoute,
|
routeKind: "none",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "settings",
|
id: "settings",
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
icon: "settings",
|
icon: "settings",
|
||||||
keywords: ["settings", "preferences", "config", "configuration"],
|
keywords: ["settings", "preferences", "config", "configuration"],
|
||||||
buildRoute: ({ settingsRoute }) => settingsRoute,
|
routeKind: "settings",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -93,7 +92,7 @@ export type CommandCenterActionItem = {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
icon?: "plus" | "settings";
|
icon?: "plus" | "settings";
|
||||||
route: Href;
|
route?: Href;
|
||||||
shortcutKeys?: ShortcutKey[];
|
shortcutKeys?: ShortcutKey[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -149,11 +148,6 @@ export function useCommandCenter() {
|
|||||||
return filtered;
|
return filtered;
|
||||||
}, [agents, open, query]);
|
}, [agents, open, query]);
|
||||||
|
|
||||||
const newAgentRoute = useMemo<Href>(() => {
|
|
||||||
const serverIdFromPath = activeServerId;
|
|
||||||
return serverIdFromPath ? (buildHostOpenProjectRoute(serverIdFromPath) as Href) : "/";
|
|
||||||
}, [activeServerId]);
|
|
||||||
|
|
||||||
const settingsRoute = useMemo<Href>(() => {
|
const settingsRoute = useMemo<Href>(() => {
|
||||||
const serverIdFromPath = activeServerId;
|
const serverIdFromPath = activeServerId;
|
||||||
return serverIdFromPath ? (buildHostSettingsRoute(serverIdFromPath) as Href) : "/";
|
return serverIdFromPath ? (buildHostSettingsRoute(serverIdFromPath) as Href) : "/";
|
||||||
@@ -170,10 +164,10 @@ export function useCommandCenter() {
|
|||||||
id: action.id,
|
id: action.id,
|
||||||
title: action.title,
|
title: action.title,
|
||||||
icon: action.icon,
|
icon: action.icon,
|
||||||
route: action.buildRoute({ newAgentRoute, settingsRoute }),
|
route: action.routeKind === "settings" ? settingsRoute : undefined,
|
||||||
shortcutKeys: action.shortcutKeys,
|
shortcutKeys: action.shortcutKeys,
|
||||||
}));
|
}));
|
||||||
}, [newAgentRoute, open, query, settingsRoute]);
|
}, [open, query, settingsRoute]);
|
||||||
|
|
||||||
const items = useMemo(() => {
|
const items = useMemo(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@@ -227,6 +221,9 @@ export function useCommandCenter() {
|
|||||||
void openProjectPicker();
|
void openProjectPicker();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!action.route) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
didNavigateRef.current = true;
|
didNavigateRef.current = true;
|
||||||
router.push(action.route);
|
router.push(action.route);
|
||||||
}, [openProjectPicker, setOpen]);
|
}, [openProjectPicker, setOpen]);
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import {
|
|||||||
buildLocalDaemonTransportUrl,
|
buildLocalDaemonTransportUrl,
|
||||||
createTauriLocalDaemonTransportFactory,
|
createTauriLocalDaemonTransportFactory,
|
||||||
} from "@/utils/managed-tauri-daemon-transport";
|
} from "@/utils/managed-tauri-daemon-transport";
|
||||||
import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport";
|
|
||||||
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
||||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||||
|
|
||||||
@@ -433,7 +432,6 @@ function probeIntervalForConnection(
|
|||||||
function createDefaultDeps(): HostRuntimeControllerDeps {
|
function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||||
return {
|
return {
|
||||||
createClient: ({ host, connection, clientId, runtimeGeneration }) => {
|
createClient: ({ host, connection, clientId, runtimeGeneration }) => {
|
||||||
const tauriTransportFactory = createTauriWebSocketTransportFactory();
|
|
||||||
const localTransportFactory = createTauriLocalDaemonTransportFactory();
|
const localTransportFactory = createTauriLocalDaemonTransportFactory();
|
||||||
const base = {
|
const base = {
|
||||||
suppressSendErrors: true,
|
suppressSendErrors: true,
|
||||||
@@ -454,17 +452,11 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
|||||||
if (connection.type === "directTcp") {
|
if (connection.type === "directTcp") {
|
||||||
return new DaemonClient({
|
return new DaemonClient({
|
||||||
...base,
|
...base,
|
||||||
...(tauriTransportFactory
|
|
||||||
? { transportFactory: tauriTransportFactory }
|
|
||||||
: {}),
|
|
||||||
url: buildDaemonWebSocketUrl(connection.endpoint),
|
url: buildDaemonWebSocketUrl(connection.endpoint),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return new DaemonClient({
|
return new DaemonClient({
|
||||||
...base,
|
...base,
|
||||||
...(tauriTransportFactory
|
|
||||||
? { transportFactory: tauriTransportFactory }
|
|
||||||
: {}),
|
|
||||||
url: buildRelayWebSocketUrl({
|
url: buildRelayWebSocketUrl({
|
||||||
endpoint: connection.relayEndpoint,
|
endpoint: connection.relayEndpoint,
|
||||||
serverId: host.serverId,
|
serverId: host.serverId,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { normalizeHostPort } from '@server/shared/daemon-endpoints'
|
import { normalizeHostPort, normalizeLoopbackToLocalhost } from '@server/shared/daemon-endpoints'
|
||||||
|
|
||||||
export type DirectTcpHostConnection = {
|
export type DirectTcpHostConnection = {
|
||||||
id: string
|
id: string
|
||||||
@@ -188,7 +188,7 @@ export function connectionFromListen(listen: string): HostConnection | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const endpoint = normalizeHostPort(normalizedListen)
|
const endpoint = normalizeLoopbackToLocalhost(normalizeHostPort(normalizedListen))
|
||||||
return {
|
return {
|
||||||
id: `direct:${endpoint}`,
|
id: `direct:${endpoint}`,
|
||||||
type: 'directTcp',
|
type: 'directTcp',
|
||||||
@@ -207,7 +207,7 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null {
|
|||||||
const type = typeof record.type === 'string' ? record.type : null
|
const type = typeof record.type === 'string' ? record.type : null
|
||||||
if (type === 'directTcp') {
|
if (type === 'directTcp') {
|
||||||
try {
|
try {
|
||||||
const endpoint = normalizeHostPort(String(record.endpoint ?? ''))
|
const endpoint = normalizeLoopbackToLocalhost(normalizeHostPort(String(record.endpoint ?? '')))
|
||||||
return { id: `direct:${endpoint}`, type: 'directTcp', endpoint }
|
return { id: `direct:${endpoint}`, type: 'directTcp', endpoint }
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
buildHostAgentDetailRoute,
|
buildHostAgentDetailRoute,
|
||||||
buildHostNewAgentRoute,
|
|
||||||
buildHostRootRoute,
|
buildHostRootRoute,
|
||||||
buildHostWorkspaceAgentRoute,
|
buildHostWorkspaceAgentRoute,
|
||||||
buildHostWorkspaceFileRoute,
|
buildHostWorkspaceFileRoute,
|
||||||
@@ -66,16 +65,6 @@ describe("workspace route parsing", () => {
|
|||||||
expect(buildHostRootRoute("local")).toBe("/h/local");
|
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", () => {
|
it("builds workspace routes with open intent query", () => {
|
||||||
expect(buildHostWorkspaceAgentRoute("local", "/tmp/repo", "agent-1")).toBe(
|
expect(buildHostWorkspaceAgentRoute("local", "/tmp/repo", "agent-1")).toBe(
|
||||||
"/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1"
|
"/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1"
|
||||||
|
|||||||
@@ -399,40 +399,6 @@ export function buildHostAgentsRoute(serverId: string): string {
|
|||||||
return `${base}/agents`;
|
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 {
|
export function buildHostOpenProjectRoute(serverId: string): string {
|
||||||
const base = buildHostRootRoute(serverId);
|
const base = buildHostRootRoute(serverId);
|
||||||
if (base === "/") {
|
if (base === "/") {
|
||||||
@@ -466,9 +432,6 @@ export function mapPathnameToServer(
|
|||||||
if (suffix.startsWith("agents")) {
|
if (suffix.startsWith("agents")) {
|
||||||
return `${base}/agents`;
|
return `${base}/agents`;
|
||||||
}
|
}
|
||||||
if (suffix.startsWith("new-agent")) {
|
|
||||||
return `${base}/new-agent`;
|
|
||||||
}
|
|
||||||
if (suffix.startsWith("open-project")) {
|
if (suffix.startsWith("open-project")) {
|
||||||
return `${base}/open-project`;
|
return `${base}/open-project`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void>((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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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<void>;
|
|
||||||
disconnect(): Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TauriWebSocketModule = {
|
|
||||||
connect(url: string, config?: unknown): Promise<TauriWebSocketConnection>;
|
|
||||||
};
|
|
||||||
|
|
||||||
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<void> | 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<string | number[]> = [];
|
|
||||||
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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
buildLocalDaemonTransportUrl,
|
buildLocalDaemonTransportUrl,
|
||||||
createTauriLocalDaemonTransportFactory,
|
createTauriLocalDaemonTransportFactory,
|
||||||
} from "./managed-tauri-daemon-transport";
|
} from "./managed-tauri-daemon-transport";
|
||||||
import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport";
|
|
||||||
|
|
||||||
function normalizeNonEmptyString(value: unknown): string | null {
|
function normalizeNonEmptyString(value: unknown): string | null {
|
||||||
if (typeof value !== "string") return null;
|
if (typeof value !== "string") return null;
|
||||||
@@ -50,7 +49,6 @@ export async function buildClientConfig(
|
|||||||
serverId?: string
|
serverId?: string
|
||||||
): Promise<DaemonClientConfig> {
|
): Promise<DaemonClientConfig> {
|
||||||
const clientId = await getOrCreateClientId();
|
const clientId = await getOrCreateClientId();
|
||||||
const tauriTransportFactory = createTauriWebSocketTransportFactory();
|
|
||||||
const localTransportFactory = createTauriLocalDaemonTransportFactory();
|
const localTransportFactory = createTauriLocalDaemonTransportFactory();
|
||||||
const base = {
|
const base = {
|
||||||
clientId,
|
clientId,
|
||||||
@@ -61,9 +59,7 @@ export async function buildClientConfig(
|
|||||||
? localTransportFactory
|
? localTransportFactory
|
||||||
? { transportFactory: localTransportFactory }
|
? { transportFactory: localTransportFactory }
|
||||||
: {}
|
: {}
|
||||||
: tauriTransportFactory
|
: {}),
|
||||||
? { transportFactory: tauriTransportFactory }
|
|
||||||
: {}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
||||||
|
|||||||
@@ -213,6 +213,10 @@ type BranchSuggestionsPayload = BranchSuggestionsResponse['payload']
|
|||||||
type DirectorySuggestionsPayload = DirectorySuggestionsResponse['payload']
|
type DirectorySuggestionsPayload = DirectorySuggestionsResponse['payload']
|
||||||
type PaseoWorktreeListPayload = PaseoWorktreeListResponse['payload']
|
type PaseoWorktreeListPayload = PaseoWorktreeListResponse['payload']
|
||||||
type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse['payload']
|
type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse['payload']
|
||||||
|
type CreatePaseoWorktreePayload = Extract<
|
||||||
|
SessionOutboundMessage,
|
||||||
|
{ type: 'create_paseo_worktree_response' }
|
||||||
|
>['payload']
|
||||||
type FileExplorerPayload = FileExplorerResponse['payload']
|
type FileExplorerPayload = FileExplorerResponse['payload']
|
||||||
type FileDownloadTokenPayload = FileDownloadTokenResponse['payload']
|
type FileDownloadTokenPayload = FileDownloadTokenResponse['payload']
|
||||||
type ListProviderModelsPayload = ListProviderModelsResponseMessage['payload']
|
type ListProviderModelsPayload = ListProviderModelsResponseMessage['payload']
|
||||||
@@ -2148,6 +2152,22 @@ export class DaemonClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createPaseoWorktree(
|
||||||
|
input: { cwd: string; worktreeSlug?: string },
|
||||||
|
requestId?: string
|
||||||
|
): Promise<CreatePaseoWorktreePayload> {
|
||||||
|
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(
|
async validateBranch(
|
||||||
options: { cwd: string; branchName: string },
|
options: { cwd: string; branchName: string },
|
||||||
requestId?: string
|
requestId?: string
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ export function buildOfferEndpoints({
|
|||||||
}
|
}
|
||||||
|
|
||||||
endpoints.push(`localhost:${port}`);
|
endpoints.push(`localhost:${port}`);
|
||||||
endpoints.push(`127.0.0.1:${port}`);
|
|
||||||
|
|
||||||
return dedupePreserveOrder(endpoints);
|
return dedupePreserveOrder(endpoints);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,9 @@ import { DownloadTokenStore } from './file-download/token-store.js'
|
|||||||
import { PushTokenStore } from './push/token-store.js'
|
import { PushTokenStore } from './push/token-store.js'
|
||||||
import {
|
import {
|
||||||
type WorktreeConfig,
|
type WorktreeConfig,
|
||||||
|
computeWorktreePath,
|
||||||
|
getWorktreeSetupCommands,
|
||||||
|
resolveWorktreeRuntimeEnv,
|
||||||
slugify,
|
slugify,
|
||||||
validateBranchSlug,
|
validateBranchSlug,
|
||||||
listPaseoWorktrees,
|
listPaseoWorktrees,
|
||||||
@@ -144,6 +147,7 @@ import {
|
|||||||
getCheckoutDiff,
|
getCheckoutDiff,
|
||||||
getCheckoutShortstat,
|
getCheckoutShortstat,
|
||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
|
getCheckoutStatusLite,
|
||||||
listBranchSuggestions,
|
listBranchSuggestions,
|
||||||
NotGitRepoError,
|
NotGitRepoError,
|
||||||
MergeConflictError,
|
MergeConflictError,
|
||||||
@@ -154,6 +158,7 @@ import {
|
|||||||
pushCurrentBranch,
|
pushCurrentBranch,
|
||||||
createPullRequest,
|
createPullRequest,
|
||||||
getPullRequestStatus,
|
getPullRequestStatus,
|
||||||
|
resolveRepositoryDefaultBranch,
|
||||||
} from '../utils/checkout-git.js'
|
} from '../utils/checkout-git.js'
|
||||||
import { getProjectIcon } from '../utils/project-icon.js'
|
import { getProjectIcon } from '../utils/project-icon.js'
|
||||||
import { expandTilde } from '../utils/path.js'
|
import { expandTilde } from '../utils/path.js'
|
||||||
@@ -1596,6 +1601,10 @@ export class Session {
|
|||||||
await this.handlePaseoWorktreeArchiveRequest(msg)
|
await this.handlePaseoWorktreeArchiveRequest(msg)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case 'create_paseo_worktree_request':
|
||||||
|
await this.handleCreatePaseoWorktreeRequest(msg)
|
||||||
|
break
|
||||||
|
|
||||||
case 'open_project_request':
|
case 'open_project_request':
|
||||||
await this.handleOpenProjectRequest(msg)
|
await this.handleOpenProjectRequest(msg)
|
||||||
break
|
break
|
||||||
@@ -2870,19 +2879,21 @@ export class Session {
|
|||||||
`Creating worktree '${normalized.worktreeSlug ?? targetBranch}' for branch ${targetBranch}`
|
`Creating worktree '${normalized.worktreeSlug ?? targetBranch}' for branch ${targetBranch}`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const baseBranch = normalized.baseBranch ?? (await this.resolveGitCreateBaseBranch(cwd))
|
||||||
const createdWorktree = await createAgentWorktree({
|
const createdWorktree = await createAgentWorktree({
|
||||||
branchName: targetBranch,
|
branchName: targetBranch,
|
||||||
cwd,
|
cwd,
|
||||||
baseBranch: normalized.baseBranch!,
|
baseBranch,
|
||||||
worktreeSlug: normalized.worktreeSlug ?? targetBranch,
|
worktreeSlug: normalized.worktreeSlug ?? targetBranch,
|
||||||
paseoHome: this.paseoHome,
|
paseoHome: this.paseoHome,
|
||||||
})
|
})
|
||||||
cwd = createdWorktree.worktreePath
|
cwd = createdWorktree.worktreePath
|
||||||
worktreeConfig = createdWorktree
|
worktreeConfig = createdWorktree
|
||||||
} else if (normalized.createNewBranch) {
|
} else if (normalized.createNewBranch) {
|
||||||
|
const baseBranch = normalized.baseBranch ?? (await this.resolveGitCreateBaseBranch(cwd))
|
||||||
await this.createBranchFromBase({
|
await this.createBranchFromBase({
|
||||||
cwd,
|
cwd,
|
||||||
baseBranch: normalized.baseBranch!,
|
baseBranch,
|
||||||
newBranchName: normalized.newBranchName!,
|
newBranchName: normalized.newBranchName!,
|
||||||
})
|
})
|
||||||
} else if (normalized.baseBranch) {
|
} else if (normalized.baseBranch) {
|
||||||
@@ -3094,13 +3105,6 @@ export class Session {
|
|||||||
this.assertSafeGitRef(baseBranch, 'base branch')
|
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 (createNewBranch) {
|
||||||
if (!normalizedBranchName) {
|
if (!normalizedBranchName) {
|
||||||
throw new Error('New branch name is required')
|
throw new Error('New branch name is required')
|
||||||
@@ -3133,6 +3137,20 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveGitCreateBaseBranch(cwd: string): Promise<string> {
|
||||||
|
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 {
|
private toCheckoutError(error: unknown): CheckoutErrorPayload {
|
||||||
if (error instanceof NotGitRepoError) {
|
if (error instanceof NotGitRepoError) {
|
||||||
return { code: 'NOT_GIT_REPO', message: error.message }
|
return { code: 'NOT_GIT_REPO', message: error.message }
|
||||||
@@ -5677,6 +5695,54 @@ export class Session {
|
|||||||
return (await this.reconcileWorkspaceRecord(workspaceId)).workspace
|
return (await this.reconcileWorkspaceRecord(workspaceId)).workspace
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async registerPendingWorktreeWorkspace(options: {
|
||||||
|
repoRoot: string
|
||||||
|
worktreePath: string
|
||||||
|
branchName: string
|
||||||
|
}): Promise<PersistedWorkspaceRecord> {
|
||||||
|
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<void> {
|
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
|
||||||
const existing = await this.workspaceRegistry.get(workspaceId)
|
const existing = await this.workspaceRegistry.get(workspaceId)
|
||||||
if (!existing || existing.archivedAt) {
|
if (!existing || existing.archivedAt) {
|
||||||
@@ -5910,6 +5976,129 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleCreatePaseoWorktreeRequest(
|
||||||
|
request: Extract<SessionInboundMessage, { type: 'create_paseo_worktree_request' }>
|
||||||
|
): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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(
|
private async handleArchiveWorkspaceRequest(
|
||||||
request: Extract<SessionInboundMessage, { type: 'archive_workspace_request' }>
|
request: Extract<SessionInboundMessage, { type: 'archive_workspace_request' }>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -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 { describe, expect, test, vi } from 'vitest'
|
||||||
import { Session } from './session.js'
|
import { Session } from './session.js'
|
||||||
import type { AgentSnapshotPayload } from '../shared/messages.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 () => {
|
test('workspace update fanout for multiple cwd values is deduplicated', async () => {
|
||||||
const emitted: Array<{ type: string; payload: unknown }> = []
|
const emitted: Array<{ type: string; payload: unknown }> = []
|
||||||
const session = createSessionForWorkspaceTests() as any
|
const session = createSessionForWorkspaceTests() as any
|
||||||
session.emit = (message: any) => emitted.push(message)
|
|
||||||
session.workspaceUpdatesSubscription = {
|
session.workspaceUpdatesSubscription = {
|
||||||
subscriptionId: 'sub-dedupe',
|
subscriptionId: 'sub-dedup',
|
||||||
filter: undefined,
|
filter: undefined,
|
||||||
isBootstrapping: false,
|
isBootstrapping: false,
|
||||||
pendingUpdatesByWorkspaceId: new Map(),
|
pendingUpdatesByWorkspaceId: new Map(),
|
||||||
}
|
}
|
||||||
session.reconcileActiveWorkspaceRecords = async () => new Set()
|
session.reconcileActiveWorkspaceRecords = async () => new Set(['/tmp/repo', '/tmp/repo/worktree'])
|
||||||
session.listWorkspaceDescriptorsSnapshot = async () => [
|
session.listWorkspaceDescriptorsSnapshot = async () => [
|
||||||
{
|
{
|
||||||
id: '/tmp/repo',
|
id: '/tmp/repo',
|
||||||
projectId: '/tmp/repo',
|
projectId: '/tmp/repo',
|
||||||
projectDisplayName: 'repo',
|
projectDisplayName: 'repo',
|
||||||
projectRootPath: '/tmp/repo',
|
projectRootPath: '/tmp/repo',
|
||||||
projectKind: 'non_git',
|
projectKind: 'git',
|
||||||
workspaceKind: 'directory',
|
workspaceKind: 'local_checkout',
|
||||||
name: 'repo',
|
name: 'main',
|
||||||
status: 'done',
|
status: 'done',
|
||||||
activityAt: null,
|
activityAt: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '/tmp/repo/sub',
|
id: '/tmp/repo/worktree',
|
||||||
projectId: '/tmp/repo',
|
projectId: '/tmp/repo',
|
||||||
projectDisplayName: 'repo',
|
projectDisplayName: 'repo',
|
||||||
projectRootPath: '/tmp/repo',
|
projectRootPath: '/tmp/repo',
|
||||||
projectKind: 'non_git',
|
projectKind: 'git',
|
||||||
workspaceKind: 'directory',
|
workspaceKind: 'worktree',
|
||||||
name: 'sub',
|
name: 'feature',
|
||||||
status: 'done',
|
status: 'running',
|
||||||
activityAt: null,
|
activityAt: '2026-03-01T12:00:00.000Z',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
session.onMessage = (message: { type: string; payload: unknown }) => {
|
||||||
|
emitted.push(message)
|
||||||
|
}
|
||||||
|
|
||||||
await session.emitWorkspaceUpdatesForCwds([
|
await session.emitWorkspaceUpdateForCwd('/tmp/repo/worktree')
|
||||||
'/tmp/repo',
|
|
||||||
'/tmp/repo/',
|
|
||||||
' /tmp/repo ',
|
|
||||||
'/tmp/repo/sub',
|
|
||||||
'/tmp/repo/sub/',
|
|
||||||
])
|
|
||||||
|
|
||||||
const workspaceUpdates = emitted.filter((message) => message.type === 'workspace_update') as any[]
|
const workspaceUpdates = emitted.filter((message) => message.type === 'workspace_update') as any[]
|
||||||
expect(workspaceUpdates).toHaveLength(2)
|
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',
|
||||||
'/tmp/repo/sub',
|
'/tmp/repo/worktree',
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,17 @@ export function normalizeHostPort(input: string): string {
|
|||||||
return isIpv6 ? `[${host}]:${port}` : `${host}:${port}`;
|
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 {
|
export function deriveLabelFromEndpoint(endpoint: string): string {
|
||||||
try {
|
try {
|
||||||
const { host } = parseHostPort(endpoint);
|
const { host } = parseHostPort(endpoint);
|
||||||
|
|||||||
@@ -936,6 +936,13 @@ export const PaseoWorktreeArchiveRequestSchema = z.object({
|
|||||||
requestId: z.string(),
|
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({
|
export const OpenProjectRequestSchema = z.object({
|
||||||
type: z.literal('open_project_request'),
|
type: z.literal('open_project_request'),
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
@@ -1186,6 +1193,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion('type', [
|
|||||||
DirectorySuggestionsRequestSchema,
|
DirectorySuggestionsRequestSchema,
|
||||||
PaseoWorktreeListRequestSchema,
|
PaseoWorktreeListRequestSchema,
|
||||||
PaseoWorktreeArchiveRequestSchema,
|
PaseoWorktreeArchiveRequestSchema,
|
||||||
|
CreatePaseoWorktreeRequestSchema,
|
||||||
OpenProjectRequestSchema,
|
OpenProjectRequestSchema,
|
||||||
ArchiveWorkspaceRequestSchema,
|
ArchiveWorkspaceRequestSchema,
|
||||||
FileExplorerRequestSchema,
|
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({
|
export const FileExplorerResponseSchema = z.object({
|
||||||
type: z.literal('file_explorer_response'),
|
type: z.literal('file_explorer_response'),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -2237,6 +2255,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion('type', [
|
|||||||
DirectorySuggestionsResponseSchema,
|
DirectorySuggestionsResponseSchema,
|
||||||
PaseoWorktreeListResponseSchema,
|
PaseoWorktreeListResponseSchema,
|
||||||
PaseoWorktreeArchiveResponseSchema,
|
PaseoWorktreeArchiveResponseSchema,
|
||||||
|
CreatePaseoWorktreeResponseSchema,
|
||||||
FileExplorerResponseSchema,
|
FileExplorerResponseSchema,
|
||||||
ProjectIconResponseSchema,
|
ProjectIconResponseSchema,
|
||||||
FileDownloadTokenResponseSchema,
|
FileDownloadTokenResponseSchema,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
MergeFromBaseConflictError,
|
MergeFromBaseConflictError,
|
||||||
NotGitRepoError,
|
NotGitRepoError,
|
||||||
pushCurrentBranch,
|
pushCurrentBranch,
|
||||||
|
resolveRepositoryDefaultBranch,
|
||||||
} from "./checkout-git.js";
|
} from "./checkout-git.js";
|
||||||
import { createWorktree } from "./worktree.js";
|
import { createWorktree } from "./worktree.js";
|
||||||
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
|
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
|
||||||
@@ -707,6 +708,17 @@ describe("checkout git utilities", () => {
|
|||||||
expect(baseDiff.diff).not.toContain("file.txt");
|
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 () => {
|
it("merges to stored baseRefName when baseRef is not provided", async () => {
|
||||||
// Create a non-default base branch with a unique commit.
|
// Create a non-default base branch with a unique commit.
|
||||||
execSync("git checkout -b develop", { cwd: repoDir });
|
execSync("git checkout -b develop", { cwd: repoDir });
|
||||||
|
|||||||
@@ -708,7 +708,7 @@ async function hasOriginRemote(cwd: string): Promise<boolean> {
|
|||||||
return url !== null;
|
return url !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveBaseRef(repoRoot: string): Promise<string | null> {
|
export async function resolveRepositoryDefaultBranch(repoRoot: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync("git symbolic-ref --quiet refs/remotes/origin/HEAD", {
|
const { stdout } = await execAsync("git symbolic-ref --quiet refs/remotes/origin/HEAD", {
|
||||||
cwd: repoRoot,
|
cwd: repoRoot,
|
||||||
@@ -755,6 +755,10 @@ async function resolveBaseRef(repoRoot: string): Promise<string | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveBaseRef(repoRoot: string): Promise<string | null> {
|
||||||
|
return resolveRepositoryDefaultBranch(repoRoot);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeLocalBranchRefName(input: string): string {
|
function normalizeLocalBranchRefName(input: string): string {
|
||||||
return input.startsWith("origin/") ? input.slice("origin/".length) : input;
|
return input.startsWith("origin/") ? input.slice("origin/".length) : input;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -668,12 +668,21 @@ export async function deriveWorktreeProjectHash(cwd: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getPaseoWorktreesRoot(cwd: string, paseoHome?: string): Promise<string> {
|
export async function getPaseoWorktreesRoot(cwd: string, paseoHome?: string): Promise<string> {
|
||||||
const home = paseoHome ? resolve(paseoHome) : resolvePaseoHome();
|
const home = paseoHome ? resolve(paseoHome) : resolvePaseoHome();
|
||||||
const projectHash = await deriveWorktreeProjectHash(cwd);
|
const projectHash = await deriveWorktreeProjectHash(cwd);
|
||||||
return join(home, "worktrees", projectHash);
|
return join(home, "worktrees", projectHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function computeWorktreePath(
|
||||||
|
cwd: string,
|
||||||
|
slug: string,
|
||||||
|
paseoHome?: string
|
||||||
|
): Promise<string> {
|
||||||
|
const worktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome);
|
||||||
|
return join(worktreesRoot, slug);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePathForOwnership(input: string): string {
|
function normalizePathForOwnership(input: string): string {
|
||||||
try {
|
try {
|
||||||
return realpathSync(input);
|
return realpathSync(input);
|
||||||
|
|||||||
Reference in New Issue
Block a user