diff --git a/src/App.tsx b/src/App.tsx index 956b105e..c4768f2b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -75,6 +75,7 @@ import { import { useAuthStore } from "@/features/auth/stores/auth-store"; import { ConnectDialog } from "@/features/auth/components/connect-dialog"; import { clampScale, SCALE_STEP, DEFAULT_SCALE } from "@/features/settings/lib/ui-scale"; +import { FastTabNavigator } from "./features/fast-navigator/components/FastTabNavigator"; // Interface-zoom helpers (⌘+/⌘-/⌘0). They read + write the persisted // `uiScale` setting; `updateSettings` applies it to the native WebView zoom. @@ -1273,6 +1274,7 @@ export function App() { + diff --git a/src/features/fast-navigator/components/FastTabNavigator.tsx b/src/features/fast-navigator/components/FastTabNavigator.tsx new file mode 100644 index 00000000..b3d7a3cf --- /dev/null +++ b/src/features/fast-navigator/components/FastTabNavigator.tsx @@ -0,0 +1,105 @@ +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { useLayoutStore } from "@/features/layout/stores/layout-store"; +import { cn } from "@/lib/utils"; + +// Ctrl+Tab, not Cmd+Tab — Cmd+Tab is the OS-level app switcher on macOS and +// can't be intercepted from a webview. VS Code makes the same choice on Mac. +const MODIFIER_KEY = "Control"; + +export function FastTabNavigator() { + const [open, setOpen] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); + const snapshotRef = useRef([]); + + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.key !== "Tab" || !e.ctrlKey) return; + e.preventDefault(); + e.stopPropagation(); + + const s = useLayoutStore.getState(); + + if (!open) { + const focusedIds = new Set( + s.tabs + .filter((t) => (t.groupId ?? "main") === s.focusedGroupId) + .map((t) => t.id), + ); + // tabMru is global; filter to the focused column and drop any stale + // ids (defensive — closeTab already prunes, this just guards drift). + const snapshot = s.tabMru.filter((id) => focusedIds.has(id)); + if (snapshot.length < 2) return; // nothing to cycle to + + snapshotRef.current = snapshot; + setSelectedIndex(1 % snapshot.length); // skip current (index 0) + setOpen(true); + } else { + setSelectedIndex((i) => { + const len = snapshotRef.current.length; + const delta = e.shiftKey ? -1 : 1; + return (i + delta + len) % len; + }); + } + } + + function commitAndClose() { + const targetId = snapshotRef.current[selectedIndex]; + if (targetId) { + useLayoutStore.getState().actions.setActiveTab(targetId); + } + setOpen(false); + setSelectedIndex(0); + } + + function handleKeyUp(e: KeyboardEvent) { + if (e.key !== MODIFIER_KEY || !open) return; + commitAndClose(); + } + + // Safety net: if the window loses focus while cycling (e.g. an OS-level + // Alt+Tab steals the keyup, or a DE swallows it), don't leave the + // overlay stuck open with no way to dismiss it. + function handleBlur() { + if (open) commitAndClose(); + } + + window.addEventListener("keydown", handleKeyDown, true); + window.addEventListener("keyup", handleKeyUp, true); + window.addEventListener("blur", handleBlur); + return () => { + window.removeEventListener("keydown", handleKeyDown, true); + window.removeEventListener("keyup", handleKeyUp, true); + window.removeEventListener("blur", handleBlur); + }; + }, [open, selectedIndex]); + + if (!open) return null; + + const tabsById = new Map(useLayoutStore.getState().tabs.map((t) => [t.id, t])); + + return createPortal( +
+
+ {snapshotRef.current.map((id, i) => { + const tab = tabsById.get(id); + if (!tab) return null; + return ( +
+ {tab.title} +
+ ); + })} +
+
, + document.body, + ); +} \ No newline at end of file diff --git a/src/features/layout/stores/layout-store.ts b/src/features/layout/stores/layout-store.ts index 527e8060..e3811301 100644 --- a/src/features/layout/stores/layout-store.ts +++ b/src/features/layout/stores/layout-store.ts @@ -28,6 +28,7 @@ export interface WorkspaceView { focusedGroupId: string; tabHistory: string[]; tabHistoryIndex: number; + tabMru: string[]; } interface ZenSnapshot { @@ -110,6 +111,7 @@ interface LayoutState { // navigates to a tab; back() / forward() rewind/advance an index. tabHistory: string[]; tabHistoryIndex: number; + tabMru: string[]; } interface LayoutActions { @@ -232,6 +234,7 @@ const initialState: LayoutState = { tabBarVisible: true, tabHistory: ["welcome-chat"], tabHistoryIndex: 0, + tabMru: ["welcome-chat"] }; const DEFAULT_GROUP = "main"; @@ -264,6 +267,14 @@ function pushTabHistory(s: LayoutState, id: string): void { s.tabHistoryIndex = s.tabHistory.length - 1; } +// Ctrl/Cmd+Tab MRU order — separate from tabHistory (which is a linear +// back/forward stack that doesn't reorder on revisit). This DOES reorder: +// activating an already-recent tab bumps it back to the front. +function recordTabMru(s: LayoutState, id: string): void { + const withoutId = s.tabMru.filter((t) => t !== id); + s.tabMru = [id, ...withoutId]; +} + /** Ensure every tab sits in a live column, every column has a valid active * tab, and focus is valid. Used after bulk group changes (workspace restore, * zen toggle). */ @@ -310,6 +321,7 @@ function welcomeView(wsId: string): WorkspaceView { focusedGroupId: DEFAULT_GROUP, tabHistory: [id], tabHistoryIndex: 0, + tabMru: [id], }; } @@ -323,6 +335,7 @@ function captureView(s: LayoutState): WorkspaceView { focusedGroupId: s.focusedGroupId, tabHistory: [...s.tabHistory], tabHistoryIndex: s.tabHistoryIndex, + tabMru: [...s.tabMru], }; } @@ -335,6 +348,7 @@ function applyView(s: LayoutState, v: WorkspaceView): void { s.focusedGroupId = v.focusedGroupId; s.tabHistory = [...v.tabHistory]; s.tabHistoryIndex = v.tabHistoryIndex; + s.tabMru = [...v.tabMru]; } export const useLayoutStore = createSelectors( @@ -453,6 +467,7 @@ export const useLayoutStore = createSelectors( s.focusedGroupId = targetGroup; s.activeByGroup[targetGroup] = targetId; pushTabHistory(s, targetId); + recordTabMru(s, targetId) syncActiveMirror(s); }), closeTab: (id) => @@ -467,6 +482,8 @@ export const useLayoutStore = createSelectors( .findIndex((t) => t.id === id); s.tabs.splice(idx, 1); + s.tabMru = s.tabMru.filter((t) => t !== id); + const remaining = s.tabs.filter((t) => groupOf(t) === grp); if (remaining.length === 0) { if (s.groupOrder.length > 1) { @@ -478,11 +495,13 @@ export const useLayoutStore = createSelectors( // Last column emptied — restore the permanent welcome chat. s.tabs.push(WELCOME_TAB(grp)); s.activeByGroup[grp] = "welcome-chat"; + recordTabMru(s, "welcome-chat"); } } else if (s.activeByGroup[grp] === id) { // Activate the neighbour at the same slot within the column. const next = remaining[Math.min(groupIdxInGroup, remaining.length - 1)]; s.activeByGroup[grp] = next.id; + recordTabMru(s, next.id); } syncActiveMirror(s); }), @@ -495,6 +514,7 @@ export const useLayoutStore = createSelectors( s.focusedGroupId = grp; s.activeByGroup[grp] = target; pushTabHistory(s, target); + recordTabMru(s, target); syncActiveMirror(s); }), toggleTabBar: () => @@ -537,6 +557,7 @@ export const useLayoutStore = createSelectors( if (!target) return; s.activeByGroup[s.focusedGroupId] = target.id; pushTabHistory(s, target.id); + recordTabMru(s, target.id); syncActiveMirror(s); }), cycleTab: (delta) => @@ -548,6 +569,7 @@ export const useLayoutStore = createSelectors( const next = groupTabs[(ci + delta + groupTabs.length) % groupTabs.length]; s.activeByGroup[s.focusedGroupId] = next.id; pushTabHistory(s, next.id); + recordTabMru(s, next.id) syncActiveMirror(s); }), setFocusedGroup: (groupId) =>