Skip to content

Commit 9464501

Browse files
committed
feat(enterprise): AdvancedOverflowModule — searchable/MRU overflow tab switcher (MVP)
Upgrade the free tab-overflow dropdown, in place, into a command-palette tab switcher: a search box filtering across the group's tabs, most-recently-used ordering, and full keyboard navigation. Same chevron trigger; only the popover body changes. When the module is absent the free flat list renders exactly as before. Core seam (free, no behaviour change) - Extract the dropdown body building in `TabsContainer.toggleDropdown` into a shared `createOverflowRenderContext` (row + group-header builders, plus window-bound popover open/close/focus) and `renderFreeOverflowList`. The merged overflow composition (horizontal-clip ∪ forced-overflow − excluded, i.e. pinned + maxRows) is untouched — only rendering moved. - Add a single `?.`-guarded branch: when `accessor.advancedOverflowService` is present it builds AND opens the popover; otherwise the free helper renders. - New core contracts in `moduleContracts.ts` (`IAdvancedOverflowHost`, `IAdvancedOverflowService`, render context/params, `IOverflowRow`) and the `advancedOverflowService` slot on `ServiceCollection`, plus the component getter. Keeping the popover open/close in core avoids the popout window-binding trap. Module (dockview-enterprise) - `MruTracker`: per-group panel ids ordered by last activation, component-scoped so recency survives a group closing; cross-group moves re-home the panel. - `AdvancedOverflowService` self-wires MRU off group + active-panel events (filtered to `origin === 'user'`), and builds an `OverflowListView` per popover-open: focused search input (scope `group` vs `overflow`, debounced), MRU/clipped ordering reusing the core row builder, and a `role="listbox"` keyboard controller (arrows/Home/End rove, Enter activates, Esc closes + restores chevron focus, Tab trapped). Enter is intercepted before PopupService's Enter-dismissal so the row activates first. Styling lives in core (`tabs.scss`); the module ships no CSS. Tests: MruTracker (move-to-front / add-remove / cross-group / origin filter), search predicate + scope, MRU ordering, keyboard nav (unit + jsdom integration); core free-fallback seam test; cross-window e2e opening the advanced popover inside a popped-out group. Removability is covered by the auto-generated Modules spec. Deferred (reserved, unwired): thumbnails and the onDidOverflow event / persistMru serialization. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011kNoUE25gJe1itCbKnN6oA
1 parent 665d0d1 commit 9464501

14 files changed

Lines changed: 1627 additions & 126 deletions

File tree

