mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-16 09:28:27 -04:00
fix(app): use tree directory picker everywhere (#42820)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
d9dfceddf8
commit
467722c2f9
@@ -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)
|
||||
})
|
||||
@@ -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"]'))
|
||||
|
||||
@@ -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<string>()
|
||||
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<string, number>()
|
||||
|
||||
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 (
|
||||
<Dialog title={props.title ?? language.t("command.project.open")}>
|
||||
<List
|
||||
class="px-3"
|
||||
search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }}
|
||||
emptyMessage={language.t("dialog.directory.empty")}
|
||||
loadingMessage={language.t("common.loading")}
|
||||
items={items}
|
||||
key={(x) => 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 (
|
||||
<div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
|
||||
<div class="flex items-center gap-x-3 grow min-w-0">
|
||||
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
|
||||
<div class="flex items-center text-14-regular min-w-0">
|
||||
<span class="text-text-strong whitespace-nowrap">~</span>
|
||||
<span class="text-text-weak whitespace-nowrap">/</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
|
||||
<div class="flex items-center gap-x-3 grow min-w-0">
|
||||
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
|
||||
<div class="flex items-center text-14-regular min-w-0">
|
||||
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
||||
{getDirectory(path)}
|
||||
</span>
|
||||
<span class="text-text-strong whitespace-nowrap">{getFilename(path)}</span>
|
||||
<span class="text-text-weak whitespace-nowrap">/</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</List>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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(() => <DialogSelectDirectoryV2 {...input} onSelect={onSelect} />, cancel)
|
||||
return
|
||||
}
|
||||
dialog.show(() => <DialogSelectDirectory {...input} onSelect={onSelect} />, cancel)
|
||||
dialog.show(() => <DialogSelectDirectoryV2 {...input} onSelect={onSelect} />, cancel)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user