mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
2 Commits
beta
...
open-screen
| Author | SHA1 | Date | |
|---|---|---|---|
| 00bb8291ea | |||
| 6633529389 |
@@ -722,7 +722,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "open.menu",
|
name: "open.menu",
|
||||||
title: "Open session or project",
|
title: "Open session or worktree",
|
||||||
category: "Session",
|
category: "Session",
|
||||||
slash: { name: "open", aliases: ["projects", "project"] },
|
slash: { name: "open", aliases: ["projects", "project"] },
|
||||||
run: async () => {
|
run: async () => {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
|
|||||||
import type { WorktreeListOutput } from "@opencode-ai/client"
|
import type { WorktreeListOutput } from "@opencode-ai/client"
|
||||||
import { useRoute } from "../context/route"
|
import { useRoute } from "../context/route"
|
||||||
import { DialogWorktreeName } from "./dialog-worktree-name"
|
import { DialogWorktreeName } from "./dialog-worktree-name"
|
||||||
|
import { Slug } from "@opencode-ai/core/util/slug"
|
||||||
|
|
||||||
export type MoveSessionSelection =
|
export type MoveSessionSelection =
|
||||||
| { type: "directory"; directory: string; subdirectory: boolean }
|
| { type: "directory"; directory: string; subdirectory: boolean }
|
||||||
@@ -27,6 +28,9 @@ type ProjectDirectory = WorktreeListOutput[number]
|
|||||||
|
|
||||||
type DialogMoveSessionProps = {
|
type DialogMoveSessionProps = {
|
||||||
projectID: string
|
projectID: string
|
||||||
|
title?: string
|
||||||
|
compact?: boolean
|
||||||
|
randomWorktree?: boolean
|
||||||
current?: MoveSessionSelection
|
current?: MoveSessionSelection
|
||||||
onSelect: (selection: MoveSessionSelection) => void
|
onSelect: (selection: MoveSessionSelection) => void
|
||||||
onCurrentChange?: (selection: MoveSessionSelection) => void
|
onCurrentChange?: (selection: MoveSessionSelection) => void
|
||||||
@@ -51,7 +55,8 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
|||||||
const [removing, setRemoving] = createSignal(props.initialRemoving)
|
const [removing, setRemoving] = createSignal(props.initialRemoving)
|
||||||
const [replacementCurrent, setReplacementCurrent] = createSignal<string>()
|
const [replacementCurrent, setReplacementCurrent] = createSignal<string>()
|
||||||
const [loadError, setLoadError] = createSignal<unknown>()
|
const [loadError, setLoadError] = createSignal<unknown>()
|
||||||
onMount(() => dialog.setSize("xlarge"))
|
const randomWorktree = Slug.create()
|
||||||
|
onMount(() => dialog.setSize(props.compact ? "large" : "xlarge"))
|
||||||
|
|
||||||
function reopen(initialRemoving?: string) {
|
function reopen(initialRemoving?: string) {
|
||||||
dialog.replace(() => (
|
dialog.replace(() => (
|
||||||
@@ -122,8 +127,6 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
|||||||
if (!a.strategy && !b.strategy) return a.directory.length - b.directory.length
|
if (!a.strategy && !b.strategy) return a.directory.length - b.directory.length
|
||||||
return 0
|
return 0
|
||||||
})
|
})
|
||||||
if (roots.length === 0) return []
|
|
||||||
|
|
||||||
const subdirectories = sessionData.session
|
const subdirectories = sessionData.session
|
||||||
.list()
|
.list()
|
||||||
.filter(
|
.filter(
|
||||||
@@ -150,7 +153,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
|||||||
})
|
})
|
||||||
const titleWidth = Math.max(1, dialogSelectContentWidth(Math.min(dialogWidth("xlarge"), dimensions().width - 2)))
|
const titleWidth = Math.max(1, dialogSelectContentWidth(Math.min(dialogWidth("xlarge"), dimensions().width - 2)))
|
||||||
|
|
||||||
return list.map((item) => {
|
const options: DialogSelectOption<MoveSessionSelection | undefined>[] = list.map((item) => {
|
||||||
const title = abbreviateHome(item.location, paths.home)
|
const title = abbreviateHome(item.location, paths.home)
|
||||||
const suffix =
|
const suffix =
|
||||||
item.location === item.root.directory ? undefined : path.sep + path.relative(item.root.directory, item.location)
|
item.location === item.root.directory ? undefined : path.sep + path.relative(item.root.directory, item.location)
|
||||||
@@ -183,6 +186,19 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
|||||||
truncateTitle: "left" as const,
|
truncateTitle: "left" as const,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
if (props.randomWorktree) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: "+ New random worktree",
|
||||||
|
footer: randomWorktree,
|
||||||
|
value: { type: "new", name: randomWorktree },
|
||||||
|
category: "Create",
|
||||||
|
titleWidth,
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return options
|
||||||
})
|
})
|
||||||
|
|
||||||
const current = createMemo(() => {
|
const current = createMemo(() => {
|
||||||
@@ -300,11 +316,11 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
|||||||
return (
|
return (
|
||||||
<box minHeight={showError() ? 5 : fullHeight()}>
|
<box minHeight={showError() ? 5 : fullHeight()}>
|
||||||
<DialogSelect
|
<DialogSelect
|
||||||
title="Move session"
|
title={props.title ?? "Move session"}
|
||||||
titleView={
|
titleView={
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||||
Move session
|
{props.title ?? "Move session"}
|
||||||
</text>
|
</text>
|
||||||
<Show when={working() || directories.loading || loadedProject.loading}>
|
<Show when={working() || directories.loading || loadedProject.loading}>
|
||||||
<Spinner />
|
<Spinner />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createMemo, createResource, createSignal } from "solid-js"
|
import { createMemo, createResource, createSignal } from "solid-js"
|
||||||
import type { SessionInfo } from "@opencode-ai/client"
|
import type { SessionInfo } from "@opencode-ai/client"
|
||||||
|
import path from "path"
|
||||||
import { useTerminalDimensions } from "@opentui/solid"
|
import { useTerminalDimensions } from "@opentui/solid"
|
||||||
import type { RGBA } from "@opentui/core"
|
import type { RGBA } from "@opentui/core"
|
||||||
import { dialogWidth, useDialog } from "../ui/dialog"
|
import { dialogWidth, useDialog } from "../ui/dialog"
|
||||||
@@ -19,11 +20,17 @@ import { stringWidth } from "../util/string-width"
|
|||||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||||
import { Spinner } from "./spinner"
|
import { Spinner } from "./spinner"
|
||||||
import { projectName } from "../util/project"
|
import { projectName } from "../util/project"
|
||||||
|
import { DialogMoveSession } from "./dialog-move-session"
|
||||||
|
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||||
|
import { useToast } from "../ui/toast"
|
||||||
|
import { errorMessage } from "../util/error"
|
||||||
|
|
||||||
const RECENT_LIMIT = 8
|
const RECENT_LIMIT = 3
|
||||||
export const DialogOpenKey = Symbol("DialogOpen")
|
export const DialogOpenKey = Symbol("DialogOpen")
|
||||||
|
|
||||||
type OpenTarget = { type: "session"; sessionID: string } | { type: "project"; directory: string }
|
type OpenTarget =
|
||||||
|
| { type: "session"; sessionID: string }
|
||||||
|
| { type: "location"; directory: string; projectID?: string; vcs?: "git" | "hg" }
|
||||||
|
|
||||||
export async function loadDialogOpen(data: ReturnType<typeof useData>, client: ReturnType<typeof useClient>) {
|
export async function loadDialogOpen(data: ReturnType<typeof useData>, client: ReturnType<typeof useClient>) {
|
||||||
const [, sessions] = await Promise.all([
|
const [, sessions] = await Promise.all([
|
||||||
@@ -49,8 +56,10 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
|
|||||||
const paths = useTuiPaths()
|
const paths = useTuiPaths()
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
const shortcuts = Keymap.useShortcuts()
|
const shortcuts = Keymap.useShortcuts()
|
||||||
|
const toast = useToast()
|
||||||
const [filter, setFilter] = createSignal("")
|
const [filter, setFilter] = createSignal("")
|
||||||
const [selectionMoved, setSelectionMoved] = createSignal(false)
|
const [selectionMoved, setSelectionMoved] = createSignal(false)
|
||||||
|
const [selected, setSelected] = createSignal<OpenTarget>()
|
||||||
|
|
||||||
const [matched] = createResource(
|
const [matched] = createResource(
|
||||||
() => {
|
() => {
|
||||||
@@ -84,14 +93,12 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
|
|||||||
|
|
||||||
const options = createMemo(() => {
|
const options = createMemo(() => {
|
||||||
const tabs = openTabs()
|
const tabs = openTabs()
|
||||||
// With an empty query the menu shows what is not already one keystroke away: open tabs are
|
const exact = matched()
|
||||||
// visible in the strip, so recents exclude them. Typing widens the pool to every session so
|
const recent = sessions()
|
||||||
// matching a loaded tab by name still switches to it.
|
.filter((session) => !tabs.has(session.id))
|
||||||
const recent = filter().trim()
|
.slice(0, RECENT_LIMIT)
|
||||||
? sessions()
|
.concat(exact && !tabs.has(exact.id) ? [exact] : [])
|
||||||
: sessions()
|
.filter((session, index, items) => items.findIndex((item) => item.id === session.id) === index)
|
||||||
.filter((session) => !tabs.has(session.id))
|
|
||||||
.slice(0, RECENT_LIMIT)
|
|
||||||
const sessionOptions = recent.map((session) => {
|
const sessionOptions = recent.map((session) => {
|
||||||
const project = data.project.get(session.projectID)
|
const project = data.project.get(session.projectID)
|
||||||
const name = projectName(project)
|
const name = projectName(project)
|
||||||
@@ -102,7 +109,7 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
|
|||||||
title: withTimestampedFallback(session),
|
title: withTimestampedFallback(session),
|
||||||
searchText: session.id,
|
searchText: session.id,
|
||||||
value: { type: "session", sessionID: session.id } as OpenTarget,
|
value: { type: "session", sessionID: session.id } as OpenTarget,
|
||||||
category: "Sessions",
|
category: "Recent sessions",
|
||||||
footer: `${name ? `${Locale.truncate(name, 20)} · ` : ""}${timeAgo(session.time.updated)}`,
|
footer: `${name ? `${Locale.truncate(name, 20)} · ` : ""}${timeAgo(session.time.updated)}`,
|
||||||
onSelect: () => location.set(session.location),
|
onSelect: () => location.set(session.location),
|
||||||
gutter: running
|
gutter: running
|
||||||
@@ -113,47 +120,171 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const current = location.current?.project
|
const current = location.current
|
||||||
const seen = new Set<string>()
|
const locations = new Map<
|
||||||
const projectOptions = data.project
|
string,
|
||||||
.list()
|
{
|
||||||
.filter((project) => {
|
directory: string
|
||||||
if (project.canonical === "/" || seen.has(project.canonical)) return false
|
title: string
|
||||||
seen.add(project.canonical)
|
updated: number
|
||||||
return true
|
category: "Recent worktrees" | "Recent folders"
|
||||||
|
projectID?: string
|
||||||
|
vcs?: "git" | "hg"
|
||||||
|
}
|
||||||
|
>()
|
||||||
|
for (const project of data.project.list()) {
|
||||||
|
if (project.canonical === "/" || isDisposableLocation(project.canonical) || locations.has(project.canonical))
|
||||||
|
continue
|
||||||
|
locations.set(project.canonical, {
|
||||||
|
directory: project.canonical,
|
||||||
|
title: projectName(project) ?? project.canonical,
|
||||||
|
updated: project.time.updated,
|
||||||
|
category: "Recent worktrees",
|
||||||
|
projectID: project.id,
|
||||||
|
vcs: project.vcs,
|
||||||
})
|
})
|
||||||
.map((project) => {
|
}
|
||||||
const title = projectName(project) ?? project.canonical
|
for (const session of sessions()) {
|
||||||
const footer = abbreviateHome(project.canonical, paths.home)
|
const project = data.project.get(session.projectID)
|
||||||
|
const managedProject = project && project.canonical !== "/" ? project : undefined
|
||||||
|
const worktree = managedProject && !session.subpath
|
||||||
|
const directory = worktree ? session.location.directory : (managedProject?.canonical ?? session.location.directory)
|
||||||
|
if (isDisposableLocation(directory)) continue
|
||||||
|
const existing = locations.get(directory)
|
||||||
|
if (existing) {
|
||||||
|
existing.updated = Math.max(existing.updated, session.time.updated)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
locations.set(directory, {
|
||||||
|
directory,
|
||||||
|
title:
|
||||||
|
worktree && directory !== managedProject.canonical
|
||||||
|
? [projectName(managedProject), path.basename(directory)].filter(Boolean).join(" · ")
|
||||||
|
: (projectName(project) ?? (path.basename(directory) || directory)),
|
||||||
|
updated: session.time.updated,
|
||||||
|
category: managedProject ? "Recent worktrees" : "Recent folders",
|
||||||
|
projectID: managedProject?.id,
|
||||||
|
vcs: managedProject?.vcs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const locationOptions = [...locations.values()]
|
||||||
|
.toSorted((a, b) => b.updated - a.updated)
|
||||||
|
.map((item) => {
|
||||||
|
const footer = abbreviateHome(item.directory, paths.home)
|
||||||
const width =
|
const width =
|
||||||
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(title)
|
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(item.title)
|
||||||
return {
|
return {
|
||||||
title,
|
title: item.title,
|
||||||
footer: truncateFilePath(footer, width),
|
footer: truncateFilePath(footer, width),
|
||||||
searchText: footer,
|
searchText: footer,
|
||||||
value: { type: "project", directory: project.canonical } as OpenTarget,
|
value: {
|
||||||
category: "Projects",
|
type: "location",
|
||||||
|
directory: item.directory,
|
||||||
|
projectID: item.projectID,
|
||||||
|
vcs: item.vcs,
|
||||||
|
} as OpenTarget,
|
||||||
|
category: item.category,
|
||||||
gutter:
|
gutter:
|
||||||
project.canonical === current?.canonical
|
item.directory === current?.directory || item.directory === current?.project.canonical
|
||||||
? () => <text fg={theme.text.formfield.selected}>●</text>
|
? () => <text fg={theme.text.formfield.selected}>●</text>
|
||||||
: undefined,
|
: undefined,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return [...sessionOptions, ...projectOptions]
|
return [
|
||||||
|
...sessionOptions,
|
||||||
|
...locationOptions.filter((item) => item.category === "Recent worktrees"),
|
||||||
|
...locationOptions.filter((item) => item.category === "Recent folders"),
|
||||||
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function openLocation(directory: string) {
|
||||||
|
dialog.clear()
|
||||||
|
const target = { directory }
|
||||||
|
route.navigate({ type: "home", location: target })
|
||||||
|
location.set(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openWorktrees(target: OpenTarget | undefined) {
|
||||||
|
if (target?.type !== "location" || !target.projectID || target.vcs !== "git") return
|
||||||
|
const projectID = target.projectID
|
||||||
|
dialog.replace(() => (
|
||||||
|
<DialogMoveSession
|
||||||
|
projectID={projectID}
|
||||||
|
title="Open worktree"
|
||||||
|
compact={true}
|
||||||
|
randomWorktree={true}
|
||||||
|
onSelect={(selection) => {
|
||||||
|
if (selection.type === "directory") {
|
||||||
|
openLocation(selection.directory)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void client.api.worktree
|
||||||
|
.create({
|
||||||
|
projectID,
|
||||||
|
strategy: "git",
|
||||||
|
directory: path.join(paths.worktree, projectID.slice(0, 6)),
|
||||||
|
name: selection.name,
|
||||||
|
})
|
||||||
|
.then((result) => openLocation(result.directory))
|
||||||
|
.catch((error) =>
|
||||||
|
toast.show({ variant: "error", title: "Creating worktree failed", message: errorMessage(error) }),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
function browse() {
|
||||||
|
dialog.replace(() => (
|
||||||
|
<DialogPrompt
|
||||||
|
title="Open folder"
|
||||||
|
placeholder="Absolute path"
|
||||||
|
value={location.current?.directory ?? paths.home}
|
||||||
|
onConfirm={(value) => {
|
||||||
|
const directory = value.trim().replace(/^~(?=$|[\\/])/, paths.home)
|
||||||
|
if (!directory) return
|
||||||
|
void client.api.file
|
||||||
|
.list({ location: { directory } })
|
||||||
|
.then(() => openLocation(directory))
|
||||||
|
.catch((error) =>
|
||||||
|
toast.show({ variant: "error", title: "Could not open folder", message: errorMessage(error) }),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogSelect
|
<DialogSelect
|
||||||
title="Open"
|
title="Open"
|
||||||
placeholder="Search sessions and projects…"
|
placeholder="Search sessions and worktrees…"
|
||||||
options={options()}
|
options={options()}
|
||||||
current={currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined}
|
current={currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined}
|
||||||
focusCurrent={false}
|
focusCurrent={false}
|
||||||
sectionNavigation={true}
|
sectionNavigation={true}
|
||||||
preserveSelection={selectionMoved()}
|
preserveSelection={selectionMoved()}
|
||||||
onMove={() => setSelectionMoved(true)}
|
onMove={(option) => {
|
||||||
|
setSelectionMoved(true)
|
||||||
|
setSelected(option.value)
|
||||||
|
}}
|
||||||
onFilter={setFilter}
|
onFilter={setFilter}
|
||||||
|
footer={
|
||||||
|
<text fg={theme.text.default}>
|
||||||
|
enter <span style={{ fg: theme.text.subdued }}>open</span>
|
||||||
|
{" "}→ <span style={{ fg: theme.text.subdued }}>worktrees</span>
|
||||||
|
{" "}/ <span style={{ fg: theme.text.subdued }}>browse</span>
|
||||||
|
</text>
|
||||||
|
}
|
||||||
|
bindings={[
|
||||||
|
{
|
||||||
|
bind: "right",
|
||||||
|
title: "Open worktrees",
|
||||||
|
group: "Dialog",
|
||||||
|
run: () => openWorktrees(selected() ?? options()[0]?.value),
|
||||||
|
},
|
||||||
|
{ bind: "/", title: "Browse folders", group: "Dialog", run: browse },
|
||||||
|
]}
|
||||||
noMatchView={
|
noMatchView={
|
||||||
<box paddingLeft={4} paddingRight={4}>
|
<box paddingLeft={4} paddingRight={4}>
|
||||||
<text fg={theme.text.subdued}>
|
<text fg={theme.text.subdued}>
|
||||||
@@ -169,9 +300,7 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
|
|||||||
route.navigate({ type: "session", sessionID: option.value.sessionID })
|
route.navigate({ type: "session", sessionID: option.value.sessionID })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const target = { directory: option.value.directory }
|
openLocation(option.value.directory)
|
||||||
route.navigate({ type: "home", location: target })
|
|
||||||
location.set(target)
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -189,3 +318,7 @@ function timeAgo(timestamp: number) {
|
|||||||
if (months < 12) return `${months}mo`
|
if (months < 12) return `${months}mo`
|
||||||
return `${Math.floor(days / 365)}y`
|
return `${Math.floor(days / 365)}y`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDisposableLocation(directory: string) {
|
||||||
|
return /^opencode-(?:test|e2e-project)-/.test(path.basename(directory))
|
||||||
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export const Definitions = {
|
|||||||
"session.move": keybind("none", "Move session"),
|
"session.move": keybind("none", "Move session"),
|
||||||
"session.new": keybind("<leader>n", "Create a new session"),
|
"session.new": keybind("<leader>n", "Create a new session"),
|
||||||
"session.list": keybind("<leader>l", "List all sessions"),
|
"session.list": keybind("<leader>l", "List all sessions"),
|
||||||
"open.menu": keybind("ctrl+o", "Open recent sessions and projects"),
|
"open.menu": keybind("ctrl+o", "Open recent sessions and worktrees"),
|
||||||
"session.tab.next": keybind("ctrl+tab,alt+down", "Switch to next open session tab"),
|
"session.tab.next": keybind("ctrl+tab,alt+down", "Switch to next open session tab"),
|
||||||
"session.tab.previous": keybind("ctrl+shift+tab,alt+up", "Switch to previous open session tab"),
|
"session.tab.previous": keybind("ctrl+shift+tab,alt+up", "Switch to previous open session tab"),
|
||||||
"session.tab.history.back": keybind("none", "Go back in session tab history"),
|
"session.tab.history.back": keybind("none", "Go back in session tab history"),
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ test("finds and opens an exact session ID outside the recent list", async () =>
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fixture.app.waitForFrame((frame) => frame.includes("Search sessions and projects"))
|
await fixture.app.waitForFrame((frame) => frame.includes("Search sessions and worktrees"))
|
||||||
await fixture.app.mockInput.typeText(sessionID)
|
await fixture.app.mockInput.typeText(sessionID)
|
||||||
await fixture.app.waitForFrame((frame) => frame.includes("TUI plugin slot API v2"))
|
await fixture.app.waitForFrame((frame) => frame.includes("TUI plugin slot API v2"))
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ test("waits for sessions before showing the populated picker", async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await fixture.app.renderOnce()
|
await fixture.app.renderOnce()
|
||||||
expect(fixture.app.captureCharFrame()).not.toContain("Search sessions and projects")
|
expect(fixture.app.captureCharFrame()).not.toContain("Search sessions and worktrees")
|
||||||
|
|
||||||
resolveSessions(
|
resolveSessions(
|
||||||
json({
|
json({
|
||||||
@@ -276,6 +276,148 @@ test("option arrows stay in the only visible section", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("search keeps sessions limited to recents", async () => {
|
||||||
|
const fixture = await renderOpen((url) => {
|
||||||
|
if (url.pathname === "/api/project") return json([])
|
||||||
|
if (url.pathname !== "/api/session") return undefined
|
||||||
|
return json({
|
||||||
|
data: Array.from({ length: 9 }, (_, index) => ({
|
||||||
|
id: `ses_${index}`,
|
||||||
|
projectID: `proj_${index}`,
|
||||||
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: { created: 10 - index, updated: 10 - index },
|
||||||
|
title: index === 8 ? "Ancient hidden session" : `Recent session ${index}`,
|
||||||
|
location: { directory: `/tmp/location-${index}` },
|
||||||
|
})),
|
||||||
|
cursor: {},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fixture.app.waitForFrame((frame) => frame.includes("Recent sessions"))
|
||||||
|
await fixture.app.mockInput.typeText("Ancient hidden session")
|
||||||
|
await fixture.app.waitForFrame(
|
||||||
|
(frame) => frame.includes("No matches") && frame.split("Ancient hidden session").length === 2,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
await fixture.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("opens worktrees with right and creates a random worktree", async () => {
|
||||||
|
const fixture = await renderOpen((url) => {
|
||||||
|
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
|
||||||
|
if (url.pathname !== "/api/project") return undefined
|
||||||
|
return json([
|
||||||
|
{
|
||||||
|
id: "proj_test",
|
||||||
|
canonical: "/tmp/opencode",
|
||||||
|
vcs: "git",
|
||||||
|
name: "OpenCode",
|
||||||
|
time: { created: 1, updated: 2 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fixture.app.waitForFrame((frame) => frame.includes("OpenCode") && frame.includes("Recent worktrees"))
|
||||||
|
fixture.app.mockInput.pressArrow("right")
|
||||||
|
await fixture.app.waitForFrame((frame) => frame.includes("Open worktree") && frame.includes("New random worktree"))
|
||||||
|
fixture.app.mockInput.pressEnter()
|
||||||
|
await fixture.app.waitFor(() => fixture.route.data.type === "home")
|
||||||
|
expect(fixture.route.data).toEqual({ type: "home", location: { directory: "/tmp/opencode/created" } })
|
||||||
|
} finally {
|
||||||
|
await fixture.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("opens the folder prompt with slash", async () => {
|
||||||
|
const fixture = await renderOpen((url) => {
|
||||||
|
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
|
||||||
|
if (url.pathname === "/api/project") return json([])
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fixture.app.waitForFrame((frame) => frame.includes("Search sessions and worktrees"))
|
||||||
|
await fixture.app.mockInput.typeText("/")
|
||||||
|
await fixture.app.waitForFrame((frame) => frame.includes("Open folder") && frame.includes("/tmp/opencode/home"))
|
||||||
|
} finally {
|
||||||
|
await fixture.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("hides disposable test projects from recent locations", async () => {
|
||||||
|
const fixture = await renderOpen((url) => {
|
||||||
|
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
|
||||||
|
if (url.pathname !== "/api/project") return undefined
|
||||||
|
return json([
|
||||||
|
{
|
||||||
|
id: "proj_real",
|
||||||
|
canonical: "/workspace/opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
time: { created: 1, updated: 2 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "proj_test",
|
||||||
|
canonical: "/tmp/opencode-e2e-project-BX8Aug",
|
||||||
|
time: { created: 1, updated: 3 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const frame = await fixture.app.waitForFrame((value) => value.includes("OpenCode"))
|
||||||
|
expect(frame).not.toContain("opencode-e2e-project-BX8Aug")
|
||||||
|
} finally {
|
||||||
|
await fixture.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("shows a session checkout as a recent worktree", async () => {
|
||||||
|
const fixture = await renderOpen((url) => {
|
||||||
|
if (url.pathname === "/api/session")
|
||||||
|
return json({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "ses_worktree",
|
||||||
|
projectID: "proj_opencode",
|
||||||
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: { created: 2, updated: 3 },
|
||||||
|
title: "Refine Open screen",
|
||||||
|
location: { directory: "/workspace/worktrees/open-screen" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
cursor: {},
|
||||||
|
})
|
||||||
|
if (url.pathname !== "/api/project") return undefined
|
||||||
|
return json([
|
||||||
|
{
|
||||||
|
id: "proj_opencode",
|
||||||
|
canonical: "/workspace/opencode",
|
||||||
|
vcs: "git",
|
||||||
|
name: "OpenCode",
|
||||||
|
time: { created: 1, updated: 2 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const frame = await fixture.app.waitForFrame(
|
||||||
|
(value) => value.includes("Recent worktrees") && value.includes("OpenCode · open-screen"),
|
||||||
|
)
|
||||||
|
expect(frame).toContain("/workspace/worktrees/open-screen")
|
||||||
|
} finally {
|
||||||
|
await fixture.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
async function renderOpen(
|
async function renderOpen(
|
||||||
handler: FetchHandler,
|
handler: FetchHandler,
|
||||||
beforeOpen?: (contexts: {
|
beforeOpen?: (contexts: {
|
||||||
|
|||||||
Reference in New Issue
Block a user