e2e/fixtures/index.html

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@
5555
// `?maxRows=N` caps the header at N rows (surplus spills to the
5656
// dropdown).
5757
const maxRowsParam = params.get('maxRows');
58+
// `?overflow=advanced` enables the AdvancedOverflowModule
59+
// (searchable / MRU overflow dropdown) so the advanced popover
60+
// can be exercised; other specs are unaffected.
5861
const overflow =
5962
params.get('overflow') === 'wrap'
6063
? {
@@ -63,7 +66,9 @@
6366
? { maxRows: Number(maxRowsParam) }
6467
: {}),
6568
}
66-
: undefined;
69+
: params.get('overflow') === 'advanced'
70+
? { search: true, mru: true }
71+
: undefined;
6772
// `?smooth=1` turns on the smooth tab reorder animation (needed
6873
// to exercise the 2-D cross-row wrap reorder path).
6974
const theme =
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { test, expect, Page } from '@playwright/test';
2+
3+
/**
4+
* Advanced overflow (AdvancedOverflowModule): the free chevron dropdown is
5+
* upgraded in place into a searchable / MRU command-palette tab switcher. Which
6+
* tabs overflow is decided from measured widths, so this is real-browser only.
7+
*
8+
* The cross-window case is the load-bearing one: the popover must open via the
9+
* group's window-bound `PopupService`, so it renders (and dismisses) inside a
10+
* popped-out group's own document — never the opener's.
11+
*/
12+
test.describe('advanced overflow dropdown', () => {
13+
const addOverflowingTabs = async (
14+
page: Page,
15+
count: number,
16+
prefix: string
17+
) => {
18+
await page.evaluate(
19+
({ count, prefix }) => {
20+
for (let i = 0; i < count; i++) {
21+
(window as any).__dv.addPanel(`${prefix}-${i}`);
22+
}
23+
},
24+
{ count, prefix }
25+
);
26+
};
27+
28+
test('renders a searchable popover and filters rows', async ({ page }) => {
29+
await page.setViewportSize({ width: 320, height: 500 });
30+
await page.goto('/e2e/fixtures/index.html?overflow=advanced');
31+
await page.waitForFunction(() => (window as any).__ready === true);
32+
await addOverflowingTabs(page, 12, 'overflow-long-title');
33+
34+
const handle = page.locator('.dv-tabs-overflow-dropdown-default');
35+
await expect(handle).toBeVisible();
36+
await handle.click();
37+
38+
const popover = page.locator('.dv-tabs-overflow-advanced');
39+
await expect(popover).toBeVisible();
40+
41+
const search = popover.locator('.dv-tabs-overflow-search');
42+
await expect(search).toBeFocused();
43+
44+
// A discriminating substring narrows the list; a nonsense one empties it.
45+
await search.fill('long-title-1');
46+
await expect(popover.locator('[role="option"]')).not.toHaveCount(0);
47+
await search.fill('zzz-no-match');
48+
await expect(popover.locator('[role="option"]')).toHaveCount(0);
49+
50+
// Escape dismisses.
51+
await search.press('Escape');
52+
await expect(popover).toHaveCount(0);
53+
});
54+
55+
test('arrow keys + Enter activate a hidden tab', async ({ page }) => {
56+
await page.setViewportSize({ width: 320, height: 500 });
57+
await page.goto('/e2e/fixtures/index.html?overflow=advanced');
58+
await page.waitForFunction(() => (window as any).__ready === true);
59+
await addOverflowingTabs(page, 12, 'kbd-long-title');
60+
61+
await page.locator('.dv-tabs-overflow-dropdown-default').click();
62+
const popover = page.locator('.dv-tabs-overflow-advanced');
63+
await expect(popover).toBeVisible();
64+
65+
const firstOption = popover.locator('[role="option"]').first();
66+
const title = (await firstOption.textContent())!.trim();
67+
68+
const search = popover.locator('.dv-tabs-overflow-search');
69+
await search.press('Enter'); // first option is active on open
70+
71+
await expect(page.locator('.dv-active-tab')).toHaveText(title);
72+
await expect(popover).toHaveCount(0);
73+
});
74+
75+
test('popover renders + dismisses inside a popped-out group', async ({
76+
page,
77+
context,
78+
}) => {
79+
await page.setViewportSize({ width: 400, height: 500 });
80+
await page.goto('/e2e/fixtures/index.html?overflow=advanced');
81+
await page.waitForFunction(() => (window as any).__ready === true);
82+
await addOverflowingTabs(page, 20, 'pop-long-title');
83+
84+
const [popout] = await Promise.all([
85+
context.waitForEvent('page'),
86+
page.evaluate(() => (window as any).__dv.popoutActiveGroup()),
87+
]);
88+
await (popout as Page).waitForLoadState();
89+
90+
// Narrow the popout so its strip overflows regardless of default size.
91+
await popout.setViewportSize({ width: 360, height: 500 });
92+
93+
const handle = popout.locator('.dv-tabs-overflow-dropdown-default');
94+
await expect(handle).toBeVisible();
95+
await handle.click();
96+
97+
// The popover renders in the POPOUT document (window-bound), not the
98+
// opener — this is the cross-window binding under test.
99+
const popover = popout.locator('.dv-tabs-overflow-advanced');
100+
await expect(popover).toBeVisible();
101+
await expect(page.locator('.dv-tabs-overflow-advanced')).toHaveCount(0);
102+
103+
// And it dismisses in the popout document.
104+
await popout.locator('.dv-tabs-overflow-search').press('Escape');
105+
await expect(popover).toHaveCount(0);
106+
});
107+
});
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { fireEvent } from '@testing-library/dom';
2+
import { DockviewComponent } from '../../../../dockview/dockviewComponent';
3+
import { IContentRenderer } from '../../../../dockview/types';
4+
5+
class TestPanel implements IContentRenderer {
6+
element = document.createElement('div');
7+
init(): void {
8+
// noop
9+
}
10+
layout(): void {
11+
// noop
12+
}
13+
dispose(): void {
14+
// noop
15+
}
16+
}
17+
18+
// jsdom lacks scrollIntoView; the free row activation calls it on click.
19+
beforeAll(() => {
20+
if (!Element.prototype.scrollIntoView) {
21+
Element.prototype.scrollIntoView = function scrollIntoView() {
22+
/* noop */
23+
};
24+
}
25+
});
26+
27+
/**
28+
* The overflow rendering seam: `TabsContainer.toggleDropdown` builds the popover
29+
* body through `createOverflowRenderContext` and, when
30+
* `accessor.advancedOverflowService` is present, hands it to the module;
31+
* otherwise it renders the free flat list. No module is registered in core, so
32+
* this proves the `?.`-guard falls through to the free helper — the byte-for-byte
33+
* behaviour dockview shipped before the seam existed.
34+
*/
35+
describe('advanced overflow seam — free fallback (no module)', () => {
36+
let container: HTMLElement;
37+
38+
const make = (): DockviewComponent => {
39+
container = document.createElement('div');
40+
document.body.appendChild(container);
41+
const dockview = new DockviewComponent(container, {
42+
createComponent: () => new TestPanel(),
43+
});
44+
dockview.layout(400, 300);
45+
return dockview;
46+
};
47+
48+
afterEach(() => {
49+
container?.remove();
50+
});
51+
52+
const openOverflow = (
53+
dockview: DockviewComponent,
54+
anyPanel: any,
55+
ids: string[]
56+
): void => {
57+
const overflowSet = new Set(ids);
58+
anyPanel.group.model.header.setForcedOverflow((id: string) =>
59+
overflowSet.has(id)
60+
);
61+
anyPanel.group.model.header.refreshOverflow();
62+
const root = container.querySelector<HTMLElement>(
63+
'.dv-tabs-overflow-dropdown-root'
64+
);
65+
if (root) {
66+
fireEvent.click(root);
67+
}
68+
};
69+
70+
test('renders the free flat list (no advanced upgrade) with the overflow rows', () => {
71+
const dockview = make();
72+
expect(dockview.advancedOverflowService).toBeUndefined();
73+
74+
const panels = ['a', 'b', 'c'].map((id) =>
75+
dockview.addPanel({ id, component: 'default', title: id })
76+
);
77+
78+
openOverflow(dockview, panels[0], ['b', 'c']);
79+
80+
const body = container.querySelector('.dv-tabs-overflow-container');
81+
expect(body).toBeTruthy();
82+
// The module-only marker class must be absent.
83+
expect(body!.classList.contains('dv-tabs-overflow-advanced')).toBe(
84+
false
85+
);
86+
// No search input in the free path.
87+
expect(body!.querySelector('.dv-tabs-overflow-search')).toBeNull();
88+
// The clipped tabs render as rows.
89+
expect(body!.querySelectorAll('.dv-tab').length).toBe(2);
90+
91+
dockview.dispose();
92+
});
93+
94+
test('clicking a free row activates its panel and closes the popover', () => {
95+
const dockview = make();
96+
const panels = ['a', 'b', 'c'].map((id) =>
97+
dockview.addPanel({ id, component: 'default', title: id })
98+
);
99+
100+
openOverflow(dockview, panels[0], ['b', 'c']);
101+
102+
const row = Array.from(
103+
container.querySelectorAll<HTMLElement>(
104+
'.dv-tabs-overflow-container .dv-tab'
105+
)
106+
).find((el) => el.textContent!.trim() === 'c')!;
107+
fireEvent.click(row);
108+
109+
expect(panels[2].api.isActive).toBe(true);
110+
expect(
111+
container.querySelector('.dv-tabs-overflow-container')
112+
).toBeNull();
113+
114+
dockview.dispose();
115+
});
116+
});

