Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -1273,6 +1274,7 @@ export function App() {
<FilePicker open={filePickerOpen} onOpenChange={setFilePickerOpen} />
<HintOverlay />
<NotificationPanel />
<FastTabNavigator />
<FeedbackPanel />
<UpdateAvailableModal />
<ConnectDialog />
Expand Down
105 changes: 105 additions & 0 deletions src/features/fast-navigator/components/FastTabNavigator.tsx
Original file line number Diff line number Diff line change
@@ -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<string[]>([]);

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(
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[20vh] pointer-events-none">
<div className="pointer-events-auto min-w-[360px] max-w-[520px] rounded-lg border border-[var(--border-default)] bg-[var(--bg-elevated)] shadow-[var(--shadow-overlay)] py-1.5">
{snapshotRef.current.map((id, i) => {
const tab = tabsById.get(id);
if (!tab) return null;
return (
<div
key={id}
className={cn(
"flex items-center gap-2 px-3 h-[30px] text-[12px] truncate",
i === selectedIndex
? "bg-[var(--bg-selected)] text-[var(--text-primary)]"
: "text-[var(--text-secondary)]",
)}
>
{tab.title}
</div>
);
})}
</div>
</div>,
document.body,
);
}
22 changes: 22 additions & 0 deletions src/features/layout/stores/layout-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface WorkspaceView {
focusedGroupId: string;
tabHistory: string[];
tabHistoryIndex: number;
tabMru: string[];
}

interface ZenSnapshot {
Expand Down Expand Up @@ -110,6 +111,7 @@ interface LayoutState {
// navigates to a tab; back() / forward() rewind/advance an index.
tabHistory: string[];
tabHistoryIndex: number;
tabMru: string[];
}

interface LayoutActions {
Expand Down Expand Up @@ -232,6 +234,7 @@ const initialState: LayoutState = {
tabBarVisible: true,
tabHistory: ["welcome-chat"],
tabHistoryIndex: 0,
tabMru: ["welcome-chat"]
};

const DEFAULT_GROUP = "main";
Expand Down Expand Up @@ -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). */
Expand Down Expand Up @@ -310,6 +321,7 @@ function welcomeView(wsId: string): WorkspaceView {
focusedGroupId: DEFAULT_GROUP,
tabHistory: [id],
tabHistoryIndex: 0,
tabMru: [id],
};
}

Expand All @@ -323,6 +335,7 @@ function captureView(s: LayoutState): WorkspaceView {
focusedGroupId: s.focusedGroupId,
tabHistory: [...s.tabHistory],
tabHistoryIndex: s.tabHistoryIndex,
tabMru: [...s.tabMru],
};
}

Expand All @@ -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(
Expand Down Expand Up @@ -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) =>
Expand All @@ -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) {
Expand All @@ -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);
}),
Expand All @@ -495,6 +514,7 @@ export const useLayoutStore = createSelectors(
s.focusedGroupId = grp;
s.activeByGroup[grp] = target;
pushTabHistory(s, target);
recordTabMru(s, target);
syncActiveMirror(s);
}),
toggleTabBar: () =>
Expand Down Expand Up @@ -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) =>
Expand All @@ -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) =>
Expand Down
Loading