mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 00:49:41 +02:00
Merge PR #128: auto-wrap desktop session tabs on overflow + resize re-eval
Auto-wrap desktop session tabs to a second row on overflow
This commit is contained in:
@@ -2665,6 +2665,39 @@ class CodemanApp {
|
|||||||
this._fullRenderSessionTabs();
|
this._fullRenderSessionTabs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.updateTabOverflowMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-wrap desktop session tabs to a second row when they overflow one row,
|
||||||
|
// unless the user has pinned the manual two-row layout (tabTwoRows). Mobile/
|
||||||
|
// tablet keep horizontal scroll. Policy lives in constants.js for unit testing.
|
||||||
|
updateTabOverflowMode() {
|
||||||
|
const container = this.$('sessionTabs');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const deviceType = MobileDetection.getDeviceType();
|
||||||
|
const settings = this.loadAppSettingsFromStorage();
|
||||||
|
const defaults = this.getDefaultSettings();
|
||||||
|
const manualTwoRows = deviceType === 'desktop' ? (settings.tabTwoRows ?? defaults.tabTwoRows ?? false) : false;
|
||||||
|
|
||||||
|
if (manualTwoRows || deviceType !== 'desktop') {
|
||||||
|
container.classList.remove('tabs-auto-wrap');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Measure the natural one-row overflow, then enable wrapping only if needed.
|
||||||
|
container.classList.remove('tabs-auto-wrap');
|
||||||
|
const shouldWrap = window.CodemanTabOverflow?.shouldAutoWrapTabs
|
||||||
|
? window.CodemanTabOverflow.shouldAutoWrapTabs({
|
||||||
|
deviceType,
|
||||||
|
manualTwoRows,
|
||||||
|
tabCount: this.sessions.size,
|
||||||
|
scrollWidth: container.scrollWidth,
|
||||||
|
clientWidth: container.clientWidth,
|
||||||
|
})
|
||||||
|
: container.scrollWidth > container.clientWidth + 1;
|
||||||
|
|
||||||
|
container.classList.toggle('tabs-auto-wrap', shouldWrap);
|
||||||
}
|
}
|
||||||
|
|
||||||
_fullRenderSessionTabs() {
|
_fullRenderSessionTabs() {
|
||||||
@@ -2741,6 +2774,12 @@ class CodemanApp {
|
|||||||
|
|
||||||
// Update connection lines after tabs change (positions may have shifted)
|
// Update connection lines after tabs change (positions may have shifted)
|
||||||
this.updateConnectionLines();
|
this.updateConnectionLines();
|
||||||
|
|
||||||
|
// Re-evaluate desktop auto-wrap for every full rebuild, including the incremental
|
||||||
|
// branch's early `_fullRenderSessionTabs(); return;` paths and the manual two-rows
|
||||||
|
// toggle (applyTabWrapSettings calls this) which would otherwise leave a stale
|
||||||
|
// tabs-auto-wrap class until the next content render.
|
||||||
|
this.updateTabOverflowMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up arrow key navigation for session tabs (accessibility)
|
// Set up arrow key navigation for session tabs (accessibility)
|
||||||
|
|||||||
@@ -114,9 +114,24 @@ function evaluateWebGLLongTaskTrip(recent, entries, now, config = WEBGL_FALLBACK
|
|||||||
// Expose for tests. `const` declarations at the top of a non-module script
|
// Expose for tests. `const` declarations at the top of a non-module script
|
||||||
// are global lexical bindings but not `window` properties, so explicit
|
// are global lexical bindings but not `window` properties, so explicit
|
||||||
// assignment is the test-visible API surface.
|
// assignment is the test-visible API surface.
|
||||||
|
// Desktop tab-overflow policy: auto-wrap the session tabs to a second row when
|
||||||
|
// they overflow one row (and the user hasn't pinned the manual two-row layout).
|
||||||
|
function shouldAutoWrapTabs(input) {
|
||||||
|
if (!input || input.deviceType !== 'desktop') return false;
|
||||||
|
if (input.manualTwoRows) return false;
|
||||||
|
if ((input.tabCount || 0) < 2) return false;
|
||||||
|
|
||||||
|
const scrollWidth = Number(input.scrollWidth) || 0;
|
||||||
|
const clientWidth = Number(input.clientWidth) || 0;
|
||||||
|
return scrollWidth > clientWidth + 1;
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
||||||
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
||||||
|
window.CodemanTabOverflow = {
|
||||||
|
shouldAutoWrapTabs,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scheduler API — prioritize terminal writes over background UI updates.
|
// Scheduler API — prioritize terminal writes over background UI updates.
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ const MobileDetection = {
|
|||||||
resizeTimeout = setTimeout(() => {
|
resizeTimeout = setTimeout(() => {
|
||||||
this.updateBodyClass();
|
this.updateBodyClass();
|
||||||
this.updateAppHeight();
|
this.updateAppHeight();
|
||||||
|
// Tab auto-wrap is width-driven, so it must re-evaluate on resize — the only
|
||||||
|
// other trigger is a tab content render. No-op on mobile/tablet (method bails).
|
||||||
|
if (typeof app !== 'undefined') app.updateTabOverflowMode?.();
|
||||||
}, 100);
|
}, 100);
|
||||||
};
|
};
|
||||||
window.addEventListener('resize', this._resizeHandler);
|
window.addEventListener('resize', this._resizeHandler);
|
||||||
|
|||||||
@@ -312,6 +312,13 @@ body {
|
|||||||
max-height: 120px;
|
max-height: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-tabs.tabs-auto-wrap {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
.session-tabs::-webkit-scrollbar {
|
.session-tabs::-webkit-scrollbar {
|
||||||
width: 4px;
|
width: 4px;
|
||||||
height: 0;
|
height: 0;
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
function loadTabOverflowHelper() {
|
||||||
|
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||||
|
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||||
|
vm.runInContext(source, context, { filename: 'constants.js' });
|
||||||
|
return (context.window as { CodemanTabOverflow: { shouldAutoWrapTabs: (input: unknown) => boolean } })
|
||||||
|
.CodemanTabOverflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('tab overflow layout policy', () => {
|
||||||
|
it('auto-wraps desktop tabs when their rendered width exceeds available tab space', () => {
|
||||||
|
const helper = loadTabOverflowHelper();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
helper.shouldAutoWrapTabs({
|
||||||
|
deviceType: 'desktop',
|
||||||
|
manualTwoRows: false,
|
||||||
|
tabCount: 18,
|
||||||
|
scrollWidth: 1400,
|
||||||
|
clientWidth: 760,
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not auto-wrap when manual tall tabs are enabled or on mobile/tablet', () => {
|
||||||
|
const helper = loadTabOverflowHelper();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
helper.shouldAutoWrapTabs({
|
||||||
|
deviceType: 'desktop',
|
||||||
|
manualTwoRows: true,
|
||||||
|
tabCount: 18,
|
||||||
|
scrollWidth: 1400,
|
||||||
|
clientWidth: 760,
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
helper.shouldAutoWrapTabs({
|
||||||
|
deviceType: 'mobile',
|
||||||
|
manualTwoRows: false,
|
||||||
|
tabCount: 18,
|
||||||
|
scrollWidth: 1400,
|
||||||
|
clientWidth: 320,
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects the boundary conditions (exact fit, +1 tolerance, and tabCount < 2)', () => {
|
||||||
|
const helper = loadTabOverflowHelper();
|
||||||
|
const base = { deviceType: 'desktop' as const, manualTwoRows: false, tabCount: 6 };
|
||||||
|
|
||||||
|
// Exact fit: no overflow, no wrap.
|
||||||
|
expect(helper.shouldAutoWrapTabs({ ...base, scrollWidth: 800, clientWidth: 800 })).toBe(false);
|
||||||
|
// Within the +1 sub-pixel tolerance: still no wrap.
|
||||||
|
expect(helper.shouldAutoWrapTabs({ ...base, scrollWidth: 801, clientWidth: 800 })).toBe(false);
|
||||||
|
// 2px over: wrap.
|
||||||
|
expect(helper.shouldAutoWrapTabs({ ...base, scrollWidth: 802, clientWidth: 800 })).toBe(true);
|
||||||
|
// A single overflowing tab must not wrap (need at least 2 to form a second row).
|
||||||
|
expect(helper.shouldAutoWrapTabs({ ...base, tabCount: 1, scrollWidth: 1400, clientWidth: 760 })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user