mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-12 02:09:42 +02:00
fix(tabs): redraw connector lines when a search moves the rows (#580)
A keystroke in the rail search box that does not change the group structure only toggles classes, so hidden rows collapsed and the rows below them moved up while the lineage lines and the subagent/ultracode connectors kept pointing at the old positions until the next render. The sidebar filter box had the same gap for its connectors. _applyTabListFilter() now notes whether anything it touched actually appeared or disappeared (a row, a group or case box, the state headings via tabs-filtering, the "No sessions match" line) and calls updateConnectionLines() only then. That covers both boxes and the alert re-render, costs nothing on the re-apply every render tail runs when nothing changed (the incremental path's lineage gate keeps its meaning), and coalesces with a render's own redraw. Same flag, owner's optional nit from the review: the grouped tree's posinset/roving-stop fix-up at the filter tail now runs only while a search hides something or right after one changed what shows, since both render paths already set them over an unfiltered tree. Tests: unit tests for the rail keystroke path (flat and grouped rail, no render taken), the empty note, the sidebar box, and no redraw when nothing moved; the browser test checks in Chromium that a class-only keystroke moves a row up and redraws exactly once. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+32
-7
@@ -5785,8 +5785,10 @@ class CodemanApp {
|
||||
*
|
||||
* A group or case box left with nothing showing hides with its header, its
|
||||
* count shows the rows left showing (a kept row included), and the grouped
|
||||
* tree's roving stop and posinset follow the visible items. A collapsed group's rows are not in the DOM at all,
|
||||
* which is why the rail search also expands the projection (_projectTabGroups).
|
||||
* tree's roving stop and posinset follow the visible items. A collapsed
|
||||
* group's rows are not in the DOM at all, which is why the rail search also
|
||||
* expands the projection (_projectTabGroups). Rows that appear or disappear
|
||||
* move the rows below them, so the connector lines are redrawn then.
|
||||
*/
|
||||
_applyTabListFilter() {
|
||||
const container = this.$('sessionTabs');
|
||||
@@ -5806,13 +5808,23 @@ class CodemanApp {
|
||||
}));
|
||||
const result = window.CodemanTabSearch?.filter(rows, query);
|
||||
if (!result) return;
|
||||
// Whether anything appeared or disappeared: the rows below it then moved.
|
||||
let moved = false;
|
||||
// State headings count the whole group, so they step aside while a filter
|
||||
// is narrowing the rows under them (styles.css, .tabs-filtering).
|
||||
container.classList.toggle('tabs-filtering', result.active);
|
||||
for (const row of rows) row.key.classList.toggle('tab-filtered-out', result.hidden.has(row.key));
|
||||
if (container.classList.contains('tabs-filtering') !== result.active) {
|
||||
container.classList.toggle('tabs-filtering', result.active);
|
||||
moved = true;
|
||||
}
|
||||
const setFilteredOut = (el, out) => {
|
||||
if (el.classList.contains('tab-filtered-out') === out) return;
|
||||
el.classList.toggle('tab-filtered-out', out);
|
||||
moved = true;
|
||||
};
|
||||
for (const row of rows) setFilteredOut(row.key, result.hidden.has(row.key));
|
||||
for (const section of container.querySelectorAll('.tab-layout-group, .tab-cluster')) {
|
||||
const shown = result.counts.get(section) ?? 0;
|
||||
section.classList.toggle('tab-filtered-out', result.active && shown === 0);
|
||||
setFilteredOut(section, result.active && shown === 0);
|
||||
const count = section.querySelector('.tab-layout-group-count, .tab-cluster-count');
|
||||
if (!count) continue;
|
||||
if (count.dataset.total === undefined) count.dataset.total = count.textContent;
|
||||
@@ -5820,8 +5832,21 @@ class CodemanApp {
|
||||
if (count.textContent !== text) count.textContent = text;
|
||||
}
|
||||
const empty = document.getElementById('tabRailSearchEmpty');
|
||||
if (empty) empty.hidden = !(rail && result.active && result.matchCount === 0);
|
||||
if (container.getAttribute('role') === 'tree') {
|
||||
const emptyHidden = !(rail && result.active && result.matchCount === 0);
|
||||
if (empty && empty.hidden !== emptyHidden) {
|
||||
empty.hidden = emptyHidden;
|
||||
moved = true;
|
||||
}
|
||||
// Lineage and subagent/ultracode connectors are anchored to row positions.
|
||||
// A render redraws them itself, but a keystroke in either box only toggles
|
||||
// classes here, so the rows it moved would leave the lines pointing at where
|
||||
// they were. Only when something moved: an unchanged re-apply at every
|
||||
// render tail stays free, and the call coalesces with a render's own.
|
||||
if (moved) this.updateConnectionLines?.();
|
||||
// Both render paths already set posinset and the roving stop over an
|
||||
// unfiltered tree, so this second pass only runs while a search hides
|
||||
// something or right after one changed what shows.
|
||||
if ((moved || result.active) && container.getAttribute('role') === 'tree') {
|
||||
const items = this._applyTabTreePositions(container);
|
||||
const stop = container.querySelector('[role="treeitem"][tabindex="0"]');
|
||||
if (items.length && !items.includes(stop)) {
|
||||
|
||||
@@ -103,7 +103,9 @@ describe('vertical rail session search in Chromium', () => {
|
||||
app.renderSubagentTabBadge = () => '';
|
||||
app.cancelHideSubagentDropdown = () => undefined;
|
||||
app.updateTabOverflowMode = () => undefined;
|
||||
app.updateConnectionLines = () => undefined;
|
||||
app.updateConnectionLines = () => {
|
||||
w.__redraws++;
|
||||
};
|
||||
app._applyTabEntrances = () => undefined;
|
||||
app._scrollActiveTabIntoView = () => undefined;
|
||||
app.isSessionSidebarActive = () => false;
|
||||
@@ -153,6 +155,7 @@ describe('vertical rail session search in Chromium', () => {
|
||||
app._fullRenderSessionTabs();
|
||||
w.__activation = null;
|
||||
w.__closed = [];
|
||||
w.__redraws = 0;
|
||||
}, LAYOUT);
|
||||
});
|
||||
|
||||
@@ -225,6 +228,25 @@ describe('vertical rail session search in Chromium', () => {
|
||||
expect(await page.evaluate(() => (window as any).__closed)).toEqual(['panels', 'help', 'session-manager']);
|
||||
});
|
||||
|
||||
it('redraws the connector lines when a keystroke moves the rows', async () => {
|
||||
const search = page.getByRole('searchbox', { name: 'Search sessions' });
|
||||
const notesTop = () =>
|
||||
page.evaluate(() => document.querySelector('#sessionTabs [data-id="notes"]')!.getBoundingClientRect().top);
|
||||
// The first keystroke opens the collapsed Planning group, a render that
|
||||
// redraws on its own. The next one only toggles classes.
|
||||
await search.fill('e');
|
||||
expect(await paintedRows()).toEqual(['review', 'notes']);
|
||||
const before = await notesTop();
|
||||
await page.evaluate(() => {
|
||||
(window as any).__redraws = 0;
|
||||
});
|
||||
|
||||
await search.fill('es');
|
||||
expect(await paintedRows()).toEqual(['notes']);
|
||||
expect(await notesTop()).toBeLessThan(before);
|
||||
expect(await page.evaluate(() => (window as any).__redraws)).toBe(1);
|
||||
});
|
||||
|
||||
it('walks only the matches with the arrow keys', async () => {
|
||||
await page.getByRole('searchbox', { name: 'Search sessions' }).fill('a');
|
||||
// 'a' matches Alpha API, Roadmap, API Review and Dashboard, not Notes.
|
||||
|
||||
@@ -606,6 +606,65 @@ describe('Escape in the search box', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('connector lines follow the rows a search moves', () => {
|
||||
it('redraws on a rail keystroke that hides or reveals rows, and only then', () => {
|
||||
const app = makeApp({ tabLayout: null });
|
||||
app._fullRenderSessionTabs();
|
||||
const redraw = vi.fn();
|
||||
app.updateConnectionLines = redraw;
|
||||
const full = vi.spyOn(app, '_fullRenderSessionTabs');
|
||||
|
||||
app.setTabRailSearch('road');
|
||||
// The keystroke path: no render, which would have redrawn them itself.
|
||||
expect(full).not.toHaveBeenCalled();
|
||||
expect(redraw).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Same rows showing: nothing moved, nothing to redraw.
|
||||
app.setTabRailSearch('roa');
|
||||
expect(redraw).toHaveBeenCalledTimes(1);
|
||||
|
||||
app.setTabRailSearch('');
|
||||
expect(redraw).toHaveBeenCalledTimes(2);
|
||||
// The re-apply every render tail runs costs nothing when nothing changed.
|
||||
app._applyTabListFilter();
|
||||
expect(redraw).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('redraws on a grouped rail when a group empties, and when the empty note shows', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const redraw = vi.fn();
|
||||
app.updateConnectionLines = redraw;
|
||||
const full = vi.spyOn(app, '_fullRenderSessionTabs');
|
||||
|
||||
app.setTabRailSearch('notes');
|
||||
expect(full).not.toHaveBeenCalled();
|
||||
// Only the Ungrouped section (no group id) is left showing.
|
||||
expect(visibleGroups()).toEqual(['']);
|
||||
expect(redraw).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Nothing matches: the rows were already gone, but the note pushes the list down.
|
||||
app.setTabRailSearch('notesz');
|
||||
expect(emptyNote().hidden).toBe(false);
|
||||
expect(redraw).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('redraws on a sidebar filter keystroke too', () => {
|
||||
const app = makeApp({ tabLayout: null });
|
||||
document.documentElement.setAttribute('data-tab-orientation', 'horizontal');
|
||||
app.isSessionSidebarActive = () => true;
|
||||
app._fullRenderSessionTabs();
|
||||
const redraw = vi.fn();
|
||||
app.updateConnectionLines = redraw;
|
||||
|
||||
app.applySidebarFilter('/srv/api');
|
||||
expect(visibleRows()).toEqual(['roadmap']);
|
||||
expect(redraw).toHaveBeenCalledTimes(1);
|
||||
app.applySidebarFilter('/srv/api');
|
||||
expect(redraw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Markup + zh-CN ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('markup and zh-CN', () => {
|
||||
|
||||
Reference in New Issue
Block a user