Skip to content

Commit 0f7d060

Browse files
committed
Render nested section headers with per-level indentation in the TUI
- Row gains sectionLevel (0 for flat groupByTeamPrefix sections, N for groupByTeamHierarchy nesting depth). - buildRows emits one section row per new heading transition in a hierarchical sectionPath, diffed against the last heading actually shown so unchanged ancestors aren't repeated for sibling leaves; a pending heading is still carried across filtered-out repos (mirrors the existing flat sectionLabel behavior). - renderGroups indents each section heading by 2 spaces per level; pick-mode bar and the multi-team hint keep working unchanged at whichever level is a combined section. - Mouse hit-testing (mouse-hit.ts) and row-height accounting (rowTerminalLines, isCursorVisible, normalizeScrollOffset) needed no changes: section row cost/geometry is independent of nesting depth.
1 parent 72e052a commit 0f7d060

4 files changed

Lines changed: 194 additions & 25 deletions

File tree

‎src/render.test.ts‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,66 @@ describe("buildRows", () => {
398398
sectionLabel: "squad-mobile",
399399
});
400400
});
401+
402+
it("emits one section row per sectionLevel entry for a hierarchical sectionPath", () => {
403+
const g1 = {
404+
...makeGroup("org/repoA", ["a.ts"], true),
405+
sectionPath: [
406+
{ label: "gamme-client", level: 0 },
407+
{ label: "squad-dashboard", level: 1 },
408+
],
409+
};
410+
const rows = buildRows([g1]);
411+
expect(rows).toHaveLength(3); // 2 section rows + 1 repo row
412+
expect(rows[0]).toMatchObject({
413+
type: "section",
414+
sectionLabel: "gamme-client",
415+
sectionLevel: 0,
416+
});
417+
expect(rows[1]).toMatchObject({
418+
type: "section",
419+
sectionLabel: "squad-dashboard",
420+
sectionLevel: 1,
421+
});
422+
expect(rows[2]).toMatchObject({ type: "repo", repoIndex: 0 });
423+
});
424+
425+
it("does not repeat an unchanged ancestor heading for a sibling leaf", () => {
426+
const g1 = {
427+
...makeGroup("org/repoA", ["a.ts"], true),
428+
sectionPath: [
429+
{ label: "gamme-client", level: 0 },
430+
{ label: "squad-billing", level: 1 },
431+
],
432+
};
433+
const g2 = {
434+
...makeGroup("org/repoB", ["b.ts"], true),
435+
sectionPath: [{ label: "squad-dashboard", level: 1 }],
436+
};
437+
const rows = buildRows([g1, g2]);
438+
const sectionRows = rows.filter((r) => r.type === "section");
439+
expect(sectionRows.map((r) => `${r.sectionLevel}:${r.sectionLabel}`)).toEqual([
440+
"0:gamme-client",
441+
"1:squad-billing",
442+
"1:squad-dashboard",
443+
]);
444+
});
445+
446+
it("keeps a pending hierarchical heading across a filtered-out first repo", () => {
447+
const g1 = {
448+
...makeGroup("org/repoA", ["a.ts"], true),
449+
sectionPath: [{ label: "gamme-client", level: 0 }],
450+
};
451+
const g2 = makeGroup("org/repoB", ["b.ts"], true); // same leaf, filtered out below
452+
// Filter by path so that repoA (path "a.ts") is hidden but repoB is not.
453+
const rows = buildRows([g1, g2], "b.ts", "path", false);
454+
expect(rows[0]).toMatchObject({
455+
type: "section",
456+
sectionLabel: "gamme-client",
457+
sectionLevel: 0,
458+
});
459+
expect(rows[1]).toMatchObject({ type: "repo", repoIndex: 1 });
460+
});
401461
});
402462

403463
// ─── isCursorVisible ──────────────────────────────────────────────────────────
@@ -1996,6 +2056,59 @@ describe("normalizeScrollOffset", () => {
19962056
});
19972057
});
19982058

