From ad5d1a5b16af34cb5828f1e413c9f889facb2721 Mon Sep 17 00:00:00 2001 From: DevDengChao <2325690622@qq.com> Date: Thu, 29 Oct 2026 17:06:49 +0800 Subject: [PATCH] fix(gui-plugins): dedupe new worktree actions Keep the in-flight location move on the plugin instance, keyed by session, so closing and reopening the menu cannot start a second worktree creation. Fixes #60167 Co-authored-by: DeepSeek V4.1 Flash --- .../e2e/regression/session-summary.spec.ts | 30 +++++++++++++++++++ .../gui-plugins/src/details/location-move.ts | 23 ++++++++++++++ .../src/details/location-workspace.tsx | 13 ++++++-- packages/gui-plugins/src/details/renderer.tsx | 4 ++- .../src/details/workspace-menu.tsx | 17 ++++++----- 5 files changed, 76 insertions(+), 11 deletions(-) create mode 100644 packages/gui-plugins/src/details/location-move.ts diff --git a/packages/app/e2e/regression/session-summary.spec.ts b/packages/app/e2e/regression/session-summary.spec.ts index f9cce652afad..0ae82bf2679e 100644 --- a/packages/app/e2e/regression/session-summary.spec.ts +++ b/packages/app/e2e/regression/session-summary.spec.ts @@ -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(() => {}) + }, + }) + 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, { diff --git a/packages/gui-plugins/src/details/location-move.ts b/packages/gui-plugins/src/details/location-move.ts new file mode 100644 index 000000000000..874350b520c3 --- /dev/null +++ b/packages/gui-plugins/src/details/location-move.ts @@ -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 }>({ 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 diff --git a/packages/gui-plugins/src/details/location-workspace.tsx b/packages/gui-plugins/src/details/location-workspace.tsx index 7f079197e02a..bce5d2882a66 100644 --- a/packages/gui-plugins/src/details/location-workspace.tsx +++ b/packages/gui-plugins/src/details/location-workspace.tsx @@ -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 = () => ( <> @@ -21,7 +24,13 @@ export default function SessionLocationWorkspace(props: BuiltinSlotMap["session. when={!props.session.server.data.session.get(props.session.id)?.parentID} fallback={{location()}} > - + {location()} diff --git a/packages/gui-plugins/src/details/renderer.tsx b/packages/gui-plugins/src/details/renderer.tsx index c673533ac3d2..a407a2062bc0 100644 --- a/packages/gui-plugins/src/details/renderer.tsx +++ b/packages/gui-plugins/src/details/renderer.tsx @@ -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 = (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]) => { @@ -50,7 +52,7 @@ const setup: Plugin.Setup = (ctx) => { at: "session.location.workspace", render: (input) => ( - + ), }) diff --git a/packages/gui-plugins/src/details/workspace-menu.tsx b/packages/gui-plugins/src/details/workspace-menu.tsx index d663afae5bc1..281263f83d64 100644 --- a/packages/gui-plugins/src/details/workspace-menu.tsx +++ b/packages/gui-plugins/src/details/workspace-menu.tsx @@ -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["placement"] class?: string /** Renders as a submenu of an enclosing `Menu`. */ @@ -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)) @@ -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 @@ -91,7 +92,7 @@ export function SessionWorkspaceMenu(props: { description: error instanceof Error ? error.message : ctx.t("common.requestFailed"), }) } finally { - setStore("selected", undefined) + props.moves.end(props.session.key) } } @@ -99,12 +100,12 @@ export function SessionWorkspaceMenu(props: { {ctx.t("move.menu")} - void move(props.project.worktree)}> + void move(props.project.worktree)}> {ctx.t("workspace.local")} - void move("create")}> + void move("create")}> {ctx.t("workspace.new")} @@ -118,7 +119,7 @@ export function SessionWorkspaceMenu(props: { {(workspace) => ( - void move(workspace)}> + void move(workspace)}> {getFilename(workspace)}