diff --git a/packages/app/e2e/regression/project-picker-recent-search.spec.ts b/packages/app/e2e/regression/project-picker-recent-search.spec.ts deleted file mode 100644 index 2cdb0b4a03b..00000000000 --- a/packages/app/e2e/regression/project-picker-recent-search.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -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/e2e/user-story/model-selection-flow.spec.ts b/packages/app/e2e/user-story/model-selection-flow.spec.ts index e260d52c625..f3723fbbeed 100644 --- a/packages/app/e2e/user-story/model-selection-flow.spec.ts +++ b/packages/app/e2e/user-story/model-selection-flow.spec.ts @@ -72,7 +72,12 @@ test("creates a session in a new project and selects its model", async ({ page } const addProject = page.locator('[data-action="home-add-project-row"]') await expectAppVisible(addProject) await addProject.click() - await page.locator("[data-directory-path]").click() + const directoryItem = page.getByRole("treeitem", { name: "NewProject" }) + await expect(directoryItem).toBeVisible() + await directoryItem.click() + const selectFolder = page.getByRole("button", { name: "Select folder" }) + await expect(selectFolder).toBeEnabled() + await selectFolder.click() await page.locator('[data-action="home-new-session"]').click() await expectAppVisible(page.locator('[data-component="prompt-input-v2"]')) diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx deleted file mode 100644 index 29e0c1c9c36..00000000000 --- a/packages/app/src/components/dialog-select-directory.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { Dialog } from "@opencode-ai/ui/dialog" -import { FileIcon } from "@opencode-ai/ui/file-icon" -import { List } from "@opencode-ai/ui/list" -import type { ListRef } from "@opencode-ai/ui/list" -import { getDirectory, getFilename } from "@opencode-ai/core/util/path" -import { createMemo, createResource, createSignal } from "solid-js" -import { useLanguage } from "@/context/language" -import { ServerConnection } from "@/context/servers" -import { useGlobal } from "@/context/global" -import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain" -import type { Path } from "@/types" - -interface DialogSelectDirectoryProps { - title?: string - multiple?: boolean - onSelect: (result: string | string[] | null) => void - server: ServerConnection.Any -} - -const RECENT_PROJECT_LIMIT = 5 - -type Row = { - absolute: string - search: string - group: "recent" | "folders" -} - -function toRow(absolute: string, home: string, group: Row["group"]): Row { - const full = displayPickerPath(absolute, "", "") - const tilde = displayPickerPath(full, "~", home) - const withSlash = (value: string) => { - if (!value) return "" - if (value.endsWith("/")) return value - return value + "/" - } - - const search = Array.from( - new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)), - ).join("\n") - return { absolute: full, search, group } -} - -function uniqueRows(rows: Row[]) { - const seen = new Set() - return rows.filter((row) => { - if (seen.has(row.absolute)) return false - seen.add(row.absolute) - return true - }) -} - -export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { - const global = useGlobal() - const { sync, sdk, ...serverCtx } = global.ensureServerCtx(props.server) - const dialog = useDialog() - const language = useLanguage() - - const [filter, setFilter] = createSignal("") - let list: ListRef | undefined - - const [fallbackPath] = createResource( - () => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined), - () => - sdk.api.location - .get() - .then( - (location): Path => ({ - state: "", - config: "", - worktree: location.project.directory, - directory: location.directory, - home: "", - }), - ) - .catch(() => undefined), - { initialValue: undefined }, - ) - - const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "") - const start = createMemo( - () => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory, - ) - - const directories = createDirectorySearch({ - sdk, - home, - base: start, - }) - - const recentProjects = createMemo(() => { - const projects = serverCtx.projects.list() - const byProject = new Map() - - for (const project of projects) { - let at = 0 - const dirs = [project.worktree, ...(project.sandboxes ?? [])] - for (const directory of dirs) { - const sessions = sync.child(directory, { bootstrap: false })[0].session - for (const session of sessions) { - if (session.time.archived) continue - const updated = session.time.updated ?? session.time.created - if (updated > at) at = updated - } - } - byProject.set(project.worktree, at) - } - - return projects - .map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index })) - .sort((a, b) => b.at - a.at || a.index - b.index) - .map(({ project }) => { - const row = toRow(project.worktree, home(), "recent") - const name = project.name || getFilename(project.worktree) - return { - ...row, - search: `${row.search}\n${name}`, - } - }) - }) - - const items = async (value: string) => { - const results = await directories(value) - const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders")) - // 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) { - props.onSelect(props.multiple ? [absolute] : absolute) - dialog.close() - } - - return ( - - x.absolute} - filterKeys={["search"]} - groupBy={(item) => item.group} - sortGroupsBy={(a, b) => { - if (a.category === b.category) return 0 - return a.category === "recent" ? -1 : 1 - }} - groupHeader={(group) => - group.category === "recent" ? language.t("home.recentProjects") : language.t("command.project.open") - } - ref={(r) => (list = r)} - onFilter={(value) => setFilter(cleanPickerInput(value))} - onKeyEvent={(e, item) => { - if (e.key !== "Tab") return - if (e.shiftKey) return - if (!item) return - - e.preventDefault() - e.stopPropagation() - - const value = displayPickerPath(item.absolute, filter(), home()) - list?.setFilter(value.endsWith("/") ? value : value + "/") - }} - onSelect={(path) => { - if (!path) return - resolve(path.absolute) - }} - > - {(item) => { - const path = displayPickerPath(item.absolute, filter(), home()) - if (path === "~") { - return ( -
-
- -
- ~ - / -
-
-
- ) - } - return ( -
-
- -
- - {getDirectory(path)} - - {getFilename(path)} - / -
-
-
- ) - }} -
-
- ) -} diff --git a/packages/app/src/components/directory-picker.tsx b/packages/app/src/components/directory-picker.tsx index b815e9a9a2a..74bc2aa4843 100644 --- a/packages/app/src/components/directory-picker.tsx +++ b/packages/app/src/components/directory-picker.tsx @@ -1,9 +1,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { ServerConnection } from "@/context/servers" import { usePlatform } from "@/context/platform" -import { useSettings } from "@/context/settings" import { lazy } from "solid-js" -import { DialogSelectDirectory } from "./dialog-select-directory" import { directoryPickerKind } from "./directory-picker-policy" const DialogSelectDirectoryV2 = lazy(() => @@ -19,7 +17,6 @@ type DirectoryPickerInput = { export function useDirectoryPicker() { const platform = usePlatform() - const settings = useSettings() const dialog = useDialog() return (input: DirectoryPickerInput) => { @@ -36,10 +33,6 @@ export function useDirectoryPicker() { const cancel = () => { if (!selected) input.onSelect(null) } - if (platform.platform === "desktop" && settings.general.newLayoutDesigns()) { - dialog.show(() => , cancel) - return - } - dialog.show(() => , cancel) + dialog.show(() => , cancel) } }