feat: improve agent workspace flows and directory suggestions
This commit is contained in:
Generated
+11
@@ -13681,6 +13681,15 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/lighthouse-logger": {
|
||||||
"version": "1.4.2",
|
"version": "1.4.2",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
@@ -20390,6 +20399,7 @@
|
|||||||
"expo-system-ui": "~6.0.7",
|
"expo-system-ui": "~6.0.7",
|
||||||
"expo-updates": "~29.0.12",
|
"expo-updates": "~29.0.12",
|
||||||
"expo-web-browser": "~15.0.8",
|
"expo-web-browser": "~15.0.8",
|
||||||
|
"lezer-elixir": "^1.1.2",
|
||||||
"lucide-react-native": "^0.546.0",
|
"lucide-react-native": "^0.546.0",
|
||||||
"mnemonic-id": "^3.2.7",
|
"mnemonic-id": "^3.2.7",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
@@ -20546,6 +20556,7 @@
|
|||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-basic-auth": "^1.2.1",
|
"express-basic-auth": "^1.2.1",
|
||||||
|
"lezer-elixir": "^1.1.2",
|
||||||
"mnemonic-id": "^3.2.7",
|
"mnemonic-id": "^3.2.7",
|
||||||
"node-pty": "^1.0.0",
|
"node-pty": "^1.0.0",
|
||||||
"onnxruntime-node": "^1.23.0",
|
"onnxruntime-node": "^1.23.0",
|
||||||
|
|||||||
@@ -1,18 +1,46 @@
|
|||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
const pkg = require("./package.json");
|
const pkg = require("./package.json");
|
||||||
const appVariant = process.env.APP_VARIANT ?? "production";
|
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 = {
|
const variants = {
|
||||||
production: {
|
production: {
|
||||||
name: "Paseo",
|
name: "Paseo",
|
||||||
packageId: "sh.paseo",
|
packageId: "sh.paseo",
|
||||||
googleServicesFile: "./.secrets/google-services.prod.json",
|
googleServicesFile: resolveSecretFile({
|
||||||
googleServiceInfoPlist: "./.secrets/GoogleService-Info.prod.plist",
|
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: {
|
development: {
|
||||||
name: "Paseo Debug",
|
name: "Paseo Debug",
|
||||||
packageId: "sh.paseo.debug",
|
packageId: "sh.paseo.debug",
|
||||||
googleServicesFile: "./.secrets/google-services.debug.json",
|
googleServicesFile: resolveSecretFile({
|
||||||
googleServiceInfoPlist: "./.secrets/GoogleService-Info.debug.plist",
|
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,
|
ITSAppUsesNonExemptEncryption: false,
|
||||||
},
|
},
|
||||||
bundleIdentifier: variant.packageId,
|
bundleIdentifier: variant.packageId,
|
||||||
googleServicesFile: variant.googleServiceInfoPlist,
|
...(variant.googleServiceInfoPlist
|
||||||
|
? { googleServicesFile: variant.googleServiceInfoPlist }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
android: {
|
android: {
|
||||||
adaptiveIcon: {
|
adaptiveIcon: {
|
||||||
@@ -62,7 +92,9 @@ export default {
|
|||||||
"android.permission.CAMERA",
|
"android.permission.CAMERA",
|
||||||
],
|
],
|
||||||
package: variant.packageId,
|
package: variant.packageId,
|
||||||
googleServicesFile: variant.googleServicesFile,
|
...(variant.googleServicesFile
|
||||||
|
? { googleServicesFile: variant.googleServicesFile }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
web: {
|
web: {
|
||||||
output: "single",
|
output: "single",
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
"expo-system-ui": "~6.0.7",
|
"expo-system-ui": "~6.0.7",
|
||||||
"expo-updates": "~29.0.12",
|
"expo-updates": "~29.0.12",
|
||||||
"expo-web-browser": "~15.0.8",
|
"expo-web-browser": "~15.0.8",
|
||||||
|
"lezer-elixir": "^1.1.2",
|
||||||
"lucide-react-native": "^0.546.0",
|
"lucide-react-native": "^0.546.0",
|
||||||
"mnemonic-id": "^3.2.7",
|
"mnemonic-id": "^3.2.7",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
|
|||||||
@@ -883,11 +883,9 @@ export function WorkingDirectoryDropdown({
|
|||||||
options={options}
|
options={options}
|
||||||
value={workingDir}
|
value={workingDir}
|
||||||
onSelect={onSelectPath}
|
onSelect={onSelectPath}
|
||||||
searchPlaceholder="/path/to/project"
|
searchPlaceholder="Search directories..."
|
||||||
emptyText={emptyText}
|
emptyText={emptyText}
|
||||||
allowCustomValue
|
optionsPosition="above-search"
|
||||||
customValuePrefix="Use"
|
|
||||||
customValueDescription="Launch the agent in this directory"
|
|
||||||
title="Working directory"
|
title="Working directory"
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={handleOpenChange}
|
onOpenChange={handleOpenChange}
|
||||||
|
|||||||
@@ -105,12 +105,6 @@ export function AgentList({
|
|||||||
return;
|
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);
|
const navigationKey = buildAgentNavigationKey(serverId, agentId);
|
||||||
startNavigationTiming(navigationKey, {
|
startNavigationTiming(navigationKey, {
|
||||||
from: "home",
|
from: "home",
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
|
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import * as Linking from "expo-linking";
|
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -52,6 +51,7 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||||
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
|
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
|
||||||
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Git Actions Data Structure
|
// Git Actions Data Structure
|
||||||
@@ -85,11 +85,7 @@ interface GitActions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openURLInNewTab(url: string): void {
|
function openURLInNewTab(url: string): void {
|
||||||
if (Platform.OS === "web") {
|
void openExternalUrl(url);
|
||||||
window.open(url, "_blank", "noopener");
|
|
||||||
} else {
|
|
||||||
void Linking.openURL(url);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DIFF_PANE_LOG_TAG = "[GitDiffPane]";
|
const DIFF_PANE_LOG_TAG = "[GitDiffPane]";
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import type { ReactNode, ComponentType } from "react";
|
import type { ReactNode, ComponentType } from "react";
|
||||||
import Markdown, { MarkdownIt } from "react-native-markdown-display";
|
import Markdown, { MarkdownIt } from "react-native-markdown-display";
|
||||||
import * as Linking from "expo-linking";
|
|
||||||
import MaskedView from "@react-native-masked-view/masked-view";
|
import MaskedView from "@react-native-masked-view/masked-view";
|
||||||
import {
|
import {
|
||||||
Circle,
|
Circle,
|
||||||
@@ -71,6 +70,7 @@ import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
|||||||
import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
|
import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
|
||||||
import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path";
|
import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path";
|
||||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||||
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
export type { InlinePathTarget } from "@/utils/inline-path";
|
export type { InlinePathTarget } from "@/utils/inline-path";
|
||||||
import { useToolCallSheet } from "./tool-call-sheet";
|
import { useToolCallSheet } from "./tool-call-sheet";
|
||||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||||
@@ -540,11 +540,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleLinkPress = useCallback((url: string) => {
|
const handleLinkPress = useCallback((url: string) => {
|
||||||
if (Platform.OS === "web") {
|
void openExternalUrl(url);
|
||||||
window.open(url, "_blank", "noopener,noreferrer");
|
|
||||||
} else {
|
|
||||||
void Linking.openURL(url);
|
|
||||||
}
|
|
||||||
// react-native-markdown-display opens the link itself when this returns true.
|
// react-native-markdown-display opens the link itself when this returns true.
|
||||||
// We already handled it above, so return false to avoid duplicate opens.
|
// We already handled it above, so return false to avoid duplicate opens.
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -592,11 +592,6 @@ export function SidebarAgentList({
|
|||||||
return;
|
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);
|
const navigationKey = buildAgentNavigationKey(entry.agent.serverId, entry.agent.id);
|
||||||
startNavigationTiming(navigationKey, {
|
startNavigationTiming(navigationKey, {
|
||||||
from: "home",
|
from: "home",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { SidebarAgentList } from "./sidebar-agent-list";
|
|||||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||||
import { useSidebarAgentsGrouped } from "@/hooks/use-sidebar-agents-grouped";
|
import { useSidebarAgentsGrouped } from "@/hooks/use-sidebar-agents-grouped";
|
||||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
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 { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { Combobox } from "@/components/ui/combobox";
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||||
@@ -121,6 +121,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
|||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
} = useSidebarAnimation();
|
} = useSidebarAnimation();
|
||||||
const dragHandlers = useTauriDragHandlers();
|
const dragHandlers = useTauriDragHandlers();
|
||||||
|
const trafficLightPadding = useTrafficLightPadding();
|
||||||
|
|
||||||
// Track user-initiated refresh to avoid showing spinner on background revalidation
|
// Track user-initiated refresh to avoid showing spinner on background revalidation
|
||||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||||
@@ -465,7 +466,10 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
|
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
|
||||||
<View style={styles.sidebarHeader} {...dragHandlers}>
|
<View
|
||||||
|
style={[styles.sidebarHeader, { paddingLeft: theme.spacing[2] + trafficLightPadding.left }]}
|
||||||
|
{...dragHandlers}
|
||||||
|
>
|
||||||
<View style={styles.sidebarHeaderRow}>
|
<View style={styles.sidebarHeaderRow}>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.newAgentButton}
|
style={styles.newAgentButton}
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ import Svg, {
|
|||||||
Stop,
|
Stop,
|
||||||
} from "react-native-svg";
|
} from "react-native-svg";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
|
import type { ListTerminalsResponse } from "@server/shared/messages";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
hasPendingTerminalModifiers,
|
hasPendingTerminalModifiers,
|
||||||
normalizeTerminalTransportKey,
|
normalizeTerminalTransportKey,
|
||||||
resolvePendingModifierDataInput,
|
resolvePendingModifierDataInput,
|
||||||
} from "@/utils/terminal-keys";
|
} from "@/utils/terminal-keys";
|
||||||
|
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||||
import {
|
import {
|
||||||
TerminalOutputPump,
|
TerminalOutputPump,
|
||||||
type TerminalOutputChunk,
|
type TerminalOutputChunk,
|
||||||
@@ -89,6 +91,8 @@ type PendingTerminalInput =
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||||
|
|
||||||
const EMPTY_MODIFIERS: ModifierState = {
|
const EMPTY_MODIFIERS: ModifierState = {
|
||||||
ctrl: false,
|
ctrl: false,
|
||||||
shift: false,
|
shift: false,
|
||||||
@@ -134,6 +138,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
||||||
|
const terminalsQueryKey = useMemo(() => ["terminals", serverId, cwd] as const, [cwd, serverId]);
|
||||||
const selectedTerminalByScopeRef = useRef<Map<string, string>>(new Map());
|
const selectedTerminalByScopeRef = useRef<Map<string, string>>(new Map());
|
||||||
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||||
const streamControllerRef = useRef<TerminalStreamController | null>(null);
|
const streamControllerRef = useRef<TerminalStreamController | null>(null);
|
||||||
@@ -245,8 +250,12 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
return () => clearHoverOutTimeout();
|
return () => clearHoverOutTimeout();
|
||||||
}, [clearHoverOutTimeout]);
|
}, [clearHoverOutTimeout]);
|
||||||
|
|
||||||
|
const requestTerminalFocus = useCallback(() => {
|
||||||
|
setFocusRequestToken((current) => current + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const terminalsQuery = useQuery({
|
const terminalsQuery = useQuery({
|
||||||
queryKey: ["terminals", serverId, cwd] as const,
|
queryKey: terminalsQueryKey,
|
||||||
enabled: Boolean(client && isConnected && cwd.startsWith("/")),
|
enabled: Boolean(client && isConnected && cwd.startsWith("/")),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
@@ -281,14 +290,14 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
setModifiers({ ...EMPTY_MODIFIERS });
|
setModifiers({ ...EMPTY_MODIFIERS });
|
||||||
|
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ["terminals", serverId, cwd],
|
queryKey: terminalsQueryKey,
|
||||||
});
|
});
|
||||||
void queryClient.refetchQueries({
|
void queryClient.refetchQueries({
|
||||||
queryKey: ["terminals", serverId, cwd],
|
queryKey: terminalsQueryKey,
|
||||||
type: "active",
|
type: "active",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [client, cwd, isConnected, queryClient, serverId]);
|
}, [client, isConnected, queryClient, terminalsQueryKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!client || !isConnected || !cwd.startsWith("/")) {
|
if (!client || !isConnected || !cwd.startsWith("/")) {
|
||||||
@@ -303,10 +312,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ["terminals", serverId, cwd],
|
queryKey: terminalsQueryKey,
|
||||||
});
|
});
|
||||||
void queryClient.refetchQueries({
|
void queryClient.refetchQueries({
|
||||||
queryKey: ["terminals", serverId, cwd],
|
queryKey: terminalsQueryKey,
|
||||||
type: "active",
|
type: "active",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -317,7 +326,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
unsubscribe();
|
unsubscribe();
|
||||||
client.unsubscribeTerminals({ cwd });
|
client.unsubscribeTerminals({ cwd });
|
||||||
};
|
};
|
||||||
}, [client, cwd, isConnected, queryClient, serverId]);
|
}, [client, cwd, isConnected, queryClient, terminalsQueryKey]);
|
||||||
|
|
||||||
const createTerminalMutation = useMutation({
|
const createTerminalMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
@@ -327,12 +336,26 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
return await client.createTerminal(cwd);
|
return await client.createTerminal(cwd);
|
||||||
},
|
},
|
||||||
onSuccess: (payload) => {
|
onSuccess: (payload) => {
|
||||||
if (payload.terminal) {
|
const createdTerminal = payload.terminal;
|
||||||
selectedTerminalByScopeRef.current.set(scopeKey, payload.terminal.id);
|
if (createdTerminal) {
|
||||||
setSelectedTerminalId(payload.terminal.id);
|
queryClient.setQueryData<ListTerminalsPayload>(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({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ["terminals", serverId, cwd],
|
queryKey: terminalsQueryKey,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -358,10 +381,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
setModifiers({ ...EMPTY_MODIFIERS });
|
setModifiers({ ...EMPTY_MODIFIERS });
|
||||||
}
|
}
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ["terminals", serverId, cwd],
|
queryKey: terminalsQueryKey,
|
||||||
});
|
});
|
||||||
void queryClient.refetchQueries({
|
void queryClient.refetchQueries({
|
||||||
queryKey: ["terminals", serverId, cwd],
|
queryKey: terminalsQueryKey,
|
||||||
type: "active",
|
type: "active",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -550,10 +573,6 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
[killTerminalMutation]
|
[killTerminalMutation]
|
||||||
);
|
);
|
||||||
|
|
||||||
const requestTerminalFocus = useCallback(() => {
|
|
||||||
setFocusRequestToken((current) => current + 1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearPendingModifiers = useCallback(() => {
|
const clearPendingModifiers = useCallback(() => {
|
||||||
setModifiers({ ...EMPTY_MODIFIERS });
|
setModifiers({ ...EMPTY_MODIFIERS });
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
BottomSheetBackgroundProps,
|
BottomSheetBackgroundProps,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
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 { flip, offset as floatingOffset, shift, size as floatingSize, useFloating } from "@floating-ui/react-native";
|
||||||
import { getNextActiveIndex } from "./combobox-keyboard";
|
import { getNextActiveIndex } from "./combobox-keyboard";
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ export interface ComboboxOption {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
kind?: "directory";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComboboxProps {
|
export interface ComboboxProps {
|
||||||
@@ -43,6 +44,8 @@ export interface ComboboxProps {
|
|||||||
allowCustomValue?: boolean;
|
allowCustomValue?: boolean;
|
||||||
customValuePrefix?: string;
|
customValuePrefix?: string;
|
||||||
customValueDescription?: string;
|
customValueDescription?: string;
|
||||||
|
customValueKind?: "directory";
|
||||||
|
optionsPosition?: "below-search" | "above-search";
|
||||||
title?: string;
|
title?: string;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
@@ -129,6 +132,7 @@ function SearchInput({
|
|||||||
export interface ComboboxItemProps {
|
export interface ComboboxItemProps {
|
||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
kind?: "directory";
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
@@ -138,6 +142,7 @@ export interface ComboboxItemProps {
|
|||||||
export function ComboboxItem({
|
export function ComboboxItem({
|
||||||
label,
|
label,
|
||||||
description,
|
description,
|
||||||
|
kind,
|
||||||
selected,
|
selected,
|
||||||
active,
|
active,
|
||||||
onPress,
|
onPress,
|
||||||
@@ -155,6 +160,11 @@ export function ComboboxItem({
|
|||||||
active && styles.comboboxItemActive,
|
active && styles.comboboxItemActive,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
{kind === "directory" ? (
|
||||||
|
<View style={styles.comboboxItemLeadingSlot}>
|
||||||
|
<Folder size={16} color={theme.colors.foregroundMuted} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
<View style={styles.comboboxItemContent}>
|
<View style={styles.comboboxItemContent}>
|
||||||
<Text numberOfLines={1} style={styles.comboboxItemLabel}>{label}</Text>
|
<Text numberOfLines={1} style={styles.comboboxItemLabel}>{label}</Text>
|
||||||
{description ? (
|
{description ? (
|
||||||
@@ -186,6 +196,8 @@ export function Combobox({
|
|||||||
allowCustomValue = false,
|
allowCustomValue = false,
|
||||||
customValuePrefix = "Use",
|
customValuePrefix = "Use",
|
||||||
customValueDescription,
|
customValueDescription,
|
||||||
|
customValueKind,
|
||||||
|
optionsPosition = "below-search",
|
||||||
title = "Select",
|
title = "Select",
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -196,6 +208,8 @@ export function Combobox({
|
|||||||
}: ComboboxProps): ReactElement {
|
}: ComboboxProps): ReactElement {
|
||||||
const isMobile =
|
const isMobile =
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
|
const effectiveOptionsPosition =
|
||||||
|
isMobile ? "below-search" : optionsPosition;
|
||||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||||
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
||||||
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null);
|
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null);
|
||||||
@@ -375,13 +389,20 @@ export function Combobox({
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
kind?: "directory";
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
if (showCustomOption) {
|
if (showCustomOption) {
|
||||||
|
const trimmedPrefix = customValuePrefix.trim();
|
||||||
|
const customLabel =
|
||||||
|
trimmedPrefix.length > 0
|
||||||
|
? `${trimmedPrefix} "${sanitizedSearchValue}"`
|
||||||
|
: sanitizedSearchValue;
|
||||||
next.push({
|
next.push({
|
||||||
id: sanitizedSearchValue,
|
id: sanitizedSearchValue,
|
||||||
label: `${customValuePrefix} "${sanitizedSearchValue}"`,
|
label: customLabel,
|
||||||
description: customValueDescription,
|
description: customValueDescription,
|
||||||
|
kind: customValueKind,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,35 +411,54 @@ export function Combobox({
|
|||||||
id: opt.id,
|
id: opt.id,
|
||||||
label: opt.label,
|
label: opt.label,
|
||||||
description: opt.description,
|
description: opt.description,
|
||||||
|
kind: opt.kind,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return next;
|
return next;
|
||||||
}, [
|
}, [
|
||||||
customValueDescription,
|
customValueDescription,
|
||||||
|
customValueKind,
|
||||||
customValuePrefix,
|
customValuePrefix,
|
||||||
filteredOptions,
|
filteredOptions,
|
||||||
sanitizedSearchValue,
|
sanitizedSearchValue,
|
||||||
showCustomOption,
|
showCustomOption,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const orderedVisibleOptions = useMemo(() => {
|
||||||
|
if (effectiveOptionsPosition !== "above-search") {
|
||||||
|
return visibleOptions;
|
||||||
|
}
|
||||||
|
return [...visibleOptions].reverse();
|
||||||
|
}, [effectiveOptionsPosition, visibleOptions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
if (!IS_WEB && isMobile) return;
|
if (!IS_WEB && isMobile) return;
|
||||||
|
|
||||||
if (visibleOptions.length === 0) {
|
if (orderedVisibleOptions.length === 0) {
|
||||||
setActiveIndex(-1);
|
setActiveIndex(-1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fallbackIndex =
|
||||||
|
effectiveOptionsPosition === "above-search" ? orderedVisibleOptions.length - 1 : 0;
|
||||||
|
|
||||||
if (normalizedSearch) {
|
if (normalizedSearch) {
|
||||||
setActiveIndex(0);
|
setActiveIndex(fallbackIndex);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedIndex = visibleOptions.findIndex((opt) => opt.id === value);
|
const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value);
|
||||||
setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);
|
setActiveIndex(selectedIndex >= 0 ? selectedIndex : fallbackIndex);
|
||||||
}, [isMobile, isOpen, normalizedSearch, value, visibleOptions]);
|
}, [
|
||||||
|
effectiveOptionsPosition,
|
||||||
|
isMobile,
|
||||||
|
isOpen,
|
||||||
|
normalizedSearch,
|
||||||
|
value,
|
||||||
|
orderedVisibleOptions,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
@@ -444,7 +484,7 @@ export function Combobox({
|
|||||||
setActiveIndex((currentIndex) =>
|
setActiveIndex((currentIndex) =>
|
||||||
getNextActiveIndex({
|
getNextActiveIndex({
|
||||||
currentIndex,
|
currentIndex,
|
||||||
itemCount: visibleOptions.length,
|
itemCount: orderedVisibleOptions.length,
|
||||||
key,
|
key,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -452,11 +492,11 @@ export function Combobox({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (key === "Enter") {
|
if (key === "Enter") {
|
||||||
if (visibleOptions.length === 0) return;
|
if (orderedVisibleOptions.length === 0) return;
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
const index =
|
const index =
|
||||||
activeIndex >= 0 && activeIndex < visibleOptions.length ? activeIndex : 0;
|
activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0;
|
||||||
handleSelect(visibleOptions[index]!.id);
|
handleSelect(orderedVisibleOptions[index]!.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,7 +505,7 @@ export function Combobox({
|
|||||||
handleClose();
|
handleClose();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeIndex, handleClose, handleSelect, isMobile, isOpen, visibleOptions]
|
[activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -498,12 +538,13 @@ export function Combobox({
|
|||||||
|
|
||||||
const optionsList = (
|
const optionsList = (
|
||||||
<>
|
<>
|
||||||
{visibleOptions.length > 0 ? (
|
{orderedVisibleOptions.length > 0 ? (
|
||||||
visibleOptions.map((opt, index) => (
|
orderedVisibleOptions.map((opt, index) => (
|
||||||
<ComboboxItem
|
<ComboboxItem
|
||||||
key={opt.id}
|
key={opt.id}
|
||||||
label={opt.label}
|
label={opt.label}
|
||||||
description={opt.description}
|
description={opt.description}
|
||||||
|
kind={opt.kind}
|
||||||
selected={opt.id === value}
|
selected={opt.id === value}
|
||||||
active={index === activeIndex}
|
active={index === activeIndex}
|
||||||
onPress={() => handleSelect(opt.id)}
|
onPress={() => handleSelect(opt.id)}
|
||||||
@@ -515,13 +556,16 @@ export function Combobox({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const content = children ?? (
|
const defaultContent = (
|
||||||
<>
|
<>
|
||||||
|
{effectiveOptionsPosition === "above-search" ? optionsList : null}
|
||||||
{searchable ? searchInput : null}
|
{searchable ? searchInput : null}
|
||||||
{optionsList}
|
{effectiveOptionsPosition === "below-search" ? optionsList : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const content = children ?? defaultContent;
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
@@ -662,6 +706,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
},
|
},
|
||||||
|
comboboxItemLeadingSlot: {
|
||||||
|
width: 16,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
comboboxItemLabel: {
|
comboboxItemLabel: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
|
|||||||
@@ -216,9 +216,6 @@ export function useCommandCenter() {
|
|||||||
const handleSelectAgent = useCallback(
|
const handleSelectAgent = useCallback(
|
||||||
(agent: AggregatedAgent) => {
|
(agent: AggregatedAgent) => {
|
||||||
didNavigateRef.current = true;
|
didNavigateRef.current = true;
|
||||||
const session = useSessionStore.getState().sessions[agent.serverId];
|
|
||||||
session?.client?.clearAgentAttention(agent.id);
|
|
||||||
|
|
||||||
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
|
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
|
||||||
const navigate = shouldReplace ? router.replace : router.push;
|
const navigate = shouldReplace ? router.replace : router.push;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
import { getIsTauriMac } from "@/constants/layout";
|
||||||
import { useAggregatedAgents } from "./use-aggregated-agents";
|
import { useAggregatedAgents } from "./use-aggregated-agents";
|
||||||
|
|
||||||
type FaviconStatus = "none" | "running" | "attention";
|
type FaviconStatus = "none" | "running" | "attention";
|
||||||
@@ -34,6 +35,21 @@ function deriveFaviconStatus(
|
|||||||
return "none";
|
return "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deriveMacDockBadgeCount(
|
||||||
|
agents: ReturnType<typeof useAggregatedAgents>["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 {
|
function getFaviconUri(status: FaviconStatus, colorScheme: ColorScheme): string {
|
||||||
const image = FAVICON_IMAGES[colorScheme][status];
|
const image = FAVICON_IMAGES[colorScheme][status];
|
||||||
if (typeof image === "object" && "uri" in image) {
|
if (typeof image === "object" && "uri" in image) {
|
||||||
@@ -73,9 +89,25 @@ function getSystemColorScheme(): ColorScheme {
|
|||||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
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() {
|
export function useFaviconStatus() {
|
||||||
const { agents } = useAggregatedAgents();
|
const { agents } = useAggregatedAgents();
|
||||||
const [colorScheme, setColorScheme] = useState<ColorScheme>(getSystemColorScheme);
|
const [colorScheme, setColorScheme] = useState<ColorScheme>(getSystemColorScheme);
|
||||||
|
const lastDockBadgeCountRef = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
// Listen for system color scheme changes
|
// Listen for system color scheme changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -96,5 +128,11 @@ export function useFaviconStatus() {
|
|||||||
|
|
||||||
const status = deriveFaviconStatus(agents);
|
const status = deriveFaviconStatus(agents);
|
||||||
updateFavicon(status, colorScheme);
|
updateFavicon(status, colorScheme);
|
||||||
|
|
||||||
|
const dockBadgeCount = deriveMacDockBadgeCount(agents);
|
||||||
|
if (dockBadgeCount !== lastDockBadgeCountRef.current) {
|
||||||
|
lastDockBadgeCountRef.current = dockBadgeCount;
|
||||||
|
void updateMacDockBadge(dockBadgeCount);
|
||||||
|
}
|
||||||
}, [agents, colorScheme]);
|
}, [agents, colorScheme]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import type { ImageAttachment } from "@/components/message-input";
|
import type { ImageAttachment } from "@/components/message-input";
|
||||||
|
|
||||||
@@ -13,11 +13,75 @@ interface UseFileDropZoneReturn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const IS_WEB = Platform.OS === "web";
|
const IS_WEB = Platform.OS === "web";
|
||||||
|
const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
|
||||||
|
".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 {
|
function isImageFile(file: File): boolean {
|
||||||
return file.type.startsWith("image/");
|
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<ImageAttachment> {
|
async function fileToImageAttachment(file: File): Promise<ImageAttachment> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
@@ -62,78 +126,152 @@ export function useFileDropZone({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!IS_WEB) return;
|
if (!IS_WEB) return;
|
||||||
|
|
||||||
const element = containerRef.current;
|
let disposed = false;
|
||||||
if (!element) return;
|
let cleanup: (() => void) | undefined;
|
||||||
|
|
||||||
function handleDragEnter(e: DragEvent) {
|
async function setupTauriDragDrop(): Promise<boolean> {
|
||||||
e.preventDefault();
|
if (!isTauriEnvironment()) {
|
||||||
e.stopPropagation();
|
return false;
|
||||||
|
|
||||||
if (disabled) return;
|
|
||||||
|
|
||||||
dragCounterRef.current++;
|
|
||||||
if (e.dataTransfer?.types.includes("Files")) {
|
|
||||||
setIsDragging(true);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragOver(e: DragEvent) {
|
const tauriWindow = (window as any).__TAURI__?.window?.getCurrentWindow?.();
|
||||||
e.preventDefault();
|
if (!tauriWindow || typeof tauriWindow.onDragDropEvent !== "function") {
|
||||||
e.stopPropagation();
|
return false;
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
const attachments = await Promise.all(
|
const unlisten = await tauriWindow.onDragDropEvent(
|
||||||
imageFiles.map(fileToImageAttachment)
|
(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) {
|
} 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);
|
function setupDomDragDrop() {
|
||||||
element.addEventListener("dragover", handleDragOver);
|
const element = containerRef.current;
|
||||||
element.addEventListener("dragleave", handleDragLeave);
|
if (!element) {
|
||||||
element.addEventListener("drop", handleDrop);
|
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 () => {
|
return () => {
|
||||||
element.removeEventListener("dragenter", handleDragEnter);
|
disposed = true;
|
||||||
element.removeEventListener("dragover", handleDragOver);
|
cleanup?.();
|
||||||
element.removeEventListener("dragleave", handleDragLeave);
|
|
||||||
element.removeEventListener("drop", handleDrop);
|
|
||||||
};
|
};
|
||||||
}, [disabled]);
|
}, [disabled]);
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ import {
|
|||||||
derivePendingPermissionKey,
|
derivePendingPermissionKey,
|
||||||
normalizeAgentSnapshot,
|
normalizeAgentSnapshot,
|
||||||
} from "@/utils/agent-snapshots";
|
} from "@/utils/agent-snapshots";
|
||||||
|
import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention";
|
||||||
import type { FetchAgentsEntry } from "@server/client/daemon-client";
|
import type { FetchAgentsEntry } from "@server/client/daemon-client";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -512,6 +513,9 @@ function AgentScreenContent({
|
|||||||
const isConnected = useSessionStore(
|
const isConnected = useSessionStore(
|
||||||
(state) => state.sessions[serverId]?.connection.isConnected ?? false
|
(state) => state.sessions[serverId]?.connection.isConnected ?? false
|
||||||
);
|
);
|
||||||
|
const focusedAgentId = useSessionStore(
|
||||||
|
(state) => state.sessions[serverId]?.focusedAgentId ?? null
|
||||||
|
);
|
||||||
const { ensureAgentIsInitialized, refreshAgent } = useAgentInitialization(serverId);
|
const { ensureAgentIsInitialized, refreshAgent } = useAgentInitialization(serverId);
|
||||||
const [missingAgentState, setMissingAgentState] = useState<MissingAgentState>({
|
const [missingAgentState, setMissingAgentState] = useState<MissingAgentState>({
|
||||||
kind: "idle",
|
kind: "idle",
|
||||||
@@ -860,25 +864,30 @@ function AgentScreenContent({
|
|||||||
document.title = title;
|
document.title = title;
|
||||||
}, [agent?.title]);
|
}, [agent?.title]);
|
||||||
|
|
||||||
// Track previous agent status to detect completion while viewing
|
// Clear attention as soon as the user is focused on this agent screen.
|
||||||
const previousStatusRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
// Clear attention when agent finishes while user is viewing this screen
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!resolvedAgentId || !agent || !client) {
|
const clearAgentId = resolvedAgentId?.trim();
|
||||||
|
if (!clearAgentId || !client) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
const previousStatus = previousStatusRef.current;
|
!shouldClearAgentAttentionOnView({
|
||||||
const currentStatus = agent.status;
|
agentId: clearAgentId,
|
||||||
previousStatusRef.current = currentStatus;
|
focusedAgentId,
|
||||||
|
isConnected,
|
||||||
// If agent transitioned from running to idle while we're viewing,
|
requiresAttention: agent?.requiresAttention,
|
||||||
// immediately clear attention since user witnessed the completion
|
})
|
||||||
if (previousStatus === "running" && currentStatus === "idle") {
|
) {
|
||||||
client.clearAgentAttention(resolvedAgentId);
|
return;
|
||||||
}
|
}
|
||||||
}, [resolvedAgentId, agent?.status, client]);
|
client.clearAgentAttention(clearAgentId);
|
||||||
|
}, [
|
||||||
|
agent?.requiresAttention,
|
||||||
|
client,
|
||||||
|
focusedAgentId,
|
||||||
|
isConnected,
|
||||||
|
resolvedAgentId,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleRefreshAgent = useCallback(() => {
|
const handleRefreshAgent = useCallback(() => {
|
||||||
if (!resolvedAgentId) {
|
if (!resolvedAgentId) {
|
||||||
|
|||||||
@@ -27,10 +27,13 @@ import {
|
|||||||
CHECKOUT_STATUS_STALE_TIME,
|
CHECKOUT_STATUS_STALE_TIME,
|
||||||
checkoutStatusQueryKey,
|
checkoutStatusQueryKey,
|
||||||
} from "@/hooks/use-checkout-status-query";
|
} from "@/hooks/use-checkout-status-query";
|
||||||
|
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
||||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||||
import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions";
|
import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions";
|
||||||
import { shortenPath } from "@/utils/shorten-path";
|
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 { useSessionStore } from "@/stores/session-store";
|
||||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
@@ -232,6 +235,8 @@ export function DraftAgentScreen({
|
|||||||
const [isBranchOpen, setIsBranchOpen] = useState(false);
|
const [isBranchOpen, setIsBranchOpen] = useState(false);
|
||||||
const [branchSearchQuery, setBranchSearchQuery] = useState("");
|
const [branchSearchQuery, setBranchSearchQuery] = useState("");
|
||||||
const [debouncedBranchSearchQuery, setDebouncedBranchSearchQuery] = useState("");
|
const [debouncedBranchSearchQuery, setDebouncedBranchSearchQuery] = useState("");
|
||||||
|
const [workingDirSearchQuery, setWorkingDirSearchQuery] = useState("");
|
||||||
|
const [debouncedWorkingDirSearchQuery, setDebouncedWorkingDirSearchQuery] = useState("");
|
||||||
const workingDirAnchorRef = useRef<View>(null);
|
const workingDirAnchorRef = useRef<View>(null);
|
||||||
const worktreeAnchorRef = useRef<View>(null);
|
const worktreeAnchorRef = useRef<View>(null);
|
||||||
const branchAnchorRef = useRef<View>(null);
|
const branchAnchorRef = useRef<View>(null);
|
||||||
@@ -246,6 +251,12 @@ export function DraftAgentScreen({
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [branchSearchQuery]);
|
}, [branchSearchQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const trimmed = workingDirSearchQuery.trim();
|
||||||
|
const timer = setTimeout(() => setDebouncedWorkingDirSearchQuery(trimmed), 180);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [workingDirSearchQuery]);
|
||||||
|
|
||||||
type CreateAttempt = {
|
type CreateAttempt = {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -307,6 +318,7 @@ export function DraftAgentScreen({
|
|||||||
const sessionAgents = useSessionStore((state) =>
|
const sessionAgents = useSessionStore((state) =>
|
||||||
selectedServerId ? state.sessions[selectedServerId]?.agents : undefined
|
selectedServerId ? state.sessions[selectedServerId]?.agents : undefined
|
||||||
);
|
);
|
||||||
|
const { agents: allAgents } = useAllAgentsList({ serverId: selectedServerId });
|
||||||
const worktreePathLastCreatedAt = useMemo(() => {
|
const worktreePathLastCreatedAt = useMemo(() => {
|
||||||
const map = new Map<string, number>();
|
const map = new Map<string, number>();
|
||||||
if (!sessionAgents) {
|
if (!sessionAgents) {
|
||||||
@@ -325,24 +337,23 @@ export function DraftAgentScreen({
|
|||||||
return map;
|
return map;
|
||||||
}, [sessionAgents]);
|
}, [sessionAgents]);
|
||||||
const agentWorkingDirSuggestions = useMemo(() => {
|
const agentWorkingDirSuggestions = useMemo(() => {
|
||||||
if (!selectedServerId || !sessionAgents) {
|
const liveSources = sessionAgents
|
||||||
return [];
|
? Array.from(sessionAgents.values()).map((agent) => ({
|
||||||
}
|
cwd: agent.cwd,
|
||||||
const pathLastCreated = new Map<string, Date>();
|
createdAt: agent.createdAt,
|
||||||
sessionAgents.forEach((agent) => {
|
lastActivityAt: agent.lastActivityAt,
|
||||||
if (agent.cwd && !agent.cwd.includes(".paseo/worktrees")) {
|
}))
|
||||||
const existing = pathLastCreated.get(agent.cwd);
|
: [];
|
||||||
if (!existing || agent.createdAt > existing) {
|
const fetchedSources = allAgents.map((agent) => ({
|
||||||
pathLastCreated.set(agent.cwd, agent.createdAt);
|
cwd: agent.cwd,
|
||||||
}
|
lastActivityAt: agent.lastActivityAt,
|
||||||
}
|
}));
|
||||||
});
|
|
||||||
return Array.from(pathLastCreated.keys()).sort((a, b) => {
|
return collectAgentWorkingDirectorySuggestions([
|
||||||
const aTime = pathLastCreated.get(a)!.getTime();
|
...liveSources,
|
||||||
const bTime = pathLastCreated.get(b)!.getTime();
|
...fetchedSources,
|
||||||
return bTime - aTime;
|
]);
|
||||||
});
|
}, [allAgents, sessionAgents]);
|
||||||
}, [selectedServerId, sessionAgents]);
|
|
||||||
|
|
||||||
const sessionClient = useSessionStore((state) =>
|
const sessionClient = useSessionStore((state) =>
|
||||||
selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null
|
selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null
|
||||||
@@ -511,6 +522,36 @@ export function DraftAgentScreen({
|
|||||||
staleTime: 15_000,
|
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(
|
const validateWorktreeName = useCallback(
|
||||||
(name: string): { valid: boolean; error?: string } => {
|
(name: string): { valid: boolean; error?: string } => {
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -644,6 +685,50 @@ export function DraftAgentScreen({
|
|||||||
|
|
||||||
const selectedWorktreeLabel =
|
const selectedWorktreeLabel =
|
||||||
worktreeOptions.find((option) => option.path === selectedWorktreePath)?.label ?? "";
|
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 displayWorkingDir = shortenPath(workingDir);
|
||||||
const worktreeTriggerValue =
|
const worktreeTriggerValue =
|
||||||
worktreeMode === "create"
|
worktreeMode === "create"
|
||||||
@@ -1100,21 +1185,13 @@ export function DraftAgentScreen({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Combobox
|
<Combobox
|
||||||
options={agentWorkingDirSuggestions.map((path) => ({
|
options={workingDirComboOptions}
|
||||||
id: path,
|
|
||||||
label: shortenPath(path),
|
|
||||||
}))}
|
|
||||||
value={workingDir}
|
value={workingDir}
|
||||||
onSelect={setWorkingDirFromUser}
|
onSelect={setWorkingDirFromUser}
|
||||||
searchPlaceholder="/path/to/project"
|
onSearchQueryChange={setWorkingDirSearchQuery}
|
||||||
emptyText={
|
searchPlaceholder="Search directories..."
|
||||||
agentWorkingDirSuggestions.length > 0
|
emptyText={workingDirEmptyText}
|
||||||
? "No agent directories match your search."
|
optionsPosition="above-search"
|
||||||
: "We'll suggest directories from agents on this host once they exist."
|
|
||||||
}
|
|
||||||
allowCustomValue
|
|
||||||
customValuePrefix="Use"
|
|
||||||
customValueDescription="Launch the agent in this directory"
|
|
||||||
title="Working directory"
|
title="Working directory"
|
||||||
open={isWorkingDirOpen}
|
open={isWorkingDirOpen}
|
||||||
onOpenChange={setIsWorkingDirOpen}
|
onOpenChange={setIsWorkingDirOpen}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as LegacyFileSystem from "expo-file-system/legacy";
|
|||||||
import * as Sharing from "expo-sharing";
|
import * as Sharing from "expo-sharing";
|
||||||
import type { HostProfile } from "@/contexts/daemon-registry-context";
|
import type { HostProfile } from "@/contexts/daemon-registry-context";
|
||||||
import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints";
|
import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints";
|
||||||
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
|
|
||||||
interface DownloadProgress {
|
interface DownloadProgress {
|
||||||
percent: number;
|
percent: number;
|
||||||
@@ -303,7 +304,7 @@ function buildDownloadUrl(
|
|||||||
function triggerBrowserDownload(url: string, fileName: string) {
|
function triggerBrowserDownload(url: string, fileName: string) {
|
||||||
if (typeof document === "undefined") {
|
if (typeof document === "undefined") {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.open(url, "_blank", "noopener");
|
void openExternalUrl(url);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<AgentWorkingDirectorySource>
|
||||||
|
): string[] {
|
||||||
|
const lastSeenByPath = new Map<string, number>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<void>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openExternalUrl(url: string): Promise<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { parser as cssParser } from "@lezer/css";
|
|||||||
import { parser as htmlParser } from "@lezer/html";
|
import { parser as htmlParser } from "@lezer/html";
|
||||||
import { parser as pythonParser } from "@lezer/python";
|
import { parser as pythonParser } from "@lezer/python";
|
||||||
import { parser as markdownParser } from "@lezer/markdown";
|
import { parser as markdownParser } from "@lezer/markdown";
|
||||||
|
import { parser as elixirParser } from "lezer-elixir";
|
||||||
import type { Parser } from "@lezer/common";
|
import type { Parser } from "@lezer/common";
|
||||||
|
|
||||||
// Map file extensions to parsers
|
// Map file extensions to parsers
|
||||||
@@ -26,6 +27,9 @@ const parsersByExtension: Record<string, Parser> = {
|
|||||||
htm: htmlParser,
|
htm: htmlParser,
|
||||||
// Python
|
// Python
|
||||||
py: pythonParser,
|
py: pythonParser,
|
||||||
|
// Elixir
|
||||||
|
ex: elixirParser,
|
||||||
|
exs: elixirParser,
|
||||||
// Markdown
|
// Markdown
|
||||||
md: markdownParser,
|
md: markdownParser,
|
||||||
mdx: markdownParser,
|
mdx: markdownParser,
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type {
|
||||||
|
CreateTerminalResponse,
|
||||||
|
ListTerminalsResponse,
|
||||||
|
} from "@server/shared/messages";
|
||||||
|
|
||||||
|
type TerminalListEntry = ListTerminalsResponse["payload"]["terminals"][number];
|
||||||
|
type CreatedTerminal = NonNullable<CreateTerminalResponse["payload"]["terminal"]>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
Generated
+503
@@ -81,6 +81,137 @@ version = "0.7.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
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]]
|
[[package]]
|
||||||
name = "atk"
|
name = "atk"
|
||||||
version = "0.18.2"
|
version = "0.18.2"
|
||||||
@@ -173,6 +304,19 @@ dependencies = [
|
|||||||
"objc2",
|
"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]]
|
[[package]]
|
||||||
name = "borsh"
|
name = "borsh"
|
||||||
version = "1.6.0"
|
version = "1.6.0"
|
||||||
@@ -416,6 +560,15 @@ dependencies = [
|
|||||||
"memchr",
|
"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]]
|
[[package]]
|
||||||
name = "convert_case"
|
name = "convert_case"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@@ -753,6 +906,33 @@ version = "1.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
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]]
|
[[package]]
|
||||||
name = "env_filter"
|
name = "env_filter"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -780,6 +960,43 @@ dependencies = [
|
|||||||
"typeid",
|
"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]]
|
[[package]]
|
||||||
name = "fdeflate"
|
name = "fdeflate"
|
||||||
version = "0.3.7"
|
version = "0.3.7"
|
||||||
@@ -914,6 +1131,19 @@ version = "0.3.31"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
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]]
|
[[package]]
|
||||||
name = "futures-macro"
|
name = "futures-macro"
|
||||||
version = "0.3.31"
|
version = "0.3.31"
|
||||||
@@ -1281,6 +1511,12 @@ version = "0.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hermit-abi"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hex"
|
name = "hex"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -1573,6 +1809,25 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.17"
|
version = "1.0.17"
|
||||||
@@ -1735,6 +1990,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -2165,12 +2426,34 @@ version = "1.21.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
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]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
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]]
|
[[package]]
|
||||||
name = "pango"
|
name = "pango"
|
||||||
version = "0.18.3"
|
version = "0.18.3"
|
||||||
@@ -2196,6 +2479,12 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parking"
|
||||||
|
version = "2.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking_lot"
|
name = "parking_lot"
|
||||||
version = "0.12.5"
|
version = "0.12.5"
|
||||||
@@ -2229,9 +2518,16 @@ dependencies = [
|
|||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-log",
|
"tauri-plugin-log",
|
||||||
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-websocket",
|
"tauri-plugin-websocket",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pathdiff"
|
||||||
|
version = "0.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
@@ -2384,6 +2680,17 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
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]]
|
[[package]]
|
||||||
name = "pkg-config"
|
name = "pkg-config"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -2416,6 +2723,20 @@ dependencies = [
|
|||||||
"miniz_oxide",
|
"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]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -2861,6 +3182,19 @@ dependencies = [
|
|||||||
"semver",
|
"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]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.36"
|
version = "0.23.36"
|
||||||
@@ -3205,6 +3539,16 @@ version = "1.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
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]]
|
[[package]]
|
||||||
name = "simd-adler32"
|
name = "simd-adler32"
|
||||||
version = "0.3.8"
|
version = "0.3.8"
|
||||||
@@ -3624,6 +3968,28 @@ dependencies = [
|
|||||||
"time",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-plugin-websocket"
|
name = "tauri-plugin-websocket"
|
||||||
version = "2.4.2"
|
version = "2.4.2"
|
||||||
@@ -3745,6 +4111,19 @@ dependencies = [
|
|||||||
"toml 0.9.11+spec-1.1.0",
|
"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]]
|
[[package]]
|
||||||
name = "tendril"
|
name = "tendril"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -4055,9 +4434,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"tracing-attributes",
|
||||||
"tracing-core",
|
"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]]
|
[[package]]
|
||||||
name = "tracing-core"
|
name = "tracing-core"
|
||||||
version = "0.1.36"
|
version = "0.1.36"
|
||||||
@@ -4126,6 +4517,17 @@ version = "1.19.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
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]]
|
[[package]]
|
||||||
name = "unic-char-property"
|
name = "unic-char-property"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -5070,6 +5472,67 @@ dependencies = [
|
|||||||
"synstructure",
|
"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]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.33"
|
version = "0.8.33"
|
||||||
@@ -5155,3 +5618,43 @@ name = "zmij"
|
|||||||
version = "1.0.15"
|
version = "1.0.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94f63c051f4fe3c1509da62131a678643c5b6fbdc9273b2b79d4378ebda003d2"
|
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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -27,4 +27,5 @@ serde = { version = "1.0", features = ["derive"] }
|
|||||||
log = "0.4"
|
log = "0.4"
|
||||||
tauri = { version = "2.9.5", features = [] }
|
tauri = { version = "2.9.5", features = [] }
|
||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-websocket = "2"
|
tauri-plugin-websocket = "2"
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
|
"core:window:allow-set-badge-count",
|
||||||
"core:window:allow-toggle-maximize",
|
"core:window:allow-toggle-maximize",
|
||||||
|
"opener:default",
|
||||||
"websocket:default"
|
"websocket:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ fn set_zoom_factor(webview: &WebviewWindow, factor: f64) {
|
|||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_websocket::init())
|
.plugin(tauri_plugin_websocket::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
if cfg!(debug_assertions) {
|
if cfg!(debug_assertions) {
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-basic-auth": "^1.2.1",
|
"express-basic-auth": "^1.2.1",
|
||||||
|
"lezer-elixir": "^1.1.2",
|
||||||
"mnemonic-id": "^3.2.7",
|
"mnemonic-id": "^3.2.7",
|
||||||
"node-pty": "^1.0.0",
|
"node-pty": "^1.0.0",
|
||||||
"onnxruntime-node": "^1.23.0",
|
"onnxruntime-node": "^1.23.0",
|
||||||
|
|||||||
@@ -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 () => {
|
test("requests checkout merge from base via RPC", async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import type {
|
|||||||
CheckoutPrStatusResponse,
|
CheckoutPrStatusResponse,
|
||||||
ValidateBranchResponse,
|
ValidateBranchResponse,
|
||||||
BranchSuggestionsResponse,
|
BranchSuggestionsResponse,
|
||||||
|
DirectorySuggestionsResponse,
|
||||||
PaseoWorktreeListResponse,
|
PaseoWorktreeListResponse,
|
||||||
PaseoWorktreeArchiveResponse,
|
PaseoWorktreeArchiveResponse,
|
||||||
ProjectIconResponse,
|
ProjectIconResponse,
|
||||||
@@ -197,6 +198,7 @@ type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
|
|||||||
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
||||||
type ValidateBranchPayload = ValidateBranchResponse["payload"];
|
type ValidateBranchPayload = ValidateBranchResponse["payload"];
|
||||||
type BranchSuggestionsPayload = BranchSuggestionsResponse["payload"];
|
type BranchSuggestionsPayload = BranchSuggestionsResponse["payload"];
|
||||||
|
type DirectorySuggestionsPayload = DirectorySuggestionsResponse["payload"];
|
||||||
type PaseoWorktreeListPayload = PaseoWorktreeListResponse["payload"];
|
type PaseoWorktreeListPayload = PaseoWorktreeListResponse["payload"];
|
||||||
type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse["payload"];
|
type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse["payload"];
|
||||||
type FileExplorerPayload = FileExplorerResponse["payload"];
|
type FileExplorerPayload = FileExplorerResponse["payload"];
|
||||||
@@ -2082,6 +2084,22 @@ export class DaemonClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getDirectorySuggestions(
|
||||||
|
options: { query: string; limit?: number },
|
||||||
|
requestId?: string
|
||||||
|
): Promise<DirectorySuggestionsPayload> {
|
||||||
|
return this.sendCorrelatedSessionRequest({
|
||||||
|
requestId,
|
||||||
|
message: {
|
||||||
|
type: "directory_suggestions_request",
|
||||||
|
query: options.query,
|
||||||
|
limit: options.limit,
|
||||||
|
},
|
||||||
|
responseType: "directory_suggestions_response",
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// File Explorer
|
// File Explorer
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -168,6 +168,32 @@ describe("daemon client E2E", () => {
|
|||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
}, 30000);
|
}, 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 () => {
|
test("emits server_info on websocket connect", async () => {
|
||||||
const client = new DaemonClient({
|
const client = new DaemonClient({
|
||||||
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
|
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
|
||||||
|
|||||||
@@ -105,6 +105,43 @@ async function waitForPathExists(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function withShell<T>(shell: string, run: () => Promise<T>): Promise<T> {
|
||||||
|
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<AgentTimelineItem, { type: "tool_call" }>
|
||||||
|
): WorktreeTerminalBootstrapEntry[] | null {
|
||||||
|
const detail = item.detail;
|
||||||
|
if (!detail || detail.type !== "unknown" || !detail.output) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const output = detail.output as Record<string, unknown>;
|
||||||
|
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
|
// 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_MODEL = "gpt-5.1-codex-mini";
|
||||||
const CODEX_TEST_THINKING_OPTION_ID = "low";
|
const CODEX_TEST_THINKING_OPTION_ID = "low";
|
||||||
@@ -413,100 +450,136 @@ describe("daemon E2E", () => {
|
|||||||
test(
|
test(
|
||||||
"bootstraps configured worktree terminals after setup succeeds",
|
"bootstraps configured worktree terminals after setup succeeds",
|
||||||
async () => {
|
async () => {
|
||||||
const repoRoot = tmpCwd();
|
await withShell("/bin/sh", async () => {
|
||||||
|
const repoRoot = tmpCwd();
|
||||||
|
|
||||||
const { execSync } = await import("child_process");
|
const { execSync } = await import("child_process");
|
||||||
execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" });
|
execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" });
|
||||||
execSync("git config user.email 'test@test.com'", {
|
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,
|
|
||||||
cwd: repoRoot,
|
cwd: repoRoot,
|
||||||
title: "Async Worktree Setup + Terminals Test",
|
stdio: "pipe",
|
||||||
git: {
|
});
|
||||||
createWorktree: true,
|
execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" });
|
||||||
createNewBranch: true,
|
|
||||||
baseBranch: "main",
|
writeFileSync(path.join(repoRoot, "file.txt"), "hello\n");
|
||||||
newBranchName: "async-setup-terminals-test",
|
execSync("git add .", { cwd: repoRoot, stdio: "pipe" });
|
||||||
worktreeSlug: "async-setup-terminals-test",
|
execSync("git -c commit.gpgsign=false commit -m 'initial'", {
|
||||||
},
|
cwd: repoRoot,
|
||||||
}),
|
stdio: "pipe",
|
||||||
timeoutMs: 2500,
|
});
|
||||||
label: "createAgent should not block on setup",
|
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
|
60000
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { stat } from "fs/promises";
|
|||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import { join, resolve, sep } from "path";
|
import { join, resolve, sep } from "path";
|
||||||
|
import { homedir } from "node:os";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { ToolSet } from "ai";
|
import type { ToolSet } from "ai";
|
||||||
import {
|
import {
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
type DetachTerminalStreamRequest,
|
type DetachTerminalStreamRequest,
|
||||||
type SubscribeCheckoutDiffRequest,
|
type SubscribeCheckoutDiffRequest,
|
||||||
type UnsubscribeCheckoutDiffRequest,
|
type UnsubscribeCheckoutDiffRequest,
|
||||||
|
type DirectorySuggestionsRequest,
|
||||||
type ProjectCheckoutLitePayload,
|
type ProjectCheckoutLitePayload,
|
||||||
type ProjectPlacementPayload,
|
type ProjectPlacementPayload,
|
||||||
} from "./messages.js";
|
} from "./messages.js";
|
||||||
@@ -138,6 +140,7 @@ import {
|
|||||||
} from "../utils/checkout-git.js";
|
} from "../utils/checkout-git.js";
|
||||||
import { getProjectIcon } from "../utils/project-icon.js";
|
import { getProjectIcon } from "../utils/project-icon.js";
|
||||||
import { expandTilde } from "../utils/path.js";
|
import { expandTilde } from "../utils/path.js";
|
||||||
|
import { searchHomeDirectories } from "../utils/directory-suggestions.js";
|
||||||
import {
|
import {
|
||||||
ensureLocalSpeechModels,
|
ensureLocalSpeechModels,
|
||||||
getLocalSpeechModelDir,
|
getLocalSpeechModelDir,
|
||||||
@@ -1347,6 +1350,10 @@ export class Session {
|
|||||||
await this.handleBranchSuggestionsRequest(msg);
|
await this.handleBranchSuggestionsRequest(msg);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "directory_suggestions_request":
|
||||||
|
await this.handleDirectorySuggestionsRequest(msg);
|
||||||
|
break;
|
||||||
|
|
||||||
case "subscribe_checkout_diff_request":
|
case "subscribe_checkout_diff_request":
|
||||||
await this.handleSubscribeCheckoutDiffRequest(msg);
|
await this.handleSubscribeCheckoutDiffRequest(msg);
|
||||||
break;
|
break;
|
||||||
@@ -3563,9 +3570,10 @@ export class Session {
|
|||||||
msg: Extract<SessionInboundMessage, { type: "checkout_status_request" }>
|
msg: Extract<SessionInboundMessage, { type: "checkout_status_request" }>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { cwd, requestId } = msg;
|
const { cwd, requestId } = msg;
|
||||||
|
const resolvedCwd = expandTilde(cwd);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await getCheckoutStatus(cwd, { paseoHome: this.paseoHome });
|
const status = await getCheckoutStatus(resolvedCwd, { paseoHome: this.paseoHome });
|
||||||
if (!status.isGit) {
|
if (!status.isGit) {
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "checkout_status_response",
|
type: "checkout_status_response",
|
||||||
@@ -3754,6 +3762,37 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleDirectorySuggestionsRequest(
|
||||||
|
msg: DirectorySuggestionsRequest
|
||||||
|
): Promise<void> {
|
||||||
|
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 {
|
private normalizeCheckoutDiffCompare(compare: CheckoutDiffCompareInput): CheckoutDiffCompareInput {
|
||||||
if (compare.mode === "uncommitted") {
|
if (compare.mode === "uncommitted") {
|
||||||
return { mode: "uncommitted" };
|
return { mode: "uncommitted" };
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { parser as cssParser } from "@lezer/css";
|
|||||||
import { parser as htmlParser } from "@lezer/html";
|
import { parser as htmlParser } from "@lezer/html";
|
||||||
import { parser as pythonParser } from "@lezer/python";
|
import { parser as pythonParser } from "@lezer/python";
|
||||||
import { parser as markdownParser } from "@lezer/markdown";
|
import { parser as markdownParser } from "@lezer/markdown";
|
||||||
|
import { parser as elixirParser } from "lezer-elixir";
|
||||||
import type { Parser } from "@lezer/common";
|
import type { Parser } from "@lezer/common";
|
||||||
|
|
||||||
// Map file extensions to parsers
|
// Map file extensions to parsers
|
||||||
@@ -26,6 +27,9 @@ const parsersByExtension: Record<string, Parser> = {
|
|||||||
htm: htmlParser,
|
htm: htmlParser,
|
||||||
// Python
|
// Python
|
||||||
py: pythonParser,
|
py: pythonParser,
|
||||||
|
// Elixir
|
||||||
|
ex: elixirParser,
|
||||||
|
exs: elixirParser,
|
||||||
// Markdown
|
// Markdown
|
||||||
md: markdownParser,
|
md: markdownParser,
|
||||||
mdx: markdownParser,
|
mdx: markdownParser,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
import { execSync } from "child_process";
|
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 { join } from "path";
|
||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
|
|
||||||
@@ -15,6 +15,17 @@ describe("runAsyncWorktreeBootstrap", () => {
|
|||||||
let repoDir: string;
|
let repoDir: string;
|
||||||
let paseoHome: string;
|
let paseoHome: string;
|
||||||
|
|
||||||
|
async function waitForPathExists(targetPath: string, timeoutMs = 10000): Promise<void> {
|
||||||
|
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(() => {
|
beforeEach(() => {
|
||||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-bootstrap-test-")));
|
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-bootstrap-test-")));
|
||||||
repoDir = join(tempDir, "repo");
|
repoDir = join(tempDir, "repo");
|
||||||
@@ -236,4 +247,216 @@ describe("runAsyncWorktreeBootstrap", () => {
|
|||||||
expect(persistedSetupItem.detail.log).toContain("-suffix");
|
expect(persistedSetupItem.detail.log).toContain("-suffix");
|
||||||
expect(persistedSetupItem.detail.log).toContain("...<output truncated in the middle>...");
|
expect(persistedSetupItem.detail.log).toContain("...<output truncated in the middle>...");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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<string, string> }> = [];
|
||||||
|
const createTerminalEnvs: Record<string, string>[] = [];
|
||||||
|
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<AgentTimelineItem, { type: "tool_call" }> =>
|
||||||
|
item.type === "tool_call" &&
|
||||||
|
item.name === "paseo_worktree_terminals" &&
|
||||||
|
item.status === "completed"
|
||||||
|
);
|
||||||
|
expect(terminalToolCall?.status).toBe("completed");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||||
|
import type { TerminalSession } from "../terminal/terminal.js";
|
||||||
import {
|
import {
|
||||||
createWorktree,
|
createWorktree,
|
||||||
getWorktreeTerminalSpecs,
|
getWorktreeTerminalSpecs,
|
||||||
|
resolveWorktreeRuntimeEnv,
|
||||||
runWorktreeSetupCommands,
|
runWorktreeSetupCommands,
|
||||||
WorktreeSetupError,
|
WorktreeSetupError,
|
||||||
type WorktreeConfig,
|
type WorktreeConfig,
|
||||||
type WorktreeSetupCommandResult,
|
type WorktreeSetupCommandResult,
|
||||||
|
type WorktreeRuntimeEnv,
|
||||||
} from "../utils/worktree.js";
|
} from "../utils/worktree.js";
|
||||||
import type { AgentTimelineItem } from "./agent/agent-sdk-types.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 MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES = 64 * 1024;
|
||||||
const WORKTREE_SETUP_TRUNCATION_MARKER = "\n...<output truncated in the middle>...\n";
|
const WORKTREE_SETUP_TRUNCATION_MARKER = "\n...<output truncated in the middle>...\n";
|
||||||
|
const WORKTREE_BOOTSTRAP_TERMINAL_READY_TIMEOUT_MS = 1_500;
|
||||||
|
|
||||||
type MiddleTruncationAccumulator = {
|
type MiddleTruncationAccumulator = {
|
||||||
totalBytes: number;
|
totalBytes: number;
|
||||||
@@ -335,8 +339,51 @@ function buildTerminalTimelineItem(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForTerminalBootstrapReadiness(
|
||||||
|
terminal: Pick<TerminalSession, "getOutputOffset" | "subscribeRaw">
|
||||||
|
): Promise<void> {
|
||||||
|
if (terminal.getOutputOffset() > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | 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(
|
async function runWorktreeTerminalBootstrap(
|
||||||
options: RunAsyncWorktreeBootstrapOptions
|
options: RunAsyncWorktreeBootstrapOptions,
|
||||||
|
runtimeEnv: WorktreeRuntimeEnv
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const terminalSpecs = getWorktreeTerminalSpecs(options.worktree.worktreePath);
|
const terminalSpecs = getWorktreeTerminalSpecs(options.worktree.worktreePath);
|
||||||
if (terminalSpecs.length === 0) {
|
if (terminalSpecs.length === 0) {
|
||||||
@@ -376,7 +423,9 @@ async function runWorktreeTerminalBootstrap(
|
|||||||
const terminal = await options.terminalManager.createTerminal({
|
const terminal = await options.terminalManager.createTerminal({
|
||||||
cwd: options.worktree.worktreePath,
|
cwd: options.worktree.worktreePath,
|
||||||
name: spec.name,
|
name: spec.name,
|
||||||
|
env: runtimeEnv,
|
||||||
});
|
});
|
||||||
|
await waitForTerminalBootstrapReadiness(terminal);
|
||||||
terminal.send({
|
terminal.send({
|
||||||
type: "input",
|
type: "input",
|
||||||
data: `${spec.command}\r`,
|
data: `${spec.command}\r`,
|
||||||
@@ -420,6 +469,7 @@ export async function runAsyncWorktreeBootstrap(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const setupCallId = uuidv4();
|
const setupCallId = uuidv4();
|
||||||
let setupResults: WorktreeSetupCommandResult[] = [];
|
let setupResults: WorktreeSetupCommandResult[] = [];
|
||||||
|
let runtimeEnv: WorktreeRuntimeEnv | null = null;
|
||||||
const emitLiveTimelineItem = options.emitLiveTimelineItem;
|
const emitLiveTimelineItem = options.emitLiveTimelineItem;
|
||||||
const runningResultsByIndex = new Map<number, WorktreeSetupCommandResult>();
|
const runningResultsByIndex = new Map<number, WorktreeSetupCommandResult>();
|
||||||
const outputAccumulatorsByIndex = new Map<number, MiddleTruncationAccumulator>();
|
const outputAccumulatorsByIndex = new Map<number, MiddleTruncationAccumulator>();
|
||||||
@@ -454,10 +504,20 @@ export async function runAsyncWorktreeBootstrap(
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
runtimeEnv = await resolveWorktreeRuntimeEnv({
|
||||||
|
worktreePath: options.worktree.worktreePath,
|
||||||
|
branchName: options.worktree.branchName,
|
||||||
|
});
|
||||||
|
options.terminalManager?.registerCwdEnv({
|
||||||
|
cwd: options.worktree.worktreePath,
|
||||||
|
env: runtimeEnv,
|
||||||
|
});
|
||||||
|
|
||||||
setupResults = await runWorktreeSetupCommands({
|
setupResults = await runWorktreeSetupCommands({
|
||||||
worktreePath: options.worktree.worktreePath,
|
worktreePath: options.worktree.worktreePath,
|
||||||
branchName: options.worktree.branchName,
|
branchName: options.worktree.branchName,
|
||||||
cleanupOnFailure: false,
|
cleanupOnFailure: false,
|
||||||
|
runtimeEnv,
|
||||||
onEvent: (event) => {
|
onEvent: (event) => {
|
||||||
const existing = runningResultsByIndex.get(event.index);
|
const existing = runningResultsByIndex.get(event.index);
|
||||||
const baseResult: WorktreeSetupCommandResult = existing ?? {
|
const baseResult: WorktreeSetupCommandResult = existing ?? {
|
||||||
@@ -533,5 +593,5 @@ export async function runAsyncWorktreeBootstrap(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await runWorktreeTerminalBootstrap(options);
|
await runWorktreeTerminalBootstrap(options, runtimeEnv);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,26 @@ describe("shared messages stream parsing", () => {
|
|||||||
expect(parsed.success).toBe(false);
|
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", () => {
|
it("rejects websocket envelope for removed agent_stream_snapshot message type", () => {
|
||||||
const fixture = {
|
const fixture = {
|
||||||
type: "agent_stream_snapshot",
|
type: "agent_stream_snapshot",
|
||||||
|
|||||||
@@ -826,6 +826,13 @@ export const BranchSuggestionsRequestSchema = z.object({
|
|||||||
requestId: z.string(),
|
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({
|
export const PaseoWorktreeListRequestSchema = z.object({
|
||||||
type: z.literal("paseo_worktree_list_request"),
|
type: z.literal("paseo_worktree_list_request"),
|
||||||
cwd: z.string().optional(),
|
cwd: z.string().optional(),
|
||||||
@@ -1075,6 +1082,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
CheckoutPrStatusRequestSchema,
|
CheckoutPrStatusRequestSchema,
|
||||||
ValidateBranchRequestSchema,
|
ValidateBranchRequestSchema,
|
||||||
BranchSuggestionsRequestSchema,
|
BranchSuggestionsRequestSchema,
|
||||||
|
DirectorySuggestionsRequestSchema,
|
||||||
PaseoWorktreeListRequestSchema,
|
PaseoWorktreeListRequestSchema,
|
||||||
PaseoWorktreeArchiveRequestSchema,
|
PaseoWorktreeArchiveRequestSchema,
|
||||||
FileExplorerRequestSchema,
|
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({
|
const PaseoWorktreeSchema = z.object({
|
||||||
worktreePath: z.string(),
|
worktreePath: z.string(),
|
||||||
branchName: z.string().nullable().optional(),
|
branchName: z.string().nullable().optional(),
|
||||||
@@ -2031,6 +2048,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
CheckoutPrStatusResponseSchema,
|
CheckoutPrStatusResponseSchema,
|
||||||
ValidateBranchResponseSchema,
|
ValidateBranchResponseSchema,
|
||||||
BranchSuggestionsResponseSchema,
|
BranchSuggestionsResponseSchema,
|
||||||
|
DirectorySuggestionsResponseSchema,
|
||||||
PaseoWorktreeListResponseSchema,
|
PaseoWorktreeListResponseSchema,
|
||||||
PaseoWorktreeArchiveResponseSchema,
|
PaseoWorktreeArchiveResponseSchema,
|
||||||
FileExplorerResponseSchema,
|
FileExplorerResponseSchema,
|
||||||
@@ -2164,6 +2182,8 @@ export type ValidateBranchRequest = z.infer<typeof ValidateBranchRequestSchema>;
|
|||||||
export type ValidateBranchResponse = z.infer<typeof ValidateBranchResponseSchema>;
|
export type ValidateBranchResponse = z.infer<typeof ValidateBranchResponseSchema>;
|
||||||
export type BranchSuggestionsRequest = z.infer<typeof BranchSuggestionsRequestSchema>;
|
export type BranchSuggestionsRequest = z.infer<typeof BranchSuggestionsRequestSchema>;
|
||||||
export type BranchSuggestionsResponse = z.infer<typeof BranchSuggestionsResponseSchema>;
|
export type BranchSuggestionsResponse = z.infer<typeof BranchSuggestionsResponseSchema>;
|
||||||
|
export type DirectorySuggestionsRequest = z.infer<typeof DirectorySuggestionsRequestSchema>;
|
||||||
|
export type DirectorySuggestionsResponse = z.infer<typeof DirectorySuggestionsResponseSchema>;
|
||||||
export type PaseoWorktreeListRequest = z.infer<typeof PaseoWorktreeListRequestSchema>;
|
export type PaseoWorktreeListRequest = z.infer<typeof PaseoWorktreeListRequestSchema>;
|
||||||
export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema>;
|
export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema>;
|
||||||
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
|
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, it, expect, afterEach } from "vitest";
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
import { createTerminalManager, type TerminalManager } from "./terminal-manager.js";
|
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(
|
async function waitForCondition(
|
||||||
predicate: () => boolean,
|
predicate: () => boolean,
|
||||||
@@ -16,13 +19,34 @@ async function waitForCondition(
|
|||||||
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`);
|
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function withShell<T>(shell: string, run: () => Promise<T>): Promise<T> {
|
||||||
|
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", () => {
|
describe("TerminalManager", () => {
|
||||||
let manager: TerminalManager;
|
let manager: TerminalManager;
|
||||||
|
const temporaryDirs: string[] = [];
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
if (manager) {
|
if (manager) {
|
||||||
manager.killAll();
|
manager.killAll();
|
||||||
}
|
}
|
||||||
|
while (temporaryDirs.length > 0) {
|
||||||
|
const dir = temporaryDirs.pop();
|
||||||
|
if (dir) {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getTerminals", () => {
|
describe("getTerminals", () => {
|
||||||
@@ -94,6 +118,58 @@ describe("TerminalManager", () => {
|
|||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
await expect(manager.createTerminal({ cwd: "tmp" })).rejects.toThrow("cwd must be absolute path");
|
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", () => {
|
describe("getTerminal", () => {
|
||||||
@@ -156,7 +232,7 @@ describe("TerminalManager", () => {
|
|||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
const terminals = await manager.getTerminals("/tmp");
|
const terminals = await manager.getTerminals("/tmp");
|
||||||
const exitedId = terminals[0].id;
|
const exitedId = terminals[0].id;
|
||||||
terminals[0].send({ type: "input", data: "\u0004" });
|
terminals[0].kill();
|
||||||
|
|
||||||
await waitForCondition(() => manager.getTerminal(exitedId) === undefined, 10000);
|
await waitForCondition(() => manager.getTerminal(exitedId) === undefined, 10000);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createTerminal, type TerminalSession } from "./terminal.js";
|
import { createTerminal, type TerminalSession } from "./terminal.js";
|
||||||
|
import { resolve, sep } from "node:path";
|
||||||
|
|
||||||
export interface TerminalListItem {
|
export interface TerminalListItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,7 +16,12 @@ export type TerminalsChangedListener = (input: TerminalsChangedEvent) => void;
|
|||||||
|
|
||||||
export interface TerminalManager {
|
export interface TerminalManager {
|
||||||
getTerminals(cwd: string): Promise<TerminalSession[]>;
|
getTerminals(cwd: string): Promise<TerminalSession[]>;
|
||||||
createTerminal(options: { cwd: string; name?: string }): Promise<TerminalSession>;
|
createTerminal(options: {
|
||||||
|
cwd: string;
|
||||||
|
name?: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
}): Promise<TerminalSession>;
|
||||||
|
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void;
|
||||||
getTerminal(id: string): TerminalSession | undefined;
|
getTerminal(id: string): TerminalSession | undefined;
|
||||||
killTerminal(id: string): void;
|
killTerminal(id: string): void;
|
||||||
listDirectories(): string[];
|
listDirectories(): string[];
|
||||||
@@ -28,6 +34,7 @@ export function createTerminalManager(): TerminalManager {
|
|||||||
const terminalsById = new Map<string, TerminalSession>();
|
const terminalsById = new Map<string, TerminalSession>();
|
||||||
const terminalExitUnsubscribeById = new Map<string, () => void>();
|
const terminalExitUnsubscribeById = new Map<string, () => void>();
|
||||||
const terminalsChangedListeners = new Set<TerminalsChangedListener>();
|
const terminalsChangedListeners = new Set<TerminalsChangedListener>();
|
||||||
|
const defaultEnvByRootCwd = new Map<string, Record<string, string>>();
|
||||||
|
|
||||||
function assertAbsolutePath(cwd: string): void {
|
function assertAbsolutePath(cwd: string): void {
|
||||||
if (!cwd.startsWith("/")) {
|
if (!cwd.startsWith("/")) {
|
||||||
@@ -67,6 +74,23 @@ export function createTerminalManager(): TerminalManager {
|
|||||||
emitTerminalsChanged({ cwd: session.cwd });
|
emitTerminalsChanged({ cwd: session.cwd });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveDefaultEnvForCwd(cwd: string): Record<string, string> | 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 {
|
function registerSession(session: TerminalSession): TerminalSession {
|
||||||
terminalsById.set(session.id, session);
|
terminalsById.set(session.id, session);
|
||||||
const unsubscribeExit = session.onExit(() => {
|
const unsubscribeExit = session.onExit(() => {
|
||||||
@@ -112,8 +136,13 @@ export function createTerminalManager(): TerminalManager {
|
|||||||
|
|
||||||
let terminals = terminalsByCwd.get(cwd);
|
let terminals = terminalsByCwd.get(cwd);
|
||||||
if (!terminals || terminals.length === 0) {
|
if (!terminals || terminals.length === 0) {
|
||||||
|
const inheritedEnv = resolveDefaultEnvForCwd(cwd);
|
||||||
const session = registerSession(
|
const session = registerSession(
|
||||||
await createTerminal({ cwd, name: "Terminal 1" })
|
await createTerminal({
|
||||||
|
cwd,
|
||||||
|
name: "Terminal 1",
|
||||||
|
...(inheritedEnv ? { env: inheritedEnv } : {}),
|
||||||
|
})
|
||||||
);
|
);
|
||||||
terminals = [session];
|
terminals = [session];
|
||||||
terminalsByCwd.set(cwd, terminals);
|
terminalsByCwd.set(cwd, terminals);
|
||||||
@@ -122,15 +151,25 @@ export function createTerminalManager(): TerminalManager {
|
|||||||
return terminals;
|
return terminals;
|
||||||
},
|
},
|
||||||
|
|
||||||
async createTerminal(options: { cwd: string; name?: string }): Promise<TerminalSession> {
|
async createTerminal(options: {
|
||||||
|
cwd: string;
|
||||||
|
name?: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
}): Promise<TerminalSession> {
|
||||||
assertAbsolutePath(options.cwd);
|
assertAbsolutePath(options.cwd);
|
||||||
|
|
||||||
const terminals = terminalsByCwd.get(options.cwd) ?? [];
|
const terminals = terminalsByCwd.get(options.cwd) ?? [];
|
||||||
const defaultName = `Terminal ${terminals.length + 1}`;
|
const defaultName = `Terminal ${terminals.length + 1}`;
|
||||||
|
const inheritedEnv = resolveDefaultEnvForCwd(options.cwd);
|
||||||
|
const mergedEnv =
|
||||||
|
inheritedEnv || options.env
|
||||||
|
? { ...(inheritedEnv ?? {}), ...(options.env ?? {}) }
|
||||||
|
: undefined;
|
||||||
const session = registerSession(
|
const session = registerSession(
|
||||||
await createTerminal({
|
await createTerminal({
|
||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
name: options.name ?? defaultName,
|
name: options.name ?? defaultName,
|
||||||
|
...(mergedEnv ? { env: mergedEnv } : {}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -141,6 +180,11 @@ export function createTerminalManager(): TerminalManager {
|
|||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void {
|
||||||
|
assertAbsolutePath(options.cwd);
|
||||||
|
defaultEnvByRootCwd.set(resolve(options.cwd), { ...options.env });
|
||||||
|
},
|
||||||
|
|
||||||
getTerminal(id: string): TerminalSession | undefined {
|
getTerminal(id: string): TerminalSession | undefined {
|
||||||
return terminalsById.get(id);
|
return terminalsById.get(id);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, DirectoryListCacheEntry>();
|
||||||
|
const NO_SEGMENT_INDEX = Number.MAX_SAFE_INTEGER;
|
||||||
|
const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
|
export async function searchHomeDirectories(
|
||||||
|
options: SearchHomeDirectoriesOptions
|
||||||
|
): Promise<string[]> {
|
||||||
|
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<string[]> {
|
||||||
|
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<string[]> {
|
||||||
|
const queue: Array<{ directory: string; depth: number }> = [
|
||||||
|
{ directory: input.homeRoot, depth: 0 },
|
||||||
|
];
|
||||||
|
const visited = new Set<string>([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<string, RankedDirectory>();
|
||||||
|
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<string | null> {
|
||||||
|
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<ChildDirectoryEntry[]> {
|
||||||
|
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<string | null> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,26 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|||||||
import { isAbsolute, join, resolve } from "path";
|
import { isAbsolute, join, resolve } from "path";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const PaseoWorktreeMetadataSchema = z.object({
|
const PaseoWorktreeMetadataV1Schema = z.object({
|
||||||
version: z.literal(1),
|
version: z.literal(1),
|
||||||
baseRefName: z.string().min(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<typeof PaseoWorktreeMetadataSchema>;
|
export type PaseoWorktreeMetadata = z.infer<typeof PaseoWorktreeMetadataSchema>;
|
||||||
|
|
||||||
function getGitDirForWorktreeRoot(worktreeRoot: string): string {
|
function getGitDirForWorktreeRoot(worktreeRoot: string): string {
|
||||||
@@ -68,6 +83,31 @@ export function writePaseoWorktreeMetadata(
|
|||||||
writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
|
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 {
|
export function readPaseoWorktreeMetadata(worktreeRoot: string): PaseoWorktreeMetadata | null {
|
||||||
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
|
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||||
if (!existsSync(metadataPath)) {
|
if (!existsSync(metadataPath)) {
|
||||||
@@ -85,3 +125,14 @@ export function requirePaseoWorktreeBaseRefName(worktreeRoot: string): string {
|
|||||||
}
|
}
|
||||||
return metadata.baseRefName;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
getWorktreeTerminalSpecs,
|
getWorktreeTerminalSpecs,
|
||||||
isPaseoOwnedWorktreeCwd,
|
isPaseoOwnedWorktreeCwd,
|
||||||
listPaseoWorktrees,
|
listPaseoWorktrees,
|
||||||
|
resolveWorktreeRuntimeEnv,
|
||||||
type WorktreeSetupCommandProgressEvent,
|
type WorktreeSetupCommandProgressEvent,
|
||||||
runWorktreeSetupCommands,
|
runWorktreeSetupCommands,
|
||||||
slugify,
|
slugify,
|
||||||
@@ -14,6 +15,7 @@ import { execSync } from "child_process";
|
|||||||
import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs";
|
import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
|
import net from "node:net";
|
||||||
|
|
||||||
describe("createWorktree", () => {
|
describe("createWorktree", () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
@@ -265,6 +267,68 @@ describe("createWorktree", () => {
|
|||||||
expect(progressEvents.some((event) => event.type === "command_completed")).toBe(true);
|
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<void>((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<void>((resolve, reject) => {
|
||||||
|
server.close((error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("cleans up worktree if setup command fails", async () => {
|
it("cleans up worktree if setup command fails", async () => {
|
||||||
// Create paseo.json with failing setup command
|
// Create paseo.json with failing setup command
|
||||||
const paseoConfig = {
|
const paseoConfig = {
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "fs";
|
|||||||
import { join, basename, dirname, resolve, sep } from "path";
|
import { join, basename, dirname, resolve, sep } from "path";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import { createNameId } from "mnemonic-id";
|
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";
|
import { resolvePaseoHome } from "../server/paseo-home.js";
|
||||||
|
|
||||||
interface PaseoConfig {
|
interface PaseoConfig {
|
||||||
@@ -26,6 +32,14 @@ export interface WorktreeConfig {
|
|||||||
worktreePath: string;
|
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 = {
|
export type WorktreeSetupCommandResult = {
|
||||||
command: string;
|
command: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
@@ -327,6 +341,29 @@ async function getAvailablePort(): Promise<number> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assertPortAvailable(port: number): Promise<void> {
|
||||||
|
await new Promise<void>((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<string> {
|
async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const commonDir = await getGitCommonDir(worktreePath);
|
const commonDir = await getGitCommonDir(worktreePath);
|
||||||
@@ -360,6 +397,7 @@ export async function runWorktreeSetupCommands(options: {
|
|||||||
branchName: string;
|
branchName: string;
|
||||||
cleanupOnFailure: boolean;
|
cleanupOnFailure: boolean;
|
||||||
repoRootPath?: string;
|
repoRootPath?: string;
|
||||||
|
runtimeEnv?: WorktreeRuntimeEnv;
|
||||||
onEvent?: (event: WorktreeSetupCommandProgressEvent) => void;
|
onEvent?: (event: WorktreeSetupCommandProgressEvent) => void;
|
||||||
}): Promise<WorktreeSetupCommandResult[]> {
|
}): Promise<WorktreeSetupCommandResult[]> {
|
||||||
// Read paseo.json from the worktree (it will have the same content as the source repo)
|
// 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 [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const repoRootPath =
|
const runtimeEnv =
|
||||||
options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath));
|
options.runtimeEnv ??
|
||||||
const worktreePort = await getAvailablePort();
|
(await resolveWorktreeRuntimeEnv({
|
||||||
|
worktreePath: options.worktreePath,
|
||||||
|
branchName: options.branchName,
|
||||||
|
...(options.repoRootPath ? { repoRootPath: options.repoRootPath } : {}),
|
||||||
|
}));
|
||||||
const setupEnv = {
|
const setupEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
// Source checkout path is the original git repo root (shared across worktrees), not the
|
...runtimeEnv,
|
||||||
// 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),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const results: WorktreeSetupCommandResult[] = [];
|
const results: WorktreeSetupCommandResult[] = [];
|
||||||
@@ -439,6 +472,40 @@ async function resolveBranchNameForWorktreePath(worktreePath: string): Promise<s
|
|||||||
return basename(worktreePath);
|
return basename(worktreePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveWorktreeRuntimeEnv(options: {
|
||||||
|
worktreePath: string;
|
||||||
|
branchName?: string;
|
||||||
|
repoRootPath?: string;
|
||||||
|
}): Promise<WorktreeRuntimeEnv> {
|
||||||
|
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: {
|
export async function runWorktreeDestroyCommands(options: {
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
branchName?: string;
|
branchName?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user