Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions src/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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", () => {
Expand Down
24 changes: 17 additions & 7 deletions src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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;
Expand Down
76 changes: 58 additions & 18 deletions src/render/rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RepoGroup["sectionPath"]> = [];
let lastEmittedSectionPath: NonNullable<RepoGroup["sectionPath"]> = [];

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) => {
Expand All @@ -69,21 +104,15 @@ export function buildRows(
filterTarget as Exclude<FilterTarget, "repo">,
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) {
Expand All @@ -94,6 +123,17 @@ export function buildRows(
return rows;
}

function firstDivergingPathIndex(
a: NonNullable<RepoGroup["sectionPath"]>,
b: NonNullable<RepoGroup["sectionPath"]>,
): 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
Expand Down
4 changes: 4 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
Loading