mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 07:48:24 -04:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00bb8291ea | |||
| 6633529389 | |||
| b6ea0c2209 | |||
| f7b222e95a | |||
| a7288d231e | |||
| f75244d795 | |||
| 1d44d56d9c | |||
| 9adc9bb5df |
@@ -19,6 +19,13 @@
|
||||
- Expose the meaningful state dimensions through story keybindings and list them in `StoryFooter`; include a reset command when combinations can leave the fixture in a confusing state.
|
||||
- Run a specific story with `OPENCODE_STORY=<story-id> bun run dev:live` from the development worktree, and exercise narrow and wide terminal sizes when layout is relevant.
|
||||
|
||||
## TUI Theme Tokens
|
||||
|
||||
- Choose theme tokens by semantic role, not by their current color. Do not use raw `theme.hue` values or borrow an unrelated semantic token to achieve a preferred appearance.
|
||||
- Use `text.feedback` and `background.feedback` only for outcome or status feedback such as errors, warnings, success messages, and informational messages. Use `formfield` states for form-control text, ordinals, and selection markers, and `action` states for actions.
|
||||
- If the theme does not expose a token for the required semantic role, extend the theme schema, defaults, resolution, and types with that role before using it in a component. Do not repurpose the nearest-looking existing token.
|
||||
- When changing the public theme token surface, verify the built-in light and dark defaults and the custom-theme fallback path in addition to the affected TUI component.
|
||||
|
||||
## Branch Names
|
||||
|
||||
Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`.
|
||||
|
||||
@@ -171,7 +171,7 @@ export interface SlotMap {
|
||||
readonly "prompt.footer.file": PromptFooterInput
|
||||
readonly "session.composer.top": { readonly sessionID: string }
|
||||
readonly "sidebar.content": { readonly sessionID: string }
|
||||
readonly "sidebar.footer": Readonly<Record<string, never>>
|
||||
readonly "sidebar.footer": { readonly sessionID: string }
|
||||
}
|
||||
export type SlotPath = keyof SlotMap
|
||||
|
||||
|
||||
@@ -722,7 +722,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
},
|
||||
{
|
||||
name: "open.menu",
|
||||
title: "Open session or project",
|
||||
title: "Open session or worktree",
|
||||
category: "Session",
|
||||
slash: { name: "open", aliases: ["projects", "project"] },
|
||||
run: async () => {
|
||||
@@ -1349,9 +1349,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
width={1}
|
||||
height="100%"
|
||||
backgroundColor={
|
||||
tabsResizeHovered() || tabsResizing()
|
||||
? tabsTheme.background.action.primary.hovered
|
||||
: tabsTheme.background.default
|
||||
tabsResizeHovered() || tabsResizing() ? tabsTheme.background.action.primary.hovered : undefined
|
||||
}
|
||||
/>
|
||||
</box>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
|
||||
import type { WorktreeListOutput } from "@opencode-ai/client"
|
||||
import { useRoute } from "../context/route"
|
||||
import { DialogWorktreeName } from "./dialog-worktree-name"
|
||||
import { Slug } from "@opencode-ai/core/util/slug"
|
||||
|
||||
export type MoveSessionSelection =
|
||||
| { type: "directory"; directory: string; subdirectory: boolean }
|
||||
@@ -27,6 +28,9 @@ type ProjectDirectory = WorktreeListOutput[number]
|
||||
|
||||
type DialogMoveSessionProps = {
|
||||
projectID: string
|
||||
title?: string
|
||||
compact?: boolean
|
||||
randomWorktree?: boolean
|
||||
current?: MoveSessionSelection
|
||||
onSelect: (selection: MoveSessionSelection) => void
|
||||
onCurrentChange?: (selection: MoveSessionSelection) => void
|
||||
@@ -51,7 +55,8 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
const [removing, setRemoving] = createSignal(props.initialRemoving)
|
||||
const [replacementCurrent, setReplacementCurrent] = createSignal<string>()
|
||||
const [loadError, setLoadError] = createSignal<unknown>()
|
||||
onMount(() => dialog.setSize("xlarge"))
|
||||
const randomWorktree = Slug.create()
|
||||
onMount(() => dialog.setSize(props.compact ? "large" : "xlarge"))
|
||||
|
||||
function reopen(initialRemoving?: string) {
|
||||
dialog.replace(() => (
|
||||
@@ -122,8 +127,6 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
if (!a.strategy && !b.strategy) return a.directory.length - b.directory.length
|
||||
return 0
|
||||
})
|
||||
if (roots.length === 0) return []
|
||||
|
||||
const subdirectories = sessionData.session
|
||||
.list()
|
||||
.filter(
|
||||
@@ -150,7 +153,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
})
|
||||
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 suffix =
|
||||
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,
|
||||
}
|
||||
})
|
||||
if (props.randomWorktree) {
|
||||
return [
|
||||
{
|
||||
title: "+ New random worktree",
|
||||
footer: randomWorktree,
|
||||
value: { type: "new", name: randomWorktree },
|
||||
category: "Create",
|
||||
titleWidth,
|
||||
},
|
||||
...options,
|
||||
]
|
||||
}
|
||||
return options
|
||||
})
|
||||
|
||||
const current = createMemo(() => {
|
||||
@@ -300,11 +316,11 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
return (
|
||||
<box minHeight={showError() ? 5 : fullHeight()}>
|
||||
<DialogSelect
|
||||
title="Move session"
|
||||
title={props.title ?? "Move session"}
|
||||
titleView={
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||
Move session
|
||||
{props.title ?? "Move session"}
|
||||
</text>
|
||||
<Show when={working() || directories.loading || loadedProject.loading}>
|
||||
<Spinner />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createMemo, createResource, createSignal } from "solid-js"
|
||||
import type { SessionInfo } from "@opencode-ai/client"
|
||||
import path from "path"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import type { RGBA } from "@opentui/core"
|
||||
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 { Spinner } from "./spinner"
|
||||
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")
|
||||
|
||||
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>) {
|
||||
const [, sessions] = await Promise.all([
|
||||
@@ -49,8 +56,10 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
|
||||
const paths = useTuiPaths()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const toast = useToast()
|
||||
const [filter, setFilter] = createSignal("")
|
||||
const [selectionMoved, setSelectionMoved] = createSignal(false)
|
||||
const [selected, setSelected] = createSignal<OpenTarget>()
|
||||
|
||||
const [matched] = createResource(
|
||||
() => {
|
||||
@@ -84,14 +93,12 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
|
||||
|
||||
const options = createMemo(() => {
|
||||
const tabs = openTabs()
|
||||
// With an empty query the menu shows what is not already one keystroke away: open tabs are
|
||||
// visible in the strip, so recents exclude them. Typing widens the pool to every session so
|
||||
// matching a loaded tab by name still switches to it.
|
||||
const recent = filter().trim()
|
||||
? sessions()
|
||||
: sessions()
|
||||
.filter((session) => !tabs.has(session.id))
|
||||
.slice(0, RECENT_LIMIT)
|
||||
const exact = matched()
|
||||
const recent = sessions()
|
||||
.filter((session) => !tabs.has(session.id))
|
||||
.slice(0, RECENT_LIMIT)
|
||||
.concat(exact && !tabs.has(exact.id) ? [exact] : [])
|
||||
.filter((session, index, items) => items.findIndex((item) => item.id === session.id) === index)
|
||||
const sessionOptions = recent.map((session) => {
|
||||
const project = data.project.get(session.projectID)
|
||||
const name = projectName(project)
|
||||
@@ -102,7 +109,7 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
|
||||
title: withTimestampedFallback(session),
|
||||
searchText: session.id,
|
||||
value: { type: "session", sessionID: session.id } as OpenTarget,
|
||||
category: "Sessions",
|
||||
category: "Recent sessions",
|
||||
footer: `${name ? `${Locale.truncate(name, 20)} · ` : ""}${timeAgo(session.time.updated)}`,
|
||||
onSelect: () => location.set(session.location),
|
||||
gutter: running
|
||||
@@ -113,47 +120,171 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
|
||||
}
|
||||
})
|
||||
|
||||
const current = location.current?.project
|
||||
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)
|
||||
return true
|
||||
const current = location.current
|
||||
const locations = new Map<
|
||||
string,
|
||||
{
|
||||
directory: string
|
||||
title: string
|
||||
updated: number
|
||||
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
|
||||
const footer = abbreviateHome(project.canonical, paths.home)
|
||||
}
|
||||
for (const session of sessions()) {
|
||||
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 =
|
||||
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(title)
|
||||
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(item.title)
|
||||
return {
|
||||
title,
|
||||
title: item.title,
|
||||
footer: truncateFilePath(footer, width),
|
||||
searchText: footer,
|
||||
value: { type: "project", directory: project.canonical } as OpenTarget,
|
||||
category: "Projects",
|
||||
value: {
|
||||
type: "location",
|
||||
directory: item.directory,
|
||||
projectID: item.projectID,
|
||||
vcs: item.vcs,
|
||||
} as OpenTarget,
|
||||
category: item.category,
|
||||
gutter:
|
||||
project.canonical === current?.canonical
|
||||
item.directory === current?.directory || item.directory === current?.project.canonical
|
||||
? () => <text fg={theme.text.formfield.selected}>●</text>
|
||||
: 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 (
|
||||
<DialogSelect
|
||||
title="Open"
|
||||
placeholder="Search sessions and projects…"
|
||||
placeholder="Search sessions and worktrees…"
|
||||
options={options()}
|
||||
current={currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined}
|
||||
focusCurrent={false}
|
||||
sectionNavigation={true}
|
||||
preserveSelection={selectionMoved()}
|
||||
onMove={() => setSelectionMoved(true)}
|
||||
onMove={(option) => {
|
||||
setSelectionMoved(true)
|
||||
setSelected(option.value)
|
||||
}}
|
||||
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={
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.text.subdued}>
|
||||
@@ -169,9 +300,7 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
|
||||
route.navigate({ type: "session", sessionID: option.value.sessionID })
|
||||
return
|
||||
}
|
||||
const target = { directory: option.value.directory }
|
||||
route.navigate({ type: "home", location: target })
|
||||
location.set(target)
|
||||
openLocation(option.value.directory)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
@@ -189,3 +318,7 @@ function timeAgo(timestamp: number) {
|
||||
if (months < 12) return `${months}mo`
|
||||
return `${Math.floor(days / 365)}y`
|
||||
}
|
||||
|
||||
function isDisposableLocation(directory: string) {
|
||||
return /^opencode-(?:test|e2e-project)-/.test(path.basename(directory))
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
import { DialogImagePreview } from "../dialog-image-preview"
|
||||
import { useDirectoryRecents } from "../../prompt/directory-recents"
|
||||
import { directoryRecentValue } from "../../prompt/directory-completion"
|
||||
import { useWorkingDirectoryActions } from "../../ui/working-directory-actions"
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
@@ -1539,21 +1540,25 @@ export function Prompt(props: PromptProps) {
|
||||
const width = dimensions().width < 44 ? dimensions().width - 5 : Math.min(75, dimensions().width - 4) - 5
|
||||
return Locale.takeWidth(value, Math.max(1, width)).trimEnd()
|
||||
})
|
||||
const locationLabel = createMemo(() => {
|
||||
const footerLocation = createMemo(() => {
|
||||
if (!props.sessionID) {
|
||||
// No session yet: show where the next session will be created.
|
||||
const location = currentLocation.ref ?? data.location.default()
|
||||
const directory = abbreviateHome(location.directory, paths.home)
|
||||
const branch = data.location.vcs.info(location)?.branch.current
|
||||
return branch ? `${directory}:${branch}` : directory
|
||||
return currentLocation.ref ?? data.location.default()
|
||||
}
|
||||
if (status() !== "idle") return
|
||||
const location = data.session.get(props.sessionID)?.location
|
||||
return data.session.get(props.sessionID)?.location
|
||||
})
|
||||
const locationLabel = createMemo(() => {
|
||||
const location = footerLocation()
|
||||
if (!location) return
|
||||
const directory = abbreviateHome(location.directory, paths.home)
|
||||
const branch = data.location.vcs.info(location)?.branch.current
|
||||
return branch ? `${directory}:${branch}` : directory
|
||||
})
|
||||
const locationActions = useWorkingDirectoryActions({
|
||||
directory: () => footerLocation()?.directory,
|
||||
onMove: () => void move.open(),
|
||||
})
|
||||
|
||||
const spinnerDef = createMemo(() => {
|
||||
const agent = status() === "running" ? local.agent.current() : local.agent.current()
|
||||
@@ -1874,7 +1879,17 @@ export function Prompt(props: PromptProps) {
|
||||
<Match when={true}>
|
||||
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
|
||||
{(location) => (
|
||||
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
|
||||
<text
|
||||
id="prompt.footer.location"
|
||||
fg={locationActions.hovered() ? theme.text.default : theme.text.subdued}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
onMouseOver={locationActions.onMouseOver}
|
||||
onMouseOut={locationActions.onMouseOut}
|
||||
onMouseUp={locationActions.onMouseUp}
|
||||
>
|
||||
{location()}
|
||||
</text>
|
||||
)}
|
||||
|
||||
@@ -307,6 +307,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const [addHovered, setAddHovered] = createSignal(false)
|
||||
const marquee = createTabMarquee(animations)
|
||||
const hovered = marquee.hovered
|
||||
// OpenTUI captures the first drag target, which may differ from the tab pressed on a fast move.
|
||||
const [dragging, setDragging] = createSignal<string>()
|
||||
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
|
||||
const [contextMenu, setContextMenu] = createSignal<TabContextMenuState>()
|
||||
@@ -343,6 +344,9 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const itemStatus = (tab: SessionTab) => statuses().get(tab.sessionID)!
|
||||
let rail: { screenX: number; screenY: number } | undefined
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
let didDrag = false
|
||||
// A captured drag ends with a synthetic up on its drop target; do not turn that into a click.
|
||||
let suppressClick = false
|
||||
|
||||
createEffect(() => {
|
||||
const pending = preview()
|
||||
@@ -364,6 +368,29 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
}
|
||||
})
|
||||
|
||||
const release = () => {
|
||||
const source = dragging()
|
||||
if (!source) return
|
||||
if (didDrag) suppressClick = true
|
||||
setDragging(undefined)
|
||||
const pending = preview()
|
||||
if (pending?.sessionID === source) tabs.move(pending.sessionID, pending.index)
|
||||
tabs.select(source)
|
||||
}
|
||||
|
||||
const drag = (event: MouseEvent) => {
|
||||
if (!rail) return
|
||||
const source = dragging()
|
||||
if (!source) return
|
||||
didDrag = true
|
||||
const target = Math.max(
|
||||
0,
|
||||
Math.min(tabs.tabs().length - 1, Math.floor((event.y - rail.screenY - 1 + (scroll?.scrollTop ?? 0)) / 3)),
|
||||
)
|
||||
const sourceIndex = items().findIndex((item) => item.sessionID === source)
|
||||
if (target !== sourceIndex && preview()?.index !== target) setPreview({ sessionID: source, index: target })
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
ref={(element) => (rail = element)}
|
||||
@@ -375,6 +402,15 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
paddingTop={1}
|
||||
backgroundColor={theme.background.default}
|
||||
onMouseOut={marquee.leaveHovered}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
release()
|
||||
if (!didDrag) return
|
||||
didDrag = false
|
||||
queueMicrotask(() => (suppressClick = false))
|
||||
}}
|
||||
onMouseDrag={drag}
|
||||
onMouseDragEnd={release}
|
||||
>
|
||||
<scrollbox ref={(element) => (scroll = element)} flexGrow={1} scrollbarOptions={{ visible: false }}>
|
||||
<box flexShrink={0} flexDirection="column" gap={1}>
|
||||
@@ -522,12 +558,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
: color
|
||||
return separator ? tint(faded, pulseBackground(), 0.55) : faded
|
||||
}
|
||||
const release = () => {
|
||||
setDragging(undefined)
|
||||
const pending = preview()
|
||||
if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index)
|
||||
tabs.select(tab.sessionID)
|
||||
}
|
||||
return (
|
||||
<box
|
||||
height={2}
|
||||
@@ -539,6 +569,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
onMouseOut={() => marquee.leave(tab.sessionID)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) {
|
||||
didDrag = false
|
||||
setDragging(undefined)
|
||||
if (!rail) return
|
||||
setContextMenu({
|
||||
@@ -551,26 +582,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
didDrag = false
|
||||
marquee.enter(tab.sessionID, title(), hoveredTitleWidth())
|
||||
setDragging(tab.sessionID)
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
release()
|
||||
}}
|
||||
onMouseDrag={(event) => {
|
||||
if (!rail) return
|
||||
const target = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
tabs.tabs().length - 1,
|
||||
Math.floor((event.y - rail.screenY - 1 + (scroll?.scrollTop ?? 0)) / 3),
|
||||
),
|
||||
)
|
||||
if (target !== index() && preview()?.index !== target)
|
||||
setPreview({ sessionID: tab.sessionID, index: target })
|
||||
}}
|
||||
onMouseDragEnd={release}
|
||||
>
|
||||
<TabPulse
|
||||
top={-1}
|
||||
@@ -677,8 +692,14 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
selectable={false}
|
||||
onMouseOver={() => setCloseHovered(true)}
|
||||
onMouseOut={() => setCloseHovered(false)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON || hovered() !== tab.sessionID) return
|
||||
didDrag = false
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
if (suppressClick) return
|
||||
if (hovered() !== tab.sessionID) return
|
||||
event.stopPropagation()
|
||||
tabs.close(tab.sessionID)
|
||||
@@ -737,6 +758,8 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
onMouseOver={() => setAddHovered(true)}
|
||||
onMouseOut={() => setAddHovered(false)}
|
||||
onMouseDown={(event: MouseEvent) => {
|
||||
didDrag = false
|
||||
setDragging(undefined)
|
||||
if (event.button !== RIGHT_MOUSE_BUTTON) return
|
||||
if (!rail) return
|
||||
setContextMenu({ x: event.x, y: event.y })
|
||||
@@ -745,6 +768,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
}}
|
||||
onMouseUp={(event: MouseEvent) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
if (suppressClick) return
|
||||
if (!newTab()) tabs.add?.()
|
||||
}}
|
||||
>
|
||||
@@ -774,6 +798,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
selectable={false}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
if (suppressClick) return
|
||||
if (!addHovered()) return
|
||||
event.stopPropagation()
|
||||
tabs.close()
|
||||
@@ -803,6 +828,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
const [addHovered, setAddHovered] = createSignal(false)
|
||||
const marquee = createTabMarquee(animations)
|
||||
const hovered = marquee.hovered
|
||||
// OpenTUI captures the first drag target, which may differ from the tab pressed on a fast move.
|
||||
const [dragging, setDragging] = createSignal<string>()
|
||||
// A drag reorders a local preview and persists one move on release instead of writing
|
||||
// per slot crossing; the preview holds after release until the store reflects the move,
|
||||
@@ -810,6 +836,9 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
|
||||
const [contextMenu, setContextMenu] = createSignal<TabContextMenuState>()
|
||||
let strip: { screenX: number; screenY: number } | undefined
|
||||
let didDrag = false
|
||||
// A captured drag ends with a synthetic up on its drop target; do not turn that into a click.
|
||||
let suppressClick = false
|
||||
const hueStep = () => (mode() === "light" ? 800 : 200)
|
||||
const accent = () => theme.hue.accent[hueStep()]
|
||||
const activeNumber = () => theme.hue.interactive[hueStep()]
|
||||
@@ -931,6 +960,29 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
return layout().before + layout().widths.length - 1
|
||||
}
|
||||
|
||||
const release = () => {
|
||||
const source = dragging()
|
||||
if (!source) return
|
||||
if (didDrag) suppressClick = true
|
||||
setDragging(undefined)
|
||||
const pending = preview()
|
||||
if (pending?.sessionID === source) tabs.move(pending.sessionID, pending.index)
|
||||
if (source === NEW_SESSION_TAB.sessionID) return
|
||||
tabs.select(source)
|
||||
}
|
||||
|
||||
const drag = (event: MouseEvent) => {
|
||||
const source = dragging()
|
||||
if (!source || source === NEW_SESSION_TAB.sessionID) return
|
||||
didDrag = true
|
||||
const slot = slotAt(event.x)
|
||||
const target = slot === undefined ? undefined : Math.min(slot, tabs.tabs().length - 1)
|
||||
const sourceIndex = items().findIndex((item) => item.sessionID === source)
|
||||
if (target !== undefined && target !== sourceIndex && preview()?.index !== target) {
|
||||
setPreview({ sessionID: source, index: target })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
ref={(element) => (strip = element)}
|
||||
@@ -940,6 +992,15 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
flexDirection="row"
|
||||
zIndex={1}
|
||||
onMouseOut={marquee.leaveHovered}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
release()
|
||||
if (!didDrag) return
|
||||
didDrag = false
|
||||
queueMicrotask(() => (suppressClick = false))
|
||||
}}
|
||||
onMouseDrag={drag}
|
||||
onMouseDragEnd={release}
|
||||
renderAfter={function (buffer) {
|
||||
const x = Math.max(0, this.screenX)
|
||||
const y = this.screenY + this.height
|
||||
@@ -1051,15 +1112,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
}
|
||||
const bold = () => (selected() || dragged() ? TextAttributes.BOLD : undefined)
|
||||
const closeColor = () => tint(theme.text.subdued, theme.text.default, 0.6)
|
||||
// Releasing a drag (or a plain click) selects the tab, matching browser tab strips and
|
||||
// keeping sloppy clicks indistinguishable from clean ones.
|
||||
const release = () => {
|
||||
setDragging(undefined)
|
||||
const pending = preview()
|
||||
if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index)
|
||||
if (tab === NEW_SESSION_TAB) return
|
||||
tabs.select(tab.sessionID)
|
||||
}
|
||||
return (
|
||||
<box
|
||||
width={width()}
|
||||
@@ -1070,6 +1122,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
onMouseOut={() => marquee.leave(tab.sessionID)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) {
|
||||
didDrag = false
|
||||
setDragging(undefined)
|
||||
setContextMenu({
|
||||
x: event.x,
|
||||
@@ -1081,20 +1134,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
didDrag = false
|
||||
marquee.enter(tab.sessionID, title(), hoveredTitleWidth())
|
||||
setDragging(tab.sessionID)
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
release()
|
||||
}}
|
||||
onMouseDrag={(event) => {
|
||||
if (tab === NEW_SESSION_TAB) return
|
||||
const slot = slotAt(event.x)
|
||||
if (slot !== undefined && slot !== tabNumber() - 1)
|
||||
setPreview({ sessionID: tab.sessionID, index: slot })
|
||||
}}
|
||||
onMouseDragEnd={release}
|
||||
>
|
||||
<TabPulse
|
||||
enabled={animations()}
|
||||
@@ -1140,8 +1183,14 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
selectable={false}
|
||||
onMouseOver={() => setCloseHovered(true)}
|
||||
onMouseOut={() => setCloseHovered(false)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON || hovered() !== tab.sessionID) return
|
||||
didDrag = false
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
if (suppressClick) return
|
||||
// The close mark only renders while hovered; without motion events a click can
|
||||
// land here first, and must select the tab instead of closing it invisibly.
|
||||
if (hovered() !== tab.sessionID) return
|
||||
@@ -1170,6 +1219,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
onMouseOver={() => setAddHovered(true)}
|
||||
onMouseOut={() => setAddHovered(false)}
|
||||
onMouseDown={(event) => {
|
||||
didDrag = false
|
||||
setDragging(undefined)
|
||||
if (event.button !== RIGHT_MOUSE_BUTTON) return
|
||||
setContextMenu({ x: event.x, y: event.y })
|
||||
event.preventDefault()
|
||||
@@ -1177,6 +1228,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
if (suppressClick) return
|
||||
tabs.add?.()
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -96,7 +96,7 @@ export const Definitions = {
|
||||
"session.move": keybind("none", "Move session"),
|
||||
"session.new": keybind("<leader>n", "Create a new session"),
|
||||
"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.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"),
|
||||
|
||||
@@ -20,7 +20,7 @@ function Mcp(props: { context: Plugin.Context }) {
|
||||
|
||||
return (
|
||||
<Show when={list().length}>
|
||||
<box gap={1} flexDirection="row" flexShrink={0}>
|
||||
<box gap={1} flexDirection="row" flexShrink={0} onMouseUp={() => props.context.keymap.dispatch("mcp.list")}>
|
||||
<text fg={props.context.theme.text.default}>
|
||||
<Switch>
|
||||
<Match when={failed()}>
|
||||
@@ -56,7 +56,7 @@ function Plugins(props: { context: Plugin.Context }) {
|
||||
|
||||
return (
|
||||
<Show when={failed()}>
|
||||
<box gap={1} flexDirection="row" flexShrink={0}>
|
||||
<box gap={1} flexDirection="row" flexShrink={0} onMouseUp={() => props.context.keymap.dispatch("plugins.list")}>
|
||||
<text fg={props.context.theme.text.default}>
|
||||
<span style={{ fg: props.context.theme.text.feedback.error.default }}>⊙ </span>
|
||||
{failed()} plugin{failed() === 1 ? "" : "s"} failed
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { FilePath } from "../../ui/file-path"
|
||||
import { useWorkingDirectoryActions } from "../../ui/working-directory-actions"
|
||||
import { usePromptMove } from "../../component/prompt/move"
|
||||
|
||||
function View(props: { context: Plugin.Context }) {
|
||||
function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
const move = usePromptMove({
|
||||
projectID: () => props.context.data.session.get(props.sessionID)?.projectID,
|
||||
sessionID: () => props.sessionID,
|
||||
})
|
||||
const actions = useWorkingDirectoryActions({
|
||||
directory: () => props.context.location?.directory,
|
||||
onMove: () => void move.open(),
|
||||
})
|
||||
const directory = createMemo(() => {
|
||||
if (!props.context.location) return undefined
|
||||
const value = props.context.ui.format.path(props.context.location.directory)
|
||||
@@ -11,7 +21,20 @@ function View(props: { context: Plugin.Context }) {
|
||||
})
|
||||
return (
|
||||
<Show when={directory()}>
|
||||
{(value) => <FilePath value={value()} maxWidth={38} fg={props.context.theme.text.subdued} />}
|
||||
{(value) => (
|
||||
<box
|
||||
id="sidebar.footer.location"
|
||||
onMouseOver={actions.onMouseOver}
|
||||
onMouseOut={actions.onMouseOut}
|
||||
onMouseUp={actions.onMouseUp}
|
||||
>
|
||||
<FilePath
|
||||
value={value()}
|
||||
maxWidth={38}
|
||||
fg={actions.hovered() ? props.context.theme.text.default : props.context.theme.text.subdued}
|
||||
/>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -21,6 +44,9 @@ export default Plugin.define({
|
||||
setup(context) {
|
||||
// Append keeps the path open to additive plugin claims; an external
|
||||
// replace still takes the boundary over.
|
||||
context.ui.slot({ append: "sidebar.footer", render: () => <View context={context} /> })
|
||||
context.ui.slot({
|
||||
append: "sidebar.footer",
|
||||
render: (props) => <View context={context} sessionID={props.sessionID} />,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -904,7 +904,13 @@ export function FormPrompt(props: {
|
||||
<text
|
||||
width={4}
|
||||
flexShrink={0}
|
||||
fg={picked() ? theme.text.feedback.success.default : theme.text.subdued}
|
||||
fg={
|
||||
active()
|
||||
? theme.text.formfield.focused
|
||||
: picked()
|
||||
? theme.text.formfield.selected
|
||||
: theme.text.subdued
|
||||
}
|
||||
>
|
||||
[{picked() ? "✓" : " "}]
|
||||
</text>
|
||||
@@ -914,7 +920,7 @@ export function FormPrompt(props: {
|
||||
</text>
|
||||
</box>
|
||||
<Show when={!multi()}>
|
||||
<text fg={theme.text.feedback.success.default}>{picked() ? " ✓" : ""}</text>
|
||||
<text fg={theme.text.formfield.selected}>{picked() ? " ✓" : ""}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={row.description}>
|
||||
@@ -953,7 +959,13 @@ export function FormPrompt(props: {
|
||||
<text
|
||||
width={4}
|
||||
flexShrink={0}
|
||||
fg={customChecked() ? theme.text.feedback.success.default : theme.text.subdued}
|
||||
fg={
|
||||
other()
|
||||
? theme.text.formfield.focused
|
||||
: customChecked()
|
||||
? theme.text.formfield.selected
|
||||
: theme.text.subdued
|
||||
}
|
||||
>
|
||||
[{customChecked() ? "✓" : " "}]
|
||||
</text>
|
||||
@@ -966,7 +978,7 @@ export function FormPrompt(props: {
|
||||
{input() || "Type your own answer"}
|
||||
</text>
|
||||
<Show when={!multi() && customPicked()}>
|
||||
<text fg={theme.text.feedback.success.default}>✓</text>
|
||||
<text fg={theme.text.formfield.selected}>✓</text>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
</scrollbox>
|
||||
|
||||
<box flexShrink={0} gap={1} paddingTop={1}>
|
||||
<Slot path="sidebar.footer" />
|
||||
<Slot path="sidebar.footer" input={{ sessionID: props.sessionID }} />
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import open from "open"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { useDialog } from "./dialog"
|
||||
import { DialogSelect } from "./dialog-select"
|
||||
import { useToast } from "./toast"
|
||||
|
||||
export function useWorkingDirectoryActions(input: { directory: () => string | undefined; onMove?: () => void }) {
|
||||
const clipboard = useClipboard()
|
||||
const dialog = useDialog()
|
||||
const renderer = useRenderer()
|
||||
const toast = useToast()
|
||||
const [hovered, setHovered] = createSignal(false)
|
||||
|
||||
function openMenu() {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
const directory = input.directory()
|
||||
if (!directory) return
|
||||
dialog.replace(() => (
|
||||
<DialogSelect
|
||||
title="Working directory"
|
||||
renderFilter={false}
|
||||
options={[
|
||||
{
|
||||
title: "Copy path",
|
||||
value: "location.copy",
|
||||
description: directory,
|
||||
onSelect: (dialog) => {
|
||||
void clipboard.write(directory).then(() => {
|
||||
dialog.clear()
|
||||
toast.show({ message: "Path copied to clipboard", variant: "info" })
|
||||
}, toast.error)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Open folder",
|
||||
value: "location.open",
|
||||
description: "in system file manager",
|
||||
onSelect: (dialog) => {
|
||||
dialog.clear()
|
||||
void open(directory).catch(toast.error)
|
||||
},
|
||||
},
|
||||
...(input.onMove
|
||||
? [
|
||||
{
|
||||
title: "Move session",
|
||||
value: "session.move",
|
||||
description: "to another working directory",
|
||||
onSelect: () => void input.onMove?.(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
return {
|
||||
hovered,
|
||||
onMouseOver: () => setHovered(true),
|
||||
onMouseOut: () => setHovered(false),
|
||||
onMouseUp: openMenu,
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ test("finds and opens an exact session ID outside the recent list", async () =>
|
||||
})
|
||||
|
||||
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.waitForFrame((frame) => frame.includes("TUI plugin slot API v2"))
|
||||
|
||||
@@ -158,7 +158,7 @@ test("waits for sessions before showing the populated picker", async () => {
|
||||
|
||||
try {
|
||||
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(
|
||||
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(
|
||||
handler: FetchHandler,
|
||||
beforeOpen?: (contexts: {
|
||||
|
||||
Reference in New Issue
Block a user