Files
NetMesh/components/systemManager/systemManagerTabBarScroll.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

189 lines
6.5 KiB
TypeScript

/** Edge buffer so an active tab is not stuck flush against the clip edge. */
const TAB_COMFORT_EDGE_RATIO = 0.28;
const TAB_COMFORT_EDGE_MIN = 28;
const TAB_COMFORT_EDGE_MAX = 72;
export const SYSTEM_MANAGER_TAB_BAR_ICON_ONLY_CLASS = 'system-manager-tab-bar--icon-only';
/** Quiet period after resize before re-evaluating icon-only (side-panel drag). */
export const SYSTEM_MANAGER_TAB_BAR_SETTLE_MS = 120;
/**
* Extra room required before leaving icon-only. Keeps a small hysteresis without
* locking the bar into permanent icon-only mode.
*/
export const SYSTEM_MANAGER_TAB_BAR_EXPAND_SLACK_PX = 8;
export type SystemManagerTabBarLabeledFit = {
/** Labeled tabs need more width than the bar's inner budget. */
overflows: boolean;
/** Labeled tabs fit with expand slack — safe to show text again. */
fitsWithSlack: boolean;
};
function readFlexGapPx(el: HTMLElement | null | undefined): number {
if (!el) return 0;
const style = getComputedStyle(el);
const raw = style.columnGap || style.gap || '0';
const value = parseFloat(raw);
return Number.isFinite(value) ? value : 0;
}
/**
* Measure whether the *labeled* tab strip fits in the bar's current width.
*
* Important: do not use scrollWidth vs clientWidth for the "fits" check.
* When content does not overflow, browsers often report scrollWidth === clientWidth,
* so "fitsWithSlack" becomes permanently false and icon-only never exits.
*
* Instead: force labels on, sum real tab (and overflow) box widths, compare to
* the bar's inner budget (clientWidth minus horizontal padding).
*/
export function measureSystemManagerTabBarLabeledFit(
container: HTMLElement | null | undefined,
expandSlackPx: number = SYSTEM_MANAGER_TAB_BAR_EXPAND_SLACK_PX,
): SystemManagerTabBarLabeledFit {
if (!container) {
return { overflows: false, fitsWithSlack: true };
}
const budget = container.clientWidth;
// Hidden / zero-width host: do not pretend labels fit (that would drop
// icon-only while the side panel is display:none or still laying out).
if (budget <= 0) {
return { overflows: false, fitsWithSlack: false };
}
const labelEls = Array.from(
container.querySelectorAll<HTMLElement>('.system-manager-tab-label'),
);
const hadIconOnly = container.classList.contains(SYSTEM_MANAGER_TAB_BAR_ICON_ONLY_CLASS);
const prevDisplay = labelEls.map((el) => el.style.display);
try {
if (hadIconOnly) {
container.classList.remove(SYSTEM_MANAGER_TAB_BAR_ICON_ONLY_CLASS);
}
for (const el of labelEls) {
// Force visible for measurement even if a stylesheet still hides them.
el.style.display = 'inline';
}
void container.offsetWidth;
const tabs = Array.from(container.querySelectorAll<HTMLElement>('.system-manager-tab'));
let contentWidth = 0;
for (const tab of tabs) {
contentWidth += tab.getBoundingClientRect().width;
}
const row = tabs[0]?.parentElement ?? null;
const gap = readFlexGapPx(row);
if (tabs.length > 1) {
contentWidth += gap * (tabs.length - 1);
}
const overflow = container.querySelector<HTMLElement>(
'[data-section="system-manager-tab-overflow"]',
);
if (overflow) {
const overflowWidth = overflow.getBoundingClientRect().width;
if (overflowWidth > 0.5) {
contentWidth += overflowWidth + (tabs.length > 0 ? gap : 0);
}
}
const style = getComputedStyle(container);
const padX = (parseFloat(style.paddingLeft) || 0) + (parseFloat(style.paddingRight) || 0);
// clientWidth includes padding; children lay out in the content box.
const available = Math.max(0, budget - padX);
return {
overflows: contentWidth > available + 1,
fitsWithSlack: contentWidth + expandSlackPx <= available,
};
} finally {
labelEls.forEach((el, i) => {
el.style.display = prevDisplay[i] ?? '';
});
if (hadIconOnly) {
container.classList.add(SYSTEM_MANAGER_TAB_BAR_ICON_ONLY_CLASS);
}
}
}
/**
* Enter icon-only as soon as labels overflow; leave only when they fit with slack.
*/
export function resolveSystemManagerTabBarIconOnly(
fit: SystemManagerTabBarLabeledFit,
currentlyIconOnly: boolean,
): boolean {
if (currentlyIconOnly) {
return !fit.fitsWithSlack;
}
return fit.overflows;
}
/**
* Scroll a system-manager sub-tab into a comfortable position inside its
* horizontal tab bar. Uses container.scrollTo so nested panels are not
* scrolled by element.scrollIntoView.
*/
export function scrollSystemManagerTabIntoView(
container: HTMLElement | null | undefined,
tab: HTMLElement | null | undefined,
behavior: ScrollBehavior = 'smooth',
): void {
if (!container || !tab) return;
if (container.scrollWidth <= container.clientWidth + 1) return;
const containerRect = container.getBoundingClientRect();
const tabRect = tab.getBoundingClientRect();
const edgeBuffer = Math.min(
TAB_COMFORT_EDGE_MAX,
Math.max(TAB_COMFORT_EDGE_MIN, containerRect.width * TAB_COMFORT_EDGE_RATIO),
);
const overflowsLeft = tabRect.left < containerRect.left + edgeBuffer;
const overflowsRight = tabRect.right > containerRect.right - edgeBuffer;
if (!overflowsLeft && !overflowsRight) return;
const tabCenter =
tabRect.left - containerRect.left + container.scrollLeft + tabRect.width / 2;
const maxScrollLeft = container.scrollWidth - container.clientWidth;
const targetLeft = Math.max(
0,
Math.min(maxScrollLeft, tabCenter - container.clientWidth / 2),
);
if (Math.abs(container.scrollLeft - targetLeft) < 1) return;
container.scrollTo({ left: targetLeft, behavior });
}
/**
* Map a wheel event onto horizontal scrollLeft. Vertical mouse wheel becomes
* left/right motion when the bar overflows.
*
* Returns true when the event was consumed (caller should preventDefault).
*/
export function applyHorizontalWheelToScrollContainer(
container: HTMLElement,
event: Pick<WheelEvent, 'deltaX' | 'deltaY' | 'deltaMode'>,
): boolean {
if (container.scrollWidth <= container.clientWidth + 1) return false;
// Prefer non-zero deltaX so trackpads that emit both axes feel natural.
const rawDelta = event.deltaX !== 0 ? event.deltaX : event.deltaY;
if (rawDelta === 0) return false;
// deltaMode: 0 = pixels, 1 = lines, 2 = pages
const scale = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? container.clientWidth : 1;
const delta = rawDelta * scale;
const maxScrollLeft = container.scrollWidth - container.clientWidth;
const next = Math.max(0, Math.min(maxScrollLeft, container.scrollLeft + delta));
if (next === container.scrollLeft) return false;
container.scrollLeft = next;
return true;
}