Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions packages/app/e2e/regression/session-summary.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,36 @@ test("the project menu loads changes on demand and opens review", async ({ page
await expect(page.locator("#review-panel")).toBeVisible()
})

test("new worktree stays disabled while its first creation is pending", async ({ page }) => {
const creates: unknown[] = []
await openSession(page, {
name: "PendingWorktree",
onWorktreeCreate: (input) => {
creates.push(input)

return new Promise<never>(() => {})
},
})
const trigger = page.locator('[data-slot="session-location-trigger"]')
const card = page.locator('[data-slot="session-location-card"]')

const openDestinations = async () => {
await trigger.click()
await card.getByRole("menuitem", { name: "Local repository", exact: true }).click()

return page.getByRole("menu", { name: "Local repository", exact: true })
}

const destinations = await openDestinations()
await destinations.getByRole("menuitem", { name: "New worktree", exact: true }).click()
await expect(card).toBeHidden()
await expect.poll(() => creates.length).toBe(1)

const reopened = await openDestinations()
await expect(reopened.getByRole("menuitem", { name: "New worktree", exact: true })).toBeDisabled()
expect(creates).toHaveLength(1)
})

test("a session in a worktree subfolder names its worktree and lists cached worktrees at once", async ({ page }) => {
const root = "C:/OpenCode/SmokeWorktrees"
await mockStressTimeline(page, {
Expand Down
23 changes: 23 additions & 0 deletions packages/gui-plugins/src/details/location-move.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { createStore } from "solid-js/store"

// The location card unmounts when it closes, so a move in flight must outlive the menu that started it.
export function createLocationMoves() {
const [state, setState] = createStore<{ pending: Record<string, true | undefined> }>({ pending: {} })

return {
pending(sessionKey: string) {
return !!state.pending[sessionKey]
},
begin(sessionKey: string) {
if (state.pending[sessionKey]) return false
setState("pending", sessionKey, true)

return true
},
end(sessionKey: string) {
setState("pending", sessionKey, undefined)
},
}
}

export type LocationMoves = ReturnType<typeof createLocationMoves>
13 changes: 11 additions & 2 deletions packages/gui-plugins/src/details/location-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ import { Menu } from "@opencode/ui/menu"
import { Show } from "solid-js"
import type { BuiltinSlotMap } from "@opencode/plugin/gui/builtin"
import { SessionWorkspaceMenu } from "./workspace-menu"
import type { LocationMoves } from "./location-move"

export default function SessionLocationWorkspace(props: BuiltinSlotMap["session.location.workspace"]) {
export default function SessionLocationWorkspace(
props: BuiltinSlotMap["session.location.workspace"] & { moves: LocationMoves },
) {
const location = () => (
<>
<Icon name={props.icon} class="text-v2-icon-icon-muted" />
Expand All @@ -21,7 +24,13 @@ export default function SessionLocationWorkspace(props: BuiltinSlotMap["session.
when={!props.session.server.data.session.get(props.session.id)?.parentID}
fallback={<Menu.Item disabled>{location()}</Menu.Item>}
>
<SessionWorkspaceMenu sub session={props.session} project={project()} directory={props.session.directory}>
<SessionWorkspaceMenu
sub
session={props.session}
project={project()}
directory={props.session.directory}
moves={props.moves}
>
{location()}
</SessionWorkspaceMenu>
</Show>
Expand Down
4 changes: 3 additions & 1 deletion packages/gui-plugins/src/details/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { Panel, Slot, Style, usePanel, type Plugin } from "@opencode/plugin/gui"
import type Details from "./index"
import { DetailsHeader, type DetailsMenus } from "./popover"
import { BuiltinSlot, onIdle } from "@opencode/plugin/gui/builtin"
import { createLocationMoves } from "./location-move"

const setup: Plugin.Setup<typeof Details> = (ctx) => {
const sessions = ctx.sessions

const menus: DetailsMenus = new Set()
const moves = createLocationMoves()

const DetailsPanel = lazy(() =>
Promise.all([import("./panel"), import("./details.css?inline")]).then(([panel, css]) => {
Expand Down Expand Up @@ -50,7 +52,7 @@ const setup: Plugin.Setup<typeof Details> = (ctx) => {
at: "session.location.workspace",
render: (input) => (
<Suspense>
<LocationWorkspace {...input} />
<LocationWorkspace {...input} moves={moves} />
</Suspense>
),
})
Expand Down
17 changes: 9 additions & 8 deletions packages/gui-plugins/src/details/workspace-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import { createStore } from "solid-js/store"
import { For, onCleanup, Show, type ComponentProps, type JSX } from "solid-js"
import { createKeyed, usePlugin, type Project, type MountedSession } from "@opencode/plugin/gui"
import { workspaceDirectories } from "./paths"
import type { LocationMoves } from "./location-move"

export function SessionWorkspaceMenu(props: {
session: MountedSession
project: Project
directory: string
moves: LocationMoves
placement?: ComponentProps<typeof Menu>["placement"]
class?: string
/** Renders as a submenu of an enclosing `Menu`. */
Expand All @@ -20,11 +22,11 @@ export function SessionWorkspaceMenu(props: {
const ctx = usePlugin()
const data = () => props.session.server.data

const [store, setStore] = createStore<{ selected: string | undefined; directories: string[] }>({
selected: undefined,
const [store, setStore] = createStore<{ directories: string[] }>({
directories: workspaceDirectories(props.project),
})

const pending = () => props.moves.pending(props.session.key)
const blocked = () => data().session.status(props.session.id) === "running"
const currentWorkspace = () => store.directories.find((workspace) => containsDirectory(workspace, props.directory))

Expand Down Expand Up @@ -73,10 +75,9 @@ export function SessionWorkspaceMenu(props: {
}

const move = async (selection: "create" | string) => {
if (store.selected || blocked()) return
if (blocked() || !props.moves.begin(props.session.key)) return
const client = props.session.server.client
const sessionID = props.session.id
setStore("selected", selection)

try {
const destination = selection === "create" ? await createWorktree(client, props.directory) : selection
Expand All @@ -91,20 +92,20 @@ export function SessionWorkspaceMenu(props: {
description: error instanceof Error ? error.message : ctx.t("common.requestFailed"),
})
} finally {
setStore("selected", undefined)
props.moves.end(props.session.key)
}
}

const items = () => (
<Menu.Group>
<Menu.GroupLabel>{ctx.t("move.menu")}</Menu.GroupLabel>
<Show when={comparablePath(props.directory) !== comparablePath(props.project.worktree)}>
<Menu.Item disabled={!!store.selected || blocked()} onSelect={() => void move(props.project.worktree)}>
<Menu.Item disabled={pending() || blocked()} onSelect={() => void move(props.project.worktree)}>
<Icon name="monitor" class="text-v2-icon-icon-muted" />
{ctx.t("workspace.local")}
</Menu.Item>
</Show>
<Menu.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
<Menu.Item disabled={pending() || blocked()} onSelect={() => void move("create")}>
<Icon name="plus" class="text-v2-icon-icon-muted" />
{ctx.t("workspace.new")}
</Menu.Item>
Expand All @@ -118,7 +119,7 @@ export function SessionWorkspaceMenu(props: {
<Menu.SubContent class="max-h-[66.667dvh] w-[200px] overflow-y-auto !pb-0 [&>[data-component=menu-v2-item]:last-child]:mb-0.5 [@media(max-height:600px)]:max-h-[calc(100dvh-48px)]">
<For each={workspaces()}>
{(workspace) => (
<Menu.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
<Menu.Item disabled={pending() || blocked()} onSelect={() => void move(workspace)}>
<Icon name="outline-worktree" class="text-v2-icon-icon-muted" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
</Menu.Item>
Expand Down
Loading