From 2c41a7bc30585c2da9301c3dc473836a3b1b96b8 Mon Sep 17 00:00:00 2001 From: rogalio Date: Thu, 26 Mar 2026 17:57:26 +0100 Subject: [PATCH 1/3] feat(desktop): add CLOSE_WORKSPACE hotkey and context menu delete option (#2742, #2741) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ⌘+Backspace hotkey to close/delete the active workspace via DeleteWorkspaceDialog, and expose a "Close Worktree"/"Close Workspace" option in the sidebar context menu for all workspace types using the existing deleteDialogCoordinator pattern. Closes #2742 Closes #2741 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../_authenticated/_dashboard/layout.tsx | 24 ++++++++ .../WorkspaceContextMenu.test.ts | 49 +++++++++++++++++ .../WorkspaceContextMenu.tsx | 43 ++++++++++----- .../WorkspaceListItem/WorkspaceListItem.tsx | 2 +- apps/desktop/src/shared/hotkeys.test.ts | 55 +++++++++++++++++++ apps/desktop/src/shared/hotkeys.ts | 6 ++ 6 files changed, 164 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceContextMenu.test.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx index 4be47ca9183..27382e92816 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx @@ -6,10 +6,12 @@ import { useNavigate, } from "@tanstack/react-router"; import { useFeatureFlagEnabled } from "posthog-js/react"; +import { useState } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { DashboardSidebar } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar"; import { ResizablePanel } from "renderer/screens/main/components/ResizablePanel"; import { WorkspaceSidebar } from "renderer/screens/main/components/WorkspaceSidebar"; +import { DeleteWorkspaceDialog } from "renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components"; import { useAppHotkey } from "renderer/stores/hotkeys"; import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; import { @@ -93,6 +95,19 @@ function DashboardLayout() { [openNewWorkspaceModal, currentWorkspace?.projectId], ); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + + useAppHotkey( + "CLOSE_WORKSPACE", + () => { + if (currentWorkspaceId) { + setShowDeleteDialog(true); + } + }, + { enabled: !!currentWorkspaceId }, + [currentWorkspaceId], + ); + return (
@@ -125,6 +140,15 @@ function DashboardLayout() {
+ {currentWorkspaceId && currentWorkspace && ( + + )}
); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceContextMenu.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceContextMenu.test.ts new file mode 100644 index 00000000000..70091b81844 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceContextMenu.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import { createContextMenuDeleteDialogCoordinator } from "renderer/react-query/workspaces/useWorkspaceDeleteHandler"; + +describe("WorkspaceContextMenu - delete/close option (#2741)", () => { + test("coordinator calls onDelete when close auto-focus fires after request", () => { + let deleteCalled = false; + const coordinator = createContextMenuDeleteDialogCoordinator(() => { + deleteCalled = true; + }); + + coordinator.requestOpenDeleteDialog(); + + let preventDefaultCalled = false; + coordinator.handleCloseAutoFocus({ + preventDefault: () => { + preventDefaultCalled = true; + }, + }); + + expect(preventDefaultCalled).toBe(true); + expect(deleteCalled).toBe(true); + }); + + test("coordinator does not call onDelete if no request was made", () => { + let deleteCalled = false; + const coordinator = createContextMenuDeleteDialogCoordinator(() => { + deleteCalled = true; + }); + + coordinator.handleCloseAutoFocus({ + preventDefault: () => {}, + }); + + expect(deleteCalled).toBe(false); + }); + + test("coordinator resets after firing, so a second close does not re-trigger", () => { + let callCount = 0; + const coordinator = createContextMenuDeleteDialogCoordinator(() => { + callCount += 1; + }); + + coordinator.requestOpenDeleteDialog(); + coordinator.handleCloseAutoFocus({ preventDefault: () => {} }); + coordinator.handleCloseAutoFocus({ preventDefault: () => {} }); + + expect(callCount).toBe(1); + }); +}); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceContextMenu.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceContextMenu.tsx index af12df664e1..e3750b6f770 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceContextMenu.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceContextMenu.tsx @@ -13,7 +13,7 @@ import { HoverCardContent, HoverCardTrigger, } from "@superset/ui/hover-card"; -import { useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { LuArrowRightLeft, LuBellOff, @@ -31,6 +31,7 @@ import { useMoveWorkspacesToSection, useMoveWorkspaceToSection, } from "renderer/react-query/workspaces"; +import { createContextMenuDeleteDialogCoordinator } from "renderer/react-query/workspaces/useWorkspaceDeleteHandler"; import { useWorkspaceSelectionStore } from "renderer/stores/workspace-selection"; import { STROKE_WIDTH } from "../constants"; import { WorkspaceHoverCardContent } from "./components"; @@ -49,7 +50,7 @@ interface WorkspaceContextMenuProps { onCopyPath: () => void; onSetUnread: (isUnread: boolean) => void; onResetStatus: () => void; - onClose: () => void; + onDelete: () => void; children: React.ReactNode; } @@ -66,7 +67,7 @@ export function WorkspaceContextMenu({ onCopyPath, onSetUnread, onResetStatus, - onClose, + onDelete, children, }: WorkspaceContextMenuProps) { const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); @@ -75,6 +76,10 @@ export function WorkspaceContextMenu({ const moveToSection = useMoveWorkspaceToSection(); const bulkMoveToSection = useMoveWorkspacesToSection(); const createSectionFromWorkspaces = useCreateSectionFromWorkspaces(); + const deleteDialogCoordinator = useMemo( + () => createContextMenuDeleteDialogCoordinator(onDelete), + [onDelete], + ); const handleContextMenuOpenChange = (open: boolean) => { setIsContextMenuOpen(open); @@ -176,15 +181,15 @@ export function WorkspaceContextMenu({ Clear Status )} - {!isBranchWorkspace && ( - <> - - - - Close Worktree - - - )} + + { + deleteDialogCoordinator.requestOpenDeleteDialog(); + }} + > + + {isBranchWorkspace ? "Close Workspace" : "Close Worktree"} + ); @@ -192,7 +197,13 @@ export function WorkspaceContextMenu({ return ( {children} - {commonContextMenuItems} + { + deleteDialogCoordinator.handleCloseAutoFocus(event); + }} + > + {commonContextMenuItems} + ); } @@ -207,7 +218,11 @@ export function WorkspaceContextMenu({ {children} - + { + deleteDialogCoordinator.handleCloseAutoFocus(event); + }} + > Rename diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx index 2b56af6ee39..7c6a5b8f2cc 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -462,7 +462,7 @@ export function WorkspaceListItem({ onCopyPath={handleCopyPath} onSetUnread={(unread) => setUnread.mutate({ id, isUnread: unread })} onResetStatus={() => resetWorkspaceStatus(id)} - onClose={handleDeleteClick} + onDelete={handleDeleteClick} > {content} diff --git a/apps/desktop/src/shared/hotkeys.test.ts b/apps/desktop/src/shared/hotkeys.test.ts index 0a2f1ab405b..ade0dd8d487 100644 --- a/apps/desktop/src/shared/hotkeys.test.ts +++ b/apps/desktop/src/shared/hotkeys.test.ts @@ -3,8 +3,10 @@ import { canonicalizeHotkey, canonicalizeHotkeyForPlatform, deriveNonMacDefault, + HOTKEYS, hotkeyFromKeyboardEvent, isTerminalReservedEvent, + matchesHotkeyEvent, toElectronAccelerator, } from "./hotkeys"; @@ -92,3 +94,56 @@ describe("isTerminalReservedEvent", () => { ).toBe(true); }); }); + +describe("CLOSE_WORKSPACE hotkey", () => { + it("is defined in HOTKEYS with correct properties", () => { + expect(HOTKEYS.CLOSE_WORKSPACE).toBeDefined(); + expect(HOTKEYS.CLOSE_WORKSPACE.label).toBe("Close Workspace"); + expect(HOTKEYS.CLOSE_WORKSPACE.category).toBe("Workspace"); + expect(HOTKEYS.CLOSE_WORKSPACE.defaults.darwin).toBe("meta+backspace"); + }); + + it("matches ⌘+Backspace keyboard event", () => { + const matches = matchesHotkeyEvent( + { + key: "Backspace", + code: "Backspace", + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: false, + }, + HOTKEYS.CLOSE_WORKSPACE.defaults.darwin ?? "", + ); + expect(matches).toBe(true); + }); + + it("does not match Backspace without meta", () => { + const matches = matchesHotkeyEvent( + { + key: "Backspace", + code: "Backspace", + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + }, + HOTKEYS.CLOSE_WORKSPACE.defaults.darwin ?? "", + ); + expect(matches).toBe(false); + }); + + it("does not conflict with existing workspace hotkeys", () => { + const closeDefault = HOTKEYS.CLOSE_WORKSPACE.defaults.darwin; + const workspaceHotkeys = Object.entries(HOTKEYS) + .filter( + ([key, def]) => + def.category === "Workspace" && key !== "CLOSE_WORKSPACE", + ) + .map(([key, def]) => ({ key, darwin: def.defaults.darwin })); + + for (const hotkey of workspaceHotkeys) { + expect(hotkey.darwin).not.toBe(closeDefault); + } + }); +}); diff --git a/apps/desktop/src/shared/hotkeys.ts b/apps/desktop/src/shared/hotkeys.ts index 2b1f07b2bf9..5543cf388b0 100644 --- a/apps/desktop/src/shared/hotkeys.ts +++ b/apps/desktop/src/shared/hotkeys.ts @@ -459,6 +459,12 @@ export const HOTKEYS = { label: "Next Workspace", category: "Workspace", }), + CLOSE_WORKSPACE: defineHotkey({ + keys: "meta+backspace", + label: "Close Workspace", + category: "Workspace", + description: "Close or delete the current workspace", + }), // Layout TOGGLE_SIDEBAR: defineHotkey({ From c760073d1720221957cac4bd4173f9771307d461 Mon Sep 17 00:00:00 2001 From: rogalio Date: Thu, 26 Mar 2026 18:08:07 +0100 Subject: [PATCH 2/3] fix: freeze delete target and add cross-platform conflict tests Address CodeRabbit review feedback: - Freeze workspace data at hotkey press time to prevent stale target if the active workspace changes before dialog confirmation - Extend conflict test to also check linux platform defaults --- .../_authenticated/_dashboard/layout.tsx | 30 ++++++++++++------- apps/desktop/src/shared/hotkeys.test.ts | 7 +++-- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx index 27382e92816..8b4d8d172c8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx @@ -95,17 +95,25 @@ function DashboardLayout() { [openNewWorkspaceModal, currentWorkspace?.projectId], ); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [deleteTarget, setDeleteTarget] = useState<{ + workspaceId: string; + workspaceName: string; + workspaceType: "worktree" | "branch"; + } | null>(null); useAppHotkey( "CLOSE_WORKSPACE", () => { - if (currentWorkspaceId) { - setShowDeleteDialog(true); + if (currentWorkspaceId && currentWorkspace) { + setDeleteTarget({ + workspaceId: currentWorkspaceId, + workspaceName: currentWorkspace.name, + workspaceType: currentWorkspace.type, + }); } }, { enabled: !!currentWorkspaceId }, - [currentWorkspaceId], + [currentWorkspaceId, currentWorkspace], ); return ( @@ -140,13 +148,15 @@ function DashboardLayout() {
- {currentWorkspaceId && currentWorkspace && ( + {deleteTarget && ( { + if (!open) setDeleteTarget(null); + }} /> )} diff --git a/apps/desktop/src/shared/hotkeys.test.ts b/apps/desktop/src/shared/hotkeys.test.ts index ade0dd8d487..3ce82eadb93 100644 --- a/apps/desktop/src/shared/hotkeys.test.ts +++ b/apps/desktop/src/shared/hotkeys.test.ts @@ -134,16 +134,17 @@ describe("CLOSE_WORKSPACE hotkey", () => { }); it("does not conflict with existing workspace hotkeys", () => { - const closeDefault = HOTKEYS.CLOSE_WORKSPACE.defaults.darwin; + const closeDefaults = HOTKEYS.CLOSE_WORKSPACE.defaults; const workspaceHotkeys = Object.entries(HOTKEYS) .filter( ([key, def]) => def.category === "Workspace" && key !== "CLOSE_WORKSPACE", ) - .map(([key, def]) => ({ key, darwin: def.defaults.darwin })); + .map(([key, def]) => ({ key, defaults: def.defaults })); for (const hotkey of workspaceHotkeys) { - expect(hotkey.darwin).not.toBe(closeDefault); + expect(hotkey.defaults.darwin).not.toBe(closeDefaults.darwin); + expect(hotkey.defaults.linux).not.toBe(closeDefaults.linux); } }); }); From 750b76d015a3e75680b41f4d7d46e40fa9873f2d Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sat, 28 Mar 2026 13:56:31 -0700 Subject: [PATCH 3/3] Naming --- .../workspaces/useWorkspaceDeleteHandler.ts | 2 +- .../DashboardSidebarExpandedWorkspaceRow.tsx | 11 +++++++++-- .../WorkspaceListItem/CollapsedWorkspaceItem.tsx | 2 +- .../WorkspaceListItem/WorkspaceListItem.tsx | 6 +++++- bun.lock | 2 +- 5 files changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts b/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts index 0309bf5e764..5b944577d6f 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts @@ -53,7 +53,7 @@ export function scheduleDeleteDialogOpen({ /** * Coordinates opening the delete dialog from a ContextMenu item selection. * - * When "Close Worktree" is selected, we wait for ContextMenu close and then: + * When "Close Workspace" is selected, we wait for ContextMenu close and then: * 1) prevent Radix auto-focus from returning to the trigger * 2) open the delete dialog */ diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx index ed95f8f030b..0f0a2e0dec0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx @@ -2,6 +2,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { type ComponentPropsWithoutRef, forwardRef, useMemo } from "react"; import { HiMiniXMark } from "react-icons/hi2"; +import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; import { RenameInput } from "renderer/screens/main/components/WorkspaceSidebar/RenameInput"; import type { DashboardSidebarWorkspace } from "../../../../types"; import type { WorkspaceRowMockData } from "../../utils"; @@ -179,7 +180,10 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< - Close workspace + @@ -258,7 +262,10 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< - Close workspace + diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/CollapsedWorkspaceItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/CollapsedWorkspaceItem.tsx index b0f72bb9d75..c9143d9c83b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/CollapsedWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/CollapsedWorkspaceItem.tsx @@ -139,7 +139,7 @@ export function CollapsedWorkspaceItem({ }} > - Close Worktree + Close Workspace
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx index 7c6a5b8f2cc..e930030872c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -5,6 +5,7 @@ import { cn } from "@superset/ui/utils"; import { useMatchRoute, useNavigate } from "@tanstack/react-router"; import { useEffect, useMemo, useRef, useState } from "react"; import { HiMiniXMark } from "react-icons/hi2"; +import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { getGitHubStatusQueryPolicy } from "renderer/lib/githubQueryPolicy"; @@ -416,7 +417,10 @@ export function WorkspaceListItem({ - Close workspace + )} diff --git a/bun.lock b/bun.lock index 2b9a3d4371b..5a792dc7516 100644 --- a/bun.lock +++ b/bun.lock @@ -110,7 +110,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "1.3.2", + "version": "1.4.0", "dependencies": { "@ai-sdk/anthropic": "^3.0.43", "@ai-sdk/openai": "3.0.36",