feat(ui): numbered context-panel surface switching with configurable prefix
- Add switch_context_surface shortcut (default Cmd/Ctrl + 1..9, 0 for the 10th surface) that opens/closes/switches context panel rail surfaces by their visible order, configurable and persisted in Settings -> Shortcuts. - Show order-number badges on rail icons while the modifier is held >500ms; dismiss on release, blur, or a number press until the next press-and-hold. - Remove the legacy mod+2/3/4 (diff/terminal/git) and switch_tab_1..9 bindings so numbered surface switching goes only through the new mechanism. - Replace the help-dialog 'Switch Project' row with the surface-switch row and update the shortcuts footer/header icons to the command icon.
This commit is contained in:
@@ -24,18 +24,23 @@ import { useDeviceInfo } from '@/lib/device';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
getVisibleContextRailSurfaces,
|
||||
sortContextSurfaces,
|
||||
type ContextSurfaceDescriptor,
|
||||
} from '@/lib/surfaces/registry';
|
||||
import {
|
||||
getEffectiveShortcutPrefix,
|
||||
isShortcutPrefixHeld,
|
||||
} from '@/lib/shortcuts';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||
import { useGitStatus } from '@/stores/useGitStore';
|
||||
import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
const RAIL_TOOLTIP_DELAY_MS = 150;
|
||||
// Tablet width and up: below this the walkthrough cannot show a stop and its
|
||||
// code side by side, which is the whole point of the surface.
|
||||
const WALKTHROUGH_MIN_WIDTH = 768;
|
||||
// Hold the surface-switch modifier for this long before revealing the order
|
||||
// number badges on the rail icons.
|
||||
const RAIL_NUMBER_HOLD_DELAY_MS = 500;
|
||||
const EMPTY_TABS: never[] = [];
|
||||
|
||||
type RailItemProps = {
|
||||
@@ -44,6 +49,8 @@ type RailItemProps = {
|
||||
showActivityDot: boolean;
|
||||
label: string;
|
||||
description: string;
|
||||
orderNumber?: number | null;
|
||||
showOrderNumber?: boolean;
|
||||
onSelect: (surface: ContextSurfaceDescriptor) => void;
|
||||
};
|
||||
|
||||
@@ -53,6 +60,8 @@ const ContextPanelRailItem: React.FC<RailItemProps> = ({
|
||||
showActivityDot,
|
||||
label,
|
||||
description,
|
||||
orderNumber,
|
||||
showOrderNumber,
|
||||
onSelect,
|
||||
}) => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
@@ -86,12 +95,20 @@ const ContextPanelRailItem: React.FC<RailItemProps> = ({
|
||||
) : (
|
||||
<Icon name={surface.icon} className="h-[18px] w-[18px]" />
|
||||
)}
|
||||
{showActivityDot ? (
|
||||
{showActivityDot && !showOrderNumber ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-[var(--status-info)]"
|
||||
/>
|
||||
) : null}
|
||||
{showOrderNumber && orderNumber != null ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute right-0 top-0 flex h-4 min-w-4 items-center justify-center rounded-full bg-surface-muted px-1 text-[0.625rem] font-medium leading-none text-muted-foreground"
|
||||
>
|
||||
{orderNumber === 10 ? '0' : orderNumber}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" sideOffset={8}>
|
||||
@@ -114,10 +131,86 @@ export const ContextPanelRail: React.FC = () => {
|
||||
const contextRailOrder = useUIStore((state) => state.contextRailOrder);
|
||||
const setContextRailOrder = useUIStore((state) => state.setContextRailOrder);
|
||||
const openContextSurface = useUIStore((state) => state.openContextSurface);
|
||||
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
|
||||
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
|
||||
const { screenWidth } = useDeviceInfo();
|
||||
const gitStatus = useGitStatus(directoryKey || null);
|
||||
|
||||
const surfaceSwitchPrefix = React.useMemo(
|
||||
() => getEffectiveShortcutPrefix('switch_context_surface', shortcutOverrides),
|
||||
[shortcutOverrides],
|
||||
);
|
||||
const [revealNumbers, setRevealNumbers] = React.useState(false);
|
||||
|
||||
// While the surface-switch modifier is held for RAIL_NUMBER_HOLD_DELAY_MS,
|
||||
// reveal the order number badges so users can see which digit maps to which
|
||||
// rail icon. Releasing (or losing focus) dismisses them, and pressing a
|
||||
// number key while the chord is armed consumes them for this hold — they
|
||||
// only come back on the next press-and-hold.
|
||||
React.useEffect(() => {
|
||||
const held = new Set<string>();
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let consumedWhileHeld = false;
|
||||
|
||||
const isDigitKey = (key: string) => key.length === 1 && key >= '0' && key <= '9';
|
||||
|
||||
const update = () => {
|
||||
const armed = isShortcutPrefixHeld(surfaceSwitchPrefix, held);
|
||||
if (armed) {
|
||||
if (!consumedWhileHeld && timer === null) {
|
||||
timer = setTimeout(() => setRevealNumbers(true), RAIL_NUMBER_HOLD_DELAY_MS);
|
||||
}
|
||||
} else {
|
||||
consumedWhileHeld = false;
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
setRevealNumbers(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
held.add(e.key.toLowerCase());
|
||||
if (isDigitKey(e.key) && isShortcutPrefixHeld(surfaceSwitchPrefix, held)) {
|
||||
consumedWhileHeld = true;
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
setRevealNumbers(false);
|
||||
return;
|
||||
}
|
||||
update();
|
||||
};
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
held.delete(e.key.toLowerCase());
|
||||
update();
|
||||
};
|
||||
const onWindowBlur = () => {
|
||||
held.clear();
|
||||
consumedWhileHeld = false;
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
setRevealNumbers(false);
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, true);
|
||||
window.addEventListener('keyup', onKeyUp, true);
|
||||
window.addEventListener('blur', onWindowBlur);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown, true);
|
||||
window.removeEventListener('keyup', onKeyUp, true);
|
||||
window.removeEventListener('blur', onWindowBlur);
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}, [surfaceSwitchPrefix]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 6 } }),
|
||||
@@ -128,22 +221,13 @@ export const ContextPanelRail: React.FC = () => {
|
||||
const activeMode = panelState?.isOpen ? activeTab?.mode ?? null : null;
|
||||
const changedFilesCount = gitStatus?.files.length ?? 0;
|
||||
|
||||
// Content-driven surfaces are hidden (not disabled) until content exists;
|
||||
// an existing tab keeps them visible even if the content source went away.
|
||||
const surfaces = React.useMemo(() => {
|
||||
return sortContextSurfaces(contextRailOrder).filter((surface) => {
|
||||
if (surface.id === 'plan' && !planModeEnabled) {
|
||||
return false;
|
||||
}
|
||||
// The walkthrough needs room for a stop list beside real code, and its
|
||||
// diffs come from OpenChamber's Git routes, which VS Code does not serve.
|
||||
if (surface.id === 'walkthrough' && (isVSCodeRuntime() || screenWidth < WALKTHROUGH_MIN_WIDTH)) {
|
||||
return false;
|
||||
}
|
||||
if (surface.availability === 'has-content') {
|
||||
return tabs.some((tab) => tab.mode === surface.mode);
|
||||
}
|
||||
return true;
|
||||
return getVisibleContextRailSurfaces({
|
||||
railOrder: contextRailOrder,
|
||||
planModeEnabled,
|
||||
isVSCode: isVSCodeRuntime(),
|
||||
screenWidth,
|
||||
tabs,
|
||||
});
|
||||
}, [contextRailOrder, planModeEnabled, screenWidth, tabs]);
|
||||
|
||||
@@ -174,7 +258,7 @@ export const ContextPanelRail: React.FC = () => {
|
||||
>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={surfaces.map((surface) => surface.id)} strategy={verticalListSortingStrategy}>
|
||||
{surfaces.map((surface) => (
|
||||
{surfaces.map((surface, index) => (
|
||||
<ContextPanelRailItem
|
||||
key={surface.id}
|
||||
surface={surface}
|
||||
@@ -182,6 +266,8 @@ export const ContextPanelRail: React.FC = () => {
|
||||
showActivityDot={surface.id === 'git' && changedFilesCount > 0}
|
||||
label={t(surface.labelKey)}
|
||||
description={t(surface.descriptionKey)}
|
||||
orderNumber={index + 1}
|
||||
showOrderNumber={revealNumbers}
|
||||
onSelect={(selected) => openContextSurface(directoryKey, selected.mode)}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user