mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 20:21:39 +03:00
feat(app): open the worktree picker from the new session branch (#60263)
This commit is contained in:
@@ -191,6 +191,59 @@ test("the combined location picker shows checkout branches and chooses the base
|
||||
.toEqual([expect.objectContaining({ body: expect.objectContaining({ text: "Use the selected base" }) })])
|
||||
})
|
||||
|
||||
test("the checkout branch opens the current project's worktree step", async ({ page }) => {
|
||||
const existing = "C:/OpenCode/BranchWorktrees/.worktrees/quiet-river"
|
||||
|
||||
const view = await openDraft(page, {
|
||||
name: "BranchWorktrees",
|
||||
vcs: (directory) => ({ current: directory === existing ? "quiet-river-branch" : "dev", default: "main" }),
|
||||
worktrees: () => [{ directory: "C:/OpenCode/BranchWorktrees" }, { directory: existing, strategy: "git" }],
|
||||
})
|
||||
|
||||
const screen = page.locator('[data-component="new-session"]')
|
||||
const branch = screen.getByRole("button", { name: "dev", exact: true })
|
||||
const dialog = page.getByRole("dialog")
|
||||
const search = dialog.getByRole("combobox", { name: "Search worktrees", exact: true })
|
||||
const options = dialog.getByRole("option")
|
||||
const back = dialog.getByRole("button", { name: "Back to projects", exact: true })
|
||||
|
||||
await branch.click()
|
||||
await expect(search).toBeFocused()
|
||||
await expect(back).toHaveText("Run session in")
|
||||
await expect(options.locator(".command-palette-title")).toHaveText([
|
||||
"BranchWorktrees",
|
||||
"New worktree",
|
||||
"quiet-river",
|
||||
])
|
||||
await expect(options.nth(0)).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
// Stepping back lands on the projects step with the drilled project active, as after drilling in with →.
|
||||
await search.press("Backspace")
|
||||
const projects = dialog.getByRole("combobox", { name: "Search projects", exact: true })
|
||||
await expect(projects).toBeFocused()
|
||||
await expect(back).toHaveCount(0)
|
||||
await expect(options.filter({ hasText: "BranchWorktrees" })).toHaveAttribute("aria-selected", "true")
|
||||
await projects.press("Escape")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(view.editor).toBeFocused()
|
||||
|
||||
// Escape also steps back first, then closes.
|
||||
await branch.focus()
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(search).toBeFocused()
|
||||
await search.press("Escape")
|
||||
await expect(projects).toBeFocused()
|
||||
await projects.press("Escape")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
|
||||
await branch.click()
|
||||
await options.filter({ hasText: "quiet-river" }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(newSessionLocation(page)).toContainText("quiet-river")
|
||||
await expect(screen.getByRole("button", { name: "quiet-river-branch", exact: true })).toBeVisible()
|
||||
await expect(view.editor).toBeFocused()
|
||||
})
|
||||
|
||||
test("a pending branch picker can be dismissed before its results arrive", async ({ page }) => {
|
||||
const listed = Promise.withResolvers<string[]>()
|
||||
|
||||
|
||||
@@ -46,12 +46,14 @@ export function useNewSessionLocationDialog(input: {
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
|
||||
return (options?: { onWorkspace?: (value: string) => void }) => {
|
||||
/** Pass `project` to open straight on that project's worktree step; stepping back still reaches the projects. */
|
||||
return (options?: { project?: PromptProject; onWorkspace?: (value: string) => void }) => {
|
||||
void dialog.show(
|
||||
() => (
|
||||
<NewSessionLocationDialog
|
||||
project={input.project}
|
||||
workspace={input.workspace}
|
||||
drilled={options?.project}
|
||||
onWorkspace={options?.onWorkspace}
|
||||
/>
|
||||
),
|
||||
@@ -63,6 +65,7 @@ export function useNewSessionLocationDialog(input: {
|
||||
function NewSessionLocationDialog(props: {
|
||||
project: PromptProjectController
|
||||
workspace: NewSessionWorkspaceController
|
||||
drilled?: PromptProject
|
||||
onWorkspace?: (value: string) => void
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
@@ -81,7 +84,12 @@ function NewSessionLocationDialog(props: {
|
||||
query: string
|
||||
/** The projects view to restore when stepping back out of a project or the server step. */
|
||||
back?: { query: string; active: string; others: boolean }
|
||||
}>({ query: "", others: false })
|
||||
}>({
|
||||
query: "",
|
||||
others: false,
|
||||
drilled: props.drilled,
|
||||
back: props.drilled ? { query: "", active: projectEntryID(props.drilled), others: false } : undefined,
|
||||
})
|
||||
|
||||
const connection = (project: PromptProject) =>
|
||||
project.server ? servers.list.find((item) => ServerConnection.key(item) === project.server!.key) : serverSDK.server
|
||||
|
||||
@@ -76,6 +76,14 @@ export function NewSessionView(props: {
|
||||
|
||||
const [wordmark, setWordmark] = createStore<WordmarkState>({ mode: "flat", game: false })
|
||||
|
||||
const location = (project?: PromptProject) =>
|
||||
props.location({
|
||||
project,
|
||||
onWorkspace: (value) => {
|
||||
if (value !== "main") setOnboarding("used", true)
|
||||
},
|
||||
})
|
||||
|
||||
// Imperative handles for the brick breaker; they are not reactive state.
|
||||
const handles: BrickBreakerHandles = {}
|
||||
let panel!: HTMLDivElement
|
||||
@@ -115,15 +123,15 @@ export function NewSessionView(props: {
|
||||
project={project()}
|
||||
workspace={props.workspace.bar.visible() ? props.workspace.selection.value() : "main"}
|
||||
root={props.workspace.project.root()}
|
||||
branch={<NewSessionBranch workspace={props.workspace} onDone={props.composer.restoreFocus} />}
|
||||
onboarding={onboardingReady() && !onboarding.used && props.workspace.bar.visible()}
|
||||
onClick={() =>
|
||||
props.location({
|
||||
onWorkspace: (value) => {
|
||||
if (value !== "main") setOnboarding("used", true)
|
||||
},
|
||||
})
|
||||
branch={
|
||||
<NewSessionBranch
|
||||
workspace={props.workspace}
|
||||
onDone={props.composer.restoreFocus}
|
||||
onWorktree={project().id && project().vcs === "git" ? () => location(project()) : undefined}
|
||||
/>
|
||||
}
|
||||
onboarding={onboardingReady() && !onboarding.used && props.workspace.bar.visible()}
|
||||
onClick={() => location()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -9,7 +9,12 @@ import { Typeahead } from "@opencode/ui/typeahead"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { NewSessionWorkspaceController } from "./controller"
|
||||
|
||||
export function NewSessionBranch(props: { workspace: NewSessionWorkspaceController; onDone: () => void }) {
|
||||
export function NewSessionBranch(props: {
|
||||
workspace: NewSessionWorkspaceController
|
||||
onDone: () => void
|
||||
/** Opens the project's worktree picker; the checkout branch is static text without it. */
|
||||
onWorktree?: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
|
||||
const [state, setState] = createStore({ open: false, truncated: false })
|
||||
@@ -64,6 +69,9 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll
|
||||
? language.t("session.new.workspace.fromBranch", { branch: props.workspace.bar.branch() ?? "" })
|
||||
: props.workspace.bar.branch()
|
||||
|
||||
// A truncated name keeps its full-text tooltip; otherwise the clickable checkout branch explains its action.
|
||||
const hint = () => !creating() && !!props.onWorktree && !state.truncated
|
||||
|
||||
const observe = (element: HTMLSpanElement) =>
|
||||
createResizeObserver(element, () => setState("truncated", element.scrollWidth > element.clientWidth))
|
||||
|
||||
@@ -71,20 +79,39 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll
|
||||
<Show when={props.workspace.bar.branch()}>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={label()}
|
||||
disabled={!state.truncated}
|
||||
openDelay={hint() ? 800 : undefined}
|
||||
value={hint() ? language.t("session.location.worktree") : label()}
|
||||
disabled={!state.truncated && !hint()}
|
||||
class="flex min-w-0 max-w-[220px]"
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<Show
|
||||
when={creating()}
|
||||
fallback={
|
||||
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 ps-1.5 pe-2 text-v2-text-text-faint">
|
||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span ref={observe} dir="auto" class="min-w-0 truncate">
|
||||
{label()}
|
||||
</span>
|
||||
</div>
|
||||
<Show
|
||||
when={props.onWorktree}
|
||||
fallback={
|
||||
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 ps-1.5 pe-2 text-v2-text-text-faint">
|
||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span ref={observe} dir="auto" class="min-w-0 truncate">
|
||||
{label()}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
aria-description={language.t("session.location.worktree")}
|
||||
class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 rounded-sm ps-1.5 pe-2 text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-base focus-visible:outline-none"
|
||||
onClick={() => props.onWorktree?.()}
|
||||
>
|
||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span ref={observe} dir="auto" class="min-w-0 truncate">
|
||||
{label()}
|
||||
</span>
|
||||
</button>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Popover
|
||||
|
||||
Reference in New Issue
Block a user