2059+
// ─── renderGroups — hierarchical section headings ─────────────────────────────
2060+
2061+
describe("renderGroups — hierarchical section headings (sectionLevel)", () => {
2062+
it("renders a level-0 heading without indentation", () => {
2063+
const groups = [
2064+
{
2065+
...makeGroup("org/repoA", ["a.ts"], true),
2066+
sectionPath: [{ label: "gamme-client", level: 0 }],
2067+
},
2068+
];
2069+
const rows = buildRows(groups);
2070+
const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { termWidth: 80 });
2071+
const stripped = out.replace(/\x1b\[[0-9;]*m/g, "");
2072+
expect(stripped).toContain("── gamme-client");
2073+
expect(stripped).not.toContain(" ── gamme-client");
2074+
});
2075+
2076+
it("indents a level-1 heading by 2 spaces relative to the dashes", () => {
2077+
const groups = [
2078+
{
2079+
...makeGroup("org/repoA", ["a.ts"], true),
2080+
sectionPath: [
2081+
{ label: "gamme-client", level: 0 },
2082+
{ label: "squad-dashboard", level: 1 },
2083+
],
2084+
},
2085+
];
2086+
const rows = buildRows(groups);
2087+
const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { termWidth: 80 });
2088+
const stripped = out.replace(/\x1b\[[0-9;]*m/g, "");
2089+
expect(stripped).toContain(" ── squad-dashboard");
2090+
});
2091+
2092+
it("increases indentation progressively for each nesting level", () => {
2093+
const groups = [
2094+
{
2095+
...makeGroup("org/repoA", ["a.ts"], true),
2096+
sectionPath: [
2097+
{ label: "l0", level: 0 },
2098+
{ label: "l1", level: 1 },
2099+
{ label: "l2", level: 2 },
2100+
],
2101+
},
2102+
];
2103+
const rows = buildRows(groups);
2104+
const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { termWidth: 80 });
2105+
const stripped = out.replace(/\x1b\[[0-9;]*m/g, "");
2106+
expect(stripped).toContain("── l0");
2107+
expect(stripped).toContain(" ── l1");
2108+
expect(stripped).toContain(" ── l2");
2109+
});
2110+
});
2111+
19992112
// ─── renderGroups — re-pick mode hints bar ────────────────────────────────────
20002113

