feat(desktop): ⌘W close-tab, ⌘⇧T reopen, ⌘T new tab, ⌘1-9 + ⌃Tab tab switching
parent
ac4f596ca2
commit
eae1d7d147
|
|
@ -0,0 +1,29 @@
|
|||
import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals'
|
||||
import { closeWorkspaceTab } from '@/components/pane-shell/tree/store'
|
||||
import { isFocusWithin } from '@/lib/keybinds/combo'
|
||||
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '@/store/preview'
|
||||
|
||||
/**
|
||||
* ⌘W — close the tab of the context you're in, by precedence:
|
||||
* 1. a focused terminal → its active terminal tab,
|
||||
* 2. an open preview → its active preview tab (unchanged from pre-tiling),
|
||||
* 3. the MAIN zone → its active tab (a session tile stacked into the workspace).
|
||||
* Returns false when nothing closes, so ⌘W is a no-op — it never closes the
|
||||
* window (a bare workspace stays put). Shared by the keyboard path (Win/Linux)
|
||||
* and the macOS menu-accelerator IPC.
|
||||
*/
|
||||
export function closeActiveTab(): boolean {
|
||||
if (isFocusWithin('[data-terminal]')) {
|
||||
closeActiveTerminal()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if ($filePreviewTarget.get() || $previewTarget.get()) {
|
||||
closeActiveRightRailTab()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return closeWorkspaceTab()
|
||||
}
|
||||
|
|
@ -1,18 +1,16 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { closeActiveTab } from '@/app/chat/close-tab'
|
||||
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
|
||||
import { closeActiveTerminal, createTerminal, cycleTerminal } from '@/app/right-sidebar/terminal/terminals'
|
||||
import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
|
||||
import { matchesQuery } from '@/hooks/use-media-query'
|
||||
import { PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions'
|
||||
import { activateTreeTabSlot, cycleTreeTabInFocusedZone, layoutHasRootSide } from '@/components/pane-shell/tree/store'
|
||||
import { contributedKeybindHandler, PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions'
|
||||
import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo'
|
||||
import { $repoStatus } from '@/store/coding-status'
|
||||
import { toggleCommandPalette } from '@/store/command-palette'
|
||||
import { $capture, $comboIndex, endCapture, setBinding, toggleKeybindPanel } from '@/store/keybinds'
|
||||
import {
|
||||
CHAT_SIDEBAR_PANE_ID,
|
||||
FILE_BROWSER_PANE_ID,
|
||||
requestSessionSearchFocus,
|
||||
setFileBrowserOpen,
|
||||
toggleFileBrowserOpen,
|
||||
|
|
@ -30,6 +28,7 @@ import {
|
|||
import { requestNewWorktree } from '@/store/projects'
|
||||
import { toggleReview } from '@/store/review'
|
||||
import { setModelPickerOpen } from '@/store/session'
|
||||
import { reopenLastClosedTile } from '@/store/session-states'
|
||||
import {
|
||||
$switcherOpen,
|
||||
closeSwitcher,
|
||||
|
|
@ -45,7 +44,6 @@ import { openNewSessionInNewWindow } from '@/store/windows'
|
|||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { requestComposerFocus, requestVoiceToggle } from '../chat/composer/focus'
|
||||
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
|
||||
import {
|
||||
AGENTS_ROUTE,
|
||||
ARTIFACTS_ROUTE,
|
||||
|
|
@ -62,6 +60,8 @@ export interface KeybindRuntimeDeps {
|
|||
toggleCommandCenter: () => void
|
||||
/** Drop to a fresh new-session draft. */
|
||||
startFreshSession: () => void
|
||||
/** Open a fresh session as a tab in the main zone (⌘T), leaving the primary. */
|
||||
openNewSessionTab: () => void
|
||||
/** Pin/unpin the active session. */
|
||||
toggleSelectedPin: () => void
|
||||
}
|
||||
|
|
@ -82,7 +82,13 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||
const profileSwitchHandlers: HandlerMap = {}
|
||||
|
||||
for (let slot = 1; slot <= PROFILE_SLOT_COUNT; slot += 1) {
|
||||
profileSwitchHandlers[`profile.switch.${slot}`] = () => switchProfileToSlot(slot)
|
||||
// ⌘1…⌘9 switch the FOCUSED zone's tab when it's a real tab strip; only a
|
||||
// single-pane (or unfocused) layout falls through to the profile switch.
|
||||
profileSwitchHandlers[`profile.switch.${slot}`] = () => {
|
||||
if (!activateTreeTabSlot(slot)) {
|
||||
switchProfileToSlot(slot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const goToSession = (sessionId: null | string) => {
|
||||
|
|
@ -138,9 +144,12 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||
deps.startFreshSession()
|
||||
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
|
||||
},
|
||||
'session.newTab': () => deps.openNewSessionTab(),
|
||||
'session.newWindow': () => void openNewSessionInNewWindow(),
|
||||
'session.next': () => stepSession(1),
|
||||
'session.prev': () => stepSession(-1),
|
||||
// ⌃Tab cycles the focused session/main tab strip; only a non-tabbed focus
|
||||
// falls through to the recent-session switcher.
|
||||
'session.next': () => void (cycleTreeTabInFocusedZone(1) || stepSession(1)),
|
||||
'session.prev': () => void (cycleTreeTabInFocusedZone(-1) || stepSession(-1)),
|
||||
...sessionSlotHandlers,
|
||||
'session.focusSearch': requestSessionSearchFocus,
|
||||
'session.togglePin': deps.toggleSelectedPin,
|
||||
|
|
@ -148,20 +157,13 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||
// through instead of silently doing nothing).
|
||||
'workspace.newWorktree': () => $repoStatus.get() && requestNewWorktree(),
|
||||
|
||||
'view.toggleSidebar': () => {
|
||||
if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) {
|
||||
window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: CHAT_SIDEBAR_PANE_ID } }))
|
||||
} else {
|
||||
toggleSidebarOpen()
|
||||
}
|
||||
},
|
||||
'view.toggleRightSidebar': () => {
|
||||
if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) {
|
||||
window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: FILE_BROWSER_PANE_ID } }))
|
||||
} else {
|
||||
toggleFileBrowserOpen()
|
||||
}
|
||||
},
|
||||
// Narrow-viewport reveal is handled inside the store toggles now.
|
||||
'view.toggleSidebar': toggleSidebarOpen,
|
||||
// ⌘J toggles the right sidebar — but a layout with no right side (e.g.
|
||||
// terminal-on-bottom) would leave it a dead key, so it falls back to the
|
||||
// terminal there. The single "secondary panel" toggle.
|
||||
'view.toggleRightSidebar': () =>
|
||||
layoutHasRootSide('right') ? toggleFileBrowserOpen() : setTerminalTakeover(!$terminalTakeover.get()),
|
||||
'view.toggleReview': toggleReview,
|
||||
'view.showFiles': showFiles,
|
||||
'view.showTerminal': () => setTerminalTakeover(!$terminalTakeover.get()),
|
||||
|
|
@ -177,6 +179,12 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||
'view.prevTerminal': () => $terminalTakeover.get() && cycleTerminal(-1),
|
||||
'view.closeTerminal': () => $terminalTakeover.get() && closeActiveTerminal(),
|
||||
'view.flipPanes': togglePanesFlipped,
|
||||
// ⌘W: close the focused tab (terminal / preview target / zone tree tab).
|
||||
// On macOS the menu accelerator owns ⌘W and routes through the same
|
||||
// closeActiveTab via IPC (see use-desktop-integrations); this binding is
|
||||
// the Win/Linux path where ⌘W reaches the renderer directly.
|
||||
'view.closeTab': () => void closeActiveTab(),
|
||||
'view.reopenTab': reopenLastClosedTile,
|
||||
|
||||
'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'),
|
||||
|
||||
|
|
@ -242,7 +250,9 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||
return
|
||||
}
|
||||
|
||||
const handler = handlersRef.current[actionId]
|
||||
// Built-in handlers first (they carry React context); contributed
|
||||
// actions bring their own `run` through the registry.
|
||||
const handler = handlersRef.current[actionId] ?? contributedKeybindHandler(actionId)
|
||||
|
||||
if (!handler) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
// like navigate / theme); labels come from i18n (`t.keybinds.actions[id]`). To
|
||||
// add a hotkey, add a row here and a handler there — nothing else.
|
||||
|
||||
import { registry } from '@/contrib/registry'
|
||||
|
||||
import { IS_MAC } from './combo'
|
||||
|
||||
export type KeybindCategory = 'composer' | 'profiles' | 'session' | 'navigation' | 'view'
|
||||
|
|
@ -22,6 +24,8 @@ export interface KeybindActionMeta {
|
|||
category: KeybindCategory
|
||||
/** Default combos. Empty = shipped unbound (user can assign one). */
|
||||
defaults: readonly string[]
|
||||
/** Display label for CONTRIBUTED actions (built-ins use i18n). */
|
||||
label?: string
|
||||
}
|
||||
|
||||
// Positional switch slots for *named* profiles: ⌘1…⌘9 for profiles 1-9, then
|
||||
|
|
@ -69,6 +73,7 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
|
|||
|
||||
// ── Session ──────────────────────────────────────────────────────────────
|
||||
{ id: 'session.new', category: 'session', defaults: ['mod+n', 'shift+n'] },
|
||||
{ id: 'session.newTab', category: 'session', defaults: ['mod+t'] },
|
||||
{ id: 'session.newWindow', category: 'session', defaults: ['mod+shift+n'] },
|
||||
// ⌃Tab / ⌃⇧Tab — the universal tab-cycle chord. Literally Control, not Cmd
|
||||
// (macOS reserves Cmd+Tab for app switching); see `ctrl` in combo.ts.
|
||||
|
|
@ -111,6 +116,11 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
|
|||
{ id: 'view.closeTerminal', category: 'view', defaults: ['ctrl+shift+w'] },
|
||||
// ⌘\ — the backslash reads like a mirror line flipping the layout.
|
||||
{ id: 'view.flipPanes', category: 'view', defaults: ['mod+\\'] },
|
||||
// ⌘W closes the focused zone's active tab — its own tab strip (preview) or
|
||||
// the tree tab (session tiles, files, terminal). The uncloseable workspace
|
||||
// is a no-op. ⌘⇧T reopens the last closed tab where it was.
|
||||
{ id: 'view.closeTab', category: 'view', defaults: ['mod+w'] },
|
||||
{ id: 'view.reopenTab', category: 'view', defaults: ['mod+shift+t'] },
|
||||
{ id: 'appearance.toggleMode', category: 'view', defaults: ['shift+x'] },
|
||||
{ id: 'keybinds.openPanel', category: 'view', defaults: ['mod+/'] }
|
||||
]
|
||||
|
|
@ -119,14 +129,58 @@ export const KEYBIND_ACTION_IDS: readonly string[] = KEYBIND_ACTIONS.map(action
|
|||
|
||||
const ACTION_BY_ID = new Map(KEYBIND_ACTIONS.map(action => [action.id, action]))
|
||||
|
||||
// ── Contributed actions — the `keybinds` registry area ──────────────────────
|
||||
// Same declarative schema as every other surface: a data contribution carries
|
||||
// the action's metadata AND its handler. Contributed actions are first-class:
|
||||
// they dispatch, appear in the panel, are rebindable, and their overrides
|
||||
// persist exactly like built-ins. Built-in ids can't be shadowed.
|
||||
|
||||
export const KEYBINDS_AREA = 'keybinds'
|
||||
|
||||
/** Payload of a `keybinds` data contribution. */
|
||||
export interface KeybindContribution {
|
||||
id: string
|
||||
/** Panel section. Defaults to `view`. */
|
||||
category?: KeybindCategory
|
||||
/** Default combos (canonical form, e.g. `mod+shift+\\`). Empty = unbound. */
|
||||
defaults?: readonly string[]
|
||||
label: string
|
||||
run: () => void
|
||||
}
|
||||
|
||||
export function contributedKeybinds(): KeybindContribution[] {
|
||||
return registry
|
||||
.getArea(KEYBINDS_AREA)
|
||||
.map(c => c.data as KeybindContribution)
|
||||
.filter(k => Boolean(k?.id && k.label) && typeof k?.run === 'function' && !ACTION_BY_ID.has(k.id))
|
||||
}
|
||||
|
||||
/** Built-ins + contributed, one metadata list (panel, bindings, conflicts). */
|
||||
export function allKeybindActions(): KeybindActionMeta[] {
|
||||
return [
|
||||
...KEYBIND_ACTIONS,
|
||||
...contributedKeybinds().map(k => ({
|
||||
id: k.id,
|
||||
category: k.category ?? ('view' as const),
|
||||
defaults: k.defaults ?? [],
|
||||
label: k.label
|
||||
}))
|
||||
]
|
||||
}
|
||||
|
||||
export function keybindAction(id: string): KeybindActionMeta | undefined {
|
||||
return ACTION_BY_ID.get(id)
|
||||
return ACTION_BY_ID.get(id) ?? allKeybindActions().find(action => action.id === id)
|
||||
}
|
||||
|
||||
/** The contributed handler for an action id (built-ins wire theirs in use-keybinds). */
|
||||
export function contributedKeybindHandler(id: string): (() => void) | undefined {
|
||||
return contributedKeybinds().find(k => k.id === id)?.run
|
||||
}
|
||||
|
||||
export type KeybindBindings = Record<string, string[]>
|
||||
|
||||
export function defaultBindings(): KeybindBindings {
|
||||
return Object.fromEntries(KEYBIND_ACTIONS.map(action => [action.id, [...action.defaults]]))
|
||||
return Object.fromEntries(allKeybindActions().map(action => [action.id, [...action.defaults]]))
|
||||
}
|
||||
|
||||
// Fixed, non-rebindable shortcuts surfaced read-only in the panel so the map is
|
||||
|
|
@ -150,6 +204,5 @@ export const KEYBIND_READONLY: readonly KeybindReadonly[] = [
|
|||
{ id: 'composer.history', category: 'composer', keys: ['up', 'down'] },
|
||||
{ id: 'composer.cancel', category: 'composer', keys: ['escape'] },
|
||||
// Fixed, context-local shortcuts surfaced for discoverability.
|
||||
{ id: 'view.terminalSelection', category: 'view', keys: ['mod+l'] },
|
||||
{ id: 'view.closePreviewTab', category: 'view', keys: ['mod+w'] }
|
||||
{ id: 'view.terminalSelection', category: 'view', keys: ['mod+l'] }
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in New Issue