Compare commits

...
3 changed files with 668 additions and 28 deletions
+253 -28
View File
@@ -1,9 +1,10 @@
import { createMemo, createResource, createSignal } from "solid-js"
import path from "path"
import type { SessionInfo } from "@opencode-ai/client"
import { useTerminalDimensions } from "@opentui/solid"
import type { RGBA } from "@opentui/core"
import { dialogWidth, useDialog } from "../ui/dialog"
import { DialogSelect, dialogSelectContentWidth } from "../ui/dialog-select"
import { DialogSelect, dialogSelectContentWidth, type DialogSelectRef } from "../ui/dialog-select"
import { useRoute } from "../context/route"
import { useData } from "../context/data"
import { useClient } from "../context/client"
@@ -15,6 +16,8 @@ import { Locale } from "../util/locale"
import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "../context/runtime"
import { truncateFilePath } from "../ui/file-path"
import { useToast } from "../ui/toast"
import { errorMessage } from "../util/error"
import { stringWidth } from "../util/string-width"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { Spinner } from "./spinner"
@@ -23,7 +26,11 @@ import { projectName } from "../util/project"
const RECENT_LIMIT = 8
export const DialogOpenKey = Symbol("DialogOpen")
type OpenTarget = { type: "session"; sessionID: string } | { type: "project"; directory: string }
type OpenTarget =
| { type: "session"; sessionID: string }
| { type: "project"; directory: string; projectID?: string }
| { type: "browse"; directory: string }
| { type: "new"; projectID: string }
export async function loadDialogOpen(data: ReturnType<typeof useData>, client: ReturnType<typeof useClient>) {
const [, sessions] = await Promise.all([
@@ -43,6 +50,7 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
const client = useClient()
const location = useLocation()
const sessionTabs = useSessionTabs()
const toast = useToast()
const themes = useThemes()
const theme = useTheme("elevated")
const mode = themes.mode
@@ -51,6 +59,29 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
const shortcuts = Keymap.useShortcuts()
const [filter, setFilter] = createSignal("")
const [selectionMoved, setSelectionMoved] = createSignal(false)
const [selected, setSelected] = createSignal<OpenTarget>()
const [directory, setDirectory] = createSignal<string>()
const [projectID, setProjectID] = createSignal<string>()
let select: DialogSelectRef<OpenTarget> | undefined
function browse(next?: string) {
select?.clearFilter()
setSelectionMoved(false)
setSelected(undefined)
setProjectID(undefined)
setDirectory(next)
}
const [worktrees] = createResource(projectID, (projectID) =>
client.api.worktree.list({ projectID }).catch((error: unknown) => {
toast.show({ title: "Loading worktrees failed", message: errorMessage(error), variant: "error" })
return []
}),
)
const [entries] = createResource(directory, (directory) =>
client.api.file
.list({ location: { directory, workspace: location.ref?.workspaceID ?? data.location.default().workspaceID } })
.then((result) => result.data.filter((entry) => entry.type === "directory"))
.catch(() => undefined),
)
const [matched] = createResource(
() => {
@@ -95,15 +126,17 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
const sessionOptions = recent.map((session) => {
const project = data.project.get(session.projectID)
const name = projectName(project)
const basename = path.basename(session.location.directory)
const label = name && name.toLowerCase() !== basename.toLowerCase() ? `${name} · ${basename}` : name || basename
const running =
data.session.status(session.id) === "running" ||
data.session.family(session.id).some((id) => data.session.status(id) === "running")
return {
title: withTimestampedFallback(session),
searchText: session.id,
searchText: `${session.id} ${session.location.directory}`,
value: { type: "session", sessionID: session.id } as OpenTarget,
category: "Sessions",
footer: `${name ? `${Locale.truncate(name, 20)} · ` : ""}${timeAgo(session.time.updated)}`,
footer: `${label ? `${Locale.truncate(label, 30)} · ` : ""}${timeAgo(session.time.updated)}`,
onSelect: () => location.set(session.location),
gutter: running
? (color: RGBA) => <Spinner color={color} />
@@ -113,28 +146,46 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
}
})
const current = location.current?.project
const current = location.ref?.directory ?? location.current?.directory
const seen = new Set<string>()
const projectOptions = data.project
.list()
.filter((project) => {
if (project.canonical === "/" || seen.has(project.canonical)) return false
seen.add(project.canonical)
const projectOptions = [
...data.project
.list()
.flatMap((project) => [project.canonical, ...project.sandboxes].map((directory) => ({ directory, project }))),
...sessions().map((session) => ({
directory: session.location.directory,
project: data.project.get(session.projectID),
})),
]
.filter((item) => {
if (item.directory === "/" || seen.has(item.directory)) return false
seen.add(item.directory)
return true
})
.map((project) => {
const title = projectName(project) ?? project.canonical
const footer = abbreviateHome(project.canonical, paths.home)
.map((item) => {
const title =
item.directory === item.project?.canonical
? (projectName(item.project) ?? path.basename(item.directory))
: path.basename(item.directory)
const footer = abbreviateHome(item.directory, paths.home)
const git = item.project?.vcs === "git"
const width =
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(title)
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) -
stringWidth(title) -
(git ? 2 : 0)
return {
title,
footer: truncateFilePath(footer, width),
searchText: footer,
value: { type: "project", directory: project.canonical } as OpenTarget,
footer: `${truncateFilePath(footer, width)}${git ? " →" : ""}`,
searchText: `${footer} ${projectName(item.project) ?? ""}`,
value: {
type: "project",
directory: item.directory,
...(git ? { projectID: item.project!.id } : {}),
} as OpenTarget,
category: "Projects",
gutter:
project.canonical === current?.canonical
item.directory === current ||
(item.directory === location.current?.project.canonical && (!current || !seen.has(current)))
? () => <text fg={theme.text.formfield.selected}></text>
: undefined,
}
@@ -143,33 +194,207 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
return [...sessionOptions, ...projectOptions]
})
const worktreeOptions = createMemo(() => {
const id = projectID()
if (!id) return []
const project = data.project.get(id)
if (!project) return []
const current = location.ref?.directory ?? location.current?.directory
const directories = [project.canonical, ...(worktrees() ?? []).map((worktree) => worktree.directory)]
return [
...directories
.filter((directory, index) => directories.indexOf(directory) === index)
.toSorted((a, b) => {
if (a === project.canonical) return -1
if (b === project.canonical) return 1
if (a === current) return -1
if (b === current) return 1
return 0
})
.map((directory) => {
const title =
directory === project.canonical
? (projectName(project) ?? path.basename(directory))
: path.basename(directory)
return {
title,
footer: truncateFilePath(
abbreviateHome(directory, paths.home),
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(title),
),
value: { type: "project", directory } as OpenTarget,
category: "Worktrees",
gutter: directory === current ? () => <text fg={theme.text.formfield.selected}></text> : undefined,
}
}),
{
title: "+ New worktree",
value: { type: "new", projectID: id } as OpenTarget,
category: "Worktrees",
},
]
})
const directoryOptions = createMemo(() => {
const current = directory()
if (!current) return []
return [
{
title: "Open this directory",
footer: truncateFilePath(
abbreviateHome(current, paths.home),
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) -
stringWidth("Open this directory"),
),
value: { type: "project", directory: current } as OpenTarget,
category: "Current",
},
...(path.dirname(current) !== current
? [
{
title: "..",
value: { type: "browse", directory: path.dirname(current) } as OpenTarget,
category: "Current",
},
]
: []),
...(entries() ?? [])
.toSorted((a, b) => a.path.localeCompare(b.path))
.map((entry) => ({
title: path.basename(entry.path),
value: { type: "browse", directory: path.resolve(current, entry.path) } as OpenTarget,
category: "Directories",
})),
]
})
return (
<DialogSelect
title="Open"
placeholder="Search sessions and projects…"
options={options()}
current={currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined}
focusCurrent={false}
ref={(value) => (select = value)}
title={projectID() ? "Worktrees" : "Open"}
placeholder={
directory()
? abbreviateHome(directory()!, paths.home)
: projectID()
? "Search worktrees…"
: "Search sessions and projects…"
}
options={directory() ? directoryOptions() : projectID() ? worktreeOptions() : options()}
current={
directory()
? ({ type: "project", directory: directory()! } as OpenTarget)
: projectID() && (location.ref?.directory ?? location.current?.directory)
? ({ type: "project", directory: (location.ref?.directory ?? location.current?.directory)! } as OpenTarget)
: currentSessionID()
? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget)
: undefined
}
focusCurrent={Boolean(directory() || projectID())}
sectionNavigation={true}
preserveSelection={selectionMoved()}
onMove={() => setSelectionMoved(true)}
onMove={(option) => {
setSelectionMoved(true)
setSelected(option.value)
}}
onFilter={setFilter}
bindings={[
{
bind: "ctrl+o",
title: directory() ? "Return to projects" : "Browse directories",
group: "Dialog",
run: () =>
browse(directory() ? undefined : (location.ref?.directory ?? location.current?.directory ?? paths.cwd)),
},
...(!directory() && !projectID()
? [
{
bind: "right",
title: "Show project worktrees",
group: "Dialog",
run: () => {
const target = selected() ?? select?.filtered[0]?.value
if (target?.type !== "project" || !target.projectID) return
select?.clearFilter()
setSelectionMoved(false)
setSelected(undefined)
setProjectID(target.projectID)
},
},
]
: []),
...(projectID()
? [
{
bind: "left",
title: "Return to projects",
group: "Dialog",
run: () => browse(),
},
]
: []),
...(directory() && path.dirname(directory()!) !== directory()
? [
{
bind: "ctrl+u",
title: "Browse parent directory",
group: "Dialog",
run: () => browse(path.dirname(directory()!)),
},
]
: []),
]}
footerHints={[
...(projectID() ? [{ title: "back", label: "←" }] : []),
{ title: directory() ? "back" : "browse directories", label: "ctrl+o" },
...(directory() && path.dirname(directory()!) !== directory() ? [{ title: "parent", label: "ctrl+u" }] : []),
]}
noMatchView={
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>
{shortcuts.get("session.list")
? `No matches · search all sessions with ${shortcuts.get("session.list")}`
: "No matches"}
{directory()
? entries.loading
? "Loading directories"
: "No matching directories"
: shortcuts.get("session.list")
? `No matches · search all sessions with ${shortcuts.get("session.list")}`
: "No matches"}
</text>
</box>
}
onSelect={(option) => {
if (option.value.type === "browse") {
browse(option.value.directory)
return
}
if (option.value.type === "new") {
const id = option.value.projectID
void client.api.worktree
.create({ projectID: id, strategy: "git", directory: path.join(paths.worktree, id.slice(0, 6)) })
.then((created) => {
const target = {
directory: created.directory,
...(location.ref?.workspaceID ? { workspaceID: location.ref.workspaceID } : {}),
}
dialog.clear()
route.navigate({ type: "home", location: target })
location.set(target)
})
.catch((error: unknown) =>
toast.show({ title: "Creating worktree failed", message: errorMessage(error), variant: "error" }),
)
return
}
dialog.clear()
if (option.value.type === "session") {
route.navigate({ type: "session", sessionID: option.value.sessionID })
return
}
const target = { directory: option.value.directory }
const target = {
directory: option.value.directory,
...((directory() || projectID()) && location.ref?.workspaceID
? { workspaceID: location.ref.workspaceID }
: {}),
}
route.navigate({ type: "home", location: target })
location.set(target)
}}
+8
View File
@@ -93,6 +93,7 @@ export function dialogSelectContentWidth(dialogWidth: number) {
export type DialogSelectRef<T> = {
filter: string
filtered: DialogSelectOption<T>[]
clearFilter(): void
moveTo(value: T): void
}
@@ -526,6 +527,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
get filtered() {
return filtered()
},
clearFilter() {
input.value = ""
batch(() => {
setStore("filter", "")
props.onFilter?.("")
})
},
moveTo(value) {
const index = flat().findIndex((option) => isDeepEqual(option.value, value))
if (index >= 0) moveTo(index, true)
@@ -1,5 +1,6 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import path from "path"
import { testRender } from "@opentui/solid"
import { onMount } from "solid-js"
import { DialogOpen, DialogOpenKey, loadDialogOpen } from "../../../src/component/dialog-open"
@@ -131,6 +132,412 @@ test("shows the current project and opens its root", async () => {
}
})
test("includes unique sandbox and recent session directories, including global projects", async () => {
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/project")
return json([
{
id: "proj_current",
canonical: "/tmp/opencode/project",
name: "OpenCode",
time: { created: 1, updated: 2 },
sandboxes: ["/tmp/opencode/feature-branch"],
},
{
id: "global",
canonical: "/",
time: { created: 1, updated: 2 },
sandboxes: [],
},
])
if (url.pathname !== "/api/session") return undefined
return json({
data: [
{
id: "ses_global",
projectID: "global",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 3 },
title: "Standalone session",
location: { directory: "/tmp/standalone-notes" },
},
{
id: "ses_worktree",
projectID: "proj_current",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
title: "Worktree session",
location: { directory: "/tmp/opencode/feature-branch" },
},
],
cursor: {},
})
})
try {
const frame = await fixture.app.waitForFrame(
(value) => value.includes("Standalone session") && value.includes("feature-branch") && value.includes("Projects"),
)
expect(frame).toContain("standalone-notes")
expect(frame).toContain("OpenCode · feature-branch")
expect(frame.match(/\/tmp\/opencode\/feature-branch/g)).toHaveLength(1)
await fixture.app.mockInput.typeText("standalone-notes")
await fixture.app.waitForFrame((value) => value.includes("standalone-notes"))
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home")
expect(fixture.route.data).toEqual({ type: "home", location: { directory: "/tmp/standalone-notes" } })
} finally {
await fixture.dispose()
}
})
test("shows nested Git session directories as projects and in their session footer", async () => {
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/project")
return json([
{
id: "proj_current",
canonical: "/tmp/opencode/project",
name: "OpenCode",
time: { created: 1, updated: 2 },
sandboxes: [],
},
])
if (url.pathname !== "/api/session") return undefined
return json({
data: [
{
id: "ses_dashboard",
projectID: "proj_current",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
title: "Improve dashboard",
location: { directory: "/tmp/opencode/project/packages/dashboard" },
},
],
cursor: {},
})
})
try {
const frame = await fixture.app.waitForFrame(
(value) => value.includes("Improve dashboard") && value.includes("browse directories"),
)
expect(frame).toContain("OpenCode · dashboard")
expect(frame).toContain("/tmp/opencode/project/packages/dashboard")
} finally {
await fixture.dispose()
}
})
test("loads Git worktrees only when drilling into a project or its associated directory", async () => {
const root = path.resolve("/tmp/opencode/project")
const current = path.resolve("/tmp/opencode/current-branch")
const other = path.resolve("/tmp/opencode/other-branch")
const workspaceID = "ws_worktree"
let requests = 0
const fixture = await renderOpen(
(url) => {
if (url.pathname === "/api/project")
return json([
{
id: "proj_git",
canonical: root,
name: "OpenCode",
vcs: "git",
time: { created: 1, updated: 2 },
sandboxes: [current],
},
])
if (url.pathname === "/api/location")
return json({
directory: current,
workspaceID,
project: { id: "proj_git", directory: current, canonical: root },
})
if (url.pathname !== "/api/worktree/proj_git") return undefined
requests++
return json([{ directory: other, strategy: "git" }, { directory: root }, { directory: current, strategy: "git" }])
},
async ({ data, location }) => {
await data.location.sync({ directory: current, workspaceID })
location.set({ directory: current, workspaceID })
},
)
try {
const projects = await fixture.app.waitForFrame(
(frame) => frame.includes("OpenCode") && frame.includes("current-branch") && frame.includes("→"),
)
expect(projects).not.toContain("Browse directories")
expect(requests).toBe(0)
fixture.app.mockInput.pressArrow("right")
const worktrees = await fixture.app.waitForFrame(
(frame) => frame.includes("other-branch") && frame.includes("+ New worktree"),
)
expect(requests).toBe(1)
expect(worktrees).toContain("Worktrees")
expect(worktrees).toContain("●")
expect(worktrees.indexOf("OpenCode")).toBeLessThan(worktrees.indexOf("current-branch"))
expect(worktrees.indexOf("current-branch")).toBeLessThan(worktrees.indexOf("other-branch"))
fixture.app.mockInput.pressArrow("left")
await fixture.app.waitForFrame((frame) => frame.includes("Search sessions and projects"))
await fixture.app.mockInput.typeText("current-branch")
await fixture.app.waitForFrame((frame) => frame.includes("current-branch") && !frame.includes("OpenCode"))
fixture.app.mockInput.pressArrow("right")
await fixture.app.waitForFrame((frame) => frame.includes("other-branch") && frame.includes("+ New worktree"))
expect(requests).toBe(2)
await fixture.app.mockInput.typeText("other-branch")
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home")
expect(fixture.route.data).toEqual({ type: "home", location: { directory: other, workspaceID } })
} finally {
await fixture.dispose()
}
})
test("does not show or trigger worktree navigation for non-Git and global directories", async () => {
const root = path.resolve("/tmp/plain-project")
const standalone = path.resolve("/tmp/standalone-notes")
let requests = 0
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/project")
return json([
{ id: "proj_plain", canonical: root, name: "Plain project", time: { created: 1, updated: 2 }, sandboxes: [] },
{ id: "global", canonical: "/", time: { created: 1, updated: 1 }, sandboxes: [] },
])
if (url.pathname === "/api/session")
return json({
data: [
{
id: "ses_global",
projectID: "global",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
title: "Standalone session",
location: { directory: standalone },
},
],
cursor: {},
})
if (!url.pathname.startsWith("/api/worktree/")) return undefined
requests++
return json([])
})
try {
const frame = await fixture.app.waitForFrame(
(value) => value.includes("Plain project") && value.includes("standalone-notes"),
)
expect(frame).not.toContain("→")
fixture.app.mockInput.pressArrow("down")
fixture.app.mockInput.pressArrow("right")
fixture.app.mockInput.pressArrow("down")
fixture.app.mockInput.pressArrow("right")
await fixture.app.renderOnce()
expect(fixture.app.captureCharFrame()).toContain("Search sessions and projects")
expect(requests).toBe(0)
} finally {
await fixture.dispose()
}
})
test("creates an unnamed Git worktree and opens it in the current workspace", async () => {
const projectID = "proj_git_create"
const root = path.resolve("/tmp/opencode/project")
const created = path.resolve("/tmp/opencode/created-branch")
const workspaceID = "ws_create"
let payload: unknown
const fixture = await renderOpen(
async (url, request) => {
if (url.pathname === "/api/project")
return json([
{
id: projectID,
canonical: root,
name: "OpenCode",
vcs: "git",
time: { created: 1, updated: 2 },
sandboxes: [],
},
])
if (url.pathname === "/api/location")
return json({ directory: root, workspaceID, project: { id: projectID, directory: root, canonical: root } })
if (url.pathname !== `/api/worktree/${projectID}`) return undefined
if (request.method === "GET") return json([{ directory: root }])
payload = await request.json()
return json({ directory: created })
},
async ({ data, location }) => {
await data.location.sync({ directory: root, workspaceID })
location.set({ directory: root, workspaceID })
},
)
try {
await fixture.app.waitForFrame((frame) => frame.includes("OpenCode") && frame.includes("→"))
fixture.app.mockInput.pressArrow("right")
await fixture.app.waitForFrame((frame) => frame.includes("+ New worktree"))
fixture.app.mockInput.pressArrow("down")
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home")
expect(payload).toEqual({
strategy: "git",
directory: path.join("/tmp/opencode", projectID.slice(0, 6)),
})
expect(fixture.route.data).toEqual({ type: "home", location: { directory: created, workspaceID } })
expect(fixture.location.ref).toEqual({ directory: created, workspaceID })
} finally {
await fixture.dispose()
}
})
test("keeps directory browsing in the footer and clears its search when toggling the browser", async () => {
const root = path.resolve(
"/private/var/folders/very-long-temporary-directory/opencode-drive/run-6462634d-8106-4652-ab87-e7e3cf5177ad/files",
)
const fixture = await renderOpen(
(url) => {
if (url.pathname === "/api/location")
return json({ directory: root, project: { id: "proj_current", directory: root, canonical: root } })
if (url.pathname !== "/api/fs/list") return undefined
return json({
location: { directory: root, project: { id: "proj_current", directory: root, canonical: root } },
data: [{ path: "packages", type: "directory" }],
})
},
async ({ data, location }) => {
await data.location.sync({ directory: root })
location.set({ directory: root })
},
)
try {
const initial = await fixture.app.waitForFrame((frame) => frame.includes("browse directories"))
expect(initial).not.toContain("Browse directories")
await fixture.app.mockInput.typeText("missing")
await fixture.app.waitForFrame((frame) => frame.includes("No matches"))
fixture.app.mockInput.pressKey("o", { ctrl: true })
const browser = await fixture.app.waitForFrame(
(frame) => frame.includes("Open this directory") && frame.includes("packages"),
)
expect(browser).not.toContain("No matching directories")
await fixture.app.mockInput.typeText("packages")
fixture.app.mockInput.pressKey("o", { ctrl: true })
const projects = await fixture.app.waitForFrame((frame) => frame.includes("Search sessions and projects"))
expect(projects).toContain("browse directories")
expect(projects).not.toContain("Browse directories")
} finally {
await fixture.dispose()
}
})
test("browses from the current directory and opens an arbitrary child directory", async () => {
const root = path.resolve("/tmp/opencode/project")
const packages = path.resolve(root, "packages")
const untracked = path.resolve(packages, "untracked")
const workspaceID = "ws_browser"
const fixture = await renderOpen(
(url) => {
if (url.pathname === "/api/location")
return json({ directory: root, workspaceID, project: { id: "proj_current", directory: root, canonical: root } })
if (url.pathname !== "/api/fs/list") return undefined
expect(url.searchParams.get("location[workspace]")).toBe(workspaceID)
const current = url.searchParams.get("location[directory]")
return json({
location: {
directory: current,
workspaceID,
project: { id: "proj_current", directory: root, canonical: root },
},
data:
current === root
? [
{ path: "packages", type: "directory" },
{ path: "README.md", type: "file" },
]
: current && path.normalize(current) === path.normalize(packages)
? [{ path: "untracked", type: "directory" }]
: [],
})
},
async ({ data, location }) => {
await data.location.sync({ directory: root, workspaceID })
location.set({ directory: root, workspaceID })
},
)
try {
await fixture.app.waitForFrame((frame) => frame.includes("browse"))
fixture.app.mockInput.pressKey("o", { ctrl: true })
const rootFrame = await fixture.app.waitForFrame(
(frame) => frame.includes("Open this directory") && frame.includes("packages"),
)
expect(rootFrame).not.toContain("README.md")
fixture.app.mockInput.pressArrow("down", { meta: true })
fixture.app.mockInput.pressEnter()
await fixture.app.waitForFrame((frame) => frame.includes("untracked"))
fixture.app.mockInput.pressArrow("down", { meta: true })
fixture.app.mockInput.pressEnter()
await fixture.app.waitForFrame((frame) => frame.includes(untracked))
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home")
expect(fixture.route.data).toEqual({
type: "home",
location: { directory: untracked, workspaceID },
})
} finally {
await fixture.dispose()
}
})
test("navigates to the parent directory and returns to the project picker", async () => {
const root = path.resolve("/tmp/opencode/project")
const parent = path.dirname(root)
const fixture = await renderOpen(
(url) => {
if (url.pathname === "/api/location")
return json({ directory: root, project: { id: "proj_current", directory: root, canonical: root } })
if (url.pathname !== "/api/fs/list") return undefined
const current = url.searchParams.get("location[directory]")
return json({
location: { directory: current, project: { id: "proj_current", directory: root, canonical: root } },
data:
current && path.normalize(current) === path.normalize(parent) ? [{ path: "sibling", type: "directory" }] : [],
})
},
async ({ data, location }) => {
await data.location.sync({ directory: root })
location.set({ directory: root })
},
)
try {
await fixture.app.waitForFrame((frame) => frame.includes("browse"))
fixture.app.mockInput.pressKey("o", { ctrl: true })
await fixture.app.waitForFrame((frame) => frame.includes("Open this directory"))
fixture.app.mockInput.pressKey("u", { ctrl: true })
await fixture.app.waitForFrame((frame) => frame.includes("sibling") && frame.includes(parent))
fixture.app.mockInput.pressKey("o", { ctrl: true })
await fixture.app.waitForFrame((frame) => frame.includes("Search sessions and projects"))
expect(fixture.location.ref).toEqual({ directory: root })
} finally {
await fixture.dispose()
}
})
test("waits for sessions before showing the populated picker", async () => {
let resolveSessions!: (response: Response) => void
const sessions = new Promise<Response>((resolve) => (resolveSessions = resolve))