diff --git a/src/render.test.ts b/src/render.test.ts index 7d893f3..f193c2f 100644 --- a/src/render.test.ts +++ b/src/render.test.ts @@ -398,6 +398,66 @@ describe("buildRows", () => { sectionLabel: "squad-mobile", }); }); + + it("emits one section row per sectionLevel entry for a hierarchical sectionPath", () => { + const g1 = { + ...makeGroup("org/repoA", ["a.ts"], true), + sectionPath: [ + { label: "gamme-client", level: 0 }, + { label: "squad-dashboard", level: 1 }, + ], + }; + const rows = buildRows([g1]); + expect(rows).toHaveLength(3); // 2 section rows + 1 repo row + expect(rows[0]).toMatchObject({ + type: "section", + sectionLabel: "gamme-client", + sectionLevel: 0, + }); + expect(rows[1]).toMatchObject({ + type: "section", + sectionLabel: "squad-dashboard", + sectionLevel: 1, + }); + expect(rows[2]).toMatchObject({ type: "repo", repoIndex: 0 }); + }); + + it("does not repeat an unchanged ancestor heading for a sibling leaf", () => { + const g1 = { + ...makeGroup("org/repoA", ["a.ts"], true), + sectionPath: [ + { label: "gamme-client", level: 0 }, + { label: "squad-billing", level: 1 }, + ], + }; + const g2 = { + ...makeGroup("org/repoB", ["b.ts"], true), + sectionPath: [{ label: "squad-dashboard", level: 1 }], + }; + const rows = buildRows([g1, g2]); + const sectionRows = rows.filter((r) => r.type === "section"); + expect(sectionRows.map((r) => `${r.sectionLevel}:${r.sectionLabel}`)).toEqual([ + "0:gamme-client", + "1:squad-billing", + "1:squad-dashboard", + ]); + }); + + it("keeps a pending hierarchical heading across a filtered-out first repo", () => { + const g1 = { + ...makeGroup("org/repoA", ["a.ts"], true), + sectionPath: [{ label: "gamme-client", level: 0 }], + }; + const g2 = makeGroup("org/repoB", ["b.ts"], true); // same leaf, filtered out below + // Filter by path so that repoA (path "a.ts") is hidden but repoB is not. + const rows = buildRows([g1, g2], "b.ts", "path", false); + expect(rows[0]).toMatchObject({ + type: "section", + sectionLabel: "gamme-client", + sectionLevel: 0, + }); + expect(rows[1]).toMatchObject({ type: "repo", repoIndex: 1 }); + }); }); // ─── isCursorVisible ────────────────────────────────────────────────────────── @@ -1996,6 +2056,59 @@ describe("normalizeScrollOffset", () => { }); }); +// ─── renderGroups — hierarchical section headings ───────────────────────────── + +describe("renderGroups — hierarchical section headings (sectionLevel)", () => { + it("renders a level-0 heading without indentation", () => { + const groups = [ + { + ...makeGroup("org/repoA", ["a.ts"], true), + sectionPath: [{ label: "gamme-client", level: 0 }], + }, + ]; + const rows = buildRows(groups); + const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { termWidth: 80 }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + expect(stripped).toContain("── gamme-client"); + expect(stripped).not.toContain(" ── gamme-client"); + }); + + it("indents a level-1 heading by 2 spaces relative to the dashes", () => { + const groups = [ + { + ...makeGroup("org/repoA", ["a.ts"], true), + sectionPath: [ + { label: "gamme-client", level: 0 }, + { label: "squad-dashboard", level: 1 }, + ], + }, + ]; + const rows = buildRows(groups); + const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { termWidth: 80 }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + expect(stripped).toContain(" ── squad-dashboard"); + }); + + it("increases indentation progressively for each nesting level", () => { + const groups = [ + { + ...makeGroup("org/repoA", ["a.ts"], true), + sectionPath: [ + { label: "l0", level: 0 }, + { label: "l1", level: 1 }, + { label: "l2", level: 2 }, + ], + }, + ]; + const rows = buildRows(groups); + const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { termWidth: 80 }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + expect(stripped).toContain("── l0"); + expect(stripped).toContain(" ── l1"); + expect(stripped).toContain(" ── l2"); + }); +}); + // ─── renderGroups — re-pick mode hints bar ──────────────────────────────────── describe("renderGroups — re-pick mode hints bar", () => { diff --git a/src/render.ts b/src/render.ts index 18ac700..21122f9 100644 --- a/src/render.ts +++ b/src/render.ts @@ -508,9 +508,15 @@ export function renderGroups( // is the very first row rendered — see issue #105. const sectionCost = usedLines === 0 ? 1 : 2; if (sectionCost + usedLines > viewportHeight && usedLines > 0) break; + // Nested hierarchy headings (from groupByTeamHierarchy) are indented + // 2 spaces per level; flat groupByTeamPrefix sections are always + // level 0 (no indent) — see issue #180. + const level = row.sectionLevel ?? 0; + const indent = " ".repeat(level); // Fix: clip section label to termWidth so the label line never wraps. - // "── " prefix is 3 visible chars + 1 trailing space = 4 chars total. - const SECTION_FIXED = 4; // "── " (3) + trailing " " (1) + // "── " prefix is 3 visible chars + 1 trailing space = 4 chars total, + // plus the per-level indent consumed before it. + const SECTION_FIXED = 4 + indent.length; // "── " (3) + trailing " " (1) + indent const maxLabelChars = Math.max(0, termWidth - SECTION_FIXED); if (maxLabelChars === 0) { if (usedLines > 0) lines.push(""); // blank separator when not first @@ -529,8 +535,12 @@ export function renderGroups( const pickMode = opts.teamPickMode; if (pickMode?.active && pickMode.sectionLabel === row.sectionLabel) { // Fix: clip pick bar to (termWidth - 3) so "── " + bar never wraps — see issue #121. - const bar = renderTeamPickHeader(pickMode.candidates, pickMode.focusedIndex, termWidth - 3); - lines.push(`${style.style(["magenta", "bold"], "── ")}${bar}`); + const bar = renderTeamPickHeader( + pickMode.candidates, + pickMode.focusedIndex, + termWidth - 3 - indent.length, + ); + lines.push(`${indent}${style.style(["magenta", "bold"], "── ")}${bar}`); } else if (isActiveSectionCursor) { const isMultiTeam = (row.sectionLabel ?? "").includes(" + "); if (isMultiTeam) { @@ -556,13 +566,13 @@ export function renderGroups( } } lines.push( - `${style.style(["bgMagenta", "bold"], `── ${activeLabel} `)}${hint ? style.dim(hint) : ""}`, + `${indent}${style.style(["bgMagenta", "bold"], `── ${activeLabel} `)}${hint ? style.dim(hint) : ""}`, ); } else { - lines.push(style.style(["bgMagenta", "bold"], `── ${label} `)); + lines.push(`${indent}${style.style(["bgMagenta", "bold"], `── ${label} `)}`); } } else { - lines.push(style.style(["magenta", "bold"], `── ${label} `)); + lines.push(`${indent}${style.style(["magenta", "bold"], `── ${label} `)}`); } usedLines += sectionCost; if (usedLines >= viewportHeight) break; diff --git a/src/render/rows.ts b/src/render/rows.ts index 9466c98..e540033 100644 --- a/src/render/rows.ts +++ b/src/render/rows.ts @@ -39,21 +39,56 @@ export function buildRows( ): Row[] { const rows: Row[] = []; + // Section-heading tracking shared by both filter modes below: carries + // pending sectionLabel/sectionPath transitions across filtered-out repos + // so a heading is never lost when the repo that first carried it is + // hidden by the active filter (mirrors the equivalent fix in output.ts). + let pendingSectionLabel: string | undefined; + let lastEmittedSectionLabel: string | undefined; + let pendingSectionPath: NonNullable = []; + let lastEmittedSectionPath: NonNullable = []; + + function trackPending(group: RepoGroup): void { + if (group.sectionLabel !== undefined) pendingSectionLabel = group.sectionLabel; + if (group.sectionPath !== undefined && group.sectionPath.length > 0) { + pendingSectionPath = [ + ...pendingSectionPath.slice(0, group.sectionPath[0].level), + ...group.sectionPath, + ]; + } + } + + /** Emits one "section" row per new heading transition (flat `sectionLabel` + * is a single level-0 row; hierarchical `sectionPath` emits only the + * entries that changed since the last heading actually shown). */ + function emitPendingSections(group: RepoGroup): void { + const sectionToEmit = group.sectionLabel ?? pendingSectionLabel; + if (sectionToEmit !== undefined && sectionToEmit !== lastEmittedSectionLabel) { + rows.push({ type: "section", repoIndex: -1, sectionLabel: sectionToEmit, sectionLevel: 0 }); + lastEmittedSectionLabel = sectionToEmit; + return; + } + if (pendingSectionPath.length === 0) return; + const divergeAt = firstDivergingPathIndex(lastEmittedSectionPath, pendingSectionPath); + if (divergeAt >= pendingSectionPath.length) return; // already fully shown + for (const heading of pendingSectionPath.slice(divergeAt)) { + rows.push({ + type: "section", + repoIndex: -1, + sectionLabel: heading.label, + sectionLevel: heading.level, + }); + } + lastEmittedSectionPath = pendingSectionPath; + } + if (filterTarget === "repo") { const repoMatcher = makeRepoMatcher(filterPath, filterRegex); - let pendingSectionLabel: string | undefined; - let lastEmittedSectionLabel: string | undefined; for (let ri = 0; ri < groups.length; ri++) { const group = groups[ri]; - // Track the most recent section boundary so we can emit it even when the - // first repo of a section is filtered out. - if (group.sectionLabel !== undefined) pendingSectionLabel = group.sectionLabel; + trackPending(group); if (!repoMatcher(group)) continue; - const sectionToEmit = group.sectionLabel ?? pendingSectionLabel; - if (sectionToEmit !== undefined && sectionToEmit !== lastEmittedSectionLabel) { - rows.push({ type: "section", repoIndex: -1, sectionLabel: sectionToEmit }); - lastEmittedSectionLabel = sectionToEmit; - } + emitPendingSections(group); rows.push({ type: "repo", repoIndex: ri }); if (!group.folded) { group.matches.forEach((_, ei) => { @@ -69,21 +104,15 @@ export function buildRows( filterTarget as Exclude, filterRegex, ); - let pendingSectionLabel: string | undefined; - let lastEmittedSectionLabel: string | undefined; for (let ri = 0; ri < groups.length; ri++) { const group = groups[ri]; - if (group.sectionLabel !== undefined) pendingSectionLabel = group.sectionLabel; + trackPending(group); const visibleExtractIndices = group.matches .map((m, i) => (extractMatcher(m) ? i : -1)) .filter((i) => i !== -1); if (filterPath && visibleExtractIndices.length === 0) continue; - const sectionToEmit = group.sectionLabel ?? pendingSectionLabel; - if (sectionToEmit !== undefined && sectionToEmit !== lastEmittedSectionLabel) { - rows.push({ type: "section", repoIndex: -1, sectionLabel: sectionToEmit }); - lastEmittedSectionLabel = sectionToEmit; - } + emitPendingSections(group); rows.push({ type: "repo", repoIndex: ri }); if (!group.folded) { for (const ei of visibleExtractIndices) { @@ -94,6 +123,17 @@ export function buildRows( return rows; } +function firstDivergingPathIndex( + a: NonNullable, + b: NonNullable, +): number { + let i = 0; + while (i < a.length && i < b.length && a[i].label === b[i].label && a[i].level === b[i].level) { + i++; + } + return i; +} + /** * Normalises scrollOffset downward so the viewport is always packed from the * bottom. After a fold, a filter change, or navigating near the end of the diff --git a/src/types.ts b/src/types.ts index 32398f6..c8fd941 100644 --- a/src/types.ts +++ b/src/types.ts @@ -64,6 +64,10 @@ export interface Row { extractIndex?: number; /** Populated only for `type === "section"` rows. */ sectionLabel?: string; + /** Nesting depth for `type === "section"` rows produced from a + * `groupByTeamHierarchy` tree (0, 1, 2, …). Rows from the flat + * `groupByTeamPrefix` path are always level 0. Defaults to 0 when unset. */ + sectionLevel?: number; } /**