mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Restore fuzzy project folder search and desktop browsing (#1968)
* fix(projects): restore folder search and browsing * fix(projects): preserve fuzzy search edge cases * test(projects): move picker cleanup into fixture * fix(projects): preserve absolute path descendants * test(server): compare canonical search paths * fix(projects): anchor root path suggestions * fix(projects): anchor Windows root suggestions * fix(projects): anchor directory search paths * test(server): canonicalize directory search root * fix(app): anchor rooted basename suggestions * refactor(search): unify directory suggestions Use one parameterized daemon search engine for project paths and workspace entries. Keep local recommendations as a small client overlay and remove unnecessary response metadata. * fix(search): preserve filesystem root semantics Accept absolute queries through configured root aliases while keeping traversal and output canonical. Keep blank suffix browsing matcher-independent and avoid metadata-keyed listing caches where Windows cannot invalidate them reliably.
This commit is contained in:
15
.github/workflows/ci.yml
vendored
15
.github/workflows/ci.yml
vendored
@@ -239,8 +239,13 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
name: playwright (shard ${{ matrix.shard }}/4)
|
||||
include:
|
||||
- { label: "shard 1/4", shard: 1, desktop: false }
|
||||
- { label: "shard 2/4", shard: 2, desktop: false }
|
||||
- { label: "shard 3/4", shard: 3, desktop: false }
|
||||
- { label: "shard 4/4", shard: 4, desktop: false }
|
||||
- { label: "desktop overlay", shard: "desktop", desktop: true }
|
||||
name: playwright (${{ matrix.label }})
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
@@ -276,13 +281,19 @@ jobs:
|
||||
run: npm run build:server
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
if: ${{ !matrix.desktop }}
|
||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
if: ${{ !matrix.desktop }}
|
||||
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
- name: Run desktop-overlay Playwright tests
|
||||
if: ${{ matrix.desktop }}
|
||||
run: npm run test:e2e:desktop --workspace=@getpaseo/app
|
||||
|
||||
- name: Upload test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
|
||||
@@ -185,6 +185,15 @@ New session RPCs use dotted names with `.request` and `.response` suffixes, such
|
||||
- Voice/dictation streaming events (`dictation_stream_*`, `assistant_chunk`, `audio_output`, `transcription_result`)
|
||||
- Request/response pairs for fetch, list, create, etc., correlated by `requestId`; failures use `rpc_error`
|
||||
|
||||
`directory_suggestions_request` is one daemon-owned filesystem search capability. The daemon
|
||||
configures the same `searchDirectoryEntries` engine with a root, output format, path-query policy,
|
||||
entry-kind filters, match mode, blank-query behavior, and hidden-directory traversal policy. A
|
||||
request without `cwd` searches the host home for absolute project paths; a request with `cwd`
|
||||
searches that workspace and returns relative entries. Clients may prepend their small host-scoped
|
||||
recent-project list for bare queries, but must not parse filesystem query syntax or re-filter a
|
||||
correlated daemon response. The legacy `directories` response field remains a projection of the
|
||||
typed `entries` list.
|
||||
|
||||
**Binary frames (terminal stream protocol):**
|
||||
|
||||
Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStreamFrame` in `shared/binary-frames/terminal.ts`. The layout is:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { expectOpenedProject } from "./helpers/project-picker-ui";
|
||||
import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
@@ -74,6 +75,26 @@ async function waitForSidebarProjectListReady(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
test.describe("Project picker search", () => {
|
||||
test("opens a project from a fuzzy directory-name search", async ({
|
||||
page,
|
||||
projectPickerFixture,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProjectListReady(page);
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
|
||||
const input = page.getByPlaceholder("Type a directory path...");
|
||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||
await input.fill(projectPickerFixture.fuzzyQuery);
|
||||
|
||||
const suggestion = page.getByText(projectPickerFixture.projectName, { exact: false }).first();
|
||||
await expect(suggestion).toBeVisible({ timeout: 30_000 });
|
||||
await suggestion.click();
|
||||
|
||||
const projectId = await expectOpenedProject(page, projectPickerFixture.projectName);
|
||||
projectPickerFixture.rememberProjectId(projectId);
|
||||
});
|
||||
|
||||
test("shows a loading state after typing while directory suggestions are pending", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
||||
import {
|
||||
createProjectPickerFixture,
|
||||
removeProjectPickerFixture,
|
||||
type ProjectPickerFixture,
|
||||
} from "./helpers/project-picker-fixture";
|
||||
import { connectSeedClient } from "./helpers/seed-client";
|
||||
import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace";
|
||||
|
||||
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
|
||||
|
||||
interface TrackedProjectPickerFixture extends ProjectPickerFixture {
|
||||
rememberProjectId: (projectId: string) => void;
|
||||
}
|
||||
|
||||
// Test setup is wired through an `auto: true` fixture rather than `test.beforeEach`.
|
||||
// `test.beforeEach` declared at the top level of a non-test fixture file is unreliable
|
||||
// across spec-file boundaries — Playwright sometimes skips it for the first test of a
|
||||
// subsequent spec when multiple specs run in the same worker. Auto fixtures run
|
||||
// reliably for every test that uses this `test` object.
|
||||
const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>({
|
||||
const test = base.extend<{
|
||||
paseoE2ESetup: void;
|
||||
projectPickerFixture: TrackedProjectPickerFixture;
|
||||
withWorkspace: WithWorkspace;
|
||||
}>({
|
||||
baseURL: async ({}, provide) => {
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) {
|
||||
@@ -102,6 +116,30 @@ const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>(
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
projectPickerFixture: async ({}, provide) => {
|
||||
const resource = await createProjectPickerFixture();
|
||||
const { fixture } = resource;
|
||||
let projectId: string | null = null;
|
||||
try {
|
||||
await provide({
|
||||
...fixture,
|
||||
rememberProjectId: (openedProjectId) => {
|
||||
projectId = openedProjectId;
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
const client = await connectSeedClient();
|
||||
try {
|
||||
await removeProjectPickerFixture(client, fixture, projectId);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
} finally {
|
||||
await resource.removeDirectory();
|
||||
}
|
||||
}
|
||||
},
|
||||
withWorkspace: async ({ page }, provide) => {
|
||||
const handle = createWithWorkspace(page);
|
||||
await provide(handle.withWorkspace);
|
||||
|
||||
@@ -71,6 +71,7 @@ export interface DesktopBridgeConfig {
|
||||
* false so tests that only assert copy don't inadvertently trigger state changes.
|
||||
*/
|
||||
confirmShouldAccept?: boolean;
|
||||
dialogOpenResult?: string | string[] | null;
|
||||
editorTargets?: DesktopEditorTargetConfig[];
|
||||
editorRecordPath?: string;
|
||||
}
|
||||
@@ -96,6 +97,7 @@ export interface ConfirmDialogCall {
|
||||
declare global {
|
||||
interface Window {
|
||||
__capturedDialogCall: ConfirmDialogCall | undefined;
|
||||
__capturedDialogOpenCalls: Array<Record<string, unknown> | undefined>;
|
||||
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
|
||||
}
|
||||
}
|
||||
@@ -157,6 +159,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
dialog: {
|
||||
ask: (message: string, options?: Record<string, unknown>) => Promise<boolean>;
|
||||
open: (options?: Record<string, unknown>) => Promise<string | string[] | null>;
|
||||
};
|
||||
getPendingOpenProject: () => Promise<string | null>;
|
||||
events: { on: () => Promise<() => void> };
|
||||
@@ -249,6 +252,10 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
};
|
||||
return cfg.confirmShouldAccept ?? false;
|
||||
},
|
||||
open: async (options?: Record<string, unknown>) => {
|
||||
window.__capturedDialogOpenCalls.push(options);
|
||||
return cfg.dialogOpenResult ?? null;
|
||||
},
|
||||
},
|
||||
getPendingOpenProject: async () => null,
|
||||
events: { on: async () => () => undefined },
|
||||
@@ -263,10 +270,18 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
};
|
||||
}
|
||||
|
||||
window.__capturedDialogOpenCalls = [];
|
||||
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = desktopBridge;
|
||||
}, config);
|
||||
}
|
||||
|
||||
export async function waitForDirectoryDialog(
|
||||
page: Page,
|
||||
): Promise<Record<string, unknown> | undefined> {
|
||||
await expect.poll(() => page.evaluate(() => window.__capturedDialogOpenCalls.length)).toBe(1);
|
||||
return page.evaluate(() => window.__capturedDialogOpenCalls[0]);
|
||||
}
|
||||
|
||||
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
48
packages/app/e2e/helpers/project-picker-fixture.ts
Normal file
48
packages/app/e2e/helpers/project-picker-fixture.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { SeedDaemonClient } from "./seed-client";
|
||||
|
||||
export interface ProjectPickerFixture {
|
||||
projectPath: string;
|
||||
projectName: string;
|
||||
fuzzyQuery: string;
|
||||
}
|
||||
|
||||
interface ProjectPickerFixtureResource {
|
||||
fixture: ProjectPickerFixture;
|
||||
removeDirectory: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function createProjectPickerFixture(): Promise<ProjectPickerFixtureResource> {
|
||||
const root = await mkdtemp(path.join(homedir(), "paseo-e2e-project-picker-"));
|
||||
const nonce = randomUUID().replaceAll("-", "").slice(0, 8);
|
||||
const projectPath = path.join(root, "client", "team", `paseo-desktop-fuzzy-target-${nonce}`);
|
||||
await mkdir(projectPath, { recursive: true });
|
||||
|
||||
return {
|
||||
fixture: {
|
||||
projectPath,
|
||||
projectName: path.basename(projectPath),
|
||||
fuzzyQuery: `psodfzt${nonce}`,
|
||||
},
|
||||
removeDirectory: () => rm(root, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
export async function removeProjectPickerFixture(
|
||||
client: SeedDaemonClient,
|
||||
fixture: ProjectPickerFixture,
|
||||
knownProjectId: string | null = null,
|
||||
): Promise<void> {
|
||||
let projectId = knownProjectId;
|
||||
if (!projectId) {
|
||||
const lookup = await client.addProject(fixture.projectPath);
|
||||
projectId = lookup.project?.projectId ?? null;
|
||||
if (!projectId) {
|
||||
throw new Error(lookup.error ?? "Could not resolve project picker fixture for cleanup");
|
||||
}
|
||||
}
|
||||
await client.removeProject(projectId);
|
||||
}
|
||||
13
packages/app/e2e/helpers/project-picker-ui.ts
Normal file
13
packages/app/e2e/helpers/project-picker-ui.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export async function expectOpenedProject(page: Page, projectName: string): Promise<string> {
|
||||
const projectRow = page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: projectName })
|
||||
.first();
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const testId = await projectRow.getAttribute("data-testid");
|
||||
expect(testId).not.toBeNull();
|
||||
return testId!.replace("sidebar-project-row-", "");
|
||||
}
|
||||
60
packages/app/e2e/project-picker-desktop.spec.ts
Normal file
60
packages/app/e2e/project-picker-desktop.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { injectDesktopBridge, waitForDirectoryDialog } from "./helpers/desktop-updates";
|
||||
import { expectOpenedProject } from "./helpers/project-picker-ui";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
test.skip(process.env.E2E_DESKTOP_RUNTIME !== "1", "requires Metro's Electron platform overlay");
|
||||
|
||||
test("Browse opens the folder selected by the desktop dialog", async ({
|
||||
page,
|
||||
projectPickerFixture,
|
||||
}) => {
|
||||
await injectDesktopBridge(page, {
|
||||
serverId: getServerId(),
|
||||
manageBuiltInDaemon: false,
|
||||
dialogOpenResult: projectPickerFixture.projectPath,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
const browse = page.getByRole("button", { name: "Browse…" });
|
||||
await expect(browse).toBeVisible({ timeout: 30_000 });
|
||||
await browse.click();
|
||||
|
||||
const projectId = await expectOpenedProject(page, projectPickerFixture.projectName);
|
||||
projectPickerFixture.rememberProjectId(projectId);
|
||||
});
|
||||
|
||||
test("Browse owns Enter without opening the active typed path", async ({
|
||||
page,
|
||||
projectPickerFixture,
|
||||
}) => {
|
||||
await injectDesktopBridge(page, {
|
||||
serverId: getServerId(),
|
||||
manageBuiltInDaemon: false,
|
||||
dialogOpenResult: null,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
const input = page.getByPlaceholder("Type a directory path...");
|
||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||
await input.fill(projectPickerFixture.projectPath);
|
||||
|
||||
const browse = page.getByRole("button", { name: "Browse…" });
|
||||
await expect(browse).toBeVisible({ timeout: 30_000 });
|
||||
await browse.press("Enter");
|
||||
|
||||
const dialogOptions = await waitForDirectoryDialog(page);
|
||||
expect(dialogOptions).toEqual({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
});
|
||||
await expect(input).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: projectPickerFixture.projectName }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
@@ -22,6 +22,7 @@
|
||||
"test": "vitest run",
|
||||
"test:browser": "vitest run --project browser",
|
||||
"test:e2e": "playwright test --project='Desktop Chrome'",
|
||||
"test:e2e:desktop": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --project='Desktop Chrome' e2e/project-picker-desktop.spec.ts",
|
||||
"test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"build": "npm run build:web",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderOpen } from "lucide-react-native";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { pickDirectory } from "@/desktop/pick-directory";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
import type { ProjectPickerBrowseButtonProps } from "./project-picker-browse-button.types";
|
||||
|
||||
export function ProjectPickerBrowseButton({
|
||||
serverId,
|
||||
disabled,
|
||||
onSelect,
|
||||
onError,
|
||||
}: ProjectPickerBrowseButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const isLocalDaemon = useIsLocalDaemon(serverId);
|
||||
const handlePress = useCallback(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const path = await pickDirectory();
|
||||
if (path) {
|
||||
onSelect(path);
|
||||
}
|
||||
} catch {
|
||||
onError();
|
||||
}
|
||||
})();
|
||||
}, [onError, onSelect]);
|
||||
|
||||
if (!isLocalDaemon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={FolderOpen}
|
||||
disabled={disabled}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{t("projectPicker.browse")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ProjectPickerBrowseButtonProps } from "./project-picker-browse-button.types";
|
||||
|
||||
export function ProjectPickerBrowseButton(_props: ProjectPickerBrowseButtonProps) {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface ProjectPickerBrowseButtonProps {
|
||||
serverId: string;
|
||||
disabled: boolean;
|
||||
onSelect: (path: string) => void;
|
||||
onError: () => void;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { useProjectPickerStore } from "@/stores/project-picker-store";
|
||||
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { ProjectPickerBrowseButton } from "./project-picker-browse-button";
|
||||
import { buildProjectPickerOptions, type ProjectPickerOption } from "./project-picker-options";
|
||||
|
||||
interface PathRowProps {
|
||||
@@ -155,31 +156,36 @@ export function ProjectPickerModal() {
|
||||
const directorySuggestionsQuery = useQuery({
|
||||
queryKey: ["project-picker-directory-suggestions", serverId, debouncedQuery],
|
||||
queryFn: async () => {
|
||||
if (!client) return [];
|
||||
if (!client) {
|
||||
return { query: debouncedQuery, paths: [] };
|
||||
}
|
||||
const result = await client.getDirectorySuggestions({
|
||||
query: debouncedQuery,
|
||||
includeDirectories: true,
|
||||
includeFiles: false,
|
||||
limit: 30,
|
||||
});
|
||||
return (
|
||||
result.entries?.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])) ?? []
|
||||
);
|
||||
return {
|
||||
query: debouncedQuery,
|
||||
paths:
|
||||
result.entries?.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])) ??
|
||||
[],
|
||||
};
|
||||
},
|
||||
enabled: Boolean(client) && isConnected && open,
|
||||
staleTime: 15_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
buildProjectPickerOptions({
|
||||
recommendedPaths,
|
||||
serverPaths: directorySuggestionsQuery.data ?? [],
|
||||
query,
|
||||
}),
|
||||
[directorySuggestionsQuery.data, query, recommendedPaths],
|
||||
);
|
||||
const options = useMemo(() => {
|
||||
const currentSuggestions =
|
||||
directorySuggestionsQuery.data?.query === query ? directorySuggestionsQuery.data : null;
|
||||
return buildProjectPickerOptions({
|
||||
recommendedPaths,
|
||||
serverPaths: currentSuggestions?.paths ?? [],
|
||||
query,
|
||||
});
|
||||
}, [directorySuggestionsQuery.data, query, recommendedPaths]);
|
||||
const hasQuery = query.trim().length > 0;
|
||||
const isSearching =
|
||||
hasQuery &&
|
||||
@@ -213,6 +219,8 @@ export function ProjectPickerModal() {
|
||||
}
|
||||
|
||||
setOpenErrorReason(getOpenProjectFailureReason(result));
|
||||
} catch {
|
||||
setOpenErrorReason("open_failed");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -232,6 +240,10 @@ export function ProjectPickerModal() {
|
||||
setOpenErrorReason(null);
|
||||
}, []);
|
||||
|
||||
const handleBrowseError = useCallback(() => {
|
||||
setOpenErrorReason("open_failed");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery("");
|
||||
@@ -262,7 +274,7 @@ export function ProjectPickerModal() {
|
||||
|
||||
function handler(event: KeyboardEvent) {
|
||||
const key = event.key;
|
||||
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") return;
|
||||
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Escape") return;
|
||||
|
||||
if (key === "Escape") {
|
||||
event.preventDefault();
|
||||
@@ -270,12 +282,6 @@ export function ProjectPickerModal() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "Enter") {
|
||||
event.preventDefault();
|
||||
submitActiveOption();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "ArrowDown" || key === "ArrowUp") {
|
||||
if (options.length === 0) return;
|
||||
event.preventDefault();
|
||||
@@ -291,7 +297,7 @@ export function ProjectPickerModal() {
|
||||
|
||||
window.addEventListener("keydown", handler, true);
|
||||
return () => window.removeEventListener("keydown", handler, true);
|
||||
}, [close, open, options.length, submitActiveOption]);
|
||||
}, [close, open, options.length]);
|
||||
|
||||
const panelStyle = useMemo(
|
||||
() => [
|
||||
@@ -343,6 +349,12 @@ export function ProjectPickerModal() {
|
||||
returnKeyType="go"
|
||||
onSubmitEditing={submitActiveOption}
|
||||
/>
|
||||
<ProjectPickerBrowseButton
|
||||
serverId={serverId}
|
||||
disabled={isSubmitting}
|
||||
onSelect={handleSelectPath}
|
||||
onError={handleBrowseError}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<ProjectPickerResults
|
||||
@@ -383,11 +395,15 @@ const styles = StyleSheet.create((theme) => ({
|
||||
...theme.shadow.lg,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.lg,
|
||||
paddingVertical: theme.spacing[1],
|
||||
outlineStyle: "none",
|
||||
|
||||
@@ -35,7 +35,7 @@ describe("buildProjectPickerOptions", () => {
|
||||
it("puts an absolute path row first", () => {
|
||||
const options = buildProjectPickerOptions({
|
||||
recommendedPaths: ["/repo/api"],
|
||||
serverPaths: [],
|
||||
serverPaths: ["/repo/api"],
|
||||
query: "/repo",
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ describe("buildProjectPickerOptions", () => {
|
||||
it("puts a home-relative path row first", () => {
|
||||
const options = buildProjectPickerOptions({
|
||||
recommendedPaths: ["/Users/mo/src/api"],
|
||||
serverPaths: [],
|
||||
serverPaths: ["/Users/mo/src/api"],
|
||||
query: "~/src",
|
||||
});
|
||||
|
||||
|
||||
33
packages/app/src/desktop/pick-directory.test.ts
Normal file
33
packages/app/src/desktop/pick-directory.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DesktopDialogBridge, DesktopDialogOpenOptions } from "./host";
|
||||
import { pickDirectory } from "./pick-directory";
|
||||
|
||||
describe("pickDirectory", () => {
|
||||
it("opens a single-directory picker and returns the selection", async () => {
|
||||
const recordedOptions: DesktopDialogOpenOptions[] = [];
|
||||
const dialog: DesktopDialogBridge = {
|
||||
open: async (options) => {
|
||||
if (options) {
|
||||
recordedOptions.push(options);
|
||||
}
|
||||
return "/repo/project";
|
||||
},
|
||||
};
|
||||
|
||||
await expect(pickDirectory(dialog)).resolves.toBe("/repo/project");
|
||||
expect(recordedOptions).toEqual([
|
||||
{
|
||||
directory: true,
|
||||
multiple: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns null when the picker is cancelled", async () => {
|
||||
const dialog: DesktopDialogBridge = {
|
||||
open: async () => null,
|
||||
};
|
||||
|
||||
await expect(pickDirectory(dialog)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
23
packages/app/src/desktop/pick-directory.ts
Normal file
23
packages/app/src/desktop/pick-directory.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { getDesktopHost, type DesktopDialogBridge } from "./host";
|
||||
|
||||
export async function pickDirectory(
|
||||
dialog: DesktopDialogBridge | null = getDesktopHost()?.dialog ?? null,
|
||||
): Promise<string | null> {
|
||||
const open = dialog?.open;
|
||||
if (typeof open !== "function") {
|
||||
throw new Error("Desktop dialog open() is unavailable in this environment.");
|
||||
}
|
||||
|
||||
const selection = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
});
|
||||
if (selection === null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof selection === "string") {
|
||||
return selection;
|
||||
}
|
||||
|
||||
throw new Error("Unexpected directory picker response.");
|
||||
}
|
||||
@@ -1094,6 +1094,7 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
projectPicker: {
|
||||
placeholder: "اكتب مسار الدليل...",
|
||||
browse: "استعراض…",
|
||||
opening: "افتتاح المشروع...",
|
||||
searching: "جارٍ البحث...",
|
||||
empty: "ابدأ بكتابة المسار",
|
||||
|
||||
@@ -1101,6 +1101,7 @@ export const en = {
|
||||
},
|
||||
projectPicker: {
|
||||
placeholder: "Type a directory path...",
|
||||
browse: "Browse…",
|
||||
opening: "Opening project...",
|
||||
searching: "Searching...",
|
||||
empty: "Start typing a path",
|
||||
|
||||
@@ -1130,6 +1130,7 @@ export const es: TranslationResources = {
|
||||
},
|
||||
projectPicker: {
|
||||
placeholder: "Escriba una ruta de directorio...",
|
||||
browse: "Explorar…",
|
||||
opening: "Proyecto de apertura...",
|
||||
searching: "Buscando...",
|
||||
empty: "Comience a escribir una ruta",
|
||||
|
||||
@@ -1132,6 +1132,7 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
projectPicker: {
|
||||
placeholder: "Tapez un chemin de répertoire...",
|
||||
browse: "Parcourir…",
|
||||
opening: "Projet d'ouverture...",
|
||||
searching: "Recherche en cours...",
|
||||
empty: "Commencez à taper un chemin",
|
||||
|
||||
@@ -1108,6 +1108,7 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
projectPicker: {
|
||||
placeholder: "ディレクトリパスを入力...",
|
||||
browse: "参照…",
|
||||
opening: "プロジェクトを開いています...",
|
||||
searching: "検索中...",
|
||||
empty: "パスを入力してください",
|
||||
|
||||
@@ -1116,6 +1116,7 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
projectPicker: {
|
||||
placeholder: "Digite um caminho de diretório...",
|
||||
browse: "Procurar…",
|
||||
opening: "Abrindo projeto...",
|
||||
searching: "Buscando...",
|
||||
empty: "Comece digitando um caminho",
|
||||
|
||||
@@ -1120,6 +1120,7 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
projectPicker: {
|
||||
placeholder: "Введите путь к каталогу...",
|
||||
browse: "Обзор…",
|
||||
opening: "Открытие проекта...",
|
||||
searching: "Идет поиск...",
|
||||
empty: "Начните вводить путь",
|
||||
|
||||
@@ -1079,6 +1079,7 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
projectPicker: {
|
||||
placeholder: "输入目录路径...",
|
||||
browse: "浏览…",
|
||||
opening: "正在打开 project...",
|
||||
searching: "正在搜索...",
|
||||
empty: "开始输入路径",
|
||||
|
||||
@@ -12,31 +12,40 @@ describe("buildWorkingDirectorySuggestions", () => {
|
||||
expect(results).toEqual(["/Users/me/projects/paseo"]);
|
||||
});
|
||||
|
||||
it("prioritizes matching recommended directories before server matches", () => {
|
||||
it("keeps fuzzy recommendation matches before de-duplicated daemon suggestions", () => {
|
||||
const results = buildWorkingDirectorySuggestions({
|
||||
recommendedPaths: ["/Users/me/projects/paseo", "/Users/me/documents"],
|
||||
serverPaths: [
|
||||
"/Users/me/projects/playground",
|
||||
"/Users/me/projects/paseo",
|
||||
"/Users/me/projects/planbook",
|
||||
recommendedPaths: ["/Users/me/projects/paseo-desktop", "/Users/me/documents"],
|
||||
serverPaths: ["/Users/me/projects/paseo-plan", "/Users/me/projects/paseo-desktop"],
|
||||
query: "pso",
|
||||
});
|
||||
|
||||
expect(results).toEqual(["/Users/me/projects/paseo-desktop", "/Users/me/projects/paseo-plan"]);
|
||||
});
|
||||
|
||||
it("does not reinterpret daemon-ranked suggestions", () => {
|
||||
const results = buildWorkingDirectorySuggestions({
|
||||
recommendedPaths: [],
|
||||
serverPaths: ["/Users/me/projects/paseo-desktop"],
|
||||
query: "a-query-ranked-by-the-daemon",
|
||||
});
|
||||
|
||||
expect(results).toEqual(["/Users/me/projects/paseo-desktop"]);
|
||||
});
|
||||
|
||||
it("leaves path-query semantics to the daemon", () => {
|
||||
const results = buildWorkingDirectorySuggestions({
|
||||
recommendedPaths: [
|
||||
"/Users/me/archive/projects/paseo-desktop",
|
||||
"/Users/me/projects/paseo-desktop",
|
||||
],
|
||||
query: "pla",
|
||||
serverPaths: [],
|
||||
query: "~/projects/pso",
|
||||
});
|
||||
|
||||
expect(results).toEqual(["/Users/me/projects/playground", "/Users/me/projects/planbook"]);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
it("treats '~' as an active query and includes daemon suggestions", () => {
|
||||
const results = buildWorkingDirectorySuggestions({
|
||||
recommendedPaths: ["/Users/me/projects/paseo"],
|
||||
serverPaths: ["/Users/me/documents", "/Users/me/projects"],
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { scoreMatch } from "./score-match";
|
||||
|
||||
export interface BuildWorkingDirectorySuggestionsInput {
|
||||
recommendedPaths: string[];
|
||||
serverPaths: string[];
|
||||
@@ -7,40 +9,27 @@ export interface BuildWorkingDirectorySuggestionsInput {
|
||||
export function buildWorkingDirectorySuggestions(
|
||||
input: BuildWorkingDirectorySuggestionsInput,
|
||||
): string[] {
|
||||
const rawQuery = input.query.trim();
|
||||
const query = input.query.trim();
|
||||
const recommended = uniquePaths(input.recommendedPaths);
|
||||
if (!rawQuery) {
|
||||
if (!query) {
|
||||
return recommended;
|
||||
}
|
||||
|
||||
const normalizedQuery = normalizeQuery(rawQuery);
|
||||
const shouldFilterByQuery = normalizedQuery.length > 0;
|
||||
const matchingRecommended = recommended.filter((path) =>
|
||||
recommendedPathMatchesQuery(path, query),
|
||||
);
|
||||
|
||||
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;
|
||||
// The request owner correlates these results with the current query. The
|
||||
// daemon owns filesystem query parsing, filtering, and ranking; doing it
|
||||
// again here creates a second search implementation that can disagree.
|
||||
return uniquePaths([...matchingRecommended, ...input.serverPaths]);
|
||||
}
|
||||
|
||||
function uniquePaths(paths: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const ordered: string[] = [];
|
||||
for (const pathEntry of paths) {
|
||||
const trimmed = pathEntry.trim();
|
||||
for (const path of paths) {
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed || seen.has(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
@@ -50,23 +39,20 @@ function uniquePaths(paths: string[]): string[] {
|
||||
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)) {
|
||||
function recommendedPathMatchesQuery(path: string, query: string): boolean {
|
||||
const candidate = normalizePath(path);
|
||||
const normalizedQuery = normalizePath(query);
|
||||
if (["~", "~/"].includes(normalizedQuery)) {
|
||||
return true;
|
||||
}
|
||||
const segments = lowerPath.split("/");
|
||||
return (segments[segments.length - 1] ?? "").includes(query);
|
||||
if (normalizedQuery.includes("/") || normalizedQuery.startsWith("~")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const basename = candidate.split("/").at(-1) ?? "";
|
||||
return candidate.includes(normalizedQuery) || scoreMatch(normalizedQuery, basename) !== null;
|
||||
}
|
||||
|
||||
function normalizePath(value: string): string {
|
||||
return value.trim().replace(/\\/g, "/").toLowerCase();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect, beforeAll, afterAll } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, rmSync, existsSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync, rmSync, existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { tmpdir, homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -831,6 +831,7 @@ test("update_agent persists unloaded title and labels across auto-unarchive", as
|
||||
|
||||
test("returns home-scoped directory suggestions", async () => {
|
||||
const insideHomeDir = mkdtempSync(path.join(homedir(), "paseo-dir-suggestion-"));
|
||||
const rootBrowseDir = mkdtempSync(path.join(homedir(), "000-paseo-root-browse-"));
|
||||
const outsideHomeDir = mkdtempSync(path.join(tmpdir(), "paseo-dir-suggestion-outside-"));
|
||||
|
||||
try {
|
||||
@@ -842,6 +843,17 @@ test("returns home-scoped directory suggestions", async () => {
|
||||
expect(insideResult.error).toBeNull();
|
||||
expect(insideResult.directories).toContain(insideHomeDir);
|
||||
|
||||
const rootBrowseResult = await ctx.client.getDirectorySuggestions({
|
||||
query: "~",
|
||||
limit: 100,
|
||||
});
|
||||
expect(rootBrowseResult.error).toBeNull();
|
||||
expect(rootBrowseResult.directories).toContain(rootBrowseDir);
|
||||
|
||||
const blankResult = await ctx.client.getDirectorySuggestions({ query: "", limit: 100 });
|
||||
expect(blankResult.error).toBeNull();
|
||||
expect(blankResult.entries).toEqual([]);
|
||||
|
||||
const outsideQuery = path.basename(outsideHomeDir);
|
||||
const outsideResult = await ctx.client.getDirectorySuggestions({
|
||||
query: outsideQuery,
|
||||
@@ -851,10 +863,35 @@ test("returns home-scoped directory suggestions", async () => {
|
||||
expect(outsideResult.directories).not.toContain(outsideHomeDir);
|
||||
} finally {
|
||||
rmSync(insideHomeDir, { recursive: true, force: true });
|
||||
rmSync(rootBrowseDir, { recursive: true, force: true });
|
||||
rmSync(outsideHomeDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test("returns typed relative suggestions within a requested directory", async () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-workspace-suggestion-"));
|
||||
const target = path.join(cwd, "src", "components", "message-renderer.tsx");
|
||||
|
||||
try {
|
||||
mkdirSync(path.dirname(target), { recursive: true });
|
||||
writeFileSync(target, "");
|
||||
|
||||
const result = await ctx.client.getDirectorySuggestions({
|
||||
cwd,
|
||||
query: "msgrndr",
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.directories).toEqual([]);
|
||||
expect(result.entries).toEqual([{ path: "src/components/message-renderer.tsx", kind: "file" }]);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test("receives server_info on websocket connect", async () => {
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
|
||||
|
||||
@@ -174,7 +174,7 @@ import {
|
||||
type AgentUpdatesService,
|
||||
} from "./session/agent-updates/agent-updates-service.js";
|
||||
import { expandTilde } from "../utils/path.js";
|
||||
import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js";
|
||||
import { searchDirectoryEntries } from "../utils/directory-suggestions.js";
|
||||
import type { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||
import type { Resolvable } from "./speech/provider-resolver.js";
|
||||
import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js";
|
||||
@@ -223,6 +223,14 @@ import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dis
|
||||
// the entire session message if they encounter an unknown provider.
|
||||
const LEGACY_PROVIDER_IDS = new Set(["claude", "codex", "opencode"]);
|
||||
const MIN_VERSION_ALL_PROVIDERS = "0.1.45";
|
||||
const WORKSPACE_SEARCH_HIDDEN_DIRECTORIES = [
|
||||
".agents",
|
||||
".claude",
|
||||
".codex",
|
||||
".github",
|
||||
".paseo",
|
||||
".vscode",
|
||||
];
|
||||
|
||||
function errorToFriendlyMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
@@ -3049,22 +3057,23 @@ export class Session {
|
||||
|
||||
try {
|
||||
const workspaceCwd = cwd?.trim();
|
||||
const entries = workspaceCwd
|
||||
? await searchWorkspaceEntries({
|
||||
cwd: expandTilde(workspaceCwd),
|
||||
query,
|
||||
limit,
|
||||
includeFiles,
|
||||
includeDirectories,
|
||||
matchMode,
|
||||
})
|
||||
: (
|
||||
await searchHomeDirectories({
|
||||
homeDir: process.env.HOME ?? homedir(),
|
||||
query,
|
||||
limit,
|
||||
})
|
||||
).map((path) => ({ path, kind: "directory" as const }));
|
||||
const searchesWorkspace = Boolean(workspaceCwd);
|
||||
const entries = await searchDirectoryEntries({
|
||||
root: workspaceCwd ? expandTilde(workspaceCwd) : (process.env.HOME ?? homedir()),
|
||||
query,
|
||||
pathFormat: searchesWorkspace ? "relative" : "absolute",
|
||||
pathQueryPolicy: searchesWorkspace ? "slashes" : "rooted",
|
||||
blankQueryBehavior: searchesWorkspace ? "children" : "none",
|
||||
rootAliases: searchesWorkspace ? [] : ["~"],
|
||||
traversableHiddenDirectoryNames: searchesWorkspace
|
||||
? WORKSPACE_SEARCH_HIDDEN_DIRECTORIES
|
||||
: [],
|
||||
confidentResultScanThreshold: searchesWorkspace ? undefined : 5_000,
|
||||
includeFiles,
|
||||
includeDirectories,
|
||||
matchMode,
|
||||
limit,
|
||||
});
|
||||
const directories = entries
|
||||
.filter((entry) => entry.kind === "directory")
|
||||
.map((entry) => entry.path);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user