packages/dockview-core/src/dockview/components/titlebar/tabs.scss

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,3 +586,38 @@
586586
padding-left: 16px;
587587
}
588588
}
589+
590+
/*
591+
* Advanced overflow popover (AdvancedOverflowModule): the same container as the
592+
* free list, upgraded in place with a search input, a scrollable listbox, and a
593+
* keyboard-highlighted active row. Styles live in core so the module ships no
594+
* CSS; they are inert unless the module renders this body.
595+
*/
596+
.dv-tabs-overflow-container.dv-tabs-overflow-advanced {
597+
min-width: 220px;
598+
599+
.dv-tabs-overflow-search {
600+
box-sizing: border-box;
601+
width: 100%;
602+
padding: 6px 8px;
603+
border: none;
604+
border-bottom: 1px solid var(--dv-tab-divider-color);
605+
outline: none;
606+
font-size: inherit;
607+
font-family: inherit;
608+
color: var(--dv-activegroup-visiblepanel-tab-color);
609+
background-color: var(--dv-group-view-background-color);
610+
}
611+
612+
.dv-tabs-overflow-list {
613+
display: flex;
614+
flex-direction: column;
615+
outline: none;
616+
}
617+
618+
.dv-tab.dv-tabs-overflow-option--focused {
619+
outline: 1px solid var(--dv-tab-divider-color);
620+
outline-offset: -1px;
621+
background-color: var(--dv-icon-hover-background-color);
622+
}
623+
}

0 commit comments

Comments
 (0)