mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-12 01:00:54 +00:00
Several everyday interactions re-rendered the entire shell (AppView, TopTabs,
TerminalLayer and every pane) even though nothing they show had changed:
- Clicking or focusing a split pane always replaced the workspaces array
(twice per click: onClick + onFocusCapture), cascading through the whole
app and re-saving the session restore snapshot. setWorkspaceFocusedSession
and moveFocusInWorkspace now keep the same array when focus is unchanged
(domain/workspace.ts setWorkspaceFocusedSessionId).
- Every window focus/visibility change resynced app lock and produced a new
runtime state object even when nothing changed, re-rendering the provider
tree. Equal states keep the current object and useAppLockState's result is
memoized.
- Quick switcher keystrokes went through AppSideEffects state and the
app-local UI store, re-rendering AppSideEffects, every host island, AppShell,
AppView and TopTabs per character. QuickSwitcherOverlay now owns the query
and host results; the overlay unmounts on close, which resets the query
exactly where setQuickSearch('') used to.
- Pressing/releasing Ctrl/Cmd (every ⌘C/⌘V/Ctrl+C) re-rendered the whole tab
bar to toggle shortcut number badges. The held flag now lives in a small
store read only by each tab's TabIconSlot.
- AppView passed fresh inline callbacks that defeated TopTabs' memo comparator
and every open editor tab; TextEditorTabView is now memoized. TopTabs
memoizes per-workspace session lists and hoists the root tab icons.
- Workspace drag previews only update state when the drop hint actually moves.
React commits / render time for 10 repetitions in the real app (profiling
build, 2 split panes, 300 hosts), main -> this change:
window focus 42 / 17.1 ms -> 2 / 0.4 ms
press/release Cmd 50 / 41.9 ms -> 22 / 6.6 ms
click focused pane 42 / 69.6 ms -> 3 / 0.4 ms
quick switcher typing 41 / 61.3 ms -> 16 / 34.8 ms
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
142 lines
4.6 KiB
TypeScript
142 lines
4.6 KiB
TypeScript
import { useEffect, useMemo, useSyncExternalStore } from 'react';
|
|
|
|
import { parseKeyCombo, type HotkeyScheme } from '../../domain/models/keyBindings';
|
|
|
|
export type ShortcutModifierEvent = Pick<KeyboardEvent, 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'>;
|
|
|
|
export type ShortcutModifierRequirements = Readonly<ShortcutModifierEvent>;
|
|
|
|
const MODIFIER_TOKENS = {
|
|
mac: {
|
|
'⌘': 'metaKey',
|
|
'⌃': 'ctrlKey',
|
|
'⌥': 'altKey',
|
|
Shift: 'shiftKey',
|
|
},
|
|
pc: {
|
|
Ctrl: 'ctrlKey',
|
|
Alt: 'altKey',
|
|
Shift: 'shiftKey',
|
|
Win: 'metaKey',
|
|
},
|
|
} as const;
|
|
|
|
/**
|
|
* Resolve the modifier combination for the effective [1...9] binding.
|
|
* A non-range or disabled binding cannot reveal tab numbers because the
|
|
* dispatcher does not use it for the number-switch action.
|
|
*/
|
|
export function getShortcutModifierRequirements(
|
|
keyBinding: string | null,
|
|
scheme: HotkeyScheme,
|
|
): ShortcutModifierRequirements | null {
|
|
if (scheme === 'disabled' || !keyBinding || !keyBinding.includes('[1...9]')) return null;
|
|
const parsed = parseKeyCombo(keyBinding);
|
|
if (!parsed || parsed.modifiers.length === 0) return null;
|
|
|
|
const requirements: Record<keyof ShortcutModifierEvent, boolean> = {
|
|
metaKey: false,
|
|
ctrlKey: false,
|
|
altKey: false,
|
|
shiftKey: false,
|
|
};
|
|
const tokenMap = MODIFIER_TOKENS[scheme];
|
|
for (const modifier of parsed.modifiers) {
|
|
const flag = tokenMap[modifier as keyof typeof tokenMap];
|
|
if (!flag) return null;
|
|
requirements[flag] = true;
|
|
}
|
|
|
|
return requirements;
|
|
}
|
|
|
|
/** Whether a keyboard event has exactly the modifiers required by the binding. */
|
|
export function isShortcutModifierHeld(
|
|
event: ShortcutModifierEvent,
|
|
requirements: ShortcutModifierRequirements | null,
|
|
): boolean {
|
|
if (!requirements) return false;
|
|
return event.metaKey === requirements.metaKey
|
|
&& event.ctrlKey === requirements.ctrlKey
|
|
&& event.altKey === requirements.altKey
|
|
&& event.shiftKey === requirements.shiftKey;
|
|
}
|
|
|
|
/** Whether a keyup event means the required modifier combination is no longer held. */
|
|
export function shouldReleaseShortcutModifier(
|
|
event: ShortcutModifierEvent,
|
|
requirements: ShortcutModifierRequirements | null,
|
|
): boolean {
|
|
return !isShortcutModifierHeld(event, requirements);
|
|
}
|
|
|
|
// The held flag lives in a tiny store so only the tab icon slots re-render when
|
|
// it flips. It used to be TopTabs state, which re-rendered the whole tab bar
|
|
// twice for every Ctrl/⌘ shortcut typed in a terminal (Ctrl+C, ⌘V, ...).
|
|
let modifierHeld = false;
|
|
const modifierHeldListeners = new Set<() => void>();
|
|
|
|
function publishModifierHeld(next: boolean): void {
|
|
if (modifierHeld === next) return;
|
|
modifierHeld = next;
|
|
for (const listener of modifierHeldListeners) listener();
|
|
}
|
|
|
|
function subscribeModifierHeld(listener: () => void): () => void {
|
|
modifierHeldListeners.add(listener);
|
|
return () => {
|
|
modifierHeldListeners.delete(listener);
|
|
};
|
|
}
|
|
|
|
const getModifierHeld = () => modifierHeld;
|
|
|
|
/** Whether the tab-switch modifier combination is currently held. */
|
|
export function useShortcutModifierHeldValue(): boolean {
|
|
return useSyncExternalStore(subscribeModifierHeld, getModifierHeld, getModifierHeld);
|
|
}
|
|
|
|
/**
|
|
* Track the modifiers of the effective [1...9] tab-switch binding and publish
|
|
* whether they are held. Pass a null binding to stop tracking.
|
|
*/
|
|
export function useTrackShortcutModifierHeld(
|
|
keyBinding: string | null,
|
|
scheme: HotkeyScheme,
|
|
): void {
|
|
const requirements = useMemo(
|
|
() => getShortcutModifierRequirements(keyBinding, scheme),
|
|
[keyBinding, scheme],
|
|
);
|
|
|
|
useEffect(() => {
|
|
publishModifierHeld(false);
|
|
if (!requirements) return;
|
|
|
|
// Sync on every modifier transition so extras clear the preview and
|
|
// releasing an extra modifier can re-arm when the exact combo remains.
|
|
const syncHeldState = (event: KeyboardEvent) => {
|
|
publishModifierHeld(isShortcutModifierHeld(event, requirements));
|
|
};
|
|
const clearHeldState = () => publishModifierHeld(false);
|
|
const handleVisibilityChange = () => {
|
|
if (document.visibilityState === 'hidden') {
|
|
clearHeldState();
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', syncHeldState, true);
|
|
window.addEventListener('keyup', syncHeldState, true);
|
|
window.addEventListener('blur', clearHeldState);
|
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
|
|
return () => {
|
|
window.removeEventListener('keydown', syncHeldState, true);
|
|
window.removeEventListener('keyup', syncHeldState, true);
|
|
window.removeEventListener('blur', clearHeldState);
|
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
publishModifierHeld(false);
|
|
};
|
|
}, [requirements]);
|
|
}
|