diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index aacb0a5c9e5..3bcbf8887c2 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -61,7 +61,10 @@ import { onOnboardingReopened } from './components/onboarding/show-onboarding-ev import { shouldShowOnboarding } from './components/onboarding/should-show-onboarding' import { MarkdownTemplatePicker } from './components/editor/MarkdownTemplatePicker' import { FloatingTerminalToggleButton } from './components/floating-terminal/FloatingTerminalToggleButton' -import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' +import { + TOGGLE_FLOATING_TERMINAL_EVENT, + requestFloatingTerminalOpenMaximized +} from '@/lib/floating-terminal' import { isFloatingWorkspacePanelFocused, isFloatingWorkspacePanelShortcut, @@ -1395,6 +1398,7 @@ function App(): React.JSX.Element { activeView, activeWorktreeId, actions, + floatingTerminalEnabled, floatingTerminalOpen, floatingVisibleTabCount, keybindings, @@ -1409,6 +1413,7 @@ function App(): React.JSX.Element { activeView, activeWorktreeId, actions, + floatingTerminalEnabled, floatingTerminalOpen, floatingVisibleTabCount, keybindings, @@ -1426,6 +1431,7 @@ function App(): React.JSX.Element { activeView, activeWorktreeId, actions, + floatingTerminalEnabled, floatingTerminalOpen, floatingVisibleTabCount, keybindings, @@ -1521,6 +1527,22 @@ function App(): React.JSX.Element { return } + // Why: when the floating workspace is closed, its own keydown handler is + // unmounted and cannot claim Cmd+Opt+Shift+A. Honor the maximize chord + // here by opening the panel with a one-shot intent so it mounts straight + // into the maximized state. While the panel is open, this is a no-op: the + // panel's handler owns the maximize/restore toggle. + if ( + !floatingTerminalOpen && + matchShortcut('floatingWorkspace.maximize') && + floatingTerminalEnabled + ) { + input.preventDefault() + requestFloatingTerminalOpenMaximized() + setFloatingTerminalOpenWithFocus(true) + return + } + // Why: keep this guard. TipTap's Cmd+B bold binding depends on the // window-level handler *not* toggling the sidebar when focus lives in an // editable surface. The main-process before-input-event already carves out diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx index 8306c7b9102..c571d145808 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx @@ -3,7 +3,7 @@ * asserted without mounting the full Electron renderer. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import type { KeybindingOverrides } from '../../../../shared/keybindings' +import type { KeybindingOverrides, TerminalShortcutPolicy } from '../../../../shared/keybindings' import type { BrowserTab, Tab, TabGroup, TerminalTab } from '../../../../shared/types' import type { OpenFile } from '@/store/slices/editor' import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown' @@ -14,6 +14,10 @@ import { getMaximizedFloatingTerminalBounds, type FloatingTerminalPanelBounds } from './floating-terminal-panel-bounds' +import { + consumeFloatingTerminalOpenMaximizedIntent, + requestFloatingTerminalOpenMaximized +} from '@/lib/floating-terminal' type EffectCallback = () => void | (() => void) @@ -32,6 +36,7 @@ type FloatingPanelStoreState = { activeGroupIdByWorktree: Record activeTabIdByWorktree: Record expandedPaneByTabId: Record + renamingTabId: string | null createTab: ( worktreeId: string, groupId?: string, @@ -57,6 +62,7 @@ type FloatingPanelStoreState = { activateTab: (tabId: string) => void setActiveTab: (tabId: string) => void setTabCustomTitle: (tabId: string, title: string | null) => void + setRenamingTabId: (tabId: string | null) => void setTabColor: (tabId: string, color: string | null) => void setTabPaneExpanded: (tabId: string, expanded: boolean) => void makePreviewFilePermanent: (fileId: string, tabId?: string) => void @@ -65,7 +71,11 @@ type FloatingPanelStoreState = { browserDefaultUrl: string keybindings?: KeybindingOverrides tabBarOrderByWorktree: Record - settings: { activeRuntimeEnvironmentId?: string | null; floatingTerminalCwd?: string } + settings: { + activeRuntimeEnvironmentId?: string | null + floatingTerminalCwd?: string + terminalShortcutPolicy?: TerminalShortcutPolicy + } } const hookRuntime = vi.hoisted(() => ({ @@ -102,6 +112,7 @@ const mocks = vi.hoisted(() => ({ pickFloatingMarkdownDocument: vi.fn(), pinFile: vi.fn(), setActiveTab: vi.fn(), + setRenamingTabId: vi.fn(), setTabColor: vi.fn(), setTabCustomTitle: vi.fn(), setTabPaneExpanded: vi.fn(), @@ -457,6 +468,7 @@ function resetStore(tabs: TerminalTab[] = []): void { activeGroupIdByWorktree: {}, activeTabIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: tabs[0]?.id ?? null }, expandedPaneByTabId: {}, + renamingTabId: null, activateTab: mocks.activateTab, closeBrowserTab: mocks.closeBrowserTab, closeFile: mocks.closeFile, @@ -470,6 +482,7 @@ function resetStore(tabs: TerminalTab[] = []): void { pinFile: mocks.pinFile, setActiveTab: mocks.setActiveTab, setTabCustomTitle: mocks.setTabCustomTitle, + setRenamingTabId: mocks.setRenamingTabId, setTabColor: mocks.setTabColor, setTabPaneExpanded: mocks.setTabPaneExpanded, browserDefaultUrl: 'about:blank', @@ -593,10 +606,12 @@ function getPanelClassName(element: unknown): string { } function getMockedLocalStorage(): { + clear: ReturnType getItem: ReturnType setItem: ReturnType } { return window.localStorage as unknown as { + clear: ReturnType getItem: ReturnType setItem: ReturnType } @@ -637,6 +652,79 @@ function makeMacShortcutKeyEvent({ } } +function bindFocusedFloatingPanelKeydown(element: unknown): { + keydownListener: (event: unknown) => void + panelElement: { + contains: ReturnType + focus: ReturnType + closest: ReturnType + } +} { + const panel = findByProp(element, 'data-floating-terminal-panel') + const panelElement = { + contains: vi.fn().mockReturnValue(true), + focus: vi.fn(), + closest: vi.fn() + } + panelElement.closest.mockImplementation((selector: string) => + selector === '[data-floating-terminal-panel]' ? panelElement : null + ) + Object.setPrototypeOf(panelElement, HTMLElement.prototype) + attachRef(panel.props.ref, panelElement) + vi.stubGlobal('document', { + activeElement: panelElement, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + runEffects() + const keydownListener = vi.mocked(window.addEventListener).mock.calls.find(([type]) => { + return type === 'keydown' + })?.[1] as ((event: unknown) => void) | undefined + if (!keydownListener) { + throw new Error('keydown listener not registered') + } + return { keydownListener, panelElement } +} + +function makeFocusedPanelKeyEvent({ + altKey = false, + code, + ctrlKey = false, + key, + metaKey = false, + preventDefault = vi.fn(), + shiftKey = false, + stopImmediatePropagation = vi.fn(), + stopPropagation = vi.fn(), + target +}: { + altKey?: boolean + code?: string + ctrlKey?: boolean + key: string + metaKey?: boolean + preventDefault?: () => void + shiftKey?: boolean + stopImmediatePropagation?: () => void + stopPropagation?: () => void + target: unknown +}): unknown { + return { + altKey, + code: code ?? (key.length === 1 && /[a-z]/i.test(key) ? `Key${key.toUpperCase()}` : key), + ctrlKey, + defaultPrevented: false, + key, + metaKey, + preventDefault, + repeat: false, + shiftKey, + stopImmediatePropagation, + stopPropagation, + target + } +} + describe('FloatingTerminalPanel close behavior', () => { beforeEach(() => { vi.clearAllMocks() @@ -646,6 +734,9 @@ describe('FloatingTerminalPanel close behavior', () => { hookRuntime.values = [] saveDialogBox.fileId = null resetStore() + // Why: the open-maximized intent is a module singleton; drain any leftover + // from a prior test so it cannot bleed into an unrelated render. + consumeFloatingTerminalOpenMaximizedIntent() mocks.createTab.mockReturnValue(makeTab({ id: 'created-tab' })) mocks.createWebRuntimeSessionBrowserTab.mockResolvedValue(false) mocks.createWebRuntimeSessionTerminal.mockResolvedValue(false) @@ -655,6 +746,7 @@ describe('FloatingTerminalPanel close behavior', () => { mocks.isWebRuntimeSessionActive.mockReturnValue(false) mocks.pickFloatingMarkdownDocument.mockResolvedValue(null) const localStorage = { + clear: vi.fn(), getItem: vi.fn(() => null), removeItem: vi.fn(), setItem: vi.fn() @@ -1399,6 +1491,375 @@ describe('FloatingTerminalPanel close behavior', () => { expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-2') }) + it('routes focused floating tab rename shortcuts to the active floating tab', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + const stopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + key: 'r', + metaKey: true, + preventDefault, + stopImmediatePropagation, + stopPropagation, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(stopPropagation).toHaveBeenCalledWith() + expect(stopImmediatePropagation).toHaveBeenCalledWith() + expect(mocks.setRenamingTabId).toHaveBeenCalledWith('tab-1') + expect(mocks.setTabCustomTitle).not.toHaveBeenCalled() + }) + + it('routes focused floating tab index shortcuts to the matching visible tab', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' }), makeTab({ id: 'tab-3' })]) + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + const stopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + code: 'Digit3', + ctrlKey: true, + key: '3', + preventDefault, + stopImmediatePropagation, + stopPropagation, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(stopPropagation).toHaveBeenCalledWith() + expect(stopImmediatePropagation).toHaveBeenCalledWith() + expect(mocks.activateTab).toHaveBeenCalledWith('tab-3') + expect(mocks.setActiveTab).toHaveBeenCalledWith('tab-3') + expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-3') + }) + + it('routes focused floating tab index shortcuts across mixed visible tab types', async () => { + const state = storeBox.state as FloatingPanelStoreState + const groupId = 'floating-group' + const terminalTab = makeTab({ id: 'terminal-tab' }) + const simulatorTab: Tab = { + id: 'simulator-tab', + entityId: 'simulator-tab', + groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'simulator', + label: 'Mobile Emulator', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: 1 + } + const browserTab: BrowserTab = { + id: 'browser-tab', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + url: '', + title: 'Browser', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 2 + } + const browserUnifiedTab: Tab = { + id: 'browser-unified-tab', + entityId: browserTab.id, + groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'browser', + label: 'Browser', + customLabel: null, + color: null, + sortOrder: 2, + createdAt: 2 + } + const terminalUnifiedTab: Tab = { + id: terminalTab.id, + entityId: terminalTab.id, + groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'terminal', + label: terminalTab.title, + customLabel: terminalTab.customTitle, + color: terminalTab.color, + sortOrder: 0, + createdAt: terminalTab.createdAt + } + state.tabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [terminalTab] } + state.browserTabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [browserTab] } + state.unifiedTabsByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: [terminalUnifiedTab, simulatorTab, browserUnifiedTab] + } + state.groupsByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { + id: groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + activeTabId: terminalUnifiedTab.id, + tabOrder: [terminalUnifiedTab.id, simulatorTab.id, browserUnifiedTab.id], + recentTabIds: [terminalUnifiedTab.id, simulatorTab.id, browserUnifiedTab.id] + } + ] + } + state.activeGroupIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: groupId } + state.activeTabIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: terminalTab.id } + state.tabBarOrderByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + terminalUnifiedTab.id, + simulatorTab.id, + browserUnifiedTab.id + ] + } + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + code: 'Digit2', + ctrlKey: true, + key: '2', + preventDefault, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(mocks.activateTab).toHaveBeenCalledWith('simulator-tab') + }) + + it('ignores focused floating tab index shortcuts past the visible tab count', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })]) + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + const stopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + code: 'Digit5', + ctrlKey: true, + key: '5', + preventDefault, + stopImmediatePropagation, + stopPropagation, + target: panelElement + }) + ) + + expect(preventDefault).not.toHaveBeenCalled() + expect(stopPropagation).not.toHaveBeenCalled() + expect(stopImmediatePropagation).not.toHaveBeenCalled() + expect(mocks.activateTab).not.toHaveBeenCalled() + expect(mocks.setActiveTab).not.toHaveBeenCalled() + }) + + it('ignores focused floating tab rename shortcuts when no tab is active', async () => { + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + const stopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + key: 'r', + metaKey: true, + preventDefault, + stopImmediatePropagation, + stopPropagation, + target: panelElement + }) + ) + + expect(mocks.setRenamingTabId).not.toHaveBeenCalled() + }) + + it('leaves focused floating xterm tab index shortcuts to terminal-first terminals', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })]) + ;(storeBox.state as FloatingPanelStoreState).settings = { + ...(storeBox.state as FloatingPanelStoreState).settings, + terminalShortcutPolicy: 'terminal-first' + } + const element = await renderPanel(true) + const panel = findByProp(element, 'data-floating-terminal-panel') + const panelElement = { contains: vi.fn().mockReturnValue(true), focus: vi.fn() } + const target = { + classList: { contains: vi.fn((token: string) => token === 'xterm-helper-textarea') }, + closest: vi.fn((selector: string) => + selector === '[data-floating-terminal-panel]' ? panelElement : null + ) + } + Object.setPrototypeOf(target, HTMLElement.prototype) + attachRef(panel.props.ref, panelElement) + vi.stubGlobal('document', { + activeElement: target, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + runEffects() + const keydownListener = vi.mocked(window.addEventListener).mock.calls.find(([type]) => { + return type === 'keydown' + })?.[1] as ((event: unknown) => void) | undefined + if (!keydownListener) { + throw new Error('keydown listener not registered') + } + const ctrlPreventDefault = vi.fn() + const ctrlStopPropagation = vi.fn() + const ctrlStopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + code: 'Digit2', + ctrlKey: true, + key: '2', + preventDefault: ctrlPreventDefault, + stopImmediatePropagation: ctrlStopImmediatePropagation, + stopPropagation: ctrlStopPropagation, + target + }) + ) + + vi.stubGlobal('navigator', { userAgent: 'Linux' }) + const altPreventDefault = vi.fn() + const altStopPropagation = vi.fn() + const altStopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + altKey: true, + code: 'Digit2', + key: '2', + preventDefault: altPreventDefault, + stopImmediatePropagation: altStopImmediatePropagation, + stopPropagation: altStopPropagation, + target + }) + ) + + expect(ctrlPreventDefault).not.toHaveBeenCalled() + expect(ctrlStopPropagation).not.toHaveBeenCalled() + expect(ctrlStopImmediatePropagation).not.toHaveBeenCalled() + expect(altPreventDefault).not.toHaveBeenCalled() + expect(altStopPropagation).not.toHaveBeenCalled() + expect(altStopImmediatePropagation).not.toHaveBeenCalled() + expect(mocks.activateTab).not.toHaveBeenCalled() + }) + + it('routes focused floating workspace maximize shortcuts like the titlebar control', async () => { + ;(storeBox.state as FloatingPanelStoreState).keybindings = { + 'floatingWorkspace.maximize': ['Ctrl+Alt+M'] + } as unknown as KeybindingOverrides + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + altKey: true, + ctrlKey: true, + key: 'm', + preventDefault, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(getPanelStyleBounds(await renderPanel(true))).toEqual( + getMaximizedFloatingTerminalBounds() + ) + }) + + it('opens maximized when the open-maximized intent is set, ignoring saved bounds', async () => { + const savedBounds = { left: 120, top: 96, width: 760, height: 420 } + getMockedLocalStorage().getItem.mockImplementation((key: string) => + key === FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY ? JSON.stringify(savedBounds) : null + ) + requestFloatingTerminalOpenMaximized() + + await renderPanel(true) + runEffects() + + expect(getPanelStyleBounds(await renderPanel(true))).toEqual( + getMaximizedFloatingTerminalBounds() + ) + // Why: the intent is one-shot and must be consumed by the open transition. + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(false) + }) + + it('does not maximize on open when no intent is set', async () => { + const savedBounds = { left: 120, top: 96, width: 760, height: 420 } + getMockedLocalStorage().getItem.mockImplementation((key: string) => + key === FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY ? JSON.stringify(savedBounds) : null + ) + + const element = await renderPanel(true) + runEffects() + + expect(getPanelStyleBounds(element)).toEqual(savedBounds) + }) + + it('routes focused floating workspace minimize shortcuts to close the panel', async () => { + ;(storeBox.state as FloatingPanelStoreState).keybindings = { + 'floatingWorkspace.minimize': ['Ctrl+Alt+N'] + } as unknown as KeybindingOverrides + const onOpenChange = vi.fn() + const element = await renderPanel(true, onOpenChange) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + altKey: true, + ctrlKey: true, + key: 'n', + preventDefault, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('routes focused floating workspace maximize shortcuts from a custom binding on Linux', async () => { + vi.stubGlobal('navigator', { userAgent: 'Linux' }) + ;(storeBox.state as FloatingPanelStoreState).keybindings = { + 'floatingWorkspace.maximize': ['Ctrl+Alt+M'] + } as unknown as KeybindingOverrides + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + altKey: true, + ctrlKey: true, + key: 'm', + preventDefault, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(getPanelStyleBounds(await renderPanel(true))).toEqual( + getMaximizedFloatingTerminalBounds() + ) + }) + it('keeps the empty floating workspace focused after Cmd+W closes the last tab', async () => { setFloatingTabs([makeTab({ id: 'tab-1' })]) const element = await renderPanel(true) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index f91eded8216..f7c4c31e9b8 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -58,6 +58,7 @@ import { import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { keybindingMatchesAction, + matchKeybindingDigitIndex, type KeybindingActionId, type KeybindingContext, type KeybindingMatchOptions, @@ -94,6 +95,7 @@ import { type FloatingTerminalPanelBoundsSource } from './floating-terminal-panel-bounds' import { translate } from '@/i18n/i18n' +import { consumeFloatingTerminalOpenMaximizedIntent } from '@/lib/floating-terminal' const EMPTY_TERMINAL_TABS: TerminalTab[] = [] const EMPTY_BROWSER_TABS: BrowserTabState[] = [] const EMPTY_GROUPS: TabGroup[] = [] @@ -376,6 +378,26 @@ export function FloatingTerminalPanel({ }), [activeGroup, groupTabs] ) + const visibleFloatingTabOrder = useMemo( + () => + tabBarOrder.filter((visibleId) => { + const tab = resolveGroupTabFromVisibleId(groupTabs, visibleId) + if (!tab) { + return false + } + if (tab.contentType === 'terminal') { + return terminalItems.some((item) => item.unifiedTabId === tab.id) + } + if (tab.contentType === 'browser') { + return browserItems.some((item) => item.tabId === tab.id) + } + if (tab.contentType === 'simulator') { + return simulatorItems.some((item) => item.id === tab.id) + } + return editorItems.some((item) => item.tabId === tab.id) + }), + [browserItems, editorItems, groupTabs, simulatorItems, tabBarOrder, terminalItems] + ) const activeBrowserTab = activeBrowserId ? (browserTabs.find((tab) => tab.id === activeBrowserId) ?? null) : null @@ -963,21 +985,91 @@ export function FloatingTerminalPanel({ setFloatingTerminalInputFocusedInMain(isFloatingWorkspaceTerminalInputTarget(target)) }, []) + const toggleMaximized = useCallback(() => { + if (maximized) { + const restoredState = restoreBoundsRef.current ?? { + committedBounds: getDefaultFloatingTerminalCommittedBounds(), + renderedBounds: getDefaultFloatingTerminalBounds(), + source: 'default' as const + } + restoreBoundsRef.current = null + boundsSourceRef.current = restoredState.source + committedBoundsRef.current = restoredState.committedBounds + const restoredBounds = shouldReconcileFloatingTerminalPanelBounds(restoredState.source) + ? resolveFloatingTerminalPanelBounds(restoredState.committedBounds, restoredState.source) + : restoredState.renderedBounds + stagedBoundsRef.current = null + setBounds(restoredBounds) + setMaximized(false) + return + } + restoreBoundsRef.current = { + committedBounds: committedBoundsRef.current, + renderedBounds: bounds, + source: boundsSourceRef.current + } + stagedBoundsRef.current = null + setBounds(getMaximizedFloatingTerminalBounds()) + setMaximized(true) + }, [bounds, maximized]) + + const maximizePanel = useCallback(() => { + // Why: idempotent maximize used by the open-into-maximized intent. Unlike + // toggleMaximized it never restores, and only stashes restore bounds on the + // first transition so a redundant call cannot clobber the saved size. + if (maximized) { + return + } + restoreBoundsRef.current = { + committedBounds: committedBoundsRef.current, + renderedBounds: bounds, + source: boundsSourceRef.current + } + stagedBoundsRef.current = null + setBounds(getMaximizedFloatingTerminalBounds()) + setMaximized(true) + }, [bounds, maximized]) + + useEffect(() => { + // Why: when App opens the panel via Cmd+Opt+Shift+A while it was closed, + // it records a one-shot intent; honor it once the panel is open so it + // starts maximized regardless of its last saved size. + if (open && consumeFloatingTerminalOpenMaximizedIntent()) { + maximizePanel() + } + }, [open, maximizePanel]) + const handleFloatingPanelShortcutAction = useCallback( (input: FloatingPanelShortcutInput, consume: () => void): boolean => { const state = useAppStore.getState() const platform = getShortcutPlatform() + const terminalShortcutPolicy = state.settings?.terminalShortcutPolicy + const isFloatingTerminalInput = isFloatingWorkspaceTerminalInputTarget(input.target) const context: KeybindingContext = input.doubleTapModifier ? 'app' - : isFloatingWorkspaceTerminalInputTarget(input.target) + : isFloatingTerminalInput ? 'terminal' : 'app' const matchOptions: KeybindingMatchOptions = { context, - terminalShortcutPolicy: state.settings?.terminalShortcutPolicy + terminalShortcutPolicy } + // Floating panel chrome owns these controls even when xterm has DOM focus; + // keep the rest of the shortcut table in terminal context for terminal-first. + const floatingChromeMatchOptions: KeybindingMatchOptions = + isFloatingTerminalInput && terminalShortcutPolicy === 'terminal-first' + ? { context: 'app', terminalShortcutPolicy } + : matchOptions const matches = (actionId: KeybindingActionId): boolean => keybindingMatchesAction(actionId, input, platform, state.keybindings, matchOptions) + const matchesFloatingChrome = (actionId: KeybindingActionId): boolean => + keybindingMatchesAction( + actionId, + input, + platform, + state.keybindings, + floatingChromeMatchOptions + ) if (matches('tab.newTerminal')) { consume() @@ -1013,10 +1105,43 @@ export function FloatingTerminalPanel({ } return true } + if (matchesFloatingChrome('tab.rename') && activeTab) { + consume() + state.setRenamingTabId(activeTab.id) + return true + } + const selectedTabIndex = matchKeybindingDigitIndex( + 'tab.selectByIndex', + input, + platform, + state.keybindings, + matchOptions + ) + if (selectedTabIndex !== null) { + const visibleId = visibleFloatingTabOrder[selectedTabIndex] + if (!visibleId) { + return false + } + consume() + activateFloatingItem(visibleId) + return true + } + if (matchesFloatingChrome('floatingWorkspace.maximize')) { + consume() + toggleMaximized() + return true + } + if (matchesFloatingChrome('floatingWorkspace.minimize')) { + consume() + onOpenChange(false) + return true + } return false }, [ activeClosableTab, + activeTab, + activateFloatingItem, closeFloatingItem, createFloatingBrowserTab, createFloatingMarkdownTab, @@ -1024,7 +1149,9 @@ export function FloatingTerminalPanel({ focusPanelForShortcutsAfterClose, onOpenChange, openFloatingMarkdownTab, - visibleFloatingItemCount + toggleMaximized, + visibleFloatingItemCount, + visibleFloatingTabOrder ] ) @@ -1044,14 +1171,47 @@ export function FloatingTerminalPanel({ const state = useAppStore.getState() const platform = getShortcutPlatform() - const context: KeybindingContext = isFloatingWorkspaceTerminalInputTarget(event.target) - ? 'terminal' - : 'app' + const terminalShortcutPolicy = state.settings?.terminalShortcutPolicy + const isFloatingTerminalInput = isFloatingWorkspaceTerminalInputTarget(event.target) + const context: KeybindingContext = isFloatingTerminalInput ? 'terminal' : 'app' const matchOptions: KeybindingMatchOptions = { context, - terminalShortcutPolicy: state.settings?.terminalShortcutPolicy + terminalShortcutPolicy } + const floatingChromeMatchOptions: KeybindingMatchOptions = + isFloatingTerminalInput && terminalShortcutPolicy === 'terminal-first' + ? { context: 'app', terminalShortcutPolicy } + : matchOptions const nativeEvent = event.nativeEvent + const isFloatingChromeShortcut = + keybindingMatchesAction( + 'tab.rename', + nativeEvent, + platform, + state.keybindings, + floatingChromeMatchOptions + ) || + matchKeybindingDigitIndex( + 'tab.selectByIndex', + nativeEvent, + platform, + state.keybindings, + matchOptions + ) !== null || + keybindingMatchesAction( + 'floatingWorkspace.maximize', + nativeEvent, + platform, + state.keybindings, + floatingChromeMatchOptions + ) || + keybindingMatchesAction( + 'floatingWorkspace.minimize', + nativeEvent, + platform, + state.keybindings, + floatingChromeMatchOptions + ) if ( !isFloatingWorkspacePanelShortcut( @@ -1060,7 +1220,8 @@ export function FloatingTerminalPanel({ panelRef.current, state.keybindings, matchOptions - ) + ) && + !isFloatingChromeShortcut ) { return } @@ -1245,35 +1406,6 @@ export function FloatingTerminalPanel({ window.removeEventListener('blur', handleWindowBlur) } }, [open]) - - const toggleMaximized = useCallback(() => { - if (maximized) { - const restoredState = restoreBoundsRef.current ?? { - committedBounds: getDefaultFloatingTerminalCommittedBounds(), - renderedBounds: getDefaultFloatingTerminalBounds(), - source: 'default' as const - } - restoreBoundsRef.current = null - boundsSourceRef.current = restoredState.source - committedBoundsRef.current = restoredState.committedBounds - const restoredBounds = shouldReconcileFloatingTerminalPanelBounds(restoredState.source) - ? resolveFloatingTerminalPanelBounds(restoredState.committedBounds, restoredState.source) - : restoredState.renderedBounds - stagedBoundsRef.current = null - setBounds(restoredBounds) - setMaximized(false) - return - } - restoreBoundsRef.current = { - committedBounds: committedBoundsRef.current, - renderedBounds: bounds, - source: boundsSourceRef.current - } - stagedBoundsRef.current = null - setBounds(getMaximizedFloatingTerminalBounds()) - setMaximized(true) - }, [bounds, maximized]) - const handleDragStart = (event: React.PointerEvent): void => { if (maximized) { return diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx index 950378ae1d1..514402a0f36 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx @@ -16,6 +16,7 @@ import { resolveTuiAgentLaunchEnv } from '../../../../shared/tui-agent-launch-defaults' import { translate } from '@/i18n/i18n' +import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' type FloatingTerminalWindowControlsProps = { maximized: boolean @@ -26,6 +27,14 @@ type FloatingTerminalWindowControlsProps = { const controlButtonClassName = 'border-border bg-secondary text-secondary-foreground shadow-xs hover:bg-accent hover:text-accent-foreground' +// Why: matches the repo convention (e.g. ReviewPRViewAnimatedVisual) of +// surfacing the live keybinding in a tooltip as "Label (shortcut)", while +// degrading to a bare label when the action is unbound (default on Win/Linux, +// and for minimize on every platform). +function withShortcutHint(label: string, shortcutLabel: string | null): string { + return shortcutLabel ? `${label} (${shortcutLabel})` : label +} + export function FloatingTerminalWindowControls({ maximized, onToggleMaximized, @@ -34,6 +43,8 @@ export function FloatingTerminalWindowControls({ const defaultTuiAgent = useAppStore((s) => s.settings?.defaultTuiAgent ?? null) const createTab = useAppStore((s) => s.createTab) const setActiveTabForWorktree = useAppStore((s) => s.setActiveTabForWorktree) + const maximizeShortcutLabel = useOptionalShortcutLabel('floatingWorkspace.maximize') + const minimizeShortcutLabel = useOptionalShortcutLabel('floatingWorkspace.minimize') const disabledTuiAgents = useAppStore((s) => s.settings?.disabledTuiAgents ?? []) const defaultAgent = @@ -160,13 +171,19 @@ export function FloatingTerminalWindowControls({ {maximized - ? translate( - 'auto.components.floating.terminal.FloatingTerminalWindowControls.b5686fee1e', - 'Restore' + ? withShortcutHint( + translate( + 'auto.components.floating.terminal.FloatingTerminalWindowControls.b5686fee1e', + 'Restore' + ), + maximizeShortcutLabel ) - : translate( - 'auto.components.floating.terminal.FloatingTerminalWindowControls.109870e023', - 'Maximize' + : withShortcutHint( + translate( + 'auto.components.floating.terminal.FloatingTerminalWindowControls.109870e023', + 'Maximize' + ), + maximizeShortcutLabel )} @@ -187,9 +204,12 @@ export function FloatingTerminalWindowControls({ - {translate( - 'auto.components.floating.terminal.FloatingTerminalWindowControls.2f6054342c', - 'Minimize' + {withShortcutHint( + translate( + 'auto.components.floating.terminal.FloatingTerminalWindowControls.2f6054342c', + 'Minimize' + ), + minimizeShortcutLabel )} diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index 6694b357ddb..940ad6df996 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useSortable } from '@dnd-kit/sortable' -import { X, GitCompareArrows, Eye, ShieldAlert, Pin, ListChecks } from 'lucide-react' +import { GitCompareArrows, Eye, ShieldAlert, Pin, ListChecks } from 'lucide-react' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { basename, normalizeRelativePath } from '@/lib/path' @@ -30,6 +30,7 @@ import { EditorFileTabContextMenu } from './EditorFileTabContextMenu' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { useTabStripPointerActivation } from './tab-strip-pointer-activation' +import { EditorFileTabCloseButton } from './EditorFileTabCloseButton' export default function EditorFileTab({ file, @@ -341,28 +342,14 @@ export default function EditorFileTab({ When clean: close button is shown normally (visible on active tab, on hover for others). */}
{file.isDirty && ( - + )} {!isPinned && ( - + )}
diff --git a/src/renderer/src/components/tab-bar/EditorFileTabCloseButton.tsx b/src/renderer/src/components/tab-bar/EditorFileTabCloseButton.tsx new file mode 100644 index 00000000000..fb135b92c9e --- /dev/null +++ b/src/renderer/src/components/tab-bar/EditorFileTabCloseButton.tsx @@ -0,0 +1,56 @@ +import { X } from 'lucide-react' +import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' +import { translate } from '@/i18n/i18n' + +export function EditorFileTabCloseButton({ + fileIsDirty, + showsSelectionChrome, + onClose +}: { + fileIsDirty: boolean + showsSelectionChrome: boolean + onClose: () => void +}): React.JSX.Element { + const closeShortcut = useShortcutKeyDetails('tab.close') + + return ( + + + + + + + {translate('auto.components.tab.bar.EditorFileTabCloseButton.a768f428f1', 'Close tab')} + + {closeShortcut.keys.length > 0 && ( + + )} + + + ) +} diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx index 934d93ecaa6..8167bdbda5c 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const shortcutLabelMock = vi.hoisted(() => vi.fn(() => '⌘⌥W')) +const shortcutLabelMock = vi.hoisted(() => vi.fn()) vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenu: function DropdownMenu(props: { children?: unknown }) { @@ -63,10 +63,10 @@ vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) -// Why: the menu reads the live binding for tab.closeAll; stub it to a fixed -// label so the test asserts the shortcut is surfaced, not its platform glyphs. +// Why: the menu reads live shortcut bindings; stub them to fixed labels so +// the test asserts each assigned action surfaces its own shortcut chip. vi.mock('@/hooks/useShortcutLabel', () => ({ - useShortcutLabel: shortcutLabelMock + useOptionalShortcutLabel: shortcutLabelMock })) const useAppStoreMock = Object.assign( @@ -208,10 +208,23 @@ async function renderMenu(): Promise { }) } +function assignedShortcutLabel(actionId: string): string | null { + switch (actionId) { + case 'tab.rename': + return '⌘R' + case 'tab.close': + return '⌘W' + case 'tab.closeAll': + return '⌘⌥W' + default: + return null + } +} + describe('EditorFileTabContextMenu close-all shortcut', () => { beforeEach(() => { vi.resetModules() - shortcutLabelMock.mockReturnValue('⌘⌥W') + shortcutLabelMock.mockImplementation(assignedShortcutLabel) vi.stubGlobal('navigator', { userAgent: 'Mac' }) }) @@ -219,26 +232,37 @@ describe('EditorFileTabContextMenu close-all shortcut', () => { vi.unstubAllGlobals() }) - it('renders the tab.closeAll shortcut next to Close All Editor Tabs', async () => { + it('renders assigned shortcuts next to Rename, Close, and Close All Editor Tabs', async () => { const tree = expandNode(await renderMenu()) + const menuItems = findElementsByType(tree, 'DropdownMenuItem') - const closeAllItem = findElementsByType(tree, 'DropdownMenuItem').find((item) => + const renameItem = menuItems.find((item) => extractText(item.props.children).includes('Rename')) + const closeItem = menuItems.find((item) => extractText(item.props.children) === 'Close⌘W') + const closeAllItem = menuItems.find((item) => extractText(item.props.children).includes('Close All Editor Tabs') ) + expect(renameItem).toBeTruthy() + expect(closeItem).toBeTruthy() expect(closeAllItem).toBeTruthy() - const shortcut = findElementsByType(closeAllItem, 'DropdownMenuShortcut') - expect(shortcut).toHaveLength(1) - expect(extractText(shortcut[0].props.children)).toBe('⌘⌥W') + const shortcutExpectations: [ReactElementLike | undefined, string][] = [ + [renameItem, '⌘R'], + [closeItem, '⌘W'], + [closeAllItem, '⌘⌥W'] + ] - // Why: the shortcut hint is exclusive to Close All; sibling items (Close, - // Close Tabs To The Right) must not sprout their own chips. - expect(findElementsByType(tree, 'DropdownMenuShortcut')).toHaveLength(1) + for (const [item, expectedLabel] of shortcutExpectations) { + const shortcut = findElementsByType(item, 'DropdownMenuShortcut') + expect(shortcut).toHaveLength(1) + expect(extractText(shortcut[0].props.children)).toBe(expectedLabel) + } + + expect(findElementsByType(tree, 'DropdownMenuShortcut')).toHaveLength(3) }) it('hides the shortcut chip when close-all is unassigned', async () => { - shortcutLabelMock.mockReturnValue('Unassigned') + shortcutLabelMock.mockReturnValue(null) const tree = expandNode(await renderMenu()) diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx index 95943a77156..184c05d2d97 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx @@ -9,7 +9,7 @@ import { } from '@/components/ui/dropdown-menu' import { useAppStore } from '@/store' import { showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' -import { useShortcutLabel } from '@/hooks/useShortcutLabel' +import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import type { OpenFile } from '../../store/slices/editor' import { shouldBlockEditorTabLocalOpen } from './editor-tab-local-open-guard' import { translate } from '@/i18n/i18n' @@ -81,8 +81,9 @@ export function EditorFileTabContextMenu({ onCloseToRight, onOpenMarkdownPreview }: EditorFileTabContextMenuProps): React.JSX.Element { - const closeAllShortcut = useShortcutLabel('tab.closeAll') - const showCloseAllShortcut = closeAllShortcut !== 'Unassigned' + const renameShortcut = useOptionalShortcutLabel('tab.rename') + const closeShortcut = useOptionalShortcutLabel('tab.close') + const closeAllShortcut = useOptionalShortcutLabel('tab.closeAll') return ( @@ -116,6 +117,7 @@ export function EditorFileTabContextMenu({ > {translate('auto.components.tab.bar.EditorFileTabContextMenu.68cc610e7f', 'Rename')} + {renameShortcut ? {renameShortcut} : null} @@ -128,13 +130,14 @@ export function EditorFileTabContextMenu({ !isPinned && onClose()} disabled={isPinned}> {translate('auto.components.tab.bar.EditorFileTabContextMenu.1ba8492c5b', 'Close')} + {closeShortcut ? {closeShortcut} : null} {translate( 'auto.components.tab.bar.EditorFileTabContextMenu.ba1369dd24', 'Close All Editor Tabs' )} - {showCloseAllShortcut ? ( + {closeAllShortcut ? ( {closeAllShortcut} ) : null} diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts b/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts index 91d25847e87..3c32fdd7c99 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts @@ -1,5 +1,156 @@ -import { describe, expect, it } from 'vitest' -import { shouldShowLaunchWatchdogTimeout } from './QuickLaunchButton' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { QuickLaunchAgentMenuItems, shouldShowLaunchWatchdogTimeout } from './QuickLaunchButton' + +const { shortcutLabelMock, storeState, openSettingsPageMock, openSettingsTargetMock } = vi.hoisted( + () => ({ + shortcutLabelMock: vi.fn<() => string | null>(), + storeState: { + settings: { + defaultTuiAgent: 'codex' as 'claude' | 'codex' | 'gemini' | 'blank' | null, + disabledTuiAgents: [] as string[] + }, + worktreesByRepo: {}, + repos: [], + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() + }, + openSettingsPageMock: vi.fn(), + openSettingsTargetMock: vi.fn() + }) +) + +vi.mock('@/hooks/useDetectedAgents', () => ({ + useDetectedAgents: () => ({ detectedIds: ['claude', 'codex', 'gemini'] }) +})) + +vi.mock('@/hooks/useShortcutLabel', () => ({ + useOptionalShortcutLabel: shortcutLabelMock +})) + +vi.mock('@/store', () => { + const useAppStore = Object.assign( + (selector: (state: typeof storeState) => unknown) => { + return selector(storeState) + }, + { + getState: () => storeState + } + ) + + return { useAppStore } +}) + +vi.mock('@/lib/agent-catalog', async () => { + const ReactActual = (await vi.importActual('react')) as { + createElement: typeof React.createElement + } + + return { + getAgentCatalog: () => [ + { id: 'claude', label: 'Claude' }, + { id: 'codex', label: 'Codex' }, + { id: 'gemini', label: 'Gemini' } + ], + AgentIcon: ({ agent }: { agent: string }) => ReactActual.createElement('span', null, agent) + } +}) + +vi.mock('@/components/ui/dropdown-menu', async () => { + const ReactActual = (await vi.importActual('react')) as { + createElement: typeof React.createElement + } + + return { + DropdownMenuItem: ({ children, ...props }: { children: React.ReactNode }) => + ReactActual.createElement('div', props, children), + DropdownMenuShortcut: ({ children }: { children: React.ReactNode }) => + ReactActual.createElement('span', { 'data-dropdown-shortcut': 'true' }, children) + } +}) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + Object.entries(values ?? {}).reduce( + (text, [key, value]) => text.replace(`{{${key}}}`, value), + fallback + ) +})) + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + message: vi.fn() + } +})) + +vi.mock('@/lib/launch-agent-in-new-tab', () => ({ + launchAgentInNewTab: vi.fn() +})) + +function renderAgentMenuItems(): string { + return renderToStaticMarkup( + React.createElement(QuickLaunchAgentMenuItems, { + worktreeId: 'worktree-1', + groupId: 'group-1', + onFocusTerminal: vi.fn() + }) + ) +} + +function rowMarkup(html: string, label: string): string { + const start = html.indexOf(`title="Launch ${label} in a new terminal"`) + expect(start).toBeGreaterThanOrEqual(0) + const end = html.indexOf('', start) + expect(end).toBeGreaterThan(start) + + return html.slice(start, end) +} + +beforeEach(() => { + shortcutLabelMock.mockReset() + shortcutLabelMock.mockReturnValue(null) + openSettingsPageMock.mockReset() + openSettingsTargetMock.mockReset() + storeState.settings.defaultTuiAgent = 'codex' + storeState.settings.disabledTuiAgents = [] + storeState.worktreesByRepo = {} + storeState.repos = [] + storeState.openSettingsPage = openSettingsPageMock + storeState.openSettingsTarget = openSettingsTargetMock +}) + +describe('QuickLaunchAgentMenuItems', () => { + it('renders the new-agent shortcut next to the configured default agent only', () => { + shortcutLabelMock.mockReturnValue('⌘⌥T') + + const html = renderAgentMenuItems() + + expect(html.match(/data-dropdown-shortcut="true"/g) ?? []).toHaveLength(1) + expect(rowMarkup(html, 'Codex')).toContain('⌘⌥T') + expect(rowMarkup(html, 'Claude')).not.toContain('⌘⌥T') + expect(rowMarkup(html, 'Gemini')).not.toContain('⌘⌥T') + }) + + it('hides the default-agent shortcut when the action is unbound', () => { + shortcutLabelMock.mockReturnValue(null) + + const html = renderAgentMenuItems() + + expect(html).not.toContain('data-dropdown-shortcut="true"') + }) + + it('does not label an auto-picked or blank default as configured', () => { + shortcutLabelMock.mockReturnValue('⌘⌥T') + + storeState.settings.defaultTuiAgent = null + expect(renderAgentMenuItems()).not.toContain('data-dropdown-shortcut="true"') + + storeState.settings.defaultTuiAgent = 'blank' + expect(renderAgentMenuItems()).not.toContain('data-dropdown-shortcut="true"') + }) +}) describe('shouldShowLaunchWatchdogTimeout', () => { it('does not report slow agent readiness once a PTY exists', () => { diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index 5e4b0f9ce57..84681bbc8cc 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -1,10 +1,11 @@ import React, { useCallback } from 'react' import { Settings as SettingsIcon } from 'lucide-react' import { toast } from 'sonner' -import { DropdownMenuItem } from '@/components/ui/dropdown-menu' +import { DropdownMenuItem, DropdownMenuShortcut } from '@/components/ui/dropdown-menu' import { getAgentCatalog, AgentIcon } from '@/lib/agent-catalog' import { useAppStore } from '@/store' import { useDetectedAgents } from '@/hooks/useDetectedAgents' +import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import type { TuiAgent } from '../../../../shared/types' import type { LaunchSource } from '../../../../shared/telemetry-events' @@ -116,6 +117,7 @@ function QuickLaunchAgentMenuItemsInner({ const disabledAgents = useAppStore((s) => s.settings?.disabledTuiAgents ?? []) const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) + const newAgentShortcut = useOptionalShortcutLabel('tab.newAgent') const openAgentSettings = useCallback(() => { openSettingsTarget({ pane: 'agents', repoId: null }) @@ -197,6 +199,8 @@ function QuickLaunchAgentMenuItemsInner({ {agents.map((agent) => { const entry = getCatalogEntry(agent) const label = entry?.label ?? agent + const showsDefaultAgentShortcut = + newAgentShortcut !== null && defaultAgent !== 'blank' && agent === defaultAgent return ( - {label} + {label} + {showsDefaultAgentShortcut ? ( + {newAgentShortcut} + ) : null} ) })} diff --git a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx index b47ec987e66..b22e1912d9e 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx @@ -106,7 +106,9 @@ vi.mock('lucide-react', () => ({ })) vi.mock('@/hooks/useShortcutLabel', () => ({ - formatShortcutLabel: () => '⌘⇧\\' + formatShortcutLabel: () => '⌘⇧\\', + useOptionalShortcutLabel: () => '⌘W', + useShortcutKeyDetails: () => ({ keys: ['⌘', 'W'], doubleTap: false }) })) vi.mock('@/components/ui/dropdown-menu', () => ({ @@ -119,12 +121,12 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenuItem: function DropdownMenuItem(props: { children?: unknown }) { return { type: 'DropdownMenuItem', props } }, - DropdownMenuSeparator: function DropdownMenuSeparator() { - return { type: 'DropdownMenuSeparator', props: {} } - }, DropdownMenuShortcut: function DropdownMenuShortcut(props: { children?: unknown }) { return { type: 'DropdownMenuShortcut', props } }, + DropdownMenuSeparator: function DropdownMenuSeparator() { + return { type: 'DropdownMenuSeparator', props: {} } + }, DropdownMenuLabel: function DropdownMenuLabel(props: { children?: unknown }) { return { type: 'DropdownMenuLabel', props } }, diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 80b625f6885..41426802753 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -7,6 +7,7 @@ import { stripLeadingAgentTitleDecoration } from '@/lib/agent-title-decoration' import { useTabAgent } from '@/lib/use-tab-agent' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' import type { TerminalTab } from '../../../../shared/types' import type { TabDragItemData } from '../tab-group/useTabDragSplit' import { FilledBellIcon } from '../sidebar/WorktreeCardHelpers' @@ -24,6 +25,7 @@ import { SortableTabContextMenu } from './SortableTabContextMenu' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { useTabStripPointerActivation } from './tab-strip-pointer-activation' +import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' type SortableTabProps = { tab: TerminalTab @@ -207,6 +209,7 @@ export default function SortableTab({ disabled: isEditing }) const showsSelectionChrome = showsTabSelectionChrome(isActive, isPressed) + const closeShortcut = useShortcutKeyDetails('tab.close') const tabTitle = tab.customTitle ?? tab.title const tabRoot = (
)} {!isEditing && !isPinned && ( - + + + + + + {translate('auto.components.tab.bar.SortableTab.95db5f2f7d', 'Close tab')} + {closeShortcut.keys.length > 0 && ( + + )} + + )}
) diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx index 391eb8ddb7c..71b067fa898 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx @@ -18,7 +18,8 @@ const storeMock = vi.hoisted(() => ({ })) vi.mock('@/hooks/useShortcutLabel', () => ({ - formatShortcutLabel: () => '⌘D' + formatShortcutLabel: () => '⌘D', + useOptionalShortcutLabel: () => '⌘D' })) vi.mock('@/components/ui/dropdown-menu', () => ({ diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx index 2997ab2a29c..291deb92466 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx @@ -9,7 +9,7 @@ import { } from '@/components/ui/dropdown-menu' import type { TerminalTab } from '../../../../shared/types' import { useAppStore } from '../../store' -import { formatShortcutLabel } from '@/hooks/useShortcutLabel' +import { formatShortcutLabel, useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { translate } from '@/i18n/i18n' import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection' import { requestActiveTerminalPaneSplit } from './request-active-terminal-pane-split' @@ -126,6 +126,8 @@ export function SortableTabContextMenu({ } requestActiveTerminalPaneSplit({ tabId: tab.id, direction }) } + const closeShortcut = useOptionalShortcutLabel('tab.close') + const renameShortcut = useOptionalShortcutLabel('tab.rename') return ( @@ -165,6 +167,7 @@ export function SortableTabContextMenu({ !isPinned && onClose(tab.id)} disabled={isPinned}> {translate('auto.components.tab.bar.SortableTabContextMenu.89359a36f7', 'Close')} + {closeShortcut ? {closeShortcut} : null} onCloseOthers(tab.id)} disabled={tabCount <= 1}> {translate('auto.components.tab.bar.SortableTabContextMenu.8d16f9cd30', 'Close Others')} @@ -178,6 +181,7 @@ export function SortableTabContextMenu({ {translate('auto.components.tab.bar.SortableTabContextMenu.2f697b3c31', 'Change Title')} + {renameShortcut ? {renameShortcut} : null}
diff --git a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts index fd5d8ca9a90..9e313bccd55 100644 --- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts @@ -465,7 +465,7 @@ describe('TabBar context menu wiring', () => { ) expect(menuLabels[0]).toContain('New Markdown') - expect(menuLabels[1]).toBe('Open Markdown...') + expect(menuLabels[1]).toContain('Open Markdown...') expect(menuLabels[2]).toContain('New Terminal') expect(menuLabels[3]).toContain('New Browser Tab') }) diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 0455c4fb64b..f61ff4aff33 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -51,7 +51,7 @@ import { import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { useShortcutLabel } from '@/hooks/useShortcutLabel' +import { useOptionalShortcutLabel, useShortcutLabel } from '@/hooks/useShortcutLabel' import { type BuiltInWindowsTerminalShell, WINDOWS_GIT_BASH_SHELL @@ -272,6 +272,7 @@ function TabBarInner({ const newBrowserShortcut = useShortcutLabel('tab.newBrowser') const newSimulatorShortcut = useShortcutLabel('tab.newSimulator') const newFileShortcut = useShortcutLabel('tab.newMarkdown') + const openMarkdownShortcut = useOptionalShortcutLabel('tab.openMarkdown') const generatedTabTitlesEnabled = useAppStore((s) => s.settings?.tabAutoGenerateTitle === true) const mobileEmulatorEnabled = useAppStore((s) => s.settings?.mobileEmulatorEnabled !== false) const persistedUIReady = useAppStore((s) => s.persistedUIReady) @@ -761,6 +762,9 @@ function TabBarInner({ > {translate('auto.components.tab.bar.TabBar.4f327c8b3d', 'Open Markdown...')} + {openMarkdownShortcut ? ( + {openMarkdownShortcut} + ) : null} ) : null const mobileEmulatorIntroMenuBlock = diff --git a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx index f6fa605e219..920470534ad 100644 --- a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx +++ b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx @@ -173,6 +173,19 @@ function firstOpeningTag(markup: string): string { return match[0] } +function textSpanHtml(markup: string, text: string): string { + const textIndex = markup.indexOf(`>${text}`) + expect(textIndex).toBeGreaterThanOrEqual(0) + + const tagStart = markup.lastIndexOf('', textIndex) + expect(spanEnd).toBeGreaterThan(textIndex) + + return markup.slice(tagStart, spanEnd + ''.length) +} + function expectTabContainerWidth(markup: string, root: string): void { const container = firstOpeningTag(markup) const widthClasses = 'min-w-[88px] max-w-[280px] flex-[1_1_180px] min-[1280px]:flex-[1_1_220px]' @@ -357,6 +370,7 @@ describe('tab title tooltips', () => { expect(root).toContain('role="tab"') expect(root).toContain('tabindex="0"') expect(root).toContain('data-tab-id="editor-tab-1"') + expect(textSpanHtml(markup, 'VeryLongEditorFileName.tsx')).not.toContain('renamed') expectTabContainerWidth(markup, root) }) }) diff --git a/src/renderer/src/hooks/useShortcutLabel.ts b/src/renderer/src/hooks/useShortcutLabel.ts index 6a92064f25b..9f15743ad40 100644 --- a/src/renderer/src/hooks/useShortcutLabel.ts +++ b/src/renderer/src/hooks/useShortcutLabel.ts @@ -32,6 +32,26 @@ export function useShortcutLabel(actionId: KeybindingActionId): string { return formatShortcutLabel(actionId, keybindings) } +// Why: returns null for unbound actions instead of the display sentinel +// 'Unassigned', so callers decide whether to render a hint without coupling +// UI logic to formatter copy (which may change or become localized). +export function formatOptionalShortcutLabel( + actionId: KeybindingActionId, + overrides?: KeybindingOverrides +): string | null { + const platform = getShortcutPlatform() + const bindings = getEffectiveKeybindingsForAction(actionId, platform, overrides) + if (bindings.length === 0) { + return null + } + return formatKeybindingList(bindings, platform) +} + +export function useOptionalShortcutLabel(actionId: KeybindingActionId): string | null { + const keybindings = useAppStore((state) => state.keybindings) + return formatOptionalShortcutLabel(actionId, keybindings) +} + export function formatShortcutKeys( actionId: KeybindingActionId, overrides?: KeybindingOverrides diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a699f1d5812..86a665eafb7 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2500,7 +2500,8 @@ "SortableTab": { "ab19f603eb": "Rename tab {{value0}}", "6df69d9388": "Close tab {{value0}}", - "fdb2691425": "Collapse pane" + "fdb2691425": "Collapse pane", + "95db5f2f7d": "Close tab" }, "SortableTabContextMenu": { "35e8892fd0": "Tab Color", @@ -2624,6 +2625,10 @@ "down": "Down", "up": "Up", "moveToPaneColumn": "Move Tab to Split" + }, + "EditorFileTabCloseButton": { + "4655cf570e": "Close tab", + "a768f428f1": "Close tab" } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ac7092448ff..f4592d35d23 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2500,7 +2500,8 @@ "SortableTab": { "ab19f603eb": "Cambiar nombre de pestaña {{value0}}", "6df69d9388": "Cerrar pestaña {{value0}}", - "fdb2691425": "Contraer panel" + "fdb2691425": "Contraer panel", + "95db5f2f7d": "Cerrar pestaña" }, "SortableTabContextMenu": { "35e8892fd0": "Color de pestaña", @@ -2624,6 +2625,10 @@ "down": "Abajo", "up": "Arriba", "moveToPaneColumn": "Mover pestaña a división" + }, + "EditorFileTabCloseButton": { + "4655cf570e": "Cerrar pestaña", + "a768f428f1": "Cerrar pestaña" } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 2ceb16b354d..205a953ec87 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2500,7 +2500,8 @@ "SortableTab": { "ab19f603eb": "タブ {{value0}} の名前を変更します", "6df69d9388": "タブ {{value0}} を閉じる", - "fdb2691425": "ペインを折りたたむ" + "fdb2691425": "ペインを折りたたむ", + "95db5f2f7d": "タブを閉じる" }, "SortableTabContextMenu": { "35e8892fd0": "タブの色", @@ -2624,6 +2625,10 @@ "down": "下", "up": "上", "moveToPaneColumn": "タブを分割へ移動" + }, + "EditorFileTabCloseButton": { + "4655cf570e": "タブを閉じる", + "a768f428f1": "タブを閉じる" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 7a22cac315f..60f7ac0ebbb 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2500,7 +2500,8 @@ "SortableTab": { "ab19f603eb": "탭 이름 바꾸기 {{value0}}", "6df69d9388": "탭 닫기 {{value0}}", - "fdb2691425": "창 축소" + "fdb2691425": "창 축소", + "95db5f2f7d": "탭 닫기" }, "SortableTabContextMenu": { "35e8892fd0": "탭 색상", @@ -2624,6 +2625,10 @@ "down": "아래", "up": "위", "moveToPaneColumn": "탭을 분할로 이동" + }, + "EditorFileTabCloseButton": { + "4655cf570e": "탭 닫기", + "a768f428f1": "탭 닫기" } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 8d2d12868b6..0c5d2d3c9fd 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2500,7 +2500,8 @@ "SortableTab": { "ab19f603eb": "重命名选项卡 {{value0}}", "6df69d9388": "关闭选项卡 {{value0}}", - "fdb2691425": "折叠窗格" + "fdb2691425": "折叠窗格", + "95db5f2f7d": "关闭选项卡" }, "SortableTabContextMenu": { "35e8892fd0": "标签颜色", @@ -2624,6 +2625,10 @@ "down": "下", "up": "上", "moveToPaneColumn": "将标签页移至拆分" + }, + "EditorFileTabCloseButton": { + "4655cf570e": "关闭选项卡", + "a768f428f1": "关闭选项卡" } } }, diff --git a/src/renderer/src/lib/floating-terminal.test.ts b/src/renderer/src/lib/floating-terminal.test.ts new file mode 100644 index 00000000000..ea52f857a88 --- /dev/null +++ b/src/renderer/src/lib/floating-terminal.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + consumeFloatingTerminalOpenMaximizedIntent, + requestFloatingTerminalOpenMaximized +} from './floating-terminal' + +describe('floating terminal open-maximized intent', () => { + afterEach(() => { + vi.useRealTimers() + // Drain any leftover intent so it cannot bleed into an unrelated test. + consumeFloatingTerminalOpenMaximizedIntent() + }) + + it('returns true exactly once after a request', () => { + requestFloatingTerminalOpenMaximized() + + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(true) + // One-shot: a second consume without a new request is false. + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(false) + }) + + it('returns false when no request was made', () => { + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(false) + }) + + it('expires a stale intent so an abandoned open does not leak into a later open', () => { + vi.useFakeTimers() + requestFloatingTerminalOpenMaximized() + + // Why: the open was abandoned (prevented/interrupted before the panel + // mounted); a much-later ordinary open must not consume the stale intent. + vi.advanceTimersByTime(2001) + + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(false) + }) + + it('still honors an intent consumed within the same interaction window', () => { + vi.useFakeTimers() + requestFloatingTerminalOpenMaximized() + + vi.advanceTimersByTime(50) + + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(true) + }) +}) diff --git a/src/renderer/src/lib/floating-terminal.ts b/src/renderer/src/lib/floating-terminal.ts index 85a53f5d77f..b496590cff2 100644 --- a/src/renderer/src/lib/floating-terminal.ts +++ b/src/renderer/src/lib/floating-terminal.ts @@ -1 +1,28 @@ export const TOGGLE_FLOATING_TERMINAL_EVENT = 'orca-toggle-floating-terminal' + +// Why: maximize/restore lives in the panel's own keydown handler, but that +// handler is unmounted while the panel is closed. When Cmd+Opt+Shift+A is +// pressed with the panel closed, App opens it and records a one-shot intent +// here so the freshly mounted panel starts maximized instead of at its last +// saved size. A module singleton (not a prop) bridges the closed→mounted gap +// that React state cannot, and is consumed exactly once. +let openMaximizedIntentAt: number | null = null + +// Why: the panel mounts within the same interaction as the request, so an +// intent older than this window means the open was abandoned (prevented or +// interrupted before mount). Expiring it stops a stale intent from leaking +// into a later ordinary open and maximizing it unexpectedly. +const OPEN_MAXIMIZED_INTENT_TTL_MS = 2000 + +export function requestFloatingTerminalOpenMaximized(): void { + openMaximizedIntentAt = Date.now() +} + +export function consumeFloatingTerminalOpenMaximizedIntent(): boolean { + if (openMaximizedIntentAt === null) { + return false + } + const requestedAt = openMaximizedIntentAt + openMaximizedIntentAt = null + return Date.now() - requestedAt <= OPEN_MAXIMIZED_INTENT_TTL_MS +} diff --git a/src/shared/keybindings.test.ts b/src/shared/keybindings.test.ts index 1228d7bbdd6..83db7e37efd 100644 --- a/src/shared/keybindings.test.ts +++ b/src/shared/keybindings.test.ts @@ -21,6 +21,7 @@ import { normalizeKeybindingListForAction, normalizeKeybindingList } from './keybindings' +import type { KeybindingActionId, KeybindingPlatform } from './keybindings' import { ALL_TUI_AGENTS } from './tui-agent-display-names' describe('keybindings', () => { @@ -250,6 +251,22 @@ describe('keybindings', () => { 'linux' ) ).toBe(false) + expect( + keybindingMatchesAction( + 'tab.rename', + { + key: 'r', + code: 'KeyR', + meta: true, + control: false, + alt: false, + shift: false + }, + 'darwin', + undefined, + { context: 'terminal', terminalShortcutPolicy: 'terminal-first' } + ) + ).toBe(false) // Why: tab.rename (Mod+R) intentionally shares its binding with // browser.reload, but the two live in different scopes (tabs vs browser), @@ -439,6 +456,65 @@ describe('keybindings', () => { ) }) + it('defines floating workspace panel action metadata', () => { + const actionIds = [ + 'floatingWorkspace.maximize' as KeybindingActionId, + 'floatingWorkspace.minimize' as KeybindingActionId + ] as const + + for (const actionId of actionIds) { + expect(getKeybindingDefinition(actionId), actionId).toMatchObject({ id: actionId }) + } + }) + + it('assigns the floating workspace maximize default only on macOS', () => { + const maximizeAction = 'floatingWorkspace.maximize' as KeybindingActionId + + expect(getEffectiveKeybindingsForAction(maximizeAction, 'darwin')).toEqual(['Mod+Alt+Shift+A']) + expect(getEffectiveKeybindingsForAction(maximizeAction, 'linux')).toEqual([]) + expect(getEffectiveKeybindingsForAction(maximizeAction, 'win32')).toEqual([]) + }) + + it('captures and round-trips the macOS Option-composed maximize chord', () => { + const maximizeAction = 'floatingWorkspace.maximize' as KeybindingActionId + + // Why: macOS Option+A composes to a glyph (å), so capture must resolve the + // chord through the physical-code fallback rather than the composed key, + // matching the matcher so a user override round-trips to the same binding. + const macComposedMaximize = { + key: 'å', + code: 'KeyA', + meta: true, + control: false, + alt: true, + shift: true + } + expect(keybindingFromInput(macComposedMaximize, 'darwin')).toEqual({ + ok: true, + value: 'Mod+Alt+Shift+A' + }) + expect(keybindingMatchesAction(maximizeAction, macComposedMaximize, 'darwin')).toBe(true) + // The captured override formats back to the same effective shortcut. + expect( + getEffectiveKeybindingsForAction(maximizeAction, 'darwin', { + [maximizeAction]: ['Mod+Alt+Shift+A'] + }) + ).toEqual(['Mod+Alt+Shift+A']) + expect(formatKeybindingList(['Mod+Alt+Shift+A'], 'darwin')).toBe('⌘⌥⇧A') + }) + + it('leaves floating workspace minimize unassigned because floating terminal toggle owns show and hide', () => { + const platforms: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32'] + const minimizeAction = 'floatingWorkspace.minimize' as KeybindingActionId + + for (const platform of platforms) { + expect(getEffectiveKeybindingsForAction(minimizeAction, platform)).toEqual([]) + } + expect(getEffectiveKeybindingsForAction('floatingTerminal.toggle', 'darwin')).toEqual([ + 'Mod+Alt+A' + ]) + }) + it('defines a macOS-only default for the new agent tab shortcut', () => { expect(getEffectiveKeybindingsForAction('tab.newAgent', 'darwin')).toEqual(['Mod+Alt+T']) expect(getEffectiveKeybindingsForAction('tab.newAgent', 'linux')).toEqual([]) @@ -533,6 +609,32 @@ describe('keybindings', () => { ).toBe(true) }) + it('keeps floating workspace tab shortcuts active in app focus even with terminal-first policy configured', () => { + const panelFocus = { + context: 'app', + terminalShortcutPolicy: 'terminal-first' + } as const + + expect( + keybindingMatchesAction( + 'tab.rename', + { key: 'r', code: 'KeyR', meta: true, control: false, alt: false, shift: false }, + 'darwin', + undefined, + panelFocus + ) + ).toBe(true) + expect( + matchKeybindingDigitIndex( + 'tab.selectByIndex', + { key: '4', code: 'Digit4', meta: false, control: false, alt: true, shift: false }, + 'linux', + undefined, + panelFocus + ) + ).toBe(3) + }) + it('keeps terminal-allowed app shortcuts active in terminal-first mode', () => { const deleteBinding = { key: 'Backspace', diff --git a/src/shared/keybindings.ts b/src/shared/keybindings.ts index 4f514c25626..57a8b775267 100644 --- a/src/shared/keybindings.ts +++ b/src/shared/keybindings.ts @@ -50,6 +50,8 @@ export type KeybindingActionId = | 'sidebar.ports.toggle' | 'sidebar.focusWorktreeList' | 'floatingTerminal.toggle' + | 'floatingWorkspace.maximize' + | 'floatingWorkspace.minimize' | 'zoom.in' | 'zoom.out' | 'zoom.reset' @@ -408,6 +410,57 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [ defaultBindings: platformBindings(['Mod+Alt+A']), allowInTerminal: true }, + { + id: 'floatingWorkspace.maximize', + title: 'Maximize Floating Workspace Panel', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'floating', + 'workspace', + 'panel', + 'floating workspace', + 'workspace panel', + 'maximize', + 'expand' + ], + // Why: pairs with the floatingTerminal.toggle chord (Cmd+Opt+A) so + // maximize/restore lives on the same key anchor and stays one-handed, + // instead of the two-hand reach to Cmd+Opt+ArrowUp. macOS-only default; + // Linux/Windows stay unbound for users to assign. + defaultBindings: { + darwin: ['Mod+Alt+Shift+A'], + linux: [], + win32: [] + }, + allowInTerminal: true + }, + { + id: 'floatingWorkspace.minimize', + title: 'Minimize Floating Workspace Panel', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'floating', + 'workspace', + 'panel', + 'floating workspace', + 'workspace panel', + 'minimize', + 'hide' + ], + // Why: intentionally unbound on every platform. floatingTerminal.toggle + // already owns the default show/hide chord; this action exists only so + // users can bind an explicit "hide the focused panel" shortcut in Settings. + defaultBindings: { + darwin: [], + linux: [], + win32: [] + }, + allowInTerminal: true + }, { id: 'zoom.in', title: 'Zoom In',