20012114
describe("renderGroups — re-pick mode hints bar", () => {

‎src/render.ts‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -502,9 +502,15 @@ export function renderGroups(
502502
// is the very first row rendered — see issue #105.
503503
const sectionCost = usedLines === 0 ? 1 : 2;
504504
if (sectionCost + usedLines > viewportHeight && usedLines > 0) break;
505+
// Nested hierarchy headings (from groupByTeamHierarchy) are indented
506+
// 2 spaces per level; flat groupByTeamPrefix sections are always
507+
// level 0 (no indent) — see issue #180.
508+
const level = row.sectionLevel ?? 0;
509+
const indent = " ".repeat(level);
505510
// Fix: clip section label to termWidth so the label line never wraps.
506-
// "── " prefix is 3 visible chars + 1 trailing space = 4 chars total.
507-
const SECTION_FIXED = 4; // "── " (3) + trailing " " (1)
511+
// "── " prefix is 3 visible chars + 1 trailing space = 4 chars total,
512+
// plus the per-level indent consumed before it.
513+
const SECTION_FIXED = 4 + indent.length; // "── " (3) + trailing " " (1) + indent
508514
const maxLabelChars = Math.max(0, termWidth - SECTION_FIXED);
509515
if (maxLabelChars === 0) {
510516
if (usedLines > 0) lines.push(""); // blank separator when not first
@@ -523,8 +529,12 @@ export function renderGroups(
523529
const pickMode = opts.teamPickMode;
524530
if (pickMode?.active && pickMode.sectionLabel === row.sectionLabel) {
525531
// Fix: clip pick bar to (termWidth - 3) so "── " + bar never wraps — see issue #121.
526-
const bar = renderTeamPickHeader(pickMode.candidates, pickMode.focusedIndex, termWidth - 3);
527-
lines.push(`${pc.magenta(pc.bold("── "))}${bar}`);
532+
const bar = renderTeamPickHeader(
533+
pickMode.candidates,
534+
pickMode.focusedIndex,
535+
termWidth - 3 - indent.length,
536+
);
537+
lines.push(`${indent}${pc.magenta(pc.bold("── "))}${bar}`);
528538
} else if (isActiveSectionCursor) {
529539
const isMultiTeam = (row.sectionLabel ?? "").includes(" + ");
530540
if (isMultiTeam) {
@@ -549,12 +559,14 @@ export function renderGroups(
549559
hint = hintPlain.slice(0, remainingWidth - 1) + "…";
550560
}
551561
}
552-
lines.push(`${pc.bgMagenta(pc.bold(`── ${activeLabel} `))}${hint ? pc.dim(hint) : ""}`);
562+
lines.push(
563+
`${indent}${pc.bgMagenta(pc.bold(`── ${activeLabel} `))}${hint ? pc.dim(hint) : ""}`,
564+
);
553565
} else {
554-
lines.push(pc.bgMagenta(pc.bold(`── ${label} `)));
566+
lines.push(`${indent}${pc.bgMagenta(pc.bold(`── ${label} `))}`);
555567
}
556568
} else {
557-
lines.push(pc.magenta(pc.bold(`── ${label} `)));
569+
lines.push(`${indent}${pc.magenta(pc.bold(`── ${label} `))}`);
558570
}
559571
usedLines += sectionCost;
560572
if (usedLines >= viewportHeight) break;

‎src/render/rows.ts‎

Lines changed: 58 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -39,21 +39,56 @@ export function buildRows(
3939
): Row[] {
4040
const rows: Row[] = [];
4141

42+
// Section-heading tracking shared by both filter modes below: carries
43+
// pending sectionLabel/sectionPath transitions across filtered-out repos
44+
// so a heading is never lost when the repo that first carried it is
45+
// hidden by the active filter (mirrors the equivalent fix in output.ts).
46+
let pendingSectionLabel: string | undefined;
47+
let lastEmittedSectionLabel: string | undefined;
48+
let pendingSectionPath: NonNullable<RepoGroup["sectionPath"]> = [];
49+
let lastEmittedSectionPath: NonNullable<RepoGroup["sectionPath"]> = [];
50+
51+
function trackPending(group: RepoGroup): void {
52+
if (group.sectionLabel !== undefined) pendingSectionLabel = group.sectionLabel;
53+
if (group.sectionPath !== undefined && group.sectionPath.length > 0) {
54+
pendingSectionPath = [
55+
...pendingSectionPath.slice(0, group.sectionPath[0].level),
56+
...group.sectionPath,
57+
];
58+
}
59+
}
60+
61+
/** Emits one "section" row per new heading transition (flat `sectionLabel`
62+
* is a single level-0 row; hierarchical `sectionPath` emits only the
63+
* entries that changed since the last heading actually shown). */
64+
function emitPendingSections(group: RepoGroup): void {
65+
const sectionToEmit = group.sectionLabel ?? pendingSectionLabel;
66+
if (sectionToEmit !== undefined && sectionToEmit !== lastEmittedSectionLabel) {
67+
rows.push({ type: "section", repoIndex: -1, sectionLabel: sectionToEmit, sectionLevel: 0 });
68+
lastEmittedSectionLabel = sectionToEmit;
69+
return;
70+
}
71+
if (pendingSectionPath.length === 0) return;
72+
const divergeAt = firstDivergingPathIndex(lastEmittedSectionPath, pendingSectionPath);
73+
if (divergeAt >= pendingSectionPath.length) return; // already fully shown
74+
for (const heading of pendingSectionPath.slice(divergeAt)) {
75+
rows.push({
76+
type: "section",
77+
repoIndex: -1,
78+
sectionLabel: heading.label,
79+
sectionLevel: heading.level,
80+
});
81+
}
82+
lastEmittedSectionPath = pendingSectionPath;
83+
}
84+
4285
if (filterTarget === "repo") {
4386
const repoMatcher = makeRepoMatcher(filterPath, filterRegex);
44-
let pendingSectionLabel: string | undefined;
45-
let lastEmittedSectionLabel: string | undefined;
4687
for (let ri = 0; ri < groups.length; ri++) {
4788
const group = groups[ri];
48-
// Track the most recent section boundary so we can emit it even when the
49-
// first repo of a section is filtered out.
50-
if (group.sectionLabel !== undefined) pendingSectionLabel = group.sectionLabel;
89+
trackPending(group);
5190
if (!repoMatcher(group)) continue;
52-
const sectionToEmit = group.sectionLabel ?? pendingSectionLabel;
53-
if (sectionToEmit !== undefined && sectionToEmit !== lastEmittedSectionLabel) {
54-
rows.push({ type: "section", repoIndex: -1, sectionLabel: sectionToEmit });
55-
lastEmittedSectionLabel = sectionToEmit;
56-
}
91+
emitPendingSections(group);
5792
rows.push({ type: "repo", repoIndex: ri });
5893
if (!group.folded) {
5994
group.matches.forEach((_, ei) => {
@@ -69,21 +104,15 @@ export function buildRows(
69104
filterTarget as Exclude<FilterTarget, "repo">,
70105
filterRegex,
71106
);
72-
let pendingSectionLabel: string | undefined;
73-
let lastEmittedSectionLabel: string | undefined;
74107
for (let ri = 0; ri < groups.length; ri++) {
75108
const group = groups[ri];
76-
if (group.sectionLabel !== undefined) pendingSectionLabel = group.sectionLabel;
109+
trackPending(group);
77110
const visibleExtractIndices = group.matches
78111
.map((m, i) => (extractMatcher(m) ? i : -1))
79112
.filter((i) => i !== -1);
80113
if (filterPath && visibleExtractIndices.length === 0) continue;
81114

82-
const sectionToEmit = group.sectionLabel ?? pendingSectionLabel;
83-
if (sectionToEmit !== undefined && sectionToEmit !== lastEmittedSectionLabel) {
84-
rows.push({ type: "section", repoIndex: -1, sectionLabel: sectionToEmit });
85-
lastEmittedSectionLabel = sectionToEmit;
86-
}
115+
emitPendingSections(group);
87116
rows.push({ type: "repo", repoIndex: ri });
88117
if (!group.folded) {
89118
for (const ei of visibleExtractIndices) {
@@ -94,6 +123,17 @@ export function buildRows(
94123
return rows;
95124
}
96125

126+
function firstDivergingPathIndex(
127+
a: NonNullable<RepoGroup["sectionPath"]>,
128+
b: NonNullable<RepoGroup["sectionPath"]>,
129+
): number {
130+
let i = 0;
131+
while (i < a.length && i < b.length && a[i].label === b[i].label && a[i].level === b[i].level) {
132+
i++;
133+
}
134+
return i;
135+
}
136+
97137
/**
98138
* Normalises scrollOffset downward so the viewport is always packed from the
99139
* bottom. After a fold, a filter change, or navigating near the end of the

‎src/types.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ export interface Row {
6464
extractIndex?: number;
6565
/** Populated only for `type === "section"` rows. */
6666
sectionLabel?: string;
67+
/** Nesting depth for `type === "section"` rows produced from a
68+
* `groupByTeamHierarchy` tree (0, 1, 2, …). Rows from the flat
69+
* `groupByTeamPrefix` path are always level 0. Defaults to 0 when unset. */
70+
sectionLevel?: number;
6771
}
6872

6973
/**

0 commit comments

Comments
 (0)