diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 22dec6a..d530895 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -29,11 +29,11 @@ Section header rows (shown when `--group-by-team-prefix` is active) are skipped ## Filtering -| Key | Action | -| --- | ------------------------------------------------------------------------------------------------------ | -| `f` | Open the filter bar and enter filter mode | -| `t` | Cycle the **filter target**: `path` → `content` → `repo` → `path`. Only works **outside** filter mode. | -| `r` | Reset the active filter and return to showing all repos / extracts | +| Key | Action | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `f` | Open the filter bar and enter filter mode | +| `t` | Cycle the **filter target**: `path` → `content` → `repo` → `path`. Only works **outside** filter mode and when **not** on a picked repo (see [Team ownership](#team-ownership) below). | +| `r` | Reset the active filter and return to showing all repos / extracts | ### Filter targets @@ -69,9 +69,10 @@ Invalid regex patterns do not crash the TUI but are treated as matching nothing Available only when `--group-by-team-prefix` is active. -| Key | Action | -| --- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `p` | On a **multi-team** section header: enter team pick mode to assign the section to a single owner. Does nothing on single-team section headers. | +| Key | Action | +| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `p` | On a **multi-team** section header: enter team pick mode to assign the section to a single owner. Does nothing on single-team section headers. | +| `t` | On a **picked repo** (marked `◈`, moved by a previous pick): enter re-pick mode to change its team assignment or restore it to the combined section. | ### Pick mode bindings @@ -83,6 +84,17 @@ When pick mode is active (after pressing `p` on a multi-team section header): | `Enter` | Confirm the pick and exit pick mode | | `Esc` | Cancel and exit pick mode without changes | +### Re-pick mode bindings + +When re-pick mode is active (after pressing `t` on a picked repo marked `◈`): + +| Key | Action | +| ----------- | --------------------------------------------------------------------------------------------------- | +| `←` / `→` | Cycle through candidate teams (from the original combined section label) | +| `Enter` | Confirm and move the repo to the focused candidate team | +| `0` / `u` | Undo the entire section pick — restore all repos from that combined section to their original label | +| `Esc` / `t` | Exit re-pick mode without changes | + ## Help and exit | Key | Action | diff --git a/docs/usage/interactive-mode.md b/docs/usage/interactive-mode.md index e33fb85..e883bcd 100644 --- a/docs/usage/interactive-mode.md +++ b/docs/usage/interactive-mode.md @@ -33,20 +33,20 @@ github-code-search "useFeatureFlag" --org fulll ## Keyboard shortcuts -| Key | Action | -| -------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| `↑` / `↓` | Navigate between repos and extracts | -| `←` | Fold the repo under the cursor | -| `→` | Unfold the repo under the cursor | -| `Space` | Select / deselect the current repo or extract | -| `a` | Select **all** — on a repo row: all repos and extracts; on an extract row: all extracts in that repo. Respects any active filter. | -| `n` | Select **none** — same context rules as `a`. Respects any active filter. | -| `f` | Open the **filter bar** — type to narrow visible repos or files | -| `t` | Cycle the **filter target**: `path` → `content` → `repo` → `path` | -| `r` | **Reset** the active filter and show all repos / extracts | -| `h` / `?` | Toggle the **help overlay** | -| `Enter` | Confirm and print selected results (also closes the help overlay) | -| `q` / `Ctrl+C` | Quit without printing | +| Key | Action | +| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `↑` / `↓` | Navigate between repos and extracts | +| `←` | Fold the repo under the cursor | +| `→` | Unfold the repo under the cursor | +| `Space` | Select / deselect the current repo or extract | +| `a` | Select **all** — on a repo row: all repos and extracts; on an extract row: all extracts in that repo. Respects any active filter. | +| `n` | Select **none** — same context rules as `a`. Respects any active filter. | +| `f` | Open the **filter bar** — type to narrow visible repos or files | +| `t` | Cycle the **filter target**: `path` → `content` → `repo` → `path`. When on a **picked repo** (marked `◈`, `--group-by-team-prefix` active): enter re-pick mode instead. | +| `r` | **Reset** the active filter and show all repos / extracts | +| `h` / `?` | Toggle the **help overlay** | +| `Enter` | Confirm and print selected results (also closes the help overlay) | +| `q` / `Ctrl+C` | Quit without printing | ## Selection behaviour diff --git a/docs/usage/team-grouping.md b/docs/usage/team-grouping.md index 7516c59..1e45128 100644 --- a/docs/usage/team-grouping.md +++ b/docs/usage/team-grouping.md @@ -109,11 +109,48 @@ github-code-search query "useFeatureFlag" --org fulll \ The flag is repeatable — add one `--pick-team` per combined section to resolve. The replay command emits `--pick-team` automatically when a pick was confirmed in the TUI. +> **Note:** Per-repo re-picks performed in the TUI (pressing `t` on a `◈` repo) are **not** encoded in the replay command. They are interactive-only adjustments and must be repeated manually if you re-run the command. + If the combined label is not found (typo, or the section was not formed), a warning is emitted on stderr listing the available combined sections — the run continues without error. -::: tip Combined with --dispatch -`--pick-team` resolves ownership at the **section level** (all repos in the section move to one team). For finer-grained control — assigning individual repos or extracts to different teams — see `--dispatch`. -::: +## Re-pick & undo pick + +After using `--pick-team` (or the interactive `p` shortcut) to assign a combined section to a team, individual repos marked `◈` can be re-assigned or restored to their original combined section at any time. + +### TUI — re-pick mode + +Navigate to any **picked repo** (marked `◈`) and press **`t`** to enter re-pick mode. + +```text +── squad-frontend +▶ ◈ fulll/frontend-app ← press t here +▶ ◈ fulll/mobile-sdk +``` + +The hints bar shows a horizontal pick bar — exactly like team pick mode — with the current focused team highlighted in `[ brackets ]`: + +```text +Re-pick: [ squad-frontend ] squad-mobile 0/u restore ← → move ↵ confirm Esc/t cancel +``` + +| Key | Action | +| ----------- | --------------------------------------------------------------- | +| `←` / `→` | Cycle through candidate teams | +| `Enter` | Confirm and move repo to the focused team | +| `0` / `u` | Restore **all** repos from the combined section (undo the pick) | +| `Esc` / `t` | Exit re-pick mode without changes | + +### Undoing a pick (merge) + +Pressing `0` or `u` in re-pick mode restores **all** repos from the same combined section back to where they came from (e.g. `squad-frontend + squad-mobile`). Every `◈` badge from that section is removed and all repos are treated as unassigned again. + +```text +── squad-frontend + squad-mobile ← all repos restored +▶ ◉ fulll/frontend-app +▶ ◉ fulll/mobile-sdk +``` + +In **non-interactive mode**, undoing a pick is implicit: simply omit the `--pick-team` flag for that combined section in the replay command. ## Team list cache diff --git a/src/group.test.ts b/src/group.test.ts index 9cab6f5..ea25f01 100644 --- a/src/group.test.ts +++ b/src/group.test.ts @@ -3,7 +3,10 @@ import { applyTeamPick, flattenTeamSections, groupByTeamPrefix, + moveRepoToSection, rebuildTeamSections, + undoPickedRepo, + undoSectionPick, } from "./group.ts"; import type { RepoGroup, TeamSection } from "./types.ts"; @@ -279,3 +282,379 @@ describe("rebuildTeamSections", () => { expect(rebuildTeamSections([])).toEqual([]); }); }); + +// ─── moveRepoToSection ──────────────────────────────────────────────────────── + +function makeSimpleGroup(repo: string, teams: string[] = []): RepoGroup { + return { + repoFullName: repo, + matches: [], + folded: true, + repoSelected: true, + extractSelected: [], + teams, + }; +} + +describe("moveRepoToSection", () => { + it("moves a repo to an existing target section", () => { + const sections: TeamSection[] = [ + { + label: "squad-frontend + squad-mobile", + groups: [makeSimpleGroup("org/shared", ["squad-frontend", "squad-mobile"])], + }, + { label: "squad-mobile", groups: [makeSimpleGroup("org/b", ["squad-mobile"])] }, + ]; + const flat = flattenTeamSections(sections); + const result = moveRepoToSection(flat, "org/shared", "squad-mobile"); + const labels = [...new Set(result.filter((g) => g.sectionLabel).map((g) => g.sectionLabel))]; + expect(labels).not.toContain("squad-frontend + squad-mobile"); + expect(labels).toContain("squad-mobile"); + expect(result.find((g) => g.repoFullName === "org/shared")).toBeDefined(); + }); + + it("creates a new section when target team has no existing section", () => { + const sections: TeamSection[] = [ + { label: "squad-frontend + squad-mobile", groups: [makeSimpleGroup("org/shared")] }, + ]; + const flat = flattenTeamSections(sections); + const result = moveRepoToSection(flat, "org/shared", "squad-mobile"); + expect(result.find((g) => g.sectionLabel === "squad-mobile")).toBeDefined(); + }); + + it("is a no-op when the repo does not exist", () => { + const sections: TeamSection[] = [ + { label: "squad-frontend", groups: [makeSimpleGroup("org/a")] }, + ]; + const flat = flattenTeamSections(sections); + const result = moveRepoToSection(flat, "org/nonexistent", "squad-mobile"); + expect(result).toBe(flat); + }); +}); + +// ─── undoPickedRepo ─────────────────────────────────────────────────────────── + +function makePicked( + repo: string, + pickedFrom: string, + currentSection: string, + teams: string[] = [], +): RepoGroup { + return { + repoFullName: repo, + matches: [], + folded: true, + repoSelected: true, + extractSelected: [], + teams, + pickedFrom, + sectionLabel: currentSection, + }; +} + +describe("undoPickedRepo", () => { + it("restores a picked repo back to its original combined section", () => { + // squad-frontend + squad-mobile was picked to squad-frontend + const groups: RepoGroup[] = [ + { ...makePicked("org/repoA", "squad-frontend + squad-mobile", "squad-frontend") }, + { + repoFullName: "org/repoB", + matches: [], + folded: true, + repoSelected: true, + extractSelected: [], + teams: [], + pickedFrom: "squad-frontend + squad-mobile", + }, + ]; + const result = undoPickedRepo(groups, 0); + const a = result.find((g) => g.repoFullName === "org/repoA"); + expect(a).toBeDefined(); + expect(a!.pickedFrom).toBeUndefined(); + // Must appear in the restored combined section + const sectionRow = result.find((g) => g.sectionLabel === "squad-frontend + squad-mobile"); + expect(sectionRow).toBeDefined(); + expect(sectionRow!.repoFullName).toBe("org/repoA"); + }); + + it("no-op when repo has no pickedFrom", () => { + const groups: RepoGroup[] = [ + { + repoFullName: "org/repoA", + matches: [], + folded: true, + repoSelected: true, + extractSelected: [], + teams: [], + }, + ]; + const result = undoPickedRepo(groups, 0); + expect(result).toBe(groups); // same reference — no change + }); + + it("drops the current section when it becomes empty after undo", () => { + const groups: RepoGroup[] = [ + { ...makePicked("org/repoA", "squad-frontend + squad-mobile", "squad-frontend") }, + ]; + const result = undoPickedRepo(groups, 0); + // squad-frontend section should be gone (it had only repoA) + const frontendSection = result.find((g) => g.sectionLabel === "squad-frontend"); + expect(frontendSection).toBeUndefined(); + // Combined section should exist + const combinedSection = result.find((g) => g.sectionLabel === "squad-frontend + squad-mobile"); + expect(combinedSection).toBeDefined(); + }); + + it("appends to the existing combined section if it already exists", () => { + // repoA was picked to squad-frontend, but the combined section still has repoC + const sections: TeamSection[] = [ + { + label: "squad-frontend", + groups: [{ ...makePicked("org/repoA", "squad-frontend + squad-mobile", "squad-frontend") }], + }, + { + label: "squad-frontend + squad-mobile", + groups: [ + { + repoFullName: "org/repoC", + matches: [], + folded: true, + repoSelected: true, + extractSelected: [], + teams: ["squad-frontend", "squad-mobile"], + }, + ], + }, + ]; + const flat = flattenTeamSections(sections); + const repoAIndex = flat.findIndex((g) => g.repoFullName === "org/repoA"); + const result = undoPickedRepo(flat, repoAIndex); + const combinedGroups = (() => { + let inCombined = false; + const repos: string[] = []; + for (const g of result) { + if (g.sectionLabel === "squad-frontend + squad-mobile") inCombined = true; + else if (g.sectionLabel !== undefined) inCombined = false; + if (inCombined) repos.push(g.repoFullName); + } + return repos; + })(); + expect(combinedGroups).toContain("org/repoA"); + expect(combinedGroups).toContain("org/repoC"); + }); + + it("inserts new combined section before 'other' when other exists", () => { + // repoA was picked to squad-frontend; repoB lives in "other" + // After undo, the new combined section must appear before "other" + const sections: TeamSection[] = [ + { + label: "squad-frontend", + groups: [{ ...makePicked("org/repoA", "squad-frontend + squad-mobile", "squad-frontend") }], + }, + { + label: "other", + groups: [ + { + repoFullName: "org/repoB", + matches: [], + folded: true, + repoSelected: true, + extractSelected: [], + teams: [], + }, + ], + }, + ]; + const flat = flattenTeamSections(sections); + const repoAIndex = flat.findIndex((g) => g.repoFullName === "org/repoA"); + const result = undoPickedRepo(flat, repoAIndex); + const sectionLabels = result + .filter((g) => g.sectionLabel !== undefined) + .map((g) => g.sectionLabel); + const combinedIdx = sectionLabels.indexOf("squad-frontend + squad-mobile"); + const otherIdx = sectionLabels.indexOf("other"); + expect(combinedIdx).not.toBe(-1); + expect(otherIdx).not.toBe(-1); + // Combined section must come before "other" + expect(combinedIdx).toBeLessThan(otherIdx); + }); +}); + +// ─── moveRepoToSection ──────────────────────────────────────────────────────── + +describe("moveRepoToSection — insert before other", () => { + it("inserts new target section before 'other' when other exists", () => { + // repoA (picked from combined) is moved to a new team section that doesn't exist yet + // "other" section is present — new section must appear before it + const sections: TeamSection[] = [ + { + label: "squad-frontend + squad-mobile", + groups: [ + { + ...makePicked( + "org/repoA", + "squad-frontend + squad-mobile", + "squad-frontend + squad-mobile", + ), + pickedFrom: undefined, + }, + ], + }, + { + label: "other", + groups: [ + { + repoFullName: "org/repoB", + matches: [], + folded: true, + repoSelected: true, + extractSelected: [], + teams: [], + }, + ], + }, + ]; + // Manually set pickedFrom so move is realistic + const flat = flattenTeamSections(sections).map((g) => + g.repoFullName === "org/repoA" ? { ...g, pickedFrom: "squad-frontend + squad-mobile" } : g, + ); + const result = moveRepoToSection(flat, "org/repoA", "squad-mobile"); + const sectionLabels = result + .filter((g) => g.sectionLabel !== undefined) + .map((g) => g.sectionLabel); + const mobileIdx = sectionLabels.indexOf("squad-mobile"); + const otherIdx = sectionLabels.indexOf("other"); + expect(mobileIdx).not.toBe(-1); + expect(otherIdx).not.toBe(-1); + // New squad-mobile section must come before "other" + expect(mobileIdx).toBeLessThan(otherIdx); + }); +}); + +// ─── undoSectionPick ────────────────────────────────────────────────────────── + +describe("undoSectionPick", () => { + it("no-op when no repos have the matching pickedFrom", () => { + const groups: RepoGroup[] = [ + { + repoFullName: "org/repoA", + matches: [], + folded: true, + repoSelected: true, + extractSelected: [], + teams: [], + }, + ]; + const result = undoSectionPick(groups, "squad-frontend + squad-mobile"); + expect(result).toBe(groups); // same reference — no change + }); + + it("restores all repos with matching pickedFrom to the combined section", () => { + // Two repos were both picked to different teams from the same combined section + const sections: TeamSection[] = [ + { + label: "squad-frontend", + groups: [{ ...makePicked("org/repoA", "squad-frontend + squad-mobile", "squad-frontend") }], + }, + { + label: "squad-mobile", + groups: [{ ...makePicked("org/repoB", "squad-frontend + squad-mobile", "squad-mobile") }], + }, + ]; + const flat = flattenTeamSections(sections); + const result = undoSectionPick(flat, "squad-frontend + squad-mobile"); + + // Both repos should appear in the restored combined section + const combinedSection = result.find((g) => g.sectionLabel === "squad-frontend + squad-mobile"); + expect(combinedSection).toBeDefined(); + const inCombined = (() => { + let collecting = false; + const repos: string[] = []; + for (const g of result) { + if (g.sectionLabel === "squad-frontend + squad-mobile") collecting = true; + else if (g.sectionLabel !== undefined) collecting = false; + if (collecting) repos.push(g.repoFullName); + } + return repos; + })(); + expect(inCombined).toContain("org/repoA"); + expect(inCombined).toContain("org/repoB"); + // pickedFrom must be stripped + for (const g of result) { + expect(g.pickedFrom).toBeUndefined(); + } + // Source sections should be gone (empty after restore) + expect(result.find((g) => g.sectionLabel === "squad-frontend")).toBeUndefined(); + expect(result.find((g) => g.sectionLabel === "squad-mobile")).toBeUndefined(); + }); + + it("inserts restored combined section before 'other'", () => { + // repoA was picked from combined to squad-frontend; repoB is in "other" + const sections: TeamSection[] = [ + { + label: "squad-frontend", + groups: [{ ...makePicked("org/repoA", "squad-frontend + squad-mobile", "squad-frontend") }], + }, + { + label: "other", + groups: [ + { + repoFullName: "org/repoB", + matches: [], + folded: true, + repoSelected: true, + extractSelected: [], + teams: [], + }, + ], + }, + ]; + const flat = flattenTeamSections(sections); + const result = undoSectionPick(flat, "squad-frontend + squad-mobile"); + const sectionLabels = result + .filter((g) => g.sectionLabel !== undefined) + .map((g) => g.sectionLabel); + const combinedIdx = sectionLabels.indexOf("squad-frontend + squad-mobile"); + const otherIdx = sectionLabels.indexOf("other"); + expect(combinedIdx).not.toBe(-1); + expect(otherIdx).not.toBe(-1); + expect(combinedIdx).toBeLessThan(otherIdx); + }); + + it("appends to existing combined section when it still has other repos", () => { + // repoA was picked to squad-frontend, but repoC still lives in the combined section + const sections: TeamSection[] = [ + { + label: "squad-frontend", + groups: [{ ...makePicked("org/repoA", "squad-frontend + squad-mobile", "squad-frontend") }], + }, + { + label: "squad-frontend + squad-mobile", + groups: [ + { + repoFullName: "org/repoC", + matches: [], + folded: true, + repoSelected: true, + extractSelected: [], + teams: ["squad-frontend", "squad-mobile"], + }, + ], + }, + ]; + const flat = flattenTeamSections(sections); + const result = undoSectionPick(flat, "squad-frontend + squad-mobile"); + const inCombined = (() => { + let collecting = false; + const repos: string[] = []; + for (const g of result) { + if (g.sectionLabel === "squad-frontend + squad-mobile") collecting = true; + else if (g.sectionLabel !== undefined) collecting = false; + if (collecting) repos.push(g.repoFullName); + } + return repos; + })(); + expect(inCombined).toContain("org/repoA"); + expect(inCombined).toContain("org/repoC"); + }); +}); diff --git a/src/group.ts b/src/group.ts index f3a5fa0..37e7c6d 100644 --- a/src/group.ts +++ b/src/group.ts @@ -160,3 +160,161 @@ export function flattenTeamSections(sections: TeamSection[]): RepoGroup[] { } return result; } + +// ─── Internal helpers ───────────────────────────────────────────────────────── + +/** + * Inserts `newSection` before the `"other"` section, or appends it at the end + * when no `"other"` section exists. Keeps `"other"` as the last section, which + * is an invariant established by `groupByTeamPrefix`. + */ +function insertBeforeOther(sections: TeamSection[], newSection: TeamSection): TeamSection[] { + const otherIdx = sections.findIndex((s) => s.label === "other"); + return otherIdx === -1 + ? [...sections, newSection] + : [...sections.slice(0, otherIdx), newSection, ...sections.slice(otherIdx)]; +} + +// ─── Undo pick helper ───────────────────────────────────────────────────────── + +/** + * Restores a previously picked repo back to its original combined section. + * + * The target repo is identified by `repoIndex` in the flat `groups` array. + * It must have a `pickedFrom` field set (otherwise the array is returned as-is). + * The repo is removed from its current section and placed in the `pickedFrom` + * combined section (which is created if it no longer exists). + * `pickedFrom` is stripped from the restored repo so it is treated as a plain + * unassigned entry again. + * + * Pure function — no mutation. + */ +export function undoPickedRepo(groups: RepoGroup[], repoIndex: number): RepoGroup[] { + const g = groups[repoIndex]; + if (!g?.pickedFrom) return groups; + + const combinedLabel = g.pickedFrom; + // Strip pick metadata from the repo being restored + const { pickedFrom: _p, ...restored } = g; + void _p; + const restoredRepo = restored as RepoGroup; + + let sections = rebuildTeamSections(groups); + + // Remove the repo from its current section (drop section if it becomes empty) + const srcIdx = sections.findIndex((s) => s.groups.some((r) => r.repoFullName === g.repoFullName)); + if (srcIdx !== -1) { + const newSrcGroups = sections[srcIdx].groups.filter((r) => r.repoFullName !== g.repoFullName); + sections = + newSrcGroups.length > 0 + ? sections.map((s, i) => (i === srcIdx ? { ...s, groups: newSrcGroups } : s)) + : sections.filter((_, i) => i !== srcIdx); + } + + // Place the repo into the original combined section (create if absent) + const dstIdx = sections.findIndex((s) => s.label === combinedLabel); + if (dstIdx !== -1) { + sections = sections.map((s, i) => + i === dstIdx ? { ...s, groups: [...s.groups, restoredRepo] } : s, + ); + } else { + // No existing section found — insert before "other" (which must remain last) + // or append at the end when "other" is absent. + sections = insertBeforeOther(sections, { label: combinedLabel, groups: [restoredRepo] }); + } + + return flattenTeamSections(sections); +} + +// ─── Re-pick move helper ────────────────────────────────────────────────────── + +/** + * Moves a single repo (identified by its full `org/repo` name) into the + * target team's section. Used by the TUI re-pick confirmation handler. + * + * The repo's `pickedFrom` field is preserved so an undo can restore it later. + * + * Returns a new `RepoGroup[]` without mutating the input. + */ +export function moveRepoToSection( + groups: RepoGroup[], + repoFullName: string, + targetTeam: string, +): RepoGroup[] { + let sections = rebuildTeamSections(groups); + + const srcIdx = sections.findIndex((s) => s.groups.some((g) => g.repoFullName === repoFullName)); + if (srcIdx === -1) return groups; + + const groupToMove = sections[srcIdx].groups.find((g) => g.repoFullName === repoFullName)!; + const newSrcGroups = sections[srcIdx].groups.filter((g) => g.repoFullName !== repoFullName); + + const intermediate = + newSrcGroups.length > 0 + ? sections.map((s, i) => (i === srcIdx ? { ...s, groups: newSrcGroups } : s)) + : sections.filter((_, i) => i !== srcIdx); + + const dstIdx = intermediate.findIndex((s) => s.label === targetTeam); + if (dstIdx !== -1) { + sections = intermediate.map((s, i) => + i === dstIdx ? { ...s, groups: [...s.groups, groupToMove] } : s, + ); + } else { + // Target section doesn't exist yet — insert before "other" (which must remain last) + // or append at the end when "other" is absent. + sections = insertBeforeOther(intermediate, { label: targetTeam, groups: [groupToMove] }); + } + + return flattenTeamSections(sections); +} + +// ─── Undo section pick helper ───────────────────────────────────────────────── + +/** + * Restores ALL repos that were assigned from `combinedLabel` (every repo whose + * `pickedFrom === combinedLabel`) back to the combined section in one operation. + * + * This is the inverse of the full `applyTeamPick` for that label — it removes + * all per-repo picks made from a combined section, so that the `confirmedPicks` + * entry and the replay `--pick-team` flag can be cleanly removed without leaving + * the interactive state in a partially-undone, non-replayable configuration. + * + * Pure function — no mutation. + */ +export function undoSectionPick(groups: RepoGroup[], combinedLabel: string): RepoGroup[] { + if (!groups.some((g) => g.pickedFrom === combinedLabel)) return groups; + + let sections = rebuildTeamSections(groups); + + // Collect all repos to restore (preserving their relative order across sections) + const toRestore: RepoGroup[] = []; + sections = sections + .map((s) => { + const keep: RepoGroup[] = []; + for (const g of s.groups) { + if (g.pickedFrom === combinedLabel) { + const { pickedFrom: _p, ...rest } = g; + void _p; + toRestore.push(rest as RepoGroup); + } else { + keep.push(g); + } + } + return { ...s, groups: keep }; + }) + .filter((s) => s.groups.length > 0); + + if (toRestore.length === 0) return groups; + + // Append to existing combined section or create a new one + const dstIdx = sections.findIndex((s) => s.label === combinedLabel); + if (dstIdx !== -1) { + sections = sections.map((s, i) => + i === dstIdx ? { ...s, groups: [...s.groups, ...toRestore] } : s, + ); + } else { + sections = insertBeforeOther(sections, { label: combinedLabel, groups: toRestore }); + } + + return flattenTeamSections(sections); +} diff --git a/src/render.test.ts b/src/render.test.ts index efd15bb..3e2806a 100644 --- a/src/render.test.ts +++ b/src/render.test.ts @@ -1981,3 +1981,169 @@ describe("normalizeScrollOffset", () => { expect(normalizeScrollOffset(1, rows, groups, 3)).toBe(1); }); }); + +// ─── renderGroups — re-pick mode hints bar ──────────────────────────────────── + +describe("renderGroups — re-pick mode hints bar", () => { + it("shows Re-pick: prefix and candidate teams when repickMode is active", () => { + const groups = [makeGroup("org/repo", ["a.ts"])]; + const rows = buildRows(groups); + const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { + repickMode: { + active: true, + repoIndex: 0, + candidates: ["squad-frontend", "squad-mobile"], + focusedIndex: 0, + }, + termWidth: 120, + }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + expect(stripped).toContain("Re-pick:"); + expect(stripped).toContain("squad-frontend"); + expect(stripped).toContain("0/u restore"); + expect(stripped).toContain("Esc/t cancel"); + }); + + it("re-pick hints line visible width never exceeds termWidth", () => { + // Fix: clip hints to termWidth so the line never wraps — see issue #105. + const groups = [makeGroup("org/repo", ["a.ts"])]; + const rows = buildRows(groups); + const termWidth = 60; + const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { + repickMode: { + active: true, + repoIndex: 0, + candidates: ["squad-frontend", "squad-mobile"], + focusedIndex: 0, + }, + termWidth, + }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + const repickLine = stripped.split("\n").find((l) => l.includes("Re-pick:")); + expect(repickLine).toBeDefined(); + expect(repickLine!.length).toBeLessThanOrEqual(termWidth); + }); + + it("truncates suffix gracefully on very narrow terminal without crashing", () => { + const groups = [makeGroup("org/repo", ["a.ts"])]; + const rows = buildRows(groups); + const termWidth = 25; // narrow — full suffix won't fit + const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { + repickMode: { + active: true, + repoIndex: 0, + candidates: ["squad-frontend", "squad-mobile"], + focusedIndex: 0, + }, + termWidth, + }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + const repickLine = stripped.split("\n").find((l) => l.includes("Re-pick:")); + expect(repickLine).toBeDefined(); + expect(repickLine!.length).toBeLessThanOrEqual(termWidth); + }); + + it("focuses the correct candidate in [ brackets ]", () => { + const groups = [makeGroup("org/repo", ["a.ts"])]; + const rows = buildRows(groups); + const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { + repickMode: { + active: true, + repoIndex: 0, + candidates: ["squad-alpha", "squad-beta"], + focusedIndex: 1, + }, + termWidth: 120, + }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + // focusedIndex=1 → squad-beta is focused → should appear in [ brackets ] + expect(stripped).toContain("[ squad-beta ]"); + }); + + it("suffix is right-aligned: hints block always ends at the terminal edge", () => { + // With a wide terminal, the bar is short but the hints must still be at the right edge. + const groups = [makeGroup("org/repo", ["a.ts"])]; + const rows = buildRows(groups); + const termWidth = 100; + const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { + repickMode: { + active: true, + repoIndex: 0, + candidates: ["squad-a", "squad-b"], // short names → bar << barWidth + focusedIndex: 0, + }, + termWidth, + }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + const repickLine = stripped.split("\n").find((l) => l.includes("Re-pick:")); + expect(repickLine).toBeDefined(); + // Line should extend to termWidth (padded) — suffix anchored at right edge. + expect(repickLine!.length).toBe(termWidth); + // Suffix must be present and not truncated. + expect(repickLine!).toContain("0/u restore"); + expect(repickLine!).toContain("Esc/t cancel"); + }); + + it("suffix position stays fixed as focusedIndex changes", () => { + // Switching focus must not shift the suffix — only the middle bar content changes. + const groups = [makeGroup("org/repo", ["a.ts"])]; + const rows = buildRows(groups); + const termWidth = 120; + const candidates = ["squad-alpha", "squad-beta", "squad-gamma"]; + + const lineFor = (focusedIndex: number) => { + const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { + repickMode: { active: true, repoIndex: 0, candidates, focusedIndex }, + termWidth, + }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + return stripped.split("\n").find((l) => l.includes("Re-pick:"))!; + }; + + const line0 = lineFor(0); + const line1 = lineFor(1); + const line2 = lineFor(2); + + // All lines same length (padded to termWidth). + expect(line0.length).toBe(termWidth); + expect(line1.length).toBe(termWidth); + expect(line2.length).toBe(termWidth); + + // "Esc/t cancel" always ends at the same column (right edge). + const suffix = "Esc/t cancel"; + expect(line0.endsWith(suffix)).toBe(true); + expect(line1.endsWith(suffix)).toBe(true); + expect(line2.endsWith(suffix)).toBe(true); + }); + + it("focused team is always visible with many candidates (windowing)", () => { + // 8 teams — the bar is too narrow to show all at once. + // Every focusedIndex must produce a line containing that team in [ brackets ]. + const groups = [makeGroup("org/repo", ["a.ts"])]; + const rows = buildRows(groups); + const termWidth = 80; // narrow: barWidth = 80−9−49 = 22 + const candidates = [ + "chapter-a", + "chapter-b", + "chapter-c", + "chapter-d", + "chapter-e", + "chapter-f", + "chapter-g", + "chapter-h", + ]; + + for (let focusedIndex = 0; focusedIndex < candidates.length; focusedIndex++) { + const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { + repickMode: { active: true, repoIndex: 0, candidates, focusedIndex }, + termWidth, + }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + const repickLine = stripped.split("\n").find((l) => l.includes("Re-pick:")); + expect(repickLine).toBeDefined(); + expect(repickLine!.length).toBeLessThanOrEqual(termWidth); + // The focused team must be visible in [ brackets ] no matter where focus is. + expect(repickLine!).toContain(`[ ${candidates[focusedIndex]} ]`); + } + }); +}); diff --git a/src/render.ts b/src/render.ts index 30e4052..13ba7c1 100644 --- a/src/render.ts +++ b/src/render.ts @@ -320,6 +320,13 @@ interface RenderOptions { candidates: string[]; focusedIndex: number; }; + /** Active team re-pick mode state — when set, shows the re-pick bar in the hints line. */ + repickMode?: { + active: boolean; + repoIndex: number; + candidates: string[]; + focusedIndex: number; + }; } export function renderGroups( @@ -458,13 +465,31 @@ export function renderGroups( } // Fix: clip hints to termWidth visible chars so the line never wraps — see issue #105. - if (opts.teamPickMode?.active) { + if (opts.repickMode?.active) { + const dm = opts.repickMode; + // Re-pick bar layout: + // "Re-pick: " | | | " 0/u restore ← → …" + // + // The candidate bar uses a sliding window (renderTeamPickHeader) so the + // focused team is always visible regardless of how many teams exist. + // The suffix is right-aligned by padding with spaces between the bar and + // the suffix so the hints block always sits at the right terminal edge. + const REPICK_PREFIX = "Re-pick: "; + const REPICK_SUFFIX = " 0/u restore ← → move ↵ confirm Esc/t cancel"; + const barWidth = Math.max(0, termWidth - REPICK_PREFIX.length - REPICK_SUFFIX.length); + const bar = renderTeamPickHeader(dm.candidates, dm.focusedIndex, barWidth); + const barPlain = stripAnsi(bar); + // Pad between bar content and suffix to keep suffix right-aligned. + const padLen = Math.max(0, barWidth - barPlain.length); + const line = pc.dim(REPICK_PREFIX) + bar + " ".repeat(padLen) + pc.dim(REPICK_SUFFIX); + lines.push(clipAnsi(line, termWidth) + "\n"); + } else if (opts.teamPickMode?.active) { const PICK_HINTS = `Pick team: ← / → move focus ↵ confirm Esc cancel`; const clippedPick = PICK_HINTS.length > termWidth ? PICK_HINTS.slice(0, termWidth) : PICK_HINTS; lines.push(pc.dim(`${clippedPick}\n`)); } else { const HINTS_TEXT = - "← / → fold/unfold Z fold-all ↑ / ↓ navigate gg/G top/bot PgUp/Dn page spc select a all n none o open f filter t target p pick-team h help ↵ confirm q quit"; + "← / → fold/unfold Z fold-all ↑ / ↓ navigate gg/G top/bot PgUp/Dn page spc select a all n none o open f filter t target/re-pick p pick-team h help ↵ confirm q quit"; const clippedHints = HINTS_TEXT.length > termWidth ? HINTS_TEXT.slice(0, termWidth) : HINTS_TEXT; lines.push(pc.dim(`${clippedHints}\n`)); diff --git a/src/render/team-pick.test.ts b/src/render/team-pick.test.ts index bb38794..4ba15ba 100644 --- a/src/render/team-pick.test.ts +++ b/src/render/team-pick.test.ts @@ -55,24 +55,24 @@ describe("renderTeamPickHeader — maxWidth clipping", () => { expect(result).toBe("[ squad-a ] squad-b"); }); - it("clips and appends … when bar exceeds maxWidth", () => { + it("clips and appends … when focused=0 and bar exceeds maxWidth", () => { // "[ squad-a ]" = 11 chars; " squad-b" = 9 more = 20 total; limit to 15 + // Window=[0,0]: items(11) + right-ellipsis(3) = 14 ≤ 15 → show "[ squad-a ] …" const result = strip(renderTeamPickHeader(["squad-a", "squad-b"], 0, 15)); expect(result).toContain("[ squad-a ]"); expect(result).toContain("…"); expect(result).not.toContain("squad-b"); }); - it("omits … when even the ellipsis does not fit", () => { - // maxWidth=0: no room for any char, not even "…" + it("omits … when even the ellipsis does not fit (maxWidth=0)", () => { const result = strip(renderTeamPickHeader(["squad-a", "squad-b"], 0, 0)); expect(result).not.toContain("…"); expect(result).toBe(""); }); - it("clips three candidates to two + ellipsis", () => { - // "[ squad-a ]" = 11, " squad-b" = 9 → 20 total; " …" = 3 → needs maxWidth ≥ 23 - // With maxWidth=23: squad-a + squad-b + " …" fits; squad-c does not + it("clips three candidates to two + ellipsis when focused=0", () => { + // "[ squad-a ]" = 11, " squad-b" = 9 → 20; " …" = 3 → need ≥ 23 + // With maxWidth=23: squad-a + squad-b (20) + right-ellipsis (3) = 23 ✓; squad-c hidden const result = strip(renderTeamPickHeader(["squad-a", "squad-b", "squad-c"], 0, 23)); expect(result).toContain("[ squad-a ]"); expect(result).toContain("squad-b"); @@ -80,3 +80,110 @@ describe("renderTeamPickHeader — maxWidth clipping", () => { expect(result).not.toContain("squad-c"); }); }); + +// ─── renderTeamPickHeader — windowed scrolling (focused always visible) ──────── + +describe("renderTeamPickHeader — windowed scrolling", () => { + it("shows left ellipsis when focused is not the first candidate", () => { + // candidates=["A","B","C"], focused=2, maxWidth=10 + // "[ C ]"=5; window=[2,2]: items(5)+left(3)+right(0)=8 ≤ 10 ✓ + // Try expand left to 1: items(5+2+1=8)+left(3)=11 > 10 ✗ → A not included + // Result: "… [ C ]" (8 chars) + const result = strip(renderTeamPickHeader(["A", "B", "C"], 2, 10)); + expect(result).toContain("[ C ]"); + expect(result.startsWith("…")).toBe(true); // left ellipsis + expect(result).not.toContain(" A"); // A hidden on left + }); + + it("shows both ellipses when focused is in the middle and candidates overflow both sides", () => { + // 5 equal-width candidates ("tm1"–"tm5", each 3 chars); focused=2; maxWidth=16 + // "[ tm3 ]"=7; + right(3)=10; + left(3)=13; expand right: 7+SEP+3=12 → 12+3+3=18>16 + // window=[2,2]; totalWidth=7+3+3=13 ≤ 16; both ellipses shown → "… [ tm3 ] …" (13 chars) + const result = strip(renderTeamPickHeader(["tm1", "tm2", "tm3", "tm4", "tm5"], 2, 16)); + expect(result).toContain("[ tm3 ]"); + expect(result.startsWith("…")).toBe(true); + expect(result.endsWith("…")).toBe(true); + expect(result).not.toContain("tm1"); + expect(result).not.toContain("tm5"); + }); + + it("focused team is always visible regardless of focusedIndex", () => { + const teams = ["squad-a", "squad-b", "squad-c", "squad-d", "squad-e"]; + // Use a narrow maxWidth so not all teams fit at once + const maxWidth = 22; + for (let i = 0; i < teams.length; i++) { + const result = strip(renderTeamPickHeader(teams, i, maxWidth)); + expect(result).toContain(`[ ${teams[i]} ]`); + expect(result.length).toBeLessThanOrEqual(maxWidth); + } + }); + + it("window shifts rightward as focusedIndex increases", () => { + const teams = ["A", "B", "C", "D", "E"]; // 1 char each + // "[ X ]"=5 chars; with maxWidth=10: items(5)+right(3)=8 OR left(3)+items(5)=8 + // Window holds 1 item at a time with ellipses + const result0 = strip(renderTeamPickHeader(teams, 0, 10)); + const result4 = strip(renderTeamPickHeader(teams, 4, 10)); + // First focused: no left ellipsis + expect(result0.startsWith("…")).toBe(false); + expect(result0).toContain("[ A ]"); + // Last focused: no right ellipsis + expect(result4.endsWith("…")).toBe(false); + expect(result4).toContain("[ E ]"); + }); + + it("shows the focused item clipped when it alone exceeds maxWidth", () => { + // "[ very-long-team-name ]" = 23 chars > maxWidth=10 → clip + const result = strip(renderTeamPickHeader(["very-long-team-name"], 0, 10)); + expect(result.length).toBeLessThanOrEqual(10); + expect(result.endsWith("…")).toBe(true); + }); + + it("expands to fill available space symmetrically around focused", () => { + // 5 teams of 3 chars each; focused=2 (middle) + const teams = ["tm1", "tm2", "tm3", "tm4", "tm5"]; + // maxWidth=30: "… tm2 [ tm3 ] tm4 …" = 3+3+2+7+2+3+3=23 ≤ 30 → add tm1 on left + // Let's use a width where all 5 fit: widths=[3,3,7,3,3], SEP between=(4*2)=8, total=19 + const result = strip(renderTeamPickHeader(teams, 2, 30)); + expect(result).toContain("tm1"); + expect(result).toContain("tm2"); + expect(result).toContain("[ tm3 ]"); + expect(result).toContain("tm4"); + expect(result).toContain("tm5"); + expect(result).not.toContain("…"); // all teams fit → no ellipsis + }); +}); + +// ─── renderTeamPickHeader — guard conditions ────────────────────────────────── + +describe("renderTeamPickHeader — guard conditions", () => { + it("returns empty string for empty candidateTeams (no maxWidth)", () => { + expect(renderTeamPickHeader([], 0)).toBe(""); + }); + + it("returns empty string for empty candidateTeams (with maxWidth)", () => { + expect(renderTeamPickHeader([], 0, 80)).toBe(""); + }); + + it("clamps negative focusedIndex to 0", () => { + const result = strip(renderTeamPickHeader(["squad-a", "squad-b"], -1)); + expect(result).toContain("[ squad-a ]"); + expect(result).not.toMatch(/\[\s*squad-b\s*\]/); + }); + + it("clamps out-of-bounds focusedIndex to last candidate", () => { + const result = strip(renderTeamPickHeader(["squad-a", "squad-b"], 99)); + expect(result).toContain("[ squad-b ]"); + expect(result).not.toMatch(/\[\s*squad-a\s*\]/); + }); + + it("clamps negative focusedIndex to first candidate with maxWidth", () => { + const result = strip(renderTeamPickHeader(["squad-a", "squad-b"], -1, 80)); + expect(result).toContain("[ squad-a ]"); + }); + + it("clamps out-of-bounds focusedIndex to last candidate with maxWidth", () => { + const result = strip(renderTeamPickHeader(["squad-a", "squad-b"], 99, 80)); + expect(result).toContain("[ squad-b ]"); + }); +}); diff --git a/src/render/team-pick.ts b/src/render/team-pick.ts index 7a1c9cd..ef711e3 100644 --- a/src/render/team-pick.ts +++ b/src/render/team-pick.ts @@ -2,48 +2,126 @@ import pc from "picocolors"; const SEP = " "; +// Visible-char cost of the overflow ellipsis indicators. +// Left side: "…" (1) + SEP before first windowed item (2) = 3 +// Right side: SEP after last windowed item (2) + "…" (1) = 3 +const EL_LEFT = 1 + SEP.length; // 3 +const EL_RIGHT = SEP.length + 1; // 3 + /** * Renders a horizontal pick bar for team pick mode. * * The focused team is shown in bold + full colour (magenta); the others are - * dimmed. Used as the section header content when team pick mode is active. + * dimmed. Used as the section header content when team pick mode is active or + * as the candidate list in the re-pick mode hints bar. * - * When `maxWidth` is provided the bar is clipped to that many visible - * characters (ANSI codes do not count) and a "…" is appended when candidates - * are omitted, so the line never wraps. + * When `maxWidth` is provided the bar is rendered with a **sliding window** so + * that the focused team is always visible, regardless of how many other teams + * are listed. Candidates hidden to the left of the window are indicated with a + * leading `…`; candidates hidden to the right with a trailing `…` — like a + * horizontal tab strip. The total visible width (ANSI codes excluded) never + * exceeds `maxWidth`. * * Example output (focusedIndex = 0, * candidates = ["squad-frontend", "squad-mobile"]): * * [ squad-frontend ] squad-mobile + * + * Example output when many candidates don't fit (focusedIndex = 2, + * maxWidth constrains to 3 teams): + * + * … squad-b [ squad-c ] squad-d … */ export function renderTeamPickHeader( candidateTeams: string[], focusedIndex: number, maxWidth?: number, ): string { - const result: string[] = []; - let visibleWidth = 0; - - for (let i = 0; i < candidateTeams.length; i++) { - const team = candidateTeams[i]; - const visibleText = i === focusedIndex ? `[ ${team} ]` : team; - const sep = i > 0 ? SEP : ""; - const cost = sep.length + visibleText.length; - - if (maxWidth !== undefined && visibleWidth + cost > maxWidth) { - // Append "…" only if it fits within the remaining space. - const ellipsisWidth = (i > 0 ? SEP.length : 0) + 1; - if (visibleWidth + ellipsisWidth <= maxWidth) { - result.push((i > 0 ? SEP : "") + "…"); + const n = candidateTeams.length; + // Guard: empty list — nothing to render. + if (n === 0) return ""; + // Guard: clamp focusedIndex so an out-of-range value never reaches array indexing. + const safeIndex = Math.max(0, Math.min(focusedIndex, n - 1)); + + // Pre-compute visible text for each candidate (focused gets [ ] brackets). + const texts = candidateTeams.map((t, i) => (i === safeIndex ? `[ ${t} ]` : t)); + const widths = texts.map((t) => t.length); + + if (maxWidth === undefined) { + // No width constraint — render all candidates. + return texts + .map( + (text, i) => + (i > 0 ? SEP : "") + (i === safeIndex ? pc.bold(pc.magenta(text)) : pc.dim(text)), + ) + .join(""); + } + + if (maxWidth <= 0) return ""; + + // If the focused item alone is wider than maxWidth, clip it. + if (widths[safeIndex] > maxWidth) { + const clipped = texts[safeIndex].slice(0, maxWidth - 1) + "…"; + return pc.bold(pc.magenta(clipped)); + } + + // ── Windowed rendering ──────────────────────────────────────────────────── + // Find the largest window [start, end] that contains focusedIndex and whose + // total visible width — including overflow ellipsis indicators — fits within + // maxWidth. Width model: + // + // • items: sum of widths[start..end] + (end-start) × SEP.length + // • left ellipsis ("…" + SEP before first item): EL_LEFT = 3 (when start > 0) + // • right ellipsis (SEP + "…" after last item): EL_RIGHT = 3 (when end < n-1) + // + // Start with a single-item window at safeIndex, then greedily expand + // right then left until neither direction fits any more. + + let start = safeIndex; + let end = safeIndex; + let usedWidth = widths[safeIndex]; // width of all items in [start,end] + inter-item SEPs + + const totalWidth = (s: number, e: number, itemsW: number): number => + itemsW + (s > 0 ? EL_LEFT : 0) + (e < n - 1 ? EL_RIGHT : 0); + + for (;;) { + let expanded = false; + // Try expanding right. + if (end + 1 < n) { + const addCost = SEP.length + widths[end + 1]; + if (totalWidth(start, end + 1, usedWidth + addCost) <= maxWidth) { + usedWidth += addCost; + end++; + expanded = true; } - break; } + // Try expanding left. + if (start > 0) { + const addCost = widths[start - 1] + SEP.length; + if (totalWidth(start - 1, end, usedWidth + addCost) <= maxWidth) { + usedWidth += addCost; + start--; + expanded = true; + } + } + if (!expanded) break; + } + + // ── Build the bar string ────────────────────────────────────────────────── + // Guard: only emit overflow ellipsis when the items + both ellipses actually + // fit. In the edge case where the focused item fills maxWidth (leaving no + // room for a 3-char ellipsis), omit the indicator rather than overflowing. + const needed = totalWidth(start, end, usedWidth); + const addLeftEl = start > 0 && needed <= maxWidth; + const addRightEl = end < n - 1 && needed <= maxWidth; - const ansi = i === focusedIndex ? pc.bold(pc.magenta(visibleText)) : pc.dim(visibleText); - result.push(sep + ansi); - visibleWidth += cost; + const parts: string[] = []; + if (addLeftEl) parts.push(pc.dim("…")); + for (let i = start; i <= end; i++) { + if (i > start || addLeftEl) parts.push(SEP); + parts.push(i === safeIndex ? pc.bold(pc.magenta(texts[i])) : pc.dim(texts[i])); } + if (addRightEl) parts.push(SEP + pc.dim("…")); - return result.join(""); + return parts.join(""); } diff --git a/src/tui.ts b/src/tui.ts index 49d7157..049ea4e 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -12,7 +12,13 @@ import { type FilterStats, } from "./render.ts"; import { buildOutput } from "./output.ts"; -import { applyTeamPick, flattenTeamSections, rebuildTeamSections } from "./group.ts"; +import { + applyTeamPick, + moveRepoToSection, + undoSectionPick, + flattenTeamSections, + rebuildTeamSections, +} from "./group.ts"; import type { FilterTarget, OutputFormat, OutputType, RepoGroup, Row } from "./types.ts"; // ─── Key binding constants ──────────────────────────────────────────────────── @@ -194,6 +200,15 @@ export async function runInteractive( * so they are included in the replay command even if no additional interactive picks are made. */ const confirmedPicks: Record = { ...initialPickTeams }; + // ─── Team re-pick mode state ────────────────────────────────────────────────── + // Feat: re-pick mode — re-assign a picked (◈) repo to a different team — see issue #87 + let repickMode: { + active: boolean; + repoIndex: number; + candidates: string[]; + focusedIndex: number; + } = { active: false, repoIndex: -1, candidates: [], focusedIndex: 0 }; + /** Schedule a debounced stats recompute (while typing in filter bar). */ const scheduleStatsUpdate = () => { if (statsDebounceTimer !== null) clearTimeout(statsDebounceTimer); @@ -235,6 +250,7 @@ export async function runInteractive( filterTarget, filterRegex, teamPickMode: teamPickMode.active ? teamPickMode : undefined, + repickMode: repickMode.active ? repickMode : undefined, }); process.stdout.write(ANSI_CLEAR); process.stdout.write(rendered); @@ -292,6 +308,61 @@ export async function runInteractive( continue; } + // ── Team re-pick mode key handler ───────────────────────────────────────── + // Feat: re-pick mode — re-assign a picked (◈) repo to a different team — see issue #87 + if (repickMode.active) { + if (key === KEY_CTRL_C) { + process.stdout.write(ANSI_CLEAR); + process.stdin.setRawMode(false); + process.exit(0); + } else if (key === ANSI_ARROW_LEFT) { + // ← — cycle candidate teams backwards + repickMode = { + ...repickMode, + focusedIndex: + (repickMode.focusedIndex - 1 + repickMode.candidates.length) % + repickMode.candidates.length, + }; + } else if (key === ANSI_ARROW_RIGHT) { + // → — cycle candidate teams forwards + repickMode = { + ...repickMode, + focusedIndex: (repickMode.focusedIndex + 1) % repickMode.candidates.length, + }; + } else if (key === KEY_ENTER_CR || key === KEY_ENTER_LF) { + // Enter — confirm re-pick, move repo to the focused candidate team + const targetTeam = repickMode.candidates[repickMode.focusedIndex]; + const g = groups[repickMode.repoIndex]; + groups = moveRepoToSection(groups, g.repoFullName, targetTeam); + const newRows = buildRows(groups, filterPath, filterTarget, filterRegex); + cursor = Math.min(cursor, Math.max(0, newRows.length - 1)); + scrollOffset = Math.min(scrollOffset, cursor); + repickMode = { active: false, repoIndex: -1, candidates: [], focusedIndex: 0 }; + } else if (key === "0" || key === "u") { + // 0 / u — undo the entire section pick: restore ALL repos that were + // assigned from the same combined label back to the combined section. + // Deleting confirmedPicks keeps the replay command in sync — omitting + // --pick-team for that label is the exact non-interactive equivalent. + const combinedLabel = groups[repickMode.repoIndex]?.pickedFrom; + if (combinedLabel) { + delete confirmedPicks[combinedLabel]; + groups = undoSectionPick(groups, combinedLabel); + } + const newRows = buildRows(groups, filterPath, filterTarget, filterRegex); + cursor = Math.min(cursor, Math.max(0, newRows.length - 1)); + scrollOffset = Math.min(scrollOffset, cursor); + repickMode = { active: false, repoIndex: -1, candidates: [], focusedIndex: 0 }; + } else if (key === "\x1b" && !key.startsWith("\x1b[") && !key.startsWith("\x1b\x1b")) { + // Esc — cancel re-pick mode + repickMode = { active: false, repoIndex: -1, candidates: [], focusedIndex: 0 }; + } else if (key === "t") { + // t — toggle re-pick mode off + repickMode = { active: false, repoIndex: -1, candidates: [], focusedIndex: 0 }; + } + redraw(); + continue; + } + // ── Filter input mode ──────────────────────────────────────────────────── if (filterMode) { if (key === KEY_CTRL_C) { @@ -467,15 +538,26 @@ export async function runInteractive( continue; } - // `t` — cycle filter target: path → content → repo → path + // `t` — on a picked repo (◈, has pickedFrom): enter re-pick mode to re-assign to a + // different team. Otherwise cycle the filter target: path → content → repo → path. + // Feat: re-pick mode — see issue #87 if (key === "t") { - filterTarget = - filterTarget === "path" ? "content" : filterTarget === "content" ? "repo" : "path"; - if (filterPath) { - // Rebuild rows with new target - const newRows = buildRows(groups, filterPath, filterTarget, filterRegex); - cursor = Math.min(cursor, Math.max(0, newRows.length - 1)); - scrollOffset = Math.min(scrollOffset, cursor); + const isPickedRepo = + groupByTeamPrefix && row?.type === "repo" && !!groups[row.repoIndex]?.pickedFrom; + if (isPickedRepo) { + // Enter re-pick mode — candidates come from the original combined label + const pickedFrom = groups[row!.repoIndex].pickedFrom!; + const candidates = pickedFrom.split(" + ").map((c) => c.trim()); + repickMode = { active: true, repoIndex: row!.repoIndex, candidates, focusedIndex: 0 }; + } else { + // Cycle filter target when not on a picked repo + filterTarget = + filterTarget === "path" ? "content" : filterTarget === "content" ? "repo" : "path"; + if (filterPath) { + const newRows = buildRows(groups, filterPath, filterTarget, filterRegex); + cursor = Math.min(cursor, Math.max(0, newRows.length - 1)); + scrollOffset = Math.min(scrollOffset, cursor); + } } redraw(); continue;