diff --git a/package-lock.json b/package-lock.json index 4cb07cbef..2bd97e88c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13681,6 +13681,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lezer-elixir": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/lezer-elixir/-/lezer-elixir-1.1.2.tgz", + "integrity": "sha512-K3yPMJcNhqCL6ugr5NkgOC1g37rcOM38XZezO9lBXy0LwWFd8zdWXfmRbY829vZVk0OGCQoI02yDWp9FF2OWZA==", + "dependencies": { + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.3.0" + } + }, "node_modules/lighthouse-logger": { "version": "1.4.2", "license": "Apache-2.0", @@ -20390,6 +20399,7 @@ "expo-system-ui": "~6.0.7", "expo-updates": "~29.0.12", "expo-web-browser": "~15.0.8", + "lezer-elixir": "^1.1.2", "lucide-react-native": "^0.546.0", "mnemonic-id": "^3.2.7", "react": "19.1.0", @@ -20546,6 +20556,7 @@ "dotenv": "^17.2.3", "express": "^4.18.2", "express-basic-auth": "^1.2.1", + "lezer-elixir": "^1.1.2", "mnemonic-id": "^3.2.7", "node-pty": "^1.0.0", "onnxruntime-node": "^1.23.0", diff --git a/packages/app/app.config.js b/packages/app/app.config.js index ecd055cef..890b25bb3 100644 --- a/packages/app/app.config.js +++ b/packages/app/app.config.js @@ -1,18 +1,46 @@ +const fs = require("node:fs"); +const path = require("node:path"); const pkg = require("./package.json"); const appVariant = process.env.APP_VARIANT ?? "production"; +function resolveSecretFile(params) { + const fromEnv = process.env[params.envKey]; + if (typeof fromEnv === "string" && fromEnv.trim().length > 0) { + return fromEnv.trim(); + } + + const fallbackAbsolutePath = path.resolve(__dirname, params.fallbackRelativePath); + if (fs.existsSync(fallbackAbsolutePath)) { + return params.fallbackRelativePath; + } + + return undefined; +} + const variants = { production: { name: "Paseo", packageId: "sh.paseo", - googleServicesFile: "./.secrets/google-services.prod.json", - googleServiceInfoPlist: "./.secrets/GoogleService-Info.prod.plist", + googleServicesFile: resolveSecretFile({ + envKey: "GOOGLE_SERVICES_FILE_PROD", + fallbackRelativePath: "./.secrets/google-services.prod.json", + }), + googleServiceInfoPlist: resolveSecretFile({ + envKey: "GOOGLE_SERVICE_INFO_PLIST_PROD", + fallbackRelativePath: "./.secrets/GoogleService-Info.prod.plist", + }), }, development: { name: "Paseo Debug", packageId: "sh.paseo.debug", - googleServicesFile: "./.secrets/google-services.debug.json", - googleServiceInfoPlist: "./.secrets/GoogleService-Info.debug.plist", + googleServicesFile: resolveSecretFile({ + envKey: "GOOGLE_SERVICES_FILE_DEBUG", + fallbackRelativePath: "./.secrets/google-services.debug.json", + }), + googleServiceInfoPlist: resolveSecretFile({ + envKey: "GOOGLE_SERVICE_INFO_PLIST_DEBUG", + fallbackRelativePath: "./.secrets/GoogleService-Info.debug.plist", + }), }, }; @@ -42,7 +70,9 @@ export default { ITSAppUsesNonExemptEncryption: false, }, bundleIdentifier: variant.packageId, - googleServicesFile: variant.googleServiceInfoPlist, + ...(variant.googleServiceInfoPlist + ? { googleServicesFile: variant.googleServiceInfoPlist } + : {}), }, android: { adaptiveIcon: { @@ -62,7 +92,9 @@ export default { "android.permission.CAMERA", ], package: variant.packageId, - googleServicesFile: variant.googleServicesFile, + ...(variant.googleServicesFile + ? { googleServicesFile: variant.googleServicesFile } + : {}), }, web: { output: "single", diff --git a/packages/app/package.json b/packages/app/package.json index d2a8cf048..598ec4d12 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -77,6 +77,7 @@ "expo-system-ui": "~6.0.7", "expo-updates": "~29.0.12", "expo-web-browser": "~15.0.8", + "lezer-elixir": "^1.1.2", "lucide-react-native": "^0.546.0", "mnemonic-id": "^3.2.7", "react": "19.1.0", diff --git a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx index 9b2146f0d..c1c44d147 100644 --- a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx +++ b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx @@ -883,11 +883,9 @@ export function WorkingDirectoryDropdown({ options={options} value={workingDir} onSelect={onSelectPath} - searchPlaceholder="/path/to/project" + searchPlaceholder="Search directories..." emptyText={emptyText} - allowCustomValue - customValuePrefix="Use" - customValueDescription="Launch the agent in this directory" + optionsPosition="above-search" title="Working directory" open={isOpen} onOpenChange={handleOpenChange} diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 7a8757910..cf10fd742 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -105,12 +105,6 @@ export function AgentList({ return; } - // Clear attention flag when opening agent - const session = useSessionStore.getState().sessions[serverId]; - if (session?.client) { - session.client.clearAgentAttention(agentId); - } - const navigationKey = buildAgentNavigationKey(serverId, agentId); startNavigationTiming(navigationKey, { from: "home", diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index dabb003b0..0043e1747 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -14,7 +14,6 @@ import { import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import AsyncStorage from "@react-native-async-storage/async-storage"; -import * as Linking from "expo-linking"; import { Archive, ChevronDown, @@ -52,6 +51,7 @@ import { } from "@/components/ui/dropdown-menu"; import { GitHubIcon } from "@/components/icons/github-icon"; import { buildHostAgentDraftRoute } from "@/utils/host-routes"; +import { openExternalUrl } from "@/utils/open-external-url"; // ============================================================================= // Git Actions Data Structure @@ -85,11 +85,7 @@ interface GitActions { } function openURLInNewTab(url: string): void { - if (Platform.OS === "web") { - window.open(url, "_blank", "noopener"); - } else { - void Linking.openURL(url); - } + void openExternalUrl(url); } const DIFF_PANE_LOG_TAG = "[GitDiffPane]"; diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 21685a3b5..4105cfa1d 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -20,7 +20,6 @@ import { } from "react"; import type { ReactNode, ComponentType } from "react"; import Markdown, { MarkdownIt } from "react-native-markdown-display"; -import * as Linking from "expo-linking"; import MaskedView from "@react-native-masked-view/masked-view"; import { Circle, @@ -71,6 +70,7 @@ import { resolveToolCallIcon } from "@/utils/tool-call-icon"; import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf"; import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path"; import { getMarkdownListMarker } from "@/utils/markdown-list"; +import { openExternalUrl } from "@/utils/open-external-url"; export type { InlinePathTarget } from "@/utils/inline-path"; import { useToolCallSheet } from "./tool-call-sheet"; import { ToolCallDetailsContent } from "./tool-call-details"; @@ -540,11 +540,7 @@ export const AssistantMessage = memo(function AssistantMessage({ ); const handleLinkPress = useCallback((url: string) => { - if (Platform.OS === "web") { - window.open(url, "_blank", "noopener,noreferrer"); - } else { - void Linking.openURL(url); - } + void openExternalUrl(url); // react-native-markdown-display opens the link itself when this returns true. // We already handled it above, so return false to avoid duplicate opens. return false; diff --git a/packages/app/src/components/sidebar-agent-list.tsx b/packages/app/src/components/sidebar-agent-list.tsx index 39f69a053..1d5d394e0 100644 --- a/packages/app/src/components/sidebar-agent-list.tsx +++ b/packages/app/src/components/sidebar-agent-list.tsx @@ -592,11 +592,6 @@ export function SidebarAgentList({ return; } - const session = useSessionStore.getState().sessions[entry.agent.serverId]; - if (session?.client) { - session.client.clearAgentAttention(entry.agent.id); - } - const navigationKey = buildAgentNavigationKey(entry.agent.serverId, entry.agent.id); startNavigationTiming(navigationKey, { from: "home", diff --git a/packages/app/src/components/sliding-sidebar.tsx b/packages/app/src/components/sliding-sidebar.tsx index 4d9f679dc..2514bb839 100644 --- a/packages/app/src/components/sliding-sidebar.tsx +++ b/packages/app/src/components/sliding-sidebar.tsx @@ -16,7 +16,7 @@ import { SidebarAgentList } from "./sidebar-agent-list"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { useSidebarAgentsGrouped } from "@/hooks/use-sidebar-agents-grouped"; import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; -import { useTauriDragHandlers } from "@/utils/tauri-window"; +import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { Combobox } from "@/components/ui/combobox"; import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; @@ -121,6 +121,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { closeGestureRef, } = useSidebarAnimation(); const dragHandlers = useTauriDragHandlers(); + const trafficLightPadding = useTrafficLightPadding(); // Track user-initiated refresh to avoid showing spinner on background revalidation const [isManualRefresh, setIsManualRefresh] = useState(false); @@ -465,7 +466,10 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { return ( - + terminalScopeKey({ serverId, cwd }), [serverId, cwd]); + const terminalsQueryKey = useMemo(() => ["terminals", serverId, cwd] as const, [cwd, serverId]); const selectedTerminalByScopeRef = useRef>(new Map()); const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null); const streamControllerRef = useRef(null); @@ -245,8 +250,12 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { return () => clearHoverOutTimeout(); }, [clearHoverOutTimeout]); + const requestTerminalFocus = useCallback(() => { + setFocusRequestToken((current) => current + 1); + }, []); + const terminalsQuery = useQuery({ - queryKey: ["terminals", serverId, cwd] as const, + queryKey: terminalsQueryKey, enabled: Boolean(client && isConnected && cwd.startsWith("/")), queryFn: async () => { if (!client) { @@ -281,14 +290,14 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { setModifiers({ ...EMPTY_MODIFIERS }); void queryClient.invalidateQueries({ - queryKey: ["terminals", serverId, cwd], + queryKey: terminalsQueryKey, }); void queryClient.refetchQueries({ - queryKey: ["terminals", serverId, cwd], + queryKey: terminalsQueryKey, type: "active", }); }); - }, [client, cwd, isConnected, queryClient, serverId]); + }, [client, isConnected, queryClient, terminalsQueryKey]); useEffect(() => { if (!client || !isConnected || !cwd.startsWith("/")) { @@ -303,10 +312,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { return; } void queryClient.invalidateQueries({ - queryKey: ["terminals", serverId, cwd], + queryKey: terminalsQueryKey, }); void queryClient.refetchQueries({ - queryKey: ["terminals", serverId, cwd], + queryKey: terminalsQueryKey, type: "active", }); }); @@ -317,7 +326,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { unsubscribe(); client.unsubscribeTerminals({ cwd }); }; - }, [client, cwd, isConnected, queryClient, serverId]); + }, [client, cwd, isConnected, queryClient, terminalsQueryKey]); const createTerminalMutation = useMutation({ mutationFn: async () => { @@ -327,12 +336,26 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { return await client.createTerminal(cwd); }, onSuccess: (payload) => { - if (payload.terminal) { - selectedTerminalByScopeRef.current.set(scopeKey, payload.terminal.id); - setSelectedTerminalId(payload.terminal.id); + const createdTerminal = payload.terminal; + if (createdTerminal) { + queryClient.setQueryData(terminalsQueryKey, (current) => { + const nextTerminals = upsertTerminalListEntry({ + terminals: current?.terminals ?? [], + terminal: createdTerminal, + }); + + return { + cwd: current?.cwd ?? cwd, + terminals: nextTerminals, + requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`, + }; + }); + selectedTerminalByScopeRef.current.set(scopeKey, createdTerminal.id); + setSelectedTerminalId(createdTerminal.id); + requestTerminalFocus(); } void queryClient.invalidateQueries({ - queryKey: ["terminals", serverId, cwd], + queryKey: terminalsQueryKey, }); }, }); @@ -358,10 +381,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { setModifiers({ ...EMPTY_MODIFIERS }); } void queryClient.invalidateQueries({ - queryKey: ["terminals", serverId, cwd], + queryKey: terminalsQueryKey, }); void queryClient.refetchQueries({ - queryKey: ["terminals", serverId, cwd], + queryKey: terminalsQueryKey, type: "active", }); }, @@ -550,10 +573,6 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { [killTerminalMutation] ); - const requestTerminalFocus = useCallback(() => { - setFocusRequestToken((current) => current + 1); - }, []); - const clearPendingModifiers = useCallback(() => { setModifiers({ ...EMPTY_MODIFIERS }); }, []); diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index ba2cb33f8..87b53e0cc 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -19,7 +19,7 @@ import { BottomSheetBackgroundProps, } from "@gorhom/bottom-sheet"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; -import { Check, Search } from "lucide-react-native"; +import { Check, Folder, Search } from "lucide-react-native"; import { flip, offset as floatingOffset, shift, size as floatingSize, useFloating } from "@floating-ui/react-native"; import { getNextActiveIndex } from "./combobox-keyboard"; @@ -29,6 +29,7 @@ export interface ComboboxOption { id: string; label: string; description?: string; + kind?: "directory"; } export interface ComboboxProps { @@ -43,6 +44,8 @@ export interface ComboboxProps { allowCustomValue?: boolean; customValuePrefix?: string; customValueDescription?: string; + customValueKind?: "directory"; + optionsPosition?: "below-search" | "above-search"; title?: string; open?: boolean; onOpenChange?: (open: boolean) => void; @@ -129,6 +132,7 @@ function SearchInput({ export interface ComboboxItemProps { label: string; description?: string; + kind?: "directory"; selected?: boolean; active?: boolean; onPress: () => void; @@ -138,6 +142,7 @@ export interface ComboboxItemProps { export function ComboboxItem({ label, description, + kind, selected, active, onPress, @@ -155,6 +160,11 @@ export function ComboboxItem({ active && styles.comboboxItemActive, ]} > + {kind === "directory" ? ( + + + + ) : null} {label} {description ? ( @@ -186,6 +196,8 @@ export function Combobox({ allowCustomValue = false, customValuePrefix = "Use", customValueDescription, + customValueKind, + optionsPosition = "below-search", title = "Select", open, onOpenChange, @@ -196,6 +208,8 @@ export function Combobox({ }: ComboboxProps): ReactElement { const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const effectiveOptionsPosition = + isMobile ? "below-search" : optionsPosition; const bottomSheetRef = useRef(null); const snapPoints = useMemo(() => ["60%", "90%"], []); const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null); @@ -375,13 +389,20 @@ export function Combobox({ id: string; label: string; description?: string; + kind?: "directory"; }> = []; if (showCustomOption) { + const trimmedPrefix = customValuePrefix.trim(); + const customLabel = + trimmedPrefix.length > 0 + ? `${trimmedPrefix} "${sanitizedSearchValue}"` + : sanitizedSearchValue; next.push({ id: sanitizedSearchValue, - label: `${customValuePrefix} "${sanitizedSearchValue}"`, + label: customLabel, description: customValueDescription, + kind: customValueKind, }); } @@ -390,35 +411,54 @@ export function Combobox({ id: opt.id, label: opt.label, description: opt.description, + kind: opt.kind, }); } return next; }, [ customValueDescription, + customValueKind, customValuePrefix, filteredOptions, sanitizedSearchValue, showCustomOption, ]); + const orderedVisibleOptions = useMemo(() => { + if (effectiveOptionsPosition !== "above-search") { + return visibleOptions; + } + return [...visibleOptions].reverse(); + }, [effectiveOptionsPosition, visibleOptions]); + useEffect(() => { if (!isOpen) return; if (!IS_WEB && isMobile) return; - if (visibleOptions.length === 0) { + if (orderedVisibleOptions.length === 0) { setActiveIndex(-1); return; } + const fallbackIndex = + effectiveOptionsPosition === "above-search" ? orderedVisibleOptions.length - 1 : 0; + if (normalizedSearch) { - setActiveIndex(0); + setActiveIndex(fallbackIndex); return; } - const selectedIndex = visibleOptions.findIndex((opt) => opt.id === value); - setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0); - }, [isMobile, isOpen, normalizedSearch, value, visibleOptions]); + const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value); + setActiveIndex(selectedIndex >= 0 ? selectedIndex : fallbackIndex); + }, [ + effectiveOptionsPosition, + isMobile, + isOpen, + normalizedSearch, + value, + orderedVisibleOptions, + ]); const handleSelect = useCallback( (id: string) => { @@ -444,7 +484,7 @@ export function Combobox({ setActiveIndex((currentIndex) => getNextActiveIndex({ currentIndex, - itemCount: visibleOptions.length, + itemCount: orderedVisibleOptions.length, key, }) ); @@ -452,11 +492,11 @@ export function Combobox({ } if (key === "Enter") { - if (visibleOptions.length === 0) return; + if (orderedVisibleOptions.length === 0) return; event?.preventDefault(); const index = - activeIndex >= 0 && activeIndex < visibleOptions.length ? activeIndex : 0; - handleSelect(visibleOptions[index]!.id); + activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0; + handleSelect(orderedVisibleOptions[index]!.id); return; } @@ -465,7 +505,7 @@ export function Combobox({ handleClose(); } }, - [activeIndex, handleClose, handleSelect, isMobile, isOpen, visibleOptions] + [activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions] ); useEffect(() => { @@ -498,12 +538,13 @@ export function Combobox({ const optionsList = ( <> - {visibleOptions.length > 0 ? ( - visibleOptions.map((opt, index) => ( + {orderedVisibleOptions.length > 0 ? ( + orderedVisibleOptions.map((opt, index) => ( handleSelect(opt.id)} @@ -515,13 +556,16 @@ export function Combobox({ ); - const content = children ?? ( + const defaultContent = ( <> + {effectiveOptionsPosition === "above-search" ? optionsList : null} {searchable ? searchInput : null} - {optionsList} + {effectiveOptionsPosition === "below-search" ? optionsList : null} ); + const content = children ?? defaultContent; + if (isMobile) { return ( ({ flex: 1, flexShrink: 1, }, + comboboxItemLeadingSlot: { + width: 16, + alignItems: "center", + justifyContent: "center", + }, comboboxItemLabel: { fontSize: theme.fontSize.sm, color: theme.colors.foreground, diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index 93d70974b..be4ab751c 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -216,9 +216,6 @@ export function useCommandCenter() { const handleSelectAgent = useCallback( (agent: AggregatedAgent) => { didNavigateRef.current = true; - const session = useSessionStore.getState().sessions[agent.serverId]; - session?.client?.clearAgentAttention(agent.id); - const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname)); const navigate = shouldReplace ? router.replace : router.push; diff --git a/packages/app/src/hooks/use-favicon-status.ts b/packages/app/src/hooks/use-favicon-status.ts index cb7497fe7..52431df35 100644 --- a/packages/app/src/hooks/use-favicon-status.ts +++ b/packages/app/src/hooks/use-favicon-status.ts @@ -1,5 +1,6 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Platform } from "react-native"; +import { getIsTauriMac } from "@/constants/layout"; import { useAggregatedAgents } from "./use-aggregated-agents"; type FaviconStatus = "none" | "running" | "attention"; @@ -34,6 +35,21 @@ function deriveFaviconStatus( return "none"; } +function deriveMacDockBadgeCount( + agents: ReturnType["agents"] +): number | undefined { + const attentionCount = agents.filter( + (agent) => + agent.requiresAttention && + (agent.attentionReason === "permission" || agent.attentionReason === "finished") + ).length; + if (attentionCount > 0) { + return attentionCount; + } + + return undefined; +} + function getFaviconUri(status: FaviconStatus, colorScheme: ColorScheme): string { const image = FAVICON_IMAGES[colorScheme][status]; if (typeof image === "object" && "uri" in image) { @@ -73,9 +89,25 @@ function getSystemColorScheme(): ColorScheme { return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } +async function updateMacDockBadge(count?: number) { + if (Platform.OS !== "web" || typeof window === "undefined" || !getIsTauriMac()) return; + + const tauriWindow = (window as any).__TAURI__?.window?.getCurrentWindow?.(); + if (!tauriWindow || typeof tauriWindow.setBadgeCount !== "function") { + return; + } + + try { + await tauriWindow.setBadgeCount(count); + } catch (error) { + console.warn("[useFaviconStatus] Failed to update macOS dock badge", error); + } +} + export function useFaviconStatus() { const { agents } = useAggregatedAgents(); const [colorScheme, setColorScheme] = useState(getSystemColorScheme); + const lastDockBadgeCountRef = useRef(undefined); // Listen for system color scheme changes useEffect(() => { @@ -96,5 +128,11 @@ export function useFaviconStatus() { const status = deriveFaviconStatus(agents); updateFavicon(status, colorScheme); + + const dockBadgeCount = deriveMacDockBadgeCount(agents); + if (dockBadgeCount !== lastDockBadgeCountRef.current) { + lastDockBadgeCountRef.current = dockBadgeCount; + void updateMacDockBadge(dockBadgeCount); + } }, [agents, colorScheme]); } diff --git a/packages/app/src/hooks/use-file-drop-zone.ts b/packages/app/src/hooks/use-file-drop-zone.ts index dbfd68b3b..e6df47b3c 100644 --- a/packages/app/src/hooks/use-file-drop-zone.ts +++ b/packages/app/src/hooks/use-file-drop-zone.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef, useEffect } from "react"; +import { useState, useRef, useEffect } from "react"; import { Platform } from "react-native"; import type { ImageAttachment } from "@/components/message-input"; @@ -13,11 +13,75 @@ interface UseFileDropZoneReturn { } const IS_WEB = Platform.OS === "web"; +const IMAGE_MIME_BY_EXTENSION: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", + ".svg": "image/svg+xml", + ".heic": "image/heic", + ".heif": "image/heif", + ".avif": "image/avif", + ".tif": "image/tiff", + ".tiff": "image/tiff", +}; + +type TauriDragDropPayload = + | { + type: "enter"; + paths: string[]; + } + | { + type: "over"; + } + | { + type: "drop"; + paths: string[]; + } + | { + type: "leave"; + }; + +type TauriDragDropEvent = { + payload: TauriDragDropPayload; +}; function isImageFile(file: File): boolean { return file.type.startsWith("image/"); } +function isTauriEnvironment(): boolean { + return typeof window !== "undefined" && (window as any).__TAURI__ !== undefined; +} + +function getFileExtension(path: string): string { + const normalizedPath = path.split("#", 1)[0]?.split("?", 1)[0] ?? path; + const extensionIndex = normalizedPath.lastIndexOf("."); + if (extensionIndex < 0) { + return ""; + } + return normalizedPath.slice(extensionIndex).toLowerCase(); +} + +function isImagePath(path: string): boolean { + return getFileExtension(path) in IMAGE_MIME_BY_EXTENSION; +} + +function filePathToImageAttachment(path: string): ImageAttachment { + const extension = getFileExtension(path); + const mimeType = IMAGE_MIME_BY_EXTENSION[extension] ?? "image/jpeg"; + const convertFileSrc = (window as any).__TAURI__?.core?.convertFileSrc; + const uri = + typeof convertFileSrc === "function" ? convertFileSrc(path) : path; + + return { + uri, + mimeType, + }; +} + async function fileToImageAttachment(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -62,78 +126,152 @@ export function useFileDropZone({ useEffect(() => { if (!IS_WEB) return; - const element = containerRef.current; - if (!element) return; + let disposed = false; + let cleanup: (() => void) | undefined; - function handleDragEnter(e: DragEvent) { - e.preventDefault(); - e.stopPropagation(); - - if (disabled) return; - - dragCounterRef.current++; - if (e.dataTransfer?.types.includes("Files")) { - setIsDragging(true); + async function setupTauriDragDrop(): Promise { + if (!isTauriEnvironment()) { + return false; } - } - function handleDragOver(e: DragEvent) { - e.preventDefault(); - e.stopPropagation(); - - if (disabled) return; - - if (e.dataTransfer) { - e.dataTransfer.dropEffect = "copy"; + const tauriWindow = (window as any).__TAURI__?.window?.getCurrentWindow?.(); + if (!tauriWindow || typeof tauriWindow.onDragDropEvent !== "function") { + return false; } - } - - function handleDragLeave(e: DragEvent) { - e.preventDefault(); - e.stopPropagation(); - - if (disabled) return; - - dragCounterRef.current--; - if (dragCounterRef.current === 0) { - setIsDragging(false); - } - } - - async function handleDrop(e: DragEvent) { - e.preventDefault(); - e.stopPropagation(); - - setIsDragging(false); - dragCounterRef.current = 0; - - if (disabled) return; - - const files = Array.from(e.dataTransfer?.files ?? []); - const imageFiles = files.filter(isImageFile); - - if (imageFiles.length === 0) return; try { - const attachments = await Promise.all( - imageFiles.map(fileToImageAttachment) + const unlisten = await tauriWindow.onDragDropEvent( + (event: TauriDragDropEvent) => { + const payload = event.payload; + if (payload.type === "leave") { + setIsDragging(false); + return; + } + + if (payload.type === "enter" || payload.type === "over") { + if (!disabled) { + setIsDragging(true); + } + return; + } + + // Drop always ends the current drag operation. + setIsDragging(false); + + if (disabled) return; + + const imagePaths = payload.paths.filter(isImagePath); + if (imagePaths.length === 0) { + return; + } + + const attachments = imagePaths.map(filePathToImageAttachment); + onFilesDroppedRef.current(attachments); + } ); - onFilesDroppedRef.current(attachments); + + if (disposed) { + unlisten(); + return true; + } + + cleanup = unlisten; + return true; } catch (error) { - console.error("[useFileDropZone] Failed to process dropped files:", error); + console.warn("[useFileDropZone] Failed to listen for Tauri drag-drop:", error); + return false; } } - element.addEventListener("dragenter", handleDragEnter); - element.addEventListener("dragover", handleDragOver); - element.addEventListener("dragleave", handleDragLeave); - element.addEventListener("drop", handleDrop); + function setupDomDragDrop() { + const element = containerRef.current; + if (!element) { + return; + } + + function handleDragEnter(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + + if (disabled) return; + + dragCounterRef.current++; + if (e.dataTransfer?.types.includes("Files")) { + setIsDragging(true); + } + } + + function handleDragOver(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + + if (disabled) return; + + if (e.dataTransfer) { + e.dataTransfer.dropEffect = "copy"; + } + } + + function handleDragLeave(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + + if (disabled) return; + + dragCounterRef.current--; + if (dragCounterRef.current === 0) { + setIsDragging(false); + } + } + + async function handleDrop(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + + setIsDragging(false); + dragCounterRef.current = 0; + + if (disabled) return; + + const files = Array.from(e.dataTransfer?.files ?? []); + const imageFiles = files.filter(isImageFile); + + if (imageFiles.length === 0) return; + + try { + const attachments = await Promise.all( + imageFiles.map(fileToImageAttachment) + ); + onFilesDroppedRef.current(attachments); + } catch (error) { + console.error("[useFileDropZone] Failed to process dropped files:", error); + } + } + + element.addEventListener("dragenter", handleDragEnter); + element.addEventListener("dragover", handleDragOver); + element.addEventListener("dragleave", handleDragLeave); + element.addEventListener("drop", handleDrop); + + cleanup = () => { + element.removeEventListener("dragenter", handleDragEnter); + element.removeEventListener("dragover", handleDragOver); + element.removeEventListener("dragleave", handleDragLeave); + element.removeEventListener("drop", handleDrop); + }; + } + + void (async () => { + const tauriListenersAttached = await setupTauriDragDrop(); + if (disposed || tauriListenersAttached) { + return; + } + setupDomDragDrop(); + })(); return () => { - element.removeEventListener("dragenter", handleDragEnter); - element.removeEventListener("dragover", handleDragOver); - element.removeEventListener("dragleave", handleDragLeave); - element.removeEventListener("drop", handleDrop); + disposed = true; + cleanup?.(); }; }, [disabled]); diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index 7ecf9237d..ac41fe127 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -72,6 +72,7 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot, } from "@/utils/agent-snapshots"; +import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention"; import type { FetchAgentsEntry } from "@server/client/daemon-client"; import { DropdownMenu, @@ -512,6 +513,9 @@ function AgentScreenContent({ const isConnected = useSessionStore( (state) => state.sessions[serverId]?.connection.isConnected ?? false ); + const focusedAgentId = useSessionStore( + (state) => state.sessions[serverId]?.focusedAgentId ?? null + ); const { ensureAgentIsInitialized, refreshAgent } = useAgentInitialization(serverId); const [missingAgentState, setMissingAgentState] = useState({ kind: "idle", @@ -860,25 +864,30 @@ function AgentScreenContent({ document.title = title; }, [agent?.title]); - // Track previous agent status to detect completion while viewing - const previousStatusRef = useRef(null); - - // Clear attention when agent finishes while user is viewing this screen + // Clear attention as soon as the user is focused on this agent screen. useEffect(() => { - if (!resolvedAgentId || !agent || !client) { + const clearAgentId = resolvedAgentId?.trim(); + if (!clearAgentId || !client) { return; } - - const previousStatus = previousStatusRef.current; - const currentStatus = agent.status; - previousStatusRef.current = currentStatus; - - // If agent transitioned from running to idle while we're viewing, - // immediately clear attention since user witnessed the completion - if (previousStatus === "running" && currentStatus === "idle") { - client.clearAgentAttention(resolvedAgentId); + if ( + !shouldClearAgentAttentionOnView({ + agentId: clearAgentId, + focusedAgentId, + isConnected, + requiresAttention: agent?.requiresAttention, + }) + ) { + return; } - }, [resolvedAgentId, agent?.status, client]); + client.clearAgentAttention(clearAgentId); + }, [ + agent?.requiresAttention, + client, + focusedAgentId, + isConnected, + resolvedAgentId, + ]); const handleRefreshAgent = useCallback(() => { if (!resolvedAgentId) { diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 9eb387ae4..bc3aa6bac 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -27,10 +27,13 @@ import { CHECKOUT_STATUS_STALE_TIME, checkoutStatusQueryKey, } from "@/hooks/use-checkout-status-query"; +import { useAllAgentsList } from "@/hooks/use-all-agents-list"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions"; import { shortenPath } from "@/utils/shorten-path"; +import { collectAgentWorkingDirectorySuggestions } from "@/utils/agent-working-directory-suggestions"; +import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions"; import { useSessionStore } from "@/stores/session-store"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { MAX_CONTENT_WIDTH } from "@/constants/layout"; @@ -232,6 +235,8 @@ export function DraftAgentScreen({ const [isBranchOpen, setIsBranchOpen] = useState(false); const [branchSearchQuery, setBranchSearchQuery] = useState(""); const [debouncedBranchSearchQuery, setDebouncedBranchSearchQuery] = useState(""); + const [workingDirSearchQuery, setWorkingDirSearchQuery] = useState(""); + const [debouncedWorkingDirSearchQuery, setDebouncedWorkingDirSearchQuery] = useState(""); const workingDirAnchorRef = useRef(null); const worktreeAnchorRef = useRef(null); const branchAnchorRef = useRef(null); @@ -246,6 +251,12 @@ export function DraftAgentScreen({ return () => clearTimeout(timer); }, [branchSearchQuery]); + useEffect(() => { + const trimmed = workingDirSearchQuery.trim(); + const timer = setTimeout(() => setDebouncedWorkingDirSearchQuery(trimmed), 180); + return () => clearTimeout(timer); + }, [workingDirSearchQuery]); + type CreateAttempt = { messageId: string; text: string; @@ -307,6 +318,7 @@ export function DraftAgentScreen({ const sessionAgents = useSessionStore((state) => selectedServerId ? state.sessions[selectedServerId]?.agents : undefined ); + const { agents: allAgents } = useAllAgentsList({ serverId: selectedServerId }); const worktreePathLastCreatedAt = useMemo(() => { const map = new Map(); if (!sessionAgents) { @@ -325,24 +337,23 @@ export function DraftAgentScreen({ return map; }, [sessionAgents]); const agentWorkingDirSuggestions = useMemo(() => { - if (!selectedServerId || !sessionAgents) { - return []; - } - const pathLastCreated = new Map(); - sessionAgents.forEach((agent) => { - if (agent.cwd && !agent.cwd.includes(".paseo/worktrees")) { - const existing = pathLastCreated.get(agent.cwd); - if (!existing || agent.createdAt > existing) { - pathLastCreated.set(agent.cwd, agent.createdAt); - } - } - }); - return Array.from(pathLastCreated.keys()).sort((a, b) => { - const aTime = pathLastCreated.get(a)!.getTime(); - const bTime = pathLastCreated.get(b)!.getTime(); - return bTime - aTime; - }); - }, [selectedServerId, sessionAgents]); + const liveSources = sessionAgents + ? Array.from(sessionAgents.values()).map((agent) => ({ + cwd: agent.cwd, + createdAt: agent.createdAt, + lastActivityAt: agent.lastActivityAt, + })) + : []; + const fetchedSources = allAgents.map((agent) => ({ + cwd: agent.cwd, + lastActivityAt: agent.lastActivityAt, + })); + + return collectAgentWorkingDirectorySuggestions([ + ...liveSources, + ...fetchedSources, + ]); + }, [allAgents, sessionAgents]); const sessionClient = useSessionStore((state) => selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null @@ -511,6 +522,36 @@ export function DraftAgentScreen({ staleTime: 15_000, }); + const directorySuggestionsQuery = useQuery({ + queryKey: [ + "directorySuggestions", + selectedServerId, + debouncedWorkingDirSearchQuery, + ], + queryFn: async () => { + const client = sessionClient; + if (!client) { + throw new Error("Daemon client unavailable"); + } + const payload = await client.getDirectorySuggestions({ + query: debouncedWorkingDirSearchQuery, + limit: 50, + }); + if (payload.error) { + throw new Error(payload.error); + } + return payload.directories ?? []; + }, + enabled: + Boolean(debouncedWorkingDirSearchQuery) && + Boolean(selectedServerId) && + !daemonAvailabilityError && + Boolean(sessionClient) && + isConnected, + retry: false, + staleTime: 15_000, + }); + const validateWorktreeName = useCallback( (name: string): { valid: boolean; error?: string } => { if (!name) { @@ -644,6 +685,50 @@ export function DraftAgentScreen({ const selectedWorktreeLabel = worktreeOptions.find((option) => option.path === selectedWorktreePath)?.label ?? ""; + const hasWorkingDirectorySearch = debouncedWorkingDirSearchQuery.length > 0; + const workingDirSearchError = + directorySuggestionsQuery.error instanceof Error + ? directorySuggestionsQuery.error.message + : null; + const workingDirSuggestionPaths = useMemo( + () => + buildWorkingDirectorySuggestions({ + recommendedPaths: agentWorkingDirSuggestions, + serverPaths: hasWorkingDirectorySearch ? (directorySuggestionsQuery.data ?? []) : [], + query: workingDirSearchQuery, + }), + [ + agentWorkingDirSuggestions, + directorySuggestionsQuery.data, + hasWorkingDirectorySearch, + workingDirSearchQuery, + ] + ); + const workingDirComboOptions = useMemo( + () => + workingDirSuggestionPaths.map((path) => ({ + id: path, + label: shortenPath(path), + kind: "directory" as const, + })), + [workingDirSuggestionPaths] + ); + const workingDirEmptyText = useMemo(() => { + if (hasWorkingDirectorySearch) { + if (workingDirSearchError) { + return "Failed to search directories on this host."; + } + return "No directories match your search."; + } + + return agentWorkingDirSuggestions.length > 0 + ? "No agent directories match your search." + : "We'll suggest directories from agents on this host once they exist."; + }, [ + agentWorkingDirSuggestions.length, + hasWorkingDirectorySearch, + workingDirSearchError, + ]); const displayWorkingDir = shortenPath(workingDir); const worktreeTriggerValue = worktreeMode === "create" @@ -1100,21 +1185,13 @@ export function DraftAgentScreen({ /> ({ - id: path, - label: shortenPath(path), - }))} + options={workingDirComboOptions} value={workingDir} onSelect={setWorkingDirFromUser} - searchPlaceholder="/path/to/project" - emptyText={ - agentWorkingDirSuggestions.length > 0 - ? "No agent directories match your search." - : "We'll suggest directories from agents on this host once they exist." - } - allowCustomValue - customValuePrefix="Use" - customValueDescription="Launch the agent in this directory" + onSearchQueryChange={setWorkingDirSearchQuery} + searchPlaceholder="Search directories..." + emptyText={workingDirEmptyText} + optionsPosition="above-search" title="Working directory" open={isWorkingDirOpen} onOpenChange={setIsWorkingDirOpen} diff --git a/packages/app/src/stores/download-store.ts b/packages/app/src/stores/download-store.ts index 16f3b13ee..ec884ee9b 100644 --- a/packages/app/src/stores/download-store.ts +++ b/packages/app/src/stores/download-store.ts @@ -5,6 +5,7 @@ import * as LegacyFileSystem from "expo-file-system/legacy"; import * as Sharing from "expo-sharing"; import type { HostProfile } from "@/contexts/daemon-registry-context"; import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints"; +import { openExternalUrl } from "@/utils/open-external-url"; interface DownloadProgress { percent: number; @@ -303,7 +304,7 @@ function buildDownloadUrl( function triggerBrowserDownload(url: string, fileName: string) { if (typeof document === "undefined") { if (typeof window !== "undefined") { - window.open(url, "_blank", "noopener"); + void openExternalUrl(url); } return; } diff --git a/packages/app/src/utils/agent-attention.test.ts b/packages/app/src/utils/agent-attention.test.ts new file mode 100644 index 000000000..8b7b4e674 --- /dev/null +++ b/packages/app/src/utils/agent-attention.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { shouldClearAgentAttentionOnView } from "./agent-attention"; + +describe("shouldClearAgentAttentionOnView", () => { + it("returns true only when the viewed agent is focused, connected, and requires attention", () => { + expect( + shouldClearAgentAttentionOnView({ + agentId: "agent-1", + focusedAgentId: "agent-1", + isConnected: true, + requiresAttention: true, + }) + ).toBe(true); + }); + + it("returns false when the app is disconnected", () => { + expect( + shouldClearAgentAttentionOnView({ + agentId: "agent-1", + focusedAgentId: "agent-1", + isConnected: false, + requiresAttention: true, + }) + ).toBe(false); + }); + + it("returns false when the agent is not focused", () => { + expect( + shouldClearAgentAttentionOnView({ + agentId: "agent-1", + focusedAgentId: "agent-2", + isConnected: true, + requiresAttention: true, + }) + ).toBe(false); + }); + + it("returns false when attention is already clear", () => { + expect( + shouldClearAgentAttentionOnView({ + agentId: "agent-1", + focusedAgentId: "agent-1", + isConnected: true, + requiresAttention: false, + }) + ).toBe(false); + }); + + it("returns false for empty agent ids", () => { + expect( + shouldClearAgentAttentionOnView({ + agentId: "", + focusedAgentId: "agent-1", + isConnected: true, + requiresAttention: true, + }) + ).toBe(false); + }); +}); diff --git a/packages/app/src/utils/agent-attention.ts b/packages/app/src/utils/agent-attention.ts new file mode 100644 index 000000000..ea102faa2 --- /dev/null +++ b/packages/app/src/utils/agent-attention.ts @@ -0,0 +1,22 @@ +interface ShouldClearAgentAttentionOnViewInput { + agentId: string | null | undefined; + focusedAgentId: string | null | undefined; + isConnected: boolean; + requiresAttention: boolean | null | undefined; +} + +export function shouldClearAgentAttentionOnView( + input: ShouldClearAgentAttentionOnViewInput +): boolean { + const agentId = input.agentId?.trim(); + if (!agentId) { + return false; + } + if (!input.isConnected) { + return false; + } + if (!input.requiresAttention) { + return false; + } + return input.focusedAgentId === agentId; +} diff --git a/packages/app/src/utils/agent-working-directory-suggestions.test.ts b/packages/app/src/utils/agent-working-directory-suggestions.test.ts new file mode 100644 index 000000000..8a0e2a6dc --- /dev/null +++ b/packages/app/src/utils/agent-working-directory-suggestions.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { collectAgentWorkingDirectorySuggestions } from "@/utils/agent-working-directory-suggestions"; + +describe("collectAgentWorkingDirectorySuggestions", () => { + it("deduplicates by cwd and sorts by most recent timestamp", () => { + const results = collectAgentWorkingDirectorySuggestions([ + { + cwd: "/Users/me/project-alpha", + createdAt: new Date("2026-02-10T10:00:00.000Z"), + }, + { + cwd: "/Users/me/project-beta", + createdAt: new Date("2026-02-11T10:00:00.000Z"), + }, + { + cwd: "/Users/me/project-alpha", + lastActivityAt: new Date("2026-02-12T10:00:00.000Z"), + }, + ]); + + expect(results).toEqual([ + "/Users/me/project-alpha", + "/Users/me/project-beta", + ]); + }); + + it("excludes Paseo-owned worktree paths", () => { + const results = collectAgentWorkingDirectorySuggestions([ + { + cwd: "/Users/me/repo/.paseo/worktrees/feature-a", + createdAt: new Date("2026-02-12T10:00:00.000Z"), + }, + { + cwd: "/Users/me/repo", + createdAt: new Date("2026-02-10T10:00:00.000Z"), + }, + { + cwd: "C:\\Users\\me\\repo\\.paseo\\worktrees\\feature-b", + createdAt: new Date("2026-02-11T10:00:00.000Z"), + }, + ]); + + expect(results).toEqual(["/Users/me/repo"]); + }); + + it("ignores empty cwd values", () => { + const results = collectAgentWorkingDirectorySuggestions([ + { cwd: " ", createdAt: new Date("2026-02-10T10:00:00.000Z") }, + { cwd: null, createdAt: new Date("2026-02-11T10:00:00.000Z") }, + { cwd: undefined, lastActivityAt: new Date("2026-02-12T10:00:00.000Z") }, + { + cwd: "/Users/me/project", + createdAt: new Date("2026-02-09T10:00:00.000Z"), + }, + ]); + + expect(results).toEqual(["/Users/me/project"]); + }); +}); diff --git a/packages/app/src/utils/agent-working-directory-suggestions.ts b/packages/app/src/utils/agent-working-directory-suggestions.ts new file mode 100644 index 000000000..62dba3d63 --- /dev/null +++ b/packages/app/src/utils/agent-working-directory-suggestions.ts @@ -0,0 +1,51 @@ +export interface AgentWorkingDirectorySource { + cwd?: string | null; + createdAt?: Date | null; + lastActivityAt?: Date | null; +} + +const PASEO_WORKTREE_PATH_PATTERN = /(^|\/)\.paseo\/worktrees(\/|$)/; + +export function collectAgentWorkingDirectorySuggestions( + sources: Iterable +): string[] { + const lastSeenByPath = new Map(); + + for (const source of sources) { + const cwd = source.cwd?.trim(); + if (!cwd) { + continue; + } + if (isPaseoOwnedWorktreePath(cwd)) { + continue; + } + + const timestamp = toEpochMs(source.lastActivityAt ?? source.createdAt); + const previous = lastSeenByPath.get(cwd); + if (previous === undefined || timestamp > previous) { + lastSeenByPath.set(cwd, timestamp); + } + } + + return Array.from(lastSeenByPath.entries()) + .sort((left, right) => { + const timeDiff = right[1] - left[1]; + if (timeDiff !== 0) { + return timeDiff; + } + return left[0].localeCompare(right[0]); + }) + .map(([cwd]) => cwd); +} + +function isPaseoOwnedWorktreePath(cwd: string): boolean { + return PASEO_WORKTREE_PATH_PATTERN.test(cwd.replace(/\\/g, "/")); +} + +function toEpochMs(date: Date | null | undefined): number { + if (!(date instanceof Date)) { + return 0; + } + const value = date.getTime(); + return Number.isFinite(value) ? value : 0; +} diff --git a/packages/app/src/utils/open-external-url.ts b/packages/app/src/utils/open-external-url.ts new file mode 100644 index 000000000..ee8318456 --- /dev/null +++ b/packages/app/src/utils/open-external-url.ts @@ -0,0 +1,28 @@ +import * as Linking from "expo-linking"; +import { Platform } from "react-native"; +import { getIsTauri } from "@/constants/layout"; + +interface TauriWindowWithOpener { + __TAURI__?: { + opener?: { + openUrl?: (url: string) => Promise; + }; + }; +} + +export async function openExternalUrl(url: string): Promise { + if (Platform.OS === "web") { + if (typeof window !== "undefined" && getIsTauri()) { + const opener = (window as TauriWindowWithOpener).__TAURI__?.opener?.openUrl; + if (typeof opener === "function") { + await opener(url); + return; + } + } + + window.open(url, "_blank", "noopener,noreferrer"); + return; + } + + await Linking.openURL(url); +} diff --git a/packages/app/src/utils/syntax-highlighter.ts b/packages/app/src/utils/syntax-highlighter.ts index 698015e35..e0f38a455 100644 --- a/packages/app/src/utils/syntax-highlighter.ts +++ b/packages/app/src/utils/syntax-highlighter.ts @@ -5,6 +5,7 @@ import { parser as cssParser } from "@lezer/css"; import { parser as htmlParser } from "@lezer/html"; import { parser as pythonParser } from "@lezer/python"; import { parser as markdownParser } from "@lezer/markdown"; +import { parser as elixirParser } from "lezer-elixir"; import type { Parser } from "@lezer/common"; // Map file extensions to parsers @@ -26,6 +27,9 @@ const parsersByExtension: Record = { htm: htmlParser, // Python py: pythonParser, + // Elixir + ex: elixirParser, + exs: elixirParser, // Markdown md: markdownParser, mdx: markdownParser, diff --git a/packages/app/src/utils/terminal-list.test.ts b/packages/app/src/utils/terminal-list.test.ts new file mode 100644 index 000000000..b7b38e39f --- /dev/null +++ b/packages/app/src/utils/terminal-list.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { upsertTerminalListEntry } from "./terminal-list"; + +describe("terminal-list", () => { + it("adds a created terminal when the list is empty", () => { + const result = upsertTerminalListEntry({ + terminals: [], + terminal: { + id: "term-1", + name: "Terminal 1", + cwd: "/tmp/project", + }, + }); + + expect(result).toEqual([{ id: "term-1", name: "Terminal 1" }]); + }); + + it("appends a created terminal when the id does not already exist", () => { + const result = upsertTerminalListEntry({ + terminals: [{ id: "term-1", name: "Terminal 1" }], + terminal: { + id: "term-2", + name: "Terminal 2", + cwd: "/tmp/project", + }, + }); + + expect(result).toEqual([ + { id: "term-1", name: "Terminal 1" }, + { id: "term-2", name: "Terminal 2" }, + ]); + }); + + it("replaces the existing terminal entry when ids match", () => { + const result = upsertTerminalListEntry({ + terminals: [ + { id: "term-1", name: "Terminal 1" }, + { id: "term-2", name: "Old Name" }, + ], + terminal: { + id: "term-2", + name: "Renamed Terminal", + cwd: "/tmp/project", + }, + }); + + expect(result).toEqual([ + { id: "term-1", name: "Terminal 1" }, + { id: "term-2", name: "Renamed Terminal" }, + ]); + }); +}); diff --git a/packages/app/src/utils/terminal-list.ts b/packages/app/src/utils/terminal-list.ts new file mode 100644 index 000000000..a7b2bd9ed --- /dev/null +++ b/packages/app/src/utils/terminal-list.ts @@ -0,0 +1,32 @@ +import type { + CreateTerminalResponse, + ListTerminalsResponse, +} from "@server/shared/messages"; + +type TerminalListEntry = ListTerminalsResponse["payload"]["terminals"][number]; +type CreatedTerminal = NonNullable; + +function toTerminalListEntry(input: { terminal: CreatedTerminal }): TerminalListEntry { + return { + id: input.terminal.id, + name: input.terminal.name, + }; +} + +export function upsertTerminalListEntry(input: { + terminals: TerminalListEntry[]; + terminal: CreatedTerminal; +}): TerminalListEntry[] { + const createdTerminal = toTerminalListEntry({ terminal: input.terminal }); + const existingIndex = input.terminals.findIndex( + (terminal) => terminal.id === createdTerminal.id + ); + + if (existingIndex < 0) { + return [...input.terminals, createdTerminal]; + } + + const nextTerminals = [...input.terminals]; + nextTerminals[existingIndex] = createdTerminal; + return nextTerminals; +} diff --git a/packages/app/src/utils/working-directory-suggestions.test.ts b/packages/app/src/utils/working-directory-suggestions.test.ts new file mode 100644 index 000000000..d2c8a2d3d --- /dev/null +++ b/packages/app/src/utils/working-directory-suggestions.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { buildWorkingDirectorySuggestions } from "./working-directory-suggestions"; + +describe("buildWorkingDirectorySuggestions", () => { + it("returns de-duplicated recommendations when query is empty", () => { + const results = buildWorkingDirectorySuggestions({ + recommendedPaths: ["/Users/me/projects/paseo", "/Users/me/projects/paseo"], + serverPaths: ["/Users/me/projects/playground"], + query: "", + }); + + expect(results).toEqual(["/Users/me/projects/paseo"]); + }); + + it("prioritizes matching recommended directories before server matches", () => { + const results = buildWorkingDirectorySuggestions({ + recommendedPaths: ["/Users/me/projects/paseo", "/Users/me/documents"], + serverPaths: [ + "/Users/me/projects/playground", + "/Users/me/projects/paseo", + "/Users/me/projects/planbook", + ], + query: "pla", + }); + + expect(results).toEqual([ + "/Users/me/projects/playground", + "/Users/me/projects/planbook", + ]); + }); + + it("puts matching recommended items first when they also match query", () => { + const results = buildWorkingDirectorySuggestions({ + recommendedPaths: ["/Users/me/projects/playground", "/Users/me/projects/paseo"], + serverPaths: [ + "/Users/me/projects/planbook", + "/Users/me/projects/playground", + ], + query: "pla", + }); + + expect(results).toEqual([ + "/Users/me/projects/playground", + "/Users/me/projects/planbook", + ]); + }); + + it("treats '~' as an active query and includes server suggestions", () => { + const results = buildWorkingDirectorySuggestions({ + recommendedPaths: ["/Users/me/projects/paseo"], + serverPaths: [ + "/Users/me/documents", + "/Users/me/projects", + ], + query: "~", + }); + + expect(results).toEqual([ + "/Users/me/projects/paseo", + "/Users/me/documents", + "/Users/me/projects", + ]); + }); +}); diff --git a/packages/app/src/utils/working-directory-suggestions.ts b/packages/app/src/utils/working-directory-suggestions.ts new file mode 100644 index 000000000..af3ae666b --- /dev/null +++ b/packages/app/src/utils/working-directory-suggestions.ts @@ -0,0 +1,72 @@ +export interface BuildWorkingDirectorySuggestionsInput { + recommendedPaths: string[]; + serverPaths: string[]; + query: string; +} + +export function buildWorkingDirectorySuggestions( + input: BuildWorkingDirectorySuggestionsInput +): string[] { + const rawQuery = input.query.trim(); + const recommended = uniquePaths(input.recommendedPaths); + if (!rawQuery) { + return recommended; + } + + const normalizedQuery = normalizeQuery(rawQuery); + const shouldFilterByQuery = normalizedQuery.length > 0; + + const recommendedMatches = shouldFilterByQuery + ? recommended.filter((entry) => pathMatchesQuery(entry, normalizedQuery)) + : recommended; + const seen = new Set(recommendedMatches); + const ordered = [...recommendedMatches]; + + for (const entry of uniquePaths(input.serverPaths)) { + if (shouldFilterByQuery && !pathMatchesQuery(entry, normalizedQuery)) { + continue; + } + if (seen.has(entry)) { + continue; + } + ordered.push(entry); + seen.add(entry); + } + + return ordered; +} + +function uniquePaths(paths: string[]): string[] { + const seen = new Set(); + const ordered: string[] = []; + for (const pathEntry of paths) { + const trimmed = pathEntry.trim(); + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + ordered.push(trimmed); + } + return ordered; +} + +function normalizeQuery(query: string): string { + let normalized = query.trim(); + if (!normalized) { + return ""; + } + if (normalized.startsWith("~")) { + normalized = normalized.slice(1); + } + normalized = normalized.replace(/^\/+/, "").toLowerCase(); + return normalized; +} + +function pathMatchesQuery(candidatePath: string, query: string): boolean { + const lowerPath = candidatePath.toLowerCase(); + if (lowerPath.includes(query)) { + return true; + } + const segments = lowerPath.split("/"); + return (segments[segments.length - 1] ?? "").includes(query); +} diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 161e328aa..6838aa7b4 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -81,6 +81,137 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "atk" version = "0.18.2" @@ -173,6 +304,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "borsh" version = "1.6.0" @@ -416,6 +560,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -753,6 +906,33 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "env_filter" version = "0.1.4" @@ -780,6 +960,43 @@ dependencies = [ "typeid", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "fdeflate" version = "0.3.7" @@ -914,6 +1131,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -1281,6 +1511,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1573,6 +1809,25 @@ dependencies = [ "serde", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itoa" version = "1.0.17" @@ -1735,6 +1990,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "litemap" version = "0.8.1" @@ -2165,12 +2426,34 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "dunce", + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "pango" version = "0.18.3" @@ -2196,6 +2479,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2229,9 +2518,16 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-log", + "tauri-plugin-opener", "tauri-plugin-websocket", ] +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2384,6 +2680,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -2416,6 +2723,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -2861,6 +3182,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.36" @@ -3205,6 +3539,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -3624,6 +3968,28 @@ dependencies = [ "time", ] +[[package]] +name = "tauri-plugin-opener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "url", + "windows", + "zbus", +] + [[package]] name = "tauri-plugin-websocket" version = "2.4.2" @@ -3745,6 +4111,19 @@ dependencies = [ "toml 0.9.11+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.4.3" @@ -4055,9 +4434,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -4126,6 +4517,17 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -5070,6 +5472,67 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.14", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.114", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow 0.7.14", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.33" @@ -5155,3 +5618,43 @@ name = "zmij" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94f63c051f4fe3c1509da62131a678643c5b6fbdc9273b2b79d4378ebda003d2" + +[[package]] +name = "zvariant" +version = "5.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 0.7.14", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.114", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.114", + "winnow 0.7.14", +] diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 5af352912..ca0747f7e 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -27,4 +27,5 @@ serde = { version = "1.0", features = ["derive"] } log = "0.4" tauri = { version = "2.9.5", features = [] } tauri-plugin-log = "2" +tauri-plugin-opener = "2" tauri-plugin-websocket = "2" diff --git a/packages/desktop/src-tauri/capabilities/default.json b/packages/desktop/src-tauri/capabilities/default.json index 7ec3a7c54..a8efa741f 100644 --- a/packages/desktop/src-tauri/capabilities/default.json +++ b/packages/desktop/src-tauri/capabilities/default.json @@ -14,7 +14,9 @@ "permissions": [ "core:default", "core:window:allow-start-dragging", + "core:window:allow-set-badge-count", "core:window:allow-toggle-maximize", + "opener:default", "websocket:default" ] } diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index bc8b3e553..c9b89f573 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -19,6 +19,7 @@ fn set_zoom_factor(webview: &WebviewWindow, factor: f64) { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_websocket::init()) .setup(|app| { if cfg!(debug_assertions) { diff --git a/packages/server/package.json b/packages/server/package.json index 7985c90ed..f9c315b4d 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -73,6 +73,7 @@ "dotenv": "^17.2.3", "express": "^4.18.2", "express-basic-auth": "^1.2.1", + "lezer-elixir": "^1.1.2", "mnemonic-id": "^3.2.7", "node-pty": "^1.0.0", "onnxruntime-node": "^1.23.0", diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index 5316d6ad4..2e06f4ffc 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -347,6 +347,63 @@ describe("DaemonClient", () => { }); }); + test("requests directory suggestions via RPC", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const promise = client.getDirectorySuggestions( + { query: "proj", limit: 10 }, + "req-directories" + ); + + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(mock.sent[0]) as { + type: "session"; + message: { + type: "directory_suggestions_request"; + query: string; + limit?: number; + requestId: string; + }; + }; + expect(request.message.type).toBe("directory_suggestions_request"); + expect(request.message.query).toBe("proj"); + expect(request.message.limit).toBe(10); + expect(request.message.requestId).toBe("req-directories"); + + mock.triggerMessage( + JSON.stringify({ + type: "session", + message: { + type: "directory_suggestions_response", + payload: { + directories: ["/Users/test/projects/paseo"], + error: null, + requestId: "req-directories", + }, + }, + }) + ); + + await expect(promise).resolves.toEqual({ + directories: ["/Users/test/projects/paseo"], + error: null, + requestId: "req-directories", + }); + }); + test("requests checkout merge from base via RPC", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index e135a3ae8..91dfa30c2 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -26,6 +26,7 @@ import type { CheckoutPrStatusResponse, ValidateBranchResponse, BranchSuggestionsResponse, + DirectorySuggestionsResponse, PaseoWorktreeListResponse, PaseoWorktreeArchiveResponse, ProjectIconResponse, @@ -197,6 +198,7 @@ type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"]; type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"]; type ValidateBranchPayload = ValidateBranchResponse["payload"]; type BranchSuggestionsPayload = BranchSuggestionsResponse["payload"]; +type DirectorySuggestionsPayload = DirectorySuggestionsResponse["payload"]; type PaseoWorktreeListPayload = PaseoWorktreeListResponse["payload"]; type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse["payload"]; type FileExplorerPayload = FileExplorerResponse["payload"]; @@ -2082,6 +2084,22 @@ export class DaemonClient { }); } + async getDirectorySuggestions( + options: { query: string; limit?: number }, + requestId?: string + ): Promise { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "directory_suggestions_request", + query: options.query, + limit: options.limit, + }, + responseType: "directory_suggestions_response", + timeout: 10000, + }); + } + // ============================================================================ // File Explorer // ============================================================================ diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index b23b6183a..bd2cc5cf3 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -168,6 +168,32 @@ describe("daemon client E2E", () => { rmSync(cwd, { recursive: true, force: true }); }, 30000); + test("returns home-scoped directory suggestions", async () => { + const insideHomeDir = mkdtempSync(path.join(homedir(), "paseo-dir-suggestion-")); + const outsideHomeDir = mkdtempSync(path.join(tmpdir(), "paseo-dir-suggestion-outside-")); + + try { + const insideQuery = path.basename(insideHomeDir); + const insideResult = await ctx.client.getDirectorySuggestions({ + query: insideQuery, + limit: 25, + }); + expect(insideResult.error).toBeNull(); + expect(insideResult.directories).toContain(insideHomeDir); + + const outsideQuery = path.basename(outsideHomeDir); + const outsideResult = await ctx.client.getDirectorySuggestions({ + query: outsideQuery, + limit: 25, + }); + expect(outsideResult.error).toBeNull(); + expect(outsideResult.directories).not.toContain(outsideHomeDir); + } finally { + rmSync(insideHomeDir, { recursive: true, force: true }); + rmSync(outsideHomeDir, { recursive: true, force: true }); + } + }, 30000); + test("emits server_info on websocket connect", async () => { const client = new DaemonClient({ url: `ws://127.0.0.1:${ctx.daemon.port}/ws`, diff --git a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts index cbd318ba8..6310af8a3 100644 --- a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts @@ -105,6 +105,43 @@ async function waitForPathExists( ); } +async function withShell(shell: string, run: () => Promise): Promise { + const originalShell = process.env.SHELL; + process.env.SHELL = shell; + try { + return await run(); + } finally { + if (originalShell === undefined) { + delete process.env.SHELL; + } else { + process.env.SHELL = originalShell; + } + } +} + +interface WorktreeTerminalBootstrapEntry { + name: string | null; + command: string; + status: "started" | "failed"; + terminalId: string | null; + error: string | null; +} + +function getWorktreeTerminalBootstrapEntries( + item: Extract +): WorktreeTerminalBootstrapEntry[] | null { + const detail = item.detail; + if (!detail || detail.type !== "unknown" || !detail.output) { + return null; + } + const output = detail.output as Record; + const terminals = output.terminals; + if (!Array.isArray(terminals)) { + return null; + } + return terminals as WorktreeTerminalBootstrapEntry[]; +} + // Use gpt-5.1-codex-mini with low thinking preset for faster test execution const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; const CODEX_TEST_THINKING_OPTION_ID = "low"; @@ -413,100 +450,136 @@ describe("daemon E2E", () => { test( "bootstraps configured worktree terminals after setup succeeds", async () => { - const repoRoot = tmpCwd(); + await withShell("/bin/sh", async () => { + const repoRoot = tmpCwd(); - const { execSync } = await import("child_process"); - execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" }); - execSync("git config user.email 'test@test.com'", { - cwd: repoRoot, - stdio: "pipe", - }); - execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" }); - - writeFileSync(path.join(repoRoot, "file.txt"), "hello\n"); - execSync("git add .", { cwd: repoRoot, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'initial'", { - cwd: repoRoot, - stdio: "pipe", - }); - execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" }); - - const setupCommand = - 'while [ ! -f "$PASEO_WORKTREE_PATH/allow-setup" ]; do sleep 0.05; done; echo "done" > "$PASEO_WORKTREE_PATH/setup-done.txt"'; - writeFileSync( - path.join(repoRoot, "paseo.json"), - JSON.stringify({ - worktree: { - setup: [setupCommand], - terminals: [ - { - name: "Dev Server", - command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null', - }, - { - command: 'echo "lint-watch" > lint-terminal.txt; tail -f /dev/null', - }, - ], - }, - }) - ); - execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'add setup and terminals'", { - cwd: repoRoot, - stdio: "pipe", - }); - - const agent = await withTimeout({ - promise: ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + const { execSync } = await import("child_process"); + execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" }); + execSync("git config user.email 'test@test.com'", { cwd: repoRoot, - title: "Async Worktree Setup + Terminals Test", - git: { - createWorktree: true, - createNewBranch: true, - baseBranch: "main", - newBranchName: "async-setup-terminals-test", - worktreeSlug: "async-setup-terminals-test", - }, - }), - timeoutMs: 2500, - label: "createAgent should not block on setup", + stdio: "pipe", + }); + execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" }); + + writeFileSync(path.join(repoRoot, "file.txt"), "hello\n"); + execSync("git add .", { cwd: repoRoot, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'initial'", { + cwd: repoRoot, + stdio: "pipe", + }); + execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" }); + + const setupCommand = + 'while [ ! -f "$PASEO_WORKTREE_PATH/allow-setup" ]; do sleep 0.05; done; echo "done" > "$PASEO_WORKTREE_PATH/setup-done.txt"; echo "$PASEO_WORKTREE_PORT" > "$PASEO_WORKTREE_PATH/setup-port.txt"'; + writeFileSync( + path.join(repoRoot, "paseo.json"), + JSON.stringify({ + worktree: { + setup: [setupCommand], + terminals: [ + { + name: "Dev Server", + command: "tail -f /dev/null", + }, + { + command: "tail -f /dev/null", + }, + ], + }, + }) + ); + execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'add setup and terminals'", { + cwd: repoRoot, + stdio: "pipe", + }); + + const agent = await withTimeout({ + promise: ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd: repoRoot, + title: "Async Worktree Setup + Terminals Test", + git: { + createWorktree: true, + createNewBranch: true, + baseBranch: "main", + newBranchName: "async-setup-terminals-test", + worktreeSlug: "async-setup-terminals-test", + }, + }), + timeoutMs: 2500, + label: "createAgent should not block on setup", + }); + + expect(agent.cwd).toContain(path.join(".paseo", "worktrees")); + expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(false); + expect(existsSync(path.join(agent.cwd, "dev-terminal.txt"))).toBe(false); + expect(existsSync(path.join(agent.cwd, "lint-terminal.txt"))).toBe(false); + + writeFileSync(path.join(agent.cwd, "allow-setup"), "ok\n"); + + await waitForTimelineToolCall( + collector.messages, + agent.id, + (item) => item.name === "paseo_worktree_setup" && item.status === "completed", + 20000 + ); + const terminalsBootstrapToolCall = await waitForTimelineToolCall( + collector.messages, + agent.id, + (item) => item.name === "paseo_worktree_terminals" && item.status === "completed", + 30000 + ); + const bootstrappedTerminals = getWorktreeTerminalBootstrapEntries( + terminalsBootstrapToolCall + ); + expect(bootstrappedTerminals).toBeTruthy(); + expect(bootstrappedTerminals?.length ?? 0).toBeGreaterThanOrEqual(2); + const failedBootstraps = + bootstrappedTerminals?.filter((terminal) => terminal.status === "failed") ?? []; + expect(failedBootstraps).toEqual([]); + + const list = await ctx.client.listTerminals(agent.cwd); + expect(list.error).toBeUndefined(); + expect(list.terminals.some((terminal) => terminal.name === "Dev Server")).toBe(true); + expect(list.terminals.length).toBeGreaterThanOrEqual(2); + await waitForPathExists({ + targetPath: path.join(agent.cwd, "setup-port.txt"), + timeoutMs: 30000, + label: "setup runtime port marker", + }); + + const setupPort = readFileSync(path.join(agent.cwd, "setup-port.txt"), "utf8").trim(); + expect(setupPort.length).toBeGreaterThan(0); + + const createdTerminal = await ctx.client.createTerminal(agent.cwd, "Manual Port Check"); + expect(createdTerminal.error).toBeNull(); + expect(createdTerminal.terminal).toBeTruthy(); + const manualTerminalId = createdTerminal.terminal?.id; + expect(manualTerminalId).toBeTruthy(); + if (!manualTerminalId) { + throw new Error("Expected manual terminal id"); + } + ctx.client.sendTerminalInput(manualTerminalId, { + type: "input", + data: 'echo "$PASEO_WORKTREE_PORT" > "$PASEO_WORKTREE_PATH/manual-terminal-port.txt"\r', + }); + await waitForPathExists({ + targetPath: path.join(agent.cwd, "manual-terminal-port.txt"), + timeoutMs: 30000, + label: "manual terminal runtime port marker", + }); + const manualTerminalPort = readFileSync( + path.join(agent.cwd, "manual-terminal-port.txt"), + "utf8" + ).trim(); + expect(manualTerminalPort).toBe(setupPort); + + await ctx.client.deleteAgent(agent.id); + rmSync(repoRoot, { recursive: true, force: true }); }); - - expect(agent.cwd).toContain(path.join(".paseo", "worktrees")); - expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(false); - expect(existsSync(path.join(agent.cwd, "dev-terminal.txt"))).toBe(false); - expect(existsSync(path.join(agent.cwd, "lint-terminal.txt"))).toBe(false); - - writeFileSync(path.join(agent.cwd, "allow-setup"), "ok\n"); - - await waitForTimelineToolCall( - collector.messages, - agent.id, - (item) => item.name === "paseo_worktree_setup" && item.status === "completed", - 20000 - ); - - await waitForPathExists({ - targetPath: path.join(agent.cwd, "dev-terminal.txt"), - timeoutMs: 15000, - label: "dev terminal marker", - }); - await waitForPathExists({ - targetPath: path.join(agent.cwd, "lint-terminal.txt"), - timeoutMs: 15000, - label: "lint terminal marker", - }); - - const list = await ctx.client.listTerminals(agent.cwd); - expect(list.error).toBeUndefined(); - expect(list.terminals.some((terminal) => terminal.name === "Dev Server")).toBe(true); - expect(list.terminals.length).toBeGreaterThanOrEqual(2); - - await ctx.client.deleteAgent(agent.id); - rmSync(repoRoot, { recursive: true, force: true }); }, 60000 ); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 24a8131c4..f0f73c338 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -4,6 +4,7 @@ import { stat } from "fs/promises"; import { exec } from "child_process"; import { promisify } from "util"; import { join, resolve, sep } from "path"; +import { homedir } from "node:os"; import { z } from "zod"; import type { ToolSet } from "ai"; import { @@ -26,6 +27,7 @@ import { type DetachTerminalStreamRequest, type SubscribeCheckoutDiffRequest, type UnsubscribeCheckoutDiffRequest, + type DirectorySuggestionsRequest, type ProjectCheckoutLitePayload, type ProjectPlacementPayload, } from "./messages.js"; @@ -138,6 +140,7 @@ import { } from "../utils/checkout-git.js"; import { getProjectIcon } from "../utils/project-icon.js"; import { expandTilde } from "../utils/path.js"; +import { searchHomeDirectories } from "../utils/directory-suggestions.js"; import { ensureLocalSpeechModels, getLocalSpeechModelDir, @@ -1347,6 +1350,10 @@ export class Session { await this.handleBranchSuggestionsRequest(msg); break; + case "directory_suggestions_request": + await this.handleDirectorySuggestionsRequest(msg); + break; + case "subscribe_checkout_diff_request": await this.handleSubscribeCheckoutDiffRequest(msg); break; @@ -3563,9 +3570,10 @@ export class Session { msg: Extract ): Promise { const { cwd, requestId } = msg; + const resolvedCwd = expandTilde(cwd); try { - const status = await getCheckoutStatus(cwd, { paseoHome: this.paseoHome }); + const status = await getCheckoutStatus(resolvedCwd, { paseoHome: this.paseoHome }); if (!status.isGit) { this.emit({ type: "checkout_status_response", @@ -3754,6 +3762,37 @@ export class Session { } } + private async handleDirectorySuggestionsRequest( + msg: DirectorySuggestionsRequest + ): Promise { + const { query, limit, requestId } = msg; + + try { + const directories = await searchHomeDirectories({ + homeDir: process.env.HOME ?? homedir(), + query, + limit, + }); + this.emit({ + type: "directory_suggestions_response", + payload: { + directories, + error: null, + requestId, + }, + }); + } catch (error) { + this.emit({ + type: "directory_suggestions_response", + payload: { + directories: [], + error: error instanceof Error ? error.message : String(error), + requestId, + }, + }); + } + } + private normalizeCheckoutDiffCompare(compare: CheckoutDiffCompareInput): CheckoutDiffCompareInput { if (compare.mode === "uncommitted") { return { mode: "uncommitted" }; diff --git a/packages/server/src/server/utils/syntax-highlighter.ts b/packages/server/src/server/utils/syntax-highlighter.ts index 9e365d7ad..c86a5a40e 100644 --- a/packages/server/src/server/utils/syntax-highlighter.ts +++ b/packages/server/src/server/utils/syntax-highlighter.ts @@ -5,6 +5,7 @@ import { parser as cssParser } from "@lezer/css"; import { parser as htmlParser } from "@lezer/html"; import { parser as pythonParser } from "@lezer/python"; import { parser as markdownParser } from "@lezer/markdown"; +import { parser as elixirParser } from "lezer-elixir"; import type { Parser } from "@lezer/common"; // Map file extensions to parsers @@ -26,6 +27,9 @@ const parsersByExtension: Record = { htm: htmlParser, // Python py: pythonParser, + // Elixir + ex: elixirParser, + exs: elixirParser, // Markdown md: markdownParser, mdx: markdownParser, diff --git a/packages/server/src/server/worktree-bootstrap.test.ts b/packages/server/src/server/worktree-bootstrap.test.ts index 6ea51a23e..6d640edcf 100644 --- a/packages/server/src/server/worktree-bootstrap.test.ts +++ b/packages/server/src/server/worktree-bootstrap.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { execSync } from "child_process"; -import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs"; +import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; @@ -15,6 +15,17 @@ describe("runAsyncWorktreeBootstrap", () => { let repoDir: string; let paseoHome: string; + async function waitForPathExists(targetPath: string, timeoutMs = 10000): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (existsSync(targetPath)) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`Timed out waiting for path: ${targetPath}`); + } + beforeEach(() => { tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-bootstrap-test-"))); repoDir = join(tempDir, "repo"); @@ -236,4 +247,216 @@ describe("runAsyncWorktreeBootstrap", () => { expect(persistedSetupItem.detail.log).toContain("-suffix"); expect(persistedSetupItem.detail.log).toContain("......"); }); + + it("waits for terminal output before sending bootstrap commands", async () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + worktree: { + terminals: [ + { + name: "Ready Terminal", + command: "echo ready", + }, + ], + }, + }) + ); + execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'add terminal bootstrap config'", { + cwd: repoDir, + stdio: "pipe", + }); + + const worktree = await createAgentWorktree({ + cwd: repoDir, + branchName: "feature-terminal-readiness", + baseBranch: "main", + worktreeSlug: "feature-terminal-readiness", + paseoHome, + }); + + let readyAt = 0; + let sendAt = 0; + let outputListener: ((chunk: { data: string }) => void) | null = null; + + await runAsyncWorktreeBootstrap({ + agentId: "agent-terminal-readiness", + worktree, + terminalManager: { + async getTerminals() { + return []; + }, + async createTerminal(options) { + setTimeout(() => { + readyAt = Date.now(); + outputListener?.({ data: "$ " }); + }, 25); + return { + id: "term-ready", + name: options.name ?? "Terminal", + cwd: options.cwd, + send: () => { + sendAt = Date.now(); + }, + subscribe: () => () => {}, + onExit: () => () => {}, + subscribeRaw: (listener) => { + outputListener = (chunk) => + listener({ + data: chunk.data, + startOffset: 0, + endOffset: chunk.data.length, + replay: false, + }); + return { + unsubscribe: () => { + outputListener = null; + }, + replayedFrom: 0, + currentOffset: 0, + earliestAvailableOffset: 0, + reset: false, + }; + }, + getOutputOffset: () => 0, + getState: () => ({ + rows: 0, + cols: 0, + grid: [], + scrollback: [], + cursor: { row: 0, col: 0 }, + }), + kill: () => {}, + }; + }, + registerCwdEnv() {}, + getTerminal() { + return undefined; + }, + killTerminal() {}, + listDirectories() { + return []; + }, + killAll() {}, + subscribeTerminalsChanged() { + return () => {}; + }, + }, + appendTimelineItem: async () => true, + emitLiveTimelineItem: async () => true, + }); + + expect(readyAt).toBeGreaterThan(0); + expect(sendAt).toBeGreaterThan(0); + expect(sendAt).toBeGreaterThanOrEqual(readyAt); + }); + + it("shares the same worktree runtime port across setup and bootstrap terminals", async () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + worktree: { + setup: ['echo "$PASEO_WORKTREE_PORT" > setup-port.txt'], + terminals: [ + { + name: "Port Terminal", + command: "true", + }, + ], + }, + }) + ); + execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'add port setup and terminals'", { + cwd: repoDir, + stdio: "pipe", + }); + + const worktree = await createAgentWorktree({ + cwd: repoDir, + branchName: "feature-shared-runtime-port", + baseBranch: "main", + worktreeSlug: "feature-shared-runtime-port", + paseoHome, + }); + + const registeredEnvs: Array<{ cwd: string; env: Record }> = []; + const createTerminalEnvs: Record[] = []; + const persisted: AgentTimelineItem[] = []; + await runAsyncWorktreeBootstrap({ + agentId: "agent-shared-runtime-port", + worktree, + terminalManager: { + async getTerminals() { + return []; + }, + async createTerminal(options) { + createTerminalEnvs.push(options.env ?? {}); + return { + id: "term-1", + name: options.name ?? "Terminal", + cwd: options.cwd, + send: () => {}, + subscribe: () => () => {}, + onExit: () => () => {}, + subscribeRaw: () => ({ + unsubscribe: () => {}, + replayedFrom: 0, + currentOffset: 0, + earliestAvailableOffset: 0, + reset: false, + }), + getOutputOffset: () => 1, + getState: () => ({ + rows: 0, + cols: 0, + grid: [], + scrollback: [], + cursor: { row: 0, col: 0 }, + }), + kill: () => {}, + }; + }, + registerCwdEnv(options) { + registeredEnvs.push({ cwd: options.cwd, env: options.env }); + }, + getTerminal() { + return undefined; + }, + killTerminal() {}, + listDirectories() { + return []; + }, + killAll() {}, + subscribeTerminalsChanged() { + return () => {}; + }, + }, + appendTimelineItem: async (item) => { + persisted.push(item); + return true; + }, + emitLiveTimelineItem: async () => true, + }); + + const setupPortPath = join(worktree.worktreePath, "setup-port.txt"); + await waitForPathExists(setupPortPath); + + const setupPort = readFileSync(setupPortPath, "utf8").trim(); + expect(setupPort.length).toBeGreaterThan(0); + expect(registeredEnvs).toHaveLength(1); + expect(registeredEnvs[0]?.cwd).toBe(worktree.worktreePath); + expect(registeredEnvs[0]?.env.PASEO_WORKTREE_PORT).toBe(setupPort); + expect(createTerminalEnvs.length).toBeGreaterThan(0); + expect(createTerminalEnvs[0]?.PASEO_WORKTREE_PORT).toBe(setupPort); + + const terminalToolCall = persisted.find( + (item): item is Extract => + item.type === "tool_call" && + item.name === "paseo_worktree_terminals" && + item.status === "completed" + ); + expect(terminalToolCall?.status).toBe("completed"); + }); }); diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index 6fd5e21b3..731955301 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -1,13 +1,16 @@ import { v4 as uuidv4 } from "uuid"; import type { Logger } from "pino"; import type { TerminalManager } from "../terminal/terminal-manager.js"; +import type { TerminalSession } from "../terminal/terminal.js"; import { createWorktree, getWorktreeTerminalSpecs, + resolveWorktreeRuntimeEnv, runWorktreeSetupCommands, WorktreeSetupError, type WorktreeConfig, type WorktreeSetupCommandResult, + type WorktreeRuntimeEnv, } from "../utils/worktree.js"; import type { AgentTimelineItem } from "./agent/agent-sdk-types.js"; @@ -38,6 +41,7 @@ export interface CreateAgentWorktreeOptions { const MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES = 64 * 1024; const WORKTREE_SETUP_TRUNCATION_MARKER = "\n......\n"; +const WORKTREE_BOOTSTRAP_TERMINAL_READY_TIMEOUT_MS = 1_500; type MiddleTruncationAccumulator = { totalBytes: number; @@ -335,8 +339,51 @@ function buildTerminalTimelineItem(input: { }; } +async function waitForTerminalBootstrapReadiness( + terminal: Pick +): Promise { + if (terminal.getOutputOffset() > 0) { + return; + } + + await new Promise((resolve) => { + let settled = false; + let timeout: ReturnType | null = null; + let unsubscribe: (() => void) | null = null; + + const finish = () => { + if (settled) { + return; + } + settled = true; + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + if (unsubscribe) { + unsubscribe(); + unsubscribe = null; + } + resolve(); + }; + + const rawSubscription = terminal.subscribeRaw(() => { + finish(); + }); + unsubscribe = rawSubscription.unsubscribe; + + if (terminal.getOutputOffset() > 0) { + finish(); + return; + } + + timeout = setTimeout(finish, WORKTREE_BOOTSTRAP_TERMINAL_READY_TIMEOUT_MS); + }); +} + async function runWorktreeTerminalBootstrap( - options: RunAsyncWorktreeBootstrapOptions + options: RunAsyncWorktreeBootstrapOptions, + runtimeEnv: WorktreeRuntimeEnv ): Promise { const terminalSpecs = getWorktreeTerminalSpecs(options.worktree.worktreePath); if (terminalSpecs.length === 0) { @@ -376,7 +423,9 @@ async function runWorktreeTerminalBootstrap( const terminal = await options.terminalManager.createTerminal({ cwd: options.worktree.worktreePath, name: spec.name, + env: runtimeEnv, }); + await waitForTerminalBootstrapReadiness(terminal); terminal.send({ type: "input", data: `${spec.command}\r`, @@ -420,6 +469,7 @@ export async function runAsyncWorktreeBootstrap( ): Promise { const setupCallId = uuidv4(); let setupResults: WorktreeSetupCommandResult[] = []; + let runtimeEnv: WorktreeRuntimeEnv | null = null; const emitLiveTimelineItem = options.emitLiveTimelineItem; const runningResultsByIndex = new Map(); const outputAccumulatorsByIndex = new Map(); @@ -454,10 +504,20 @@ export async function runAsyncWorktreeBootstrap( }; try { + runtimeEnv = await resolveWorktreeRuntimeEnv({ + worktreePath: options.worktree.worktreePath, + branchName: options.worktree.branchName, + }); + options.terminalManager?.registerCwdEnv({ + cwd: options.worktree.worktreePath, + env: runtimeEnv, + }); + setupResults = await runWorktreeSetupCommands({ worktreePath: options.worktree.worktreePath, branchName: options.worktree.branchName, cleanupOnFailure: false, + runtimeEnv, onEvent: (event) => { const existing = runningResultsByIndex.get(event.index); const baseResult: WorktreeSetupCommandResult = existing ?? { @@ -533,5 +593,5 @@ export async function runAsyncWorktreeBootstrap( return; } - await runWorktreeTerminalBootstrap(options); + await runWorktreeTerminalBootstrap(options, runtimeEnv); } diff --git a/packages/server/src/shared/messages.stream-parsing.test.ts b/packages/server/src/shared/messages.stream-parsing.test.ts index 7a9da87c1..018cc07a2 100644 --- a/packages/server/src/shared/messages.stream-parsing.test.ts +++ b/packages/server/src/shared/messages.stream-parsing.test.ts @@ -100,6 +100,26 @@ describe("shared messages stream parsing", () => { expect(parsed.success).toBe(false); }); + it("parses directory suggestions request and response payloads", () => { + const requestParsed = SessionInboundMessageSchema.safeParse({ + type: "directory_suggestions_request", + query: "proj", + limit: 20, + requestId: "req-dir-1", + }); + expect(requestParsed.success).toBe(true); + + const responseParsed = SessionOutboundMessageSchema.safeParse({ + type: "directory_suggestions_response", + payload: { + directories: ["/Users/test/projects/paseo"], + error: null, + requestId: "req-dir-1", + }, + }); + expect(responseParsed.success).toBe(true); + }); + it("rejects websocket envelope for removed agent_stream_snapshot message type", () => { const fixture = { type: "agent_stream_snapshot", diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index a92e11635..a3d5b94b1 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -826,6 +826,13 @@ export const BranchSuggestionsRequestSchema = z.object({ requestId: z.string(), }); +export const DirectorySuggestionsRequestSchema = z.object({ + type: z.literal("directory_suggestions_request"), + query: z.string(), + limit: z.number().int().min(1).max(100).optional(), + requestId: z.string(), +}); + export const PaseoWorktreeListRequestSchema = z.object({ type: z.literal("paseo_worktree_list_request"), cwd: z.string().optional(), @@ -1075,6 +1082,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ CheckoutPrStatusRequestSchema, ValidateBranchRequestSchema, BranchSuggestionsRequestSchema, + DirectorySuggestionsRequestSchema, PaseoWorktreeListRequestSchema, PaseoWorktreeArchiveRequestSchema, FileExplorerRequestSchema, @@ -1722,6 +1730,15 @@ export const BranchSuggestionsResponseSchema = z.object({ }), }); +export const DirectorySuggestionsResponseSchema = z.object({ + type: z.literal("directory_suggestions_response"), + payload: z.object({ + directories: z.array(z.string()), + error: z.string().nullable(), + requestId: z.string(), + }), +}); + const PaseoWorktreeSchema = z.object({ worktreePath: z.string(), branchName: z.string().nullable().optional(), @@ -2031,6 +2048,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CheckoutPrStatusResponseSchema, ValidateBranchResponseSchema, BranchSuggestionsResponseSchema, + DirectorySuggestionsResponseSchema, PaseoWorktreeListResponseSchema, PaseoWorktreeArchiveResponseSchema, FileExplorerResponseSchema, @@ -2164,6 +2182,8 @@ export type ValidateBranchRequest = z.infer; export type ValidateBranchResponse = z.infer; export type BranchSuggestionsRequest = z.infer; export type BranchSuggestionsResponse = z.infer; +export type DirectorySuggestionsRequest = z.infer; +export type DirectorySuggestionsResponse = z.infer; export type PaseoWorktreeListRequest = z.infer; export type PaseoWorktreeListResponse = z.infer; export type PaseoWorktreeArchiveRequest = z.infer; diff --git a/packages/server/src/terminal/terminal-manager.test.ts b/packages/server/src/terminal/terminal-manager.test.ts index b52e687a9..b4864d013 100644 --- a/packages/server/src/terminal/terminal-manager.test.ts +++ b/packages/server/src/terminal/terminal-manager.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect, afterEach } from "vitest"; import { createTerminalManager, type TerminalManager } from "./terminal-manager.js"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; async function waitForCondition( predicate: () => boolean, @@ -16,13 +19,34 @@ async function waitForCondition( throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`); } +async function withShell(shell: string, run: () => Promise): Promise { + const originalShell = process.env.SHELL; + process.env.SHELL = shell; + try { + return await run(); + } finally { + if (originalShell === undefined) { + delete process.env.SHELL; + } else { + process.env.SHELL = originalShell; + } + } +} + describe("TerminalManager", () => { let manager: TerminalManager; + const temporaryDirs: string[] = []; afterEach(() => { if (manager) { manager.killAll(); } + while (temporaryDirs.length > 0) { + const dir = temporaryDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } }); describe("getTerminals", () => { @@ -94,6 +118,58 @@ describe("TerminalManager", () => { manager = createTerminalManager(); await expect(manager.createTerminal({ cwd: "tmp" })).rejects.toThrow("cwd must be absolute path"); }); + + it("inherits registered env for the worktree root cwd", async () => { + await withShell("/bin/sh", async () => { + manager = createTerminalManager(); + const cwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-root-")); + temporaryDirs.push(cwd); + const markerPath = join(cwd, "root-port.txt"); + + manager.registerCwdEnv({ + cwd, + env: { PASEO_WORKTREE_PORT: "45678" }, + }); + const session = await manager.createTerminal({ cwd }); + for (let attempt = 0; attempt < 10 && !existsSync(markerPath); attempt++) { + session.send({ + type: "input", + data: `printf '%s' \"$PASEO_WORKTREE_PORT\" > ${JSON.stringify(markerPath)}\r`, + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + await waitForCondition(() => existsSync(markerPath), 10000); + expect(readFileSync(markerPath, "utf8")).toBe("45678"); + }); + }); + + it("inherits registered env for subdirectories within the worktree", async () => { + await withShell("/bin/sh", async () => { + manager = createTerminalManager(); + const rootCwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-subdir-")); + const subdirCwd = join(rootCwd, "packages", "app"); + mkdirSync(subdirCwd, { recursive: true }); + temporaryDirs.push(rootCwd); + const markerPath = join(subdirCwd, "subdir-port.txt"); + + manager.registerCwdEnv({ + cwd: rootCwd, + env: { PASEO_WORKTREE_PORT: "45679" }, + }); + const session = await manager.createTerminal({ cwd: subdirCwd }); + for (let attempt = 0; attempt < 10 && !existsSync(markerPath); attempt++) { + session.send({ + type: "input", + data: `printf '%s' \"$PASEO_WORKTREE_PORT\" > ${JSON.stringify(markerPath)}\r`, + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + await waitForCondition(() => existsSync(markerPath), 10000); + expect(readFileSync(markerPath, "utf8")).toBe("45679"); + }); + }); }); describe("getTerminal", () => { @@ -156,7 +232,7 @@ describe("TerminalManager", () => { manager = createTerminalManager(); const terminals = await manager.getTerminals("/tmp"); const exitedId = terminals[0].id; - terminals[0].send({ type: "input", data: "\u0004" }); + terminals[0].kill(); await waitForCondition(() => manager.getTerminal(exitedId) === undefined, 10000); diff --git a/packages/server/src/terminal/terminal-manager.ts b/packages/server/src/terminal/terminal-manager.ts index d47e0e345..e336130f7 100644 --- a/packages/server/src/terminal/terminal-manager.ts +++ b/packages/server/src/terminal/terminal-manager.ts @@ -1,4 +1,5 @@ import { createTerminal, type TerminalSession } from "./terminal.js"; +import { resolve, sep } from "node:path"; export interface TerminalListItem { id: string; @@ -15,7 +16,12 @@ export type TerminalsChangedListener = (input: TerminalsChangedEvent) => void; export interface TerminalManager { getTerminals(cwd: string): Promise; - createTerminal(options: { cwd: string; name?: string }): Promise; + createTerminal(options: { + cwd: string; + name?: string; + env?: Record; + }): Promise; + registerCwdEnv(options: { cwd: string; env: Record }): void; getTerminal(id: string): TerminalSession | undefined; killTerminal(id: string): void; listDirectories(): string[]; @@ -28,6 +34,7 @@ export function createTerminalManager(): TerminalManager { const terminalsById = new Map(); const terminalExitUnsubscribeById = new Map void>(); const terminalsChangedListeners = new Set(); + const defaultEnvByRootCwd = new Map>(); function assertAbsolutePath(cwd: string): void { if (!cwd.startsWith("/")) { @@ -67,6 +74,23 @@ export function createTerminalManager(): TerminalManager { emitTerminalsChanged({ cwd: session.cwd }); } + function resolveDefaultEnvForCwd(cwd: string): Record | undefined { + const normalizedCwd = resolve(cwd); + let bestMatchRoot: string | null = null; + + for (const rootCwd of defaultEnvByRootCwd.keys()) { + const matches = normalizedCwd === rootCwd || normalizedCwd.startsWith(`${rootCwd}${sep}`); + if (!matches) { + continue; + } + if (!bestMatchRoot || rootCwd.length > bestMatchRoot.length) { + bestMatchRoot = rootCwd; + } + } + + return bestMatchRoot ? defaultEnvByRootCwd.get(bestMatchRoot) : undefined; + } + function registerSession(session: TerminalSession): TerminalSession { terminalsById.set(session.id, session); const unsubscribeExit = session.onExit(() => { @@ -112,8 +136,13 @@ export function createTerminalManager(): TerminalManager { let terminals = terminalsByCwd.get(cwd); if (!terminals || terminals.length === 0) { + const inheritedEnv = resolveDefaultEnvForCwd(cwd); const session = registerSession( - await createTerminal({ cwd, name: "Terminal 1" }) + await createTerminal({ + cwd, + name: "Terminal 1", + ...(inheritedEnv ? { env: inheritedEnv } : {}), + }) ); terminals = [session]; terminalsByCwd.set(cwd, terminals); @@ -122,15 +151,25 @@ export function createTerminalManager(): TerminalManager { return terminals; }, - async createTerminal(options: { cwd: string; name?: string }): Promise { + async createTerminal(options: { + cwd: string; + name?: string; + env?: Record; + }): Promise { assertAbsolutePath(options.cwd); const terminals = terminalsByCwd.get(options.cwd) ?? []; const defaultName = `Terminal ${terminals.length + 1}`; + const inheritedEnv = resolveDefaultEnvForCwd(options.cwd); + const mergedEnv = + inheritedEnv || options.env + ? { ...(inheritedEnv ?? {}), ...(options.env ?? {}) } + : undefined; const session = registerSession( await createTerminal({ cwd: options.cwd, name: options.name ?? defaultName, + ...(mergedEnv ? { env: mergedEnv } : {}), }) ); @@ -141,6 +180,11 @@ export function createTerminalManager(): TerminalManager { return session; }, + registerCwdEnv(options: { cwd: string; env: Record }): void { + assertAbsolutePath(options.cwd); + defaultEnvByRootCwd.set(resolve(options.cwd), { ...options.env }); + }, + getTerminal(id: string): TerminalSession | undefined { return terminalsById.get(id); }, diff --git a/packages/server/src/utils/directory-suggestions.test.ts b/packages/server/src/utils/directory-suggestions.test.ts new file mode 100644 index 000000000..8424bd4f9 --- /dev/null +++ b/packages/server/src/utils/directory-suggestions.test.ts @@ -0,0 +1,154 @@ +import { + mkdtempSync, + mkdirSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { searchHomeDirectories } from "./directory-suggestions.js"; + +describe("searchHomeDirectories", () => { + let tempRoot: string; + let homeDir: string; + let outsideDir: string; + + beforeEach(() => { + tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "directory-suggestions-"))); + homeDir = path.join(tempRoot, "home"); + outsideDir = path.join(tempRoot, "outside"); + + mkdirSync(homeDir, { recursive: true }); + mkdirSync(outsideDir, { recursive: true }); + + mkdirSync(path.join(homeDir, "projects", "paseo"), { recursive: true }); + mkdirSync(path.join(homeDir, "projects", "playground"), { recursive: true }); + mkdirSync(path.join(homeDir, "documents", "plans"), { recursive: true }); + mkdirSync(path.join(homeDir, ".hidden", "cache"), { recursive: true }); + writeFileSync(path.join(homeDir, "projects", "README.md"), "not a directory\n"); + + mkdirSync(path.join(outsideDir, "outside-match"), { recursive: true }); + symlinkSync(path.join(outsideDir, "outside-match"), path.join(homeDir, "outside-link")); + }); + + afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }); + }); + + it("returns an empty list for blank queries", async () => { + await expect( + searchHomeDirectories({ + homeDir, + query: " ", + limit: 10, + }) + ).resolves.toEqual([]); + }); + + it("returns only existing directories", async () => { + const results = await searchHomeDirectories({ + homeDir, + query: "proj", + limit: 10, + }); + + expect(results).toContain(path.join(homeDir, "projects")); + expect(results).toContain(path.join(homeDir, "projects", "paseo")); + expect(results).not.toContain(path.join(homeDir, "projects", "README.md")); + }); + + it("supports home-relative path query syntax", async () => { + const results = await searchHomeDirectories({ + homeDir, + query: "~/projects/pa", + limit: 10, + }); + + expect(results).toEqual([ + path.join(homeDir, "projects", "paseo"), + ]); + }); + + it("prioritizes exact segment matches before segment-prefix matches", async () => { + const exactSegmentPath = path.join( + homeDir, + "something", + "faro", + "something-else" + ); + const prefixSegmentPath = path.join( + homeDir, + "something", + "somethingelse", + "faro-bla" + ); + mkdirSync(exactSegmentPath, { recursive: true }); + mkdirSync(prefixSegmentPath, { recursive: true }); + + const results = await searchHomeDirectories({ + homeDir, + query: "faro", + limit: 30, + }); + + const exactIndex = results.indexOf(exactSegmentPath); + const prefixIndex = results.indexOf(prefixSegmentPath); + expect(exactIndex).toBeGreaterThanOrEqual(0); + expect(prefixIndex).toBeGreaterThanOrEqual(0); + expect(exactIndex).toBeLessThan(prefixIndex); + }); + + it("prioritizes partial matches that appear earlier in the path", async () => { + const earlierPath = path.join(homeDir, "farofoo"); + const laterPath = path.join(homeDir, "x", "y", "farofoo"); + mkdirSync(earlierPath, { recursive: true }); + mkdirSync(laterPath, { recursive: true }); + + const results = await searchHomeDirectories({ + homeDir, + query: "arofo", + limit: 30, + }); + + const earlierIndex = results.indexOf(earlierPath); + const laterIndex = results.indexOf(laterPath); + expect(earlierIndex).toBeGreaterThanOrEqual(0); + expect(laterIndex).toBeGreaterThanOrEqual(0); + expect(earlierIndex).toBeLessThan(laterIndex); + }); + + it("returns home-root suggestions when query is '~'", async () => { + const results = await searchHomeDirectories({ + homeDir, + query: "~", + limit: 20, + }); + + expect(results).toContain(path.join(homeDir, "projects")); + expect(results).toContain(path.join(homeDir, "documents")); + }); + + it("does not return paths that escape home through symlinks", async () => { + const results = await searchHomeDirectories({ + homeDir, + query: "outside", + limit: 20, + }); + + expect(results).not.toContain(path.join(homeDir, "outside-link")); + expect(results).not.toContain(path.join(outsideDir, "outside-match")); + }); + + it("respects the result limit", async () => { + const results = await searchHomeDirectories({ + homeDir, + query: "p", + limit: 1, + }); + + expect(results).toHaveLength(1); + }); +}); diff --git a/packages/server/src/utils/directory-suggestions.ts b/packages/server/src/utils/directory-suggestions.ts new file mode 100644 index 000000000..f3ea554ad --- /dev/null +++ b/packages/server/src/utils/directory-suggestions.ts @@ -0,0 +1,456 @@ +import type { Dirent } from "node:fs"; +import { readdir, realpath, stat } from "node:fs/promises"; +import path from "node:path"; + +export interface SearchHomeDirectoriesOptions { + homeDir: string; + query: string; + limit?: number; + maxDepth?: number; + maxDirectoriesScanned?: number; +} + +const DEFAULT_LIMIT = 30; +const MAX_LIMIT = 100; +const DEFAULT_MAX_DEPTH = 6; +const DEFAULT_MAX_DIRECTORIES_SCANNED = 5000; +const DIRECTORY_LIST_CACHE_TTL_MS = 8_000; +const DIRECTORY_LIST_CACHE_MAX_ENTRIES = 4_000; + +type QueryParts = { + isPathQuery: boolean; + parentPart: string; + searchTerm: string; +}; + +type RankedDirectory = { + absolutePath: string; + matchTier: number; + segmentIndex: number; + matchOffset: number; + depth: number; +}; + +type ChildDirectoryEntry = { + name: string; + absolutePath: string; +}; + +type DirectoryListCacheEntry = { + expiresAt: number; + entries: ChildDirectoryEntry[]; +}; + +const directoryListCache = new Map(); +const NO_SEGMENT_INDEX = Number.MAX_SAFE_INTEGER; +const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER; + +export async function searchHomeDirectories( + options: SearchHomeDirectoriesOptions +): Promise { + const query = options.query.trim(); + if (!query) { + return []; + } + + const limit = normalizeLimit(options.limit); + const homeRoot = await resolveDirectory(options.homeDir); + if (!homeRoot) { + return []; + } + + const queryParts = normalizeQueryParts(query, homeRoot); + if (!queryParts) { + return []; + } + + if (queryParts.isPathQuery) { + return searchWithinParentDirectory({ + homeRoot, + parentPart: queryParts.parentPart, + searchTerm: queryParts.searchTerm, + limit, + }); + } + + return searchAcrossHomeTree({ + homeRoot, + searchTerm: queryParts.searchTerm, + limit, + maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH, + maxDirectoriesScanned: + options.maxDirectoriesScanned ?? DEFAULT_MAX_DIRECTORIES_SCANNED, + }); +} + +function normalizeLimit(limit: number | undefined): number { + const candidate = limit ?? DEFAULT_LIMIT; + if (!Number.isFinite(candidate)) { + return DEFAULT_LIMIT; + } + const bounded = Math.trunc(candidate); + return Math.max(1, Math.min(MAX_LIMIT, bounded)); +} + +async function searchWithinParentDirectory(input: { + homeRoot: string; + parentPart: string; + searchTerm: string; + limit: number; +}): Promise { + const parentPath = path.resolve(input.homeRoot, input.parentPart || "."); + const parentRoot = await resolveDirectory(parentPath); + if (!parentRoot || !isPathInsideRoot(input.homeRoot, parentRoot)) { + return []; + } + + const searchLower = input.searchTerm.toLowerCase(); + const ranked: RankedDirectory[] = []; + const entries = await listChildDirectories({ + directory: parentRoot, + homeRoot: input.homeRoot, + }); + + for (const entry of entries) { + if (searchLower && !entry.name.toLowerCase().includes(searchLower)) { + continue; + } + + ranked.push( + rankDirectory({ + absolutePath: entry.absolutePath, + homeRoot: input.homeRoot, + searchLower, + }) + ); + } + + return dedupeAndSort(ranked).slice(0, input.limit); +} + +async function searchAcrossHomeTree(input: { + homeRoot: string; + searchTerm: string; + limit: number; + maxDepth: number; + maxDirectoriesScanned: number; +}): Promise { + const queue: Array<{ directory: string; depth: number }> = [ + { directory: input.homeRoot, depth: 0 }, + ]; + const visited = new Set([input.homeRoot]); + const ranked: RankedDirectory[] = []; + let scanned = 0; + const searchLower = input.searchTerm.toLowerCase(); + + for ( + let queueIndex = 0; + queueIndex < queue.length && scanned < input.maxDirectoriesScanned; + queueIndex += 1 + ) { + const current = queue[queueIndex]; + if (!current) continue; + const entries = await listChildDirectories({ + directory: current.directory, + homeRoot: input.homeRoot, + }); + + for (const entry of entries) { + const resolvedCandidate = entry.absolutePath; + if (visited.has(resolvedCandidate)) { + continue; + } + visited.add(resolvedCandidate); + scanned += 1; + + const relativePath = normalizeRelativePath(input.homeRoot, resolvedCandidate); + if ( + relativePath.toLowerCase().includes(searchLower) || + entry.name.toLowerCase().includes(searchLower) + ) { + ranked.push( + rankDirectory({ + absolutePath: resolvedCandidate, + homeRoot: input.homeRoot, + searchLower, + }) + ); + } + + if ( + current.depth < input.maxDepth && + scanned < input.maxDirectoriesScanned + ) { + queue.push({ directory: resolvedCandidate, depth: current.depth + 1 }); + } + } + } + + return dedupeAndSort(ranked).slice(0, input.limit); +} + +function dedupeAndSort(ranked: RankedDirectory[]): string[] { + const byPath = new Map(); + for (const entry of ranked) { + const existing = byPath.get(entry.absolutePath); + if (!existing || compareRankedDirectories(entry, existing) < 0) { + byPath.set(entry.absolutePath, entry); + } + } + + return Array.from(byPath.values()) + .sort(compareRankedDirectories) + .map((entry) => entry.absolutePath); +} + +function compareRankedDirectories( + left: RankedDirectory, + right: RankedDirectory +): number { + if (left.matchTier !== right.matchTier) { + return left.matchTier - right.matchTier; + } + if (left.segmentIndex !== right.segmentIndex) { + return left.segmentIndex - right.segmentIndex; + } + if (left.matchOffset !== right.matchOffset) { + return left.matchOffset - right.matchOffset; + } + if (left.depth !== right.depth) { + return left.depth - right.depth; + } + return left.absolutePath.localeCompare(right.absolutePath); +} + +function rankDirectory(input: { + absolutePath: string; + homeRoot: string; + searchLower: string; +}): RankedDirectory { + const relative = normalizeRelativePath(input.homeRoot, input.absolutePath); + const relativeLower = relative.toLowerCase(); + const depth = relative === "." ? 0 : relative.split("/").length; + const searchLower = input.searchLower; + if (!searchLower) { + return { + absolutePath: input.absolutePath, + matchTier: 3, + segmentIndex: NO_SEGMENT_INDEX, + matchOffset: 0, + depth, + }; + } + const segments = relativeLower === "." ? [] : relativeLower.split("/"); + const exactSegmentIndex = findSegmentMatchIndex( + segments, + (segment) => segment === searchLower + ); + const prefixSegmentIndex = findSegmentMatchIndex( + segments, + (segment) => segment.startsWith(searchLower) + ); + const partialSegmentIndex = findSegmentMatchIndex( + segments, + (segment) => segment.includes(searchLower) + ); + const matchOffset = relativeLower.indexOf(searchLower); + let matchTier = 4; + let segmentIndex = NO_SEGMENT_INDEX; + + if (exactSegmentIndex >= 0) { + matchTier = 0; + segmentIndex = exactSegmentIndex; + } else if (prefixSegmentIndex >= 0) { + matchTier = 1; + segmentIndex = prefixSegmentIndex; + } else if (partialSegmentIndex >= 0) { + matchTier = 2; + segmentIndex = partialSegmentIndex; + } else if (relativeLower.startsWith(searchLower)) { + matchTier = 3; + } + + return { + absolutePath: input.absolutePath, + matchTier, + segmentIndex, + matchOffset: matchOffset >= 0 ? matchOffset : NO_MATCH_OFFSET, + depth, + }; +} + +function findSegmentMatchIndex( + segments: string[], + predicate: (segment: string) => boolean +): number { + for (let index = 0; index < segments.length; index += 1) { + const segment = segments[index]; + if (!segment) { + continue; + } + if (predicate(segment)) { + return index; + } + } + return -1; +} + +function normalizeRelativePath(homeRoot: string, absolutePath: string): string { + const relative = path.relative(homeRoot, absolutePath); + if (!relative) { + return "."; + } + return relative.split(path.sep).join("/"); +} + +function isPathInsideRoot(root: string, target: string): boolean { + const relative = path.relative(root, target); + return ( + relative === "" || + (!relative.startsWith("..") && !path.isAbsolute(relative)) + ); +} + +function normalizeQueryParts(query: string, homeRoot: string): QueryParts | null { + const typedQuery = query.trim().replace(/\\/g, "/"); + let normalized = typedQuery; + if (!normalized) { + return null; + } + + if (normalized.startsWith("~")) { + normalized = normalized.slice(1); + if (normalized.startsWith("/")) { + normalized = normalized.slice(1); + } + } + + if (path.isAbsolute(normalized)) { + const absolute = path.resolve(normalized); + if (!isPathInsideRoot(homeRoot, absolute)) { + return null; + } + normalized = normalizeRelativePath(homeRoot, absolute); + } + + normalized = normalized.replace(/^\.\/+/, "").replace(/\/{2,}/g, "/"); + if (!normalized) { + // Treat "~" and "~/" as a request to browse the home root. + if (typedQuery === "~" || typedQuery === "~/") { + return { + isPathQuery: true, + parentPart: "", + searchTerm: "", + }; + } + return null; + } + + const isPathQuery = normalized.includes("/"); + const slashIndex = normalized.lastIndexOf("/"); + const parentPart = slashIndex >= 0 ? normalized.slice(0, slashIndex) : ""; + const searchTerm = slashIndex >= 0 ? normalized.slice(slashIndex + 1) : normalized; + + return { + isPathQuery, + parentPart, + searchTerm, + }; +} + +async function resolveDirectory(inputPath: string): Promise { + try { + const resolved = await realpath(path.resolve(inputPath)); + const stats = await stat(resolved); + if (!stats.isDirectory()) { + return null; + } + return resolved; + } catch { + return null; + } +} + +async function listChildDirectories(input: { + directory: string; + homeRoot: string; +}): Promise { + const now = Date.now(); + const cached = directoryListCache.get(input.directory); + if (cached && cached.expiresAt > now) { + return cached.entries; + } + + const dirents = await readdir(input.directory, { withFileTypes: true }).catch( + () => [] as Dirent[] + ); + const entries: ChildDirectoryEntry[] = []; + for (const dirent of dirents) { + if (!dirent.isDirectory() && !dirent.isSymbolicLink()) { + continue; + } + const candidatePath = path.join(input.directory, dirent.name); + const absolutePath = await resolveDirectoryCandidate({ + candidatePath, + dirent, + homeRoot: input.homeRoot, + }); + if (!absolutePath) { + continue; + } + entries.push({ + name: dirent.name, + absolutePath, + }); + } + + setDirectoryListCache(input.directory, { + expiresAt: now + DIRECTORY_LIST_CACHE_TTL_MS, + entries, + }); + + return entries; +} + +async function resolveDirectoryCandidate(input: { + candidatePath: string; + dirent: Dirent; + homeRoot: string; +}): Promise { + if (input.dirent.isDirectory()) { + const resolved = path.resolve(input.candidatePath); + return isPathInsideRoot(input.homeRoot, resolved) ? resolved : null; + } + + const resolved = await resolveDirectory(input.candidatePath); + if (!resolved || !isPathInsideRoot(input.homeRoot, resolved)) { + return null; + } + return resolved; +} + +function setDirectoryListCache(cacheKey: string, entry: DirectoryListCacheEntry): void { + directoryListCache.set(cacheKey, entry); + pruneDirectoryListCache(); +} + +function pruneDirectoryListCache(): void { + if (directoryListCache.size <= DIRECTORY_LIST_CACHE_MAX_ENTRIES) { + return; + } + + const now = Date.now(); + for (const [cacheKey, entry] of directoryListCache) { + if (entry.expiresAt <= now) { + directoryListCache.delete(cacheKey); + } + } + + while (directoryListCache.size > DIRECTORY_LIST_CACHE_MAX_ENTRIES) { + const oldestKey = directoryListCache.keys().next().value as string | undefined; + if (!oldestKey) { + return; + } + directoryListCache.delete(oldestKey); + } +} diff --git a/packages/server/src/utils/worktree-metadata.ts b/packages/server/src/utils/worktree-metadata.ts index 259292006..211664d91 100644 --- a/packages/server/src/utils/worktree-metadata.ts +++ b/packages/server/src/utils/worktree-metadata.ts @@ -2,11 +2,26 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { isAbsolute, join, resolve } from "path"; import { z } from "zod"; -const PaseoWorktreeMetadataSchema = z.object({ +const PaseoWorktreeMetadataV1Schema = z.object({ version: z.literal(1), baseRefName: z.string().min(1), }); +const PaseoWorktreeMetadataV2Schema = z.object({ + version: z.literal(2), + baseRefName: z.string().min(1), + runtime: z + .object({ + worktreePort: z.number().int().positive(), + }) + .optional(), +}); + +const PaseoWorktreeMetadataSchema = z.union([ + PaseoWorktreeMetadataV1Schema, + PaseoWorktreeMetadataV2Schema, +]); + export type PaseoWorktreeMetadata = z.infer; function getGitDirForWorktreeRoot(worktreeRoot: string): string { @@ -68,6 +83,31 @@ export function writePaseoWorktreeMetadata( writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8"); } +export function writePaseoWorktreeRuntimeMetadata( + worktreeRoot: string, + options: { worktreePort: number } +): void { + if (!Number.isInteger(options.worktreePort) || options.worktreePort <= 0) { + throw new Error(`Invalid worktree runtime port: ${options.worktreePort}`); + } + + const current = readPaseoWorktreeMetadata(worktreeRoot); + if (!current) { + throw new Error("Cannot persist worktree runtime metadata: missing base metadata"); + } + + const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot); + mkdirSync(join(getGitDirForWorktreeRoot(worktreeRoot), "paseo"), { recursive: true }); + const next: PaseoWorktreeMetadata = { + version: 2, + baseRefName: current.baseRefName, + runtime: { + worktreePort: options.worktreePort, + }, + }; + writeFileSync(metadataPath, `${JSON.stringify(next, null, 2)}\n`, "utf8"); +} + export function readPaseoWorktreeMetadata(worktreeRoot: string): PaseoWorktreeMetadata | null { const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot); if (!existsSync(metadataPath)) { @@ -85,3 +125,14 @@ export function requirePaseoWorktreeBaseRefName(worktreeRoot: string): string { } return metadata.baseRefName; } + +export function readPaseoWorktreeRuntimePort(worktreeRoot: string): number | null { + const metadata = readPaseoWorktreeMetadata(worktreeRoot); + if (!metadata) { + return null; + } + if (metadata.version === 2 && metadata.runtime?.worktreePort) { + return metadata.runtime.worktreePort; + } + return null; +} diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 474a9d89a..9bf1bed2a 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -5,6 +5,7 @@ import { getWorktreeTerminalSpecs, isPaseoOwnedWorktreeCwd, listPaseoWorktrees, + resolveWorktreeRuntimeEnv, type WorktreeSetupCommandProgressEvent, runWorktreeSetupCommands, slugify, @@ -14,6 +15,7 @@ import { execSync } from "child_process"; import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; +import net from "node:net"; describe("createWorktree", () => { let tempDir: string; @@ -265,6 +267,68 @@ describe("createWorktree", () => { expect(progressEvents.some((event) => event.type === "command_completed")).toBe(true); }); + it("reuses persisted worktree runtime port across resolutions", async () => { + const result = await createWorktree({ + branchName: "main", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "runtime-env-port-reuse", + runSetup: false, + paseoHome, + }); + + const first = await resolveWorktreeRuntimeEnv({ + worktreePath: result.worktreePath, + branchName: result.branchName, + }); + const second = await resolveWorktreeRuntimeEnv({ + worktreePath: result.worktreePath, + branchName: result.branchName, + }); + + expect(second.PASEO_WORKTREE_PORT).toBe(first.PASEO_WORKTREE_PORT); + }); + + it("fails runtime env resolution when persisted port is in use", async () => { + const result = await createWorktree({ + branchName: "main", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "runtime-env-port-conflict", + runSetup: false, + paseoHome, + }); + + const env = await resolveWorktreeRuntimeEnv({ + worktreePath: result.worktreePath, + branchName: result.branchName, + }); + const port = Number(env.PASEO_WORKTREE_PORT); + + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, () => resolve()); + }); + + await expect( + resolveWorktreeRuntimeEnv({ + worktreePath: result.worktreePath, + branchName: result.branchName, + }) + ).rejects.toThrow(`Persisted worktree port ${port} is already in use`); + + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + }); + it("cleans up worktree if setup command fails", async () => { // Create paseo.json with failing setup command const paseoConfig = { diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 6bcaa1c77..a8acc14e6 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -4,7 +4,13 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "fs"; import { join, basename, dirname, resolve, sep } from "path"; import net from "node:net"; import { createNameId } from "mnemonic-id"; -import { normalizeBaseRefName, writePaseoWorktreeMetadata } from "./worktree-metadata.js"; +import { + normalizeBaseRefName, + readPaseoWorktreeMetadata, + readPaseoWorktreeRuntimePort, + writePaseoWorktreeMetadata, + writePaseoWorktreeRuntimeMetadata, +} from "./worktree-metadata.js"; import { resolvePaseoHome } from "../server/paseo-home.js"; interface PaseoConfig { @@ -26,6 +32,14 @@ export interface WorktreeConfig { worktreePath: string; } +export type WorktreeRuntimeEnv = { + PASEO_SOURCE_CHECKOUT_PATH: string; + PASEO_ROOT_PATH: string; + PASEO_WORKTREE_PATH: string; + PASEO_BRANCH_NAME: string; + PASEO_WORKTREE_PORT: string; +}; + export type WorktreeSetupCommandResult = { command: string; cwd: string; @@ -327,6 +341,29 @@ async function getAvailablePort(): Promise { }); } +async function assertPortAvailable(port: number): Promise { + await new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", (error: NodeJS.ErrnoException) => { + const message = error?.code === "EADDRINUSE" + ? `Persisted worktree port ${port} is already in use` + : error instanceof Error + ? error.message + : String(error); + reject(new Error(message)); + }); + server.listen(port, () => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + }); +} + async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise { try { const commonDir = await getGitCommonDir(worktreePath); @@ -360,6 +397,7 @@ export async function runWorktreeSetupCommands(options: { branchName: string; cleanupOnFailure: boolean; repoRootPath?: string; + runtimeEnv?: WorktreeRuntimeEnv; onEvent?: (event: WorktreeSetupCommandProgressEvent) => void; }): Promise { // Read paseo.json from the worktree (it will have the same content as the source repo) @@ -368,21 +406,16 @@ export async function runWorktreeSetupCommands(options: { return []; } - const repoRootPath = - options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath)); - const worktreePort = await getAvailablePort(); - + const runtimeEnv = + options.runtimeEnv ?? + (await resolveWorktreeRuntimeEnv({ + worktreePath: options.worktreePath, + branchName: options.branchName, + ...(options.repoRootPath ? { repoRootPath: options.repoRootPath } : {}), + })); const setupEnv = { ...process.env, - // Source checkout path is the original git repo root (shared across worktrees), not the - // worktree itself. This allows setup scripts to copy local files (e.g. .env) from the - // source checkout. - PASEO_SOURCE_CHECKOUT_PATH: repoRootPath, - // Backward-compatible alias. - PASEO_ROOT_PATH: repoRootPath, - PASEO_WORKTREE_PATH: options.worktreePath, - PASEO_BRANCH_NAME: options.branchName, - PASEO_WORKTREE_PORT: String(worktreePort), + ...runtimeEnv, }; const results: WorktreeSetupCommandResult[] = []; @@ -439,6 +472,40 @@ async function resolveBranchNameForWorktreePath(worktreePath: string): Promise { + const repoRootPath = + options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath)); + const branchName = + options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath)); + + let worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath); + if (worktreePort === null) { + worktreePort = await getAvailablePort(); + const metadata = readPaseoWorktreeMetadata(options.worktreePath); + if (metadata) { + writePaseoWorktreeRuntimeMetadata(options.worktreePath, { worktreePort }); + } + } else { + await assertPortAvailable(worktreePort); + } + + return { + // Source checkout path is the original git repo root (shared across worktrees), not the + // worktree itself. This allows setup scripts to copy local files (e.g. .env) from the + // source checkout. + PASEO_SOURCE_CHECKOUT_PATH: repoRootPath, + // Backward-compatible alias. + PASEO_ROOT_PATH: repoRootPath, + PASEO_WORKTREE_PATH: options.worktreePath, + PASEO_BRANCH_NAME: branchName, + PASEO_WORKTREE_PORT: String(worktreePort), + }; +} + export async function runWorktreeDestroyCommands(options: { worktreePath: string; branchName?: string;