diff --git a/packages/app/e2e/regression/project-picker-recent-search.spec.ts b/packages/app/e2e/regression/project-picker-recent-search.spec.ts new file mode 100644 index 000000000000..2cdb0b4a03b4 --- /dev/null +++ b/packages/app/e2e/regression/project-picker-recent-search.spec.ts @@ -0,0 +1,60 @@ +import { expect, test } from "@playwright/test" +import type { Page } from "@playwright/test" +import { fixture, pageMessages } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +const NAMES = ["alpha-service", "bravo-web", "charlie-api", "delta-tools", "echo-infra", "foxtrot-docs"] +const worktrees = NAMES.map((name) => `/opencode-demo/${name}`) + +// The sixth project sits outside the five-item recent cap, so it is only reachable if the +// dialog hands every recent project to the list filter instead of a pre-truncated slice. +const OUTSIDE_CAP = "foxtrot-docs" + +// Dialog rows carry data-directory-path; the sidebar project list does not, so this +// scopes assertions to the picker instead of matching the sidebar entry of the same name. +const rows = (page: Page) => page.locator("[data-directory-path]") +const row = (page: Page, name: string) => page.locator(`[data-directory-path*="${name}"]`) + +async function openProjectDialog(page: Page) { + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + fileList: () => [], + findFiles: () => [], + }) + await page.addInitScript((dirs) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: dirs.map((worktree: string) => ({ worktree, expanded: false })) }, + lastProject: {}, + }), + ) + }, worktrees) + await page.goto("/") + const add = page.getByRole("button", { name: "Add project" }).first() + await expectAppVisible(add) + await add.click() + await expect(rows(page)).toHaveCount(5) + return page.getByRole("textbox").last() +} + +test("searches every recent project, not just the five most recent", async ({ page }) => { + const search = await openProjectDialog(page) + await expect(row(page, OUTSIDE_CAP)).toHaveCount(0) + + await search.fill("foxtrot") + + await expect(row(page, OUTSIDE_CAP)).toHaveCount(1) +}) + +test("still caps the idle recent list at five projects", async ({ page }) => { + await openProjectDialog(page) + + await expect(row(page, NAMES[4])).toHaveCount(1) + await expect(row(page, OUTSIDE_CAP)).toHaveCount(0) +}) diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index 96bcedb9db59..fc248d821e29 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -18,6 +18,8 @@ interface DialogSelectDirectoryProps { server: ServerConnection.Any } +const RECENT_PROJECT_LIMIT = 5 + type Row = { absolute: string search: string @@ -102,7 +104,6 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { return projects .map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index })) .sort((a, b) => b.at - a.at || a.index - b.index) - .slice(0, 5) .map(({ project }) => { const row = toRow(project.worktree, home(), "recent") const name = project.name || getFilename(project.worktree) @@ -116,7 +117,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const items = async (value: string) => { const results = await directories(value) const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders")) - return uniqueRows([...recentProjects(), ...directoryRows]) + // Cap the idle list only. Once a query narrows the results, every project stays searchable. + const recent = recentProjects() + const visible = value ? recent : recent.slice(0, RECENT_PROJECT_LIMIT) + return uniqueRows([...visible, ...directoryRows]) } function resolve(absolute: string) {