diff --git a/CHANGELOG.md b/CHANGELOG.md index f518d60..5cfea87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,12 @@ Release notes and changelogs are published on the **[project blog](https://fulll Each release entry covers the motivation, new features, breaking changes (if any), and upgrade notes. -| Version | Blog post | -| ------------------------------------------------------------------------ | ---------------------------------------------------- | -| [v1.4.0](https://fulll.github.io/github-code-search/blog/release-v1-4-0) | TUI visual overhaul, community files, demo animation | -| [v1.3.0](https://fulll.github.io/github-code-search/blog/release-v1-3-0) | Team-prefix grouping, replay command, JSON output | -| [v1.0.0](https://fulll.github.io/github-code-search/blog/release-v1-0-0) | Initial release | +| Version | Blog post | +| ------------------------------------------------------------------------ | ---------------------------------------------------------- | +| [v1.5.0](https://fulll.github.io/github-code-search/blog/release-v1-5-0) | Advanced filter targets, regex mode, word-jump, scroll fix | +| [v1.4.0](https://fulll.github.io/github-code-search/blog/release-v1-4-0) | TUI visual overhaul, community files, demo animation | +| [v1.3.0](https://fulll.github.io/github-code-search/blog/release-v1-3-0) | Team-prefix grouping, replay command, JSON output | +| [v1.0.0](https://fulll.github.io/github-code-search/blog/release-v1-0-0) | Initial release | > For the full list of commits between releases, see the > [GitHub Releases page](https://github.com/fulll/github-code-search/releases). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7e30d91..601d47b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,8 +21,13 @@ bun install github-code-search.ts # CLI entry point (Commander subcommands: query, upgrade) build.ts # Build script (compiles the standalone binary) src/ - types.ts # Shared TypeScript types - api.ts # GitHub REST API client + types.ts # Shared TypeScript types (TextMatchSegment, CodeMatch, RepoGroup, Row, FilterTarget, …) + api.ts # GitHub REST API client (search, team listing) + api-utils.ts # Shared retry (fetchWithRetry) and pagination (paginatedFetch) helpers + api-utils.test.ts # Unit tests for api-utils.ts + api.test.ts # Unit tests for api.ts + cache.ts # Disk cache for the team list (24 h TTL) + cache.test.ts # Unit tests for cache.ts aggregate.ts # Result grouping and filtering logic aggregate.test.ts # Unit tests for aggregate.ts render.ts # Façade: re-exports sub-modules + TUI renderGroups/renderHelpOverlay @@ -30,8 +35,10 @@ src/ render/ highlight.ts # Syntax highlighting (language detection, token rules, highlightFragment) highlight.test.ts # Unit tests for highlight.ts (per-language tokenizer coverage) - filter.ts # Filter helpers (FilterStats, buildFilterStats) - rows.ts # Row navigation (buildRows, rowTerminalLines, isCursorVisible) + filter.ts # Filter stats (FilterStats, buildFilterStats) + filter-match.ts # Pure pattern matchers (makeExtractMatcher, makeRepoMatcher) + filter-match.test.ts # Unit tests for filter-match.ts + rows.ts # Row builder (buildRows, rowTerminalLines, isCursorVisible) summary.ts # Stats labels (buildSummary, buildSummaryFull, buildSelectionSummary) selection.ts # Selection mutations (applySelectAll, applySelectNone) output.ts # Text (markdown) and JSON output formatters diff --git a/demo/demo.gif b/demo/demo.gif index 9682a70..22d4607 100644 Binary files a/demo/demo.gif and b/demo/demo.gif differ diff --git a/demo/demo.tape b/demo/demo.tape index 952d203..bce0904 100644 --- a/demo/demo.tape +++ b/demo/demo.tape @@ -41,14 +41,12 @@ Down Sleep 300ms Down Sleep 300ms -Down -Sleep 300ms # ── Toggle selection off on current extract ─────────────────────────────────── Space Sleep 400ms -# ── Enter filter mode to exclude .ts files ──────────────────────────────────── +# ── Filter by file path (default target) ────────────────────────────────────── Type "f" Sleep 300ms Type ".yml" @@ -64,7 +62,28 @@ Sleep 500ms Type "r" Sleep 600ms -# ── Confirm and print output ────────────────────────────────────────────────── +# ── Switch to repo filter mode and filter by repo name ──────────────────────── +# Press t twice: path → content → repo +Type "t" +Sleep 300ms +Type "t" +Sleep 400ms + +# Enter filter mode and type a repo name fragment +Type "f" +Sleep 300ms +Type "toolkit" +Sleep 700ms +Enter +Sleep 500ms + +# Select all repos matching the filter +Type "a" +Sleep 500ms + +# ── Reset and confirm ───────────────────────────────────────────────────────── +Type "r" +Sleep 600ms Enter Sleep 2s diff --git a/docs/architecture/components.md b/docs/architecture/components.md index c48aca0..e368d10 100644 --- a/docs/architecture/components.md +++ b/docs/architecture/components.md @@ -41,8 +41,10 @@ C4Component ## 3b — TUI render layer -The six pure render functions called by the TUI on every redraw. All six live in -`src/render/` and are re-exported through the `src/render.ts` façade. +The render-layer modules called by the TUI on every redraw. Most live in +`src/render/` and are re-exported through the `src/render.ts` façade; +`src/output.ts` is the output formatter invoked on confirmation and `src/render/filter-match.ts` +provides shared pattern-matching helpers used by several render modules. ```mermaid %%{init: {"theme": "base", "themeVariables": {"fontFamily": "Poppins, Aestetico, Arial, sans-serif", "primaryColor": "#66CCFF", "primaryTextColor": "#000000", "lineColor": "#0000CC", "tertiaryColor": "#FFCC33"}, "themeCSS": ".label,.nodeLabel,.cluster-label > span{font-family:Poppins,Arial,sans-serif;letter-spacing:.2px} .cluster-label > span{font-weight:600;font-size:13px} .edgePath .path{stroke-width:2px}"}}%% @@ -56,10 +58,11 @@ C4Component Container_Boundary(render, "src/render/ — pure functions") { Component(rows, "Row builder", "src/render/rows.ts", "buildRows()
rowTerminalLines()
isCursorVisible()") Component(summary, "Summary builder", "src/render/summary.ts", "buildSummary()
buildSummaryFull()
buildSelectionSummary()") - Component(filter, "Filter stats", "src/render/filter.ts", "buildFilterStats()
visible vs total rows") + Component(filter, "Filter stats", "src/render/filter.ts", "buildFilterStats()
FilterStats — visible/hidden counts") Component(selection, "Selection helpers", "src/render/selection.ts", "applySelectAll()
applySelectNone()") Component(highlight, "Syntax highlighter", "src/render/highlight.ts", "highlightFragment()
ANSI token colouring") Component(outputFn, "Output formatter", "src/output.ts", "buildOutput()
markdown or JSON") + Component(filterMatch, "Pattern matchers", "src/render/filter-match.ts", "makeExtractMatcher()
makeRepoMatcher()") } Rel(tui, rows, "Build terminal
rows") @@ -80,8 +83,18 @@ C4Component Rel(tui, outputFn, "Format
on Enter") UpdateRelStyle(tui, outputFn, $offsetX="17", $offsetY="160") + Rel(rows, filterMatch, "Uses pattern
matchers") + UpdateRelStyle(rows, filterMatch, $offsetX="-5", $offsetY="-5") + + Rel(filter, filterMatch, "Uses pattern
matchers") + UpdateRelStyle(filter, filterMatch, $offsetX="45", $offsetY="-5") + + Rel(selection, filterMatch, "Uses pattern
matchers") + UpdateRelStyle(selection, filterMatch, $offsetX="165", $offsetY="-25") + UpdateElementStyle(tui, $bgColor="#FFCC33", $borderColor="#0000CC", $fontColor="#000000") UpdateElementStyle(rows, $bgColor="#9933FF", $borderColor="#0000CC", $fontColor="#ffffff") + UpdateElementStyle(filterMatch, $bgColor="#9933FF", $borderColor="#0000CC", $fontColor="#ffffff") UpdateElementStyle(summary, $bgColor="#9933FF", $borderColor="#0000CC", $fontColor="#ffffff") UpdateElementStyle(filter, $bgColor="#9933FF", $borderColor="#0000CC", $fontColor="#ffffff") UpdateElementStyle(selection, $bgColor="#9933FF", $borderColor="#0000CC", $fontColor="#ffffff") @@ -91,20 +104,21 @@ C4Component ## Component descriptions -| Component | Source file | Key exports | -| ------------------------ | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Filter & aggregation** | `src/aggregate.ts` | `aggregate()` — filters `CodeMatch[]` by repository and extract exclusion lists; normalises both `repoName` and `org/repoName` forms. | -| **Team grouping** | `src/group.ts` | `groupByTeamPrefix()` — groups `RepoGroup[]` into `TeamSection[]` keyed by team slug; `flattenTeamSections()` — converts back to a flat list for the TUI row builder. | -| **Row builder** | `src/render/rows.ts` | `buildRows()` — converts `RepoGroup[]` into `Row[]` with expanded/collapsed state; `rowTerminalLines()` — measures wrapped height; `isCursorVisible()` — viewport clipping. | -| **Summary builder** | `src/render/summary.ts` | `buildSummary()` — compact header line; `buildSummaryFull()` — detailed counts; `buildSelectionSummary()` — "N files selected" footer. | -| **Filter stats** | `src/render/filter.ts` | `buildFilterStats()` — produces the `FilterStats` object (visible count, total count, active filter string) used by the TUI status bar. | -| **Selection helpers** | `src/render/selection.ts` | `applySelectAll()` — marks all visible rows as selected; `applySelectNone()` — deselects all. | -| **Syntax highlighter** | `src/render/highlight.ts` | `highlightFragment()` — maps file extension to a language token ruleset and applies ANSI escape sequences. Falls back to plain text for unknown extensions. | -| **Output formatter** | `src/output.ts` | `buildOutput()` — entry point for both `--format markdown` and `--format json` serialisation of the confirmed selection. | +| Component | Source file | Key exports | +| ------------------------ | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Filter & aggregation** | `src/aggregate.ts` | `aggregate()` — filters `CodeMatch[]` by repository and extract exclusion lists; normalises both `repoName` and `org/repoName` forms. | +| **Team grouping** | `src/group.ts` | `groupByTeamPrefix()` — groups `RepoGroup[]` into `TeamSection[]` keyed by team slug; `flattenTeamSections()` — converts back to a flat list for the TUI row builder. | +| **Row builder** | `src/render/rows.ts` | `buildRows()` — converts `RepoGroup[]` into `Row[]` filtered by the active target (path / content / repo); `rowTerminalLines()` — measures wrapped height; `isCursorVisible()` — viewport clipping. | +| **Summary builder** | `src/render/summary.ts` | `buildSummary()` — compact header line; `buildSummaryFull()` — detailed counts; `buildSelectionSummary()` — "N files selected" footer. | +| **Filter stats** | `src/render/filter.ts` | `buildFilterStats()` — produces the `FilterStats` object (visible repos, files, matches) used by the TUI filter bar live counter. | +| **Pattern matchers** | `src/render/filter-match.ts` | `makeExtractMatcher()` — builds a case-insensitive substring or RegExp test function for path or content targets; `makeRepoMatcher()` — wraps the same logic for repo-name matching. | +| **Selection helpers** | `src/render/selection.ts` | `applySelectAll()` — marks all visible rows as selected (respects filter target); `applySelectNone()` — deselects all visible rows. | +| **Syntax highlighter** | `src/render/highlight.ts` | `highlightFragment()` — maps file extension to a language token ruleset and applies ANSI escape sequences. Falls back to plain text for unknown extensions. | +| **Output formatter** | `src/output.ts` | `buildOutput()` — entry point for both `--format markdown` and `--format json` serialisation of the confirmed selection. | ## Design principles - **No I/O.** Every component in this layer is a pure function: given the same inputs it always returns the same outputs. This makes them straightforward to test with Bun's built-in test runner. - **Single responsibility.** Each component owns exactly one concern (rows, summary, selection, …). The TUI composes them at render time rather than duplicating logic. -- **`types.ts` as the contract.** All components share the interfaces defined in `src/types.ts` (`TextMatchSegment`, `TextMatch`, `CodeMatch`, `RepoGroup`, `Row`, `TeamSection`, `OutputFormat`, `OutputType`). Changes to these types require updating all components. +- **`types.ts` as the contract.** All components share the interfaces defined in `src/types.ts` (`TextMatchSegment`, `TextMatch`, `CodeMatch`, `RepoGroup`, `Row`, `TeamSection`, `OutputFormat`, `OutputType`, `FilterTarget`). Changes to these types require updating all components. - **`render.ts` as façade.** External consumers import from `src/render.ts`, which re-exports all symbols from the `src/render/` sub-modules plus the top-level `renderGroups()` and `renderHelpOverlay()` functions. diff --git a/docs/blog/index.md b/docs/blog/index.md index 5cf50c5..ae09778 100644 --- a/docs/blog/index.md +++ b/docs/blog/index.md @@ -11,6 +11,7 @@ Full release notes and changelogs are always available on | Release | Highlights | | -------------------------- | ----------------------------------------------------------------------------------------------------- | +| [v1.5.0](./release-v1-5-0) | Advanced filter targets (content/path/repo), regex mode, word-jump shortcuts, scroll accuracy fix | | [v1.4.0](./release-v1-4-0) | TUI visual overhaul, violet branding, demo animation, SECURITY / Code of Conduct, README improvements | | [v1.3.0](./release-v1-3-0) | Richer upgrade output, update-available notice, colorized `--help`, deep doc links, What's New blog | | [v1.0.0](./release-v1-0-0) | Initial public release — interactive TUI, per-repo aggregation, markdown / JSON output | diff --git a/docs/blog/release-v1-5-0.md b/docs/blog/release-v1-5-0.md new file mode 100644 index 0000000..f5d42b6 --- /dev/null +++ b/docs/blog/release-v1-5-0.md @@ -0,0 +1,56 @@ +--- +title: "What's new in v1.5.0" +description: "Advanced filter targets (content, path, repo), regex mode in the filter bar, word-jump shortcuts, and several TUI accuracy fixes." +date: 2026-03-01 +--- + +# What's new in github-code-search v1.5.0 + +> Full release notes: + +## Highlights + +### Advanced filter targets + +The filter bar (press `f`) can now search across three different targets. Outside filter mode, press **`t`** to cycle targets; inside the filter bar, use **Shift+Tab**: + +| Badge | What is filtered | +| ----------- | ------------------------------------------ | +| `[path]` | File path of each result (default) | +| `[content]` | Code snippet text inside each extract | +| `[repo]` | Repository name (short or `org/repo` form) | + +The active target badge is always visible in the filter bar, including in the default `[path]` mode, so you always know what you are filtering against. + +### Regex mode + +Press **Tab** while the filter bar is open to toggle regex mode. When active, the filter input is interpreted as a regular expression — the badge shows `[…·regex]` to make it obvious. Invalid patterns match nothing gracefully (zero visible rows, no crash). + +### Word-jump shortcuts in the filter bar + +Three equivalent key combinations now move the cursor one word at a time: + +- **⌥←/→** (macOS Option+Arrow — the most natural shortcut on macOS terminals) +- **Ctrl+←/→** (common on Linux/Windows terminals) +- **Alt+b / Alt+f** (readline-style mnemonics) + +**⌥⌫** (Option+Backspace) / **Ctrl+W** delete the word before the cursor, as before. + +### Accurate scroll position + +The viewport used by the scroll engine now accounts for the filter bar height (0–2 lines depending on the active filter mode and badge visibility). Previously, the static `termHeight − 6` estimate could cause the cursor to stop scrolling before it was actually visible. The fix ensures `isCursorVisible` uses exactly the same window as `renderGroups`. + +### Search-match highlighting on the cursor extract row + +When the cursor is positioned on an extract row, the file path now shows the search-match highlight (yellow on magenta background) — consistent with how the highlighted repo row already behaved. + +--- + +## Upgrade + +```bash +github-code-search upgrade +``` + +Or grab the latest binary directly from the +[GitHub Releases page](https://github.com/fulll/github-code-search/releases/tag/v1.5.0). diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 468d557..49377c5 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -23,21 +23,41 @@ Section header rows (shown when `--group-by-team-prefix` is active) are skipped ## Filtering -| Key | Action | -| --- | -------------------------------------------------------------------------------------- | -| `f` | Open the filter bar — type a path substring to narrow visible files (case-insensitive) | -| `r` | Reset the active filter and show 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. | +| `r` | Reset the active filter and return to showing all repos / extracts | + +### Filter targets + +| Target | What is matched | Shown / hidden unit | +| --------- | ----------------------------------------------------------------------------- | ---------------------- | +| `path` | File path substring (default). Case-insensitive. | Individual extracts | +| `content` | Code fragment text (the snippet returned by GitHub Search). Case-insensitive. | Individual extracts | +| `repo` | Repository full name (`org/repo`). Case-insensitive. | Entire repo + extracts | + +The active target is always shown in the filter bar badge: `[path]`, `[content]`, or `[repo]` (with `·regex` appended when regex mode is on). ### Filter mode bindings When the filter bar is open (after pressing `f`): -| Key | Action | -| -------------------- | -------------------------------------------- | -| Printable characters | Append character to the filter term | -| `Backspace` | Delete the last character of the filter term | -| `Enter` | Confirm the filter and apply it | -| `Esc` | Cancel without applying the filter | +| Key | Action | +| ------------------------------------------------------------- | ------------------------------------------------------------------ | +| Printable characters / paste | Insert character(s) at the cursor position | +| `←` / `→` | Move the text cursor one character left / right | +| `⌥←` / `⌥→` (macOS) · `Ctrl+←` / `Ctrl+→` · `Alt+b` / `Alt+f` | Jump one word left / right | +| `Backspace` | Delete the character before the cursor | +| `⌥⌫` (macOS) · `Ctrl+W` | Delete the word before the cursor | +| `Tab` | Toggle **regex** mode (badge shows `[…·regex]` when on) | +| `Shift+Tab` | Cycle the **filter target** (`path` → `content` → `repo` → `path`) | +| `Enter` | Confirm the filter and apply it | +| `Esc` | Cancel without applying the filter | + +::: tip +Invalid regex patterns do not crash the TUI but are treated as matching nothing (zero visible rows). The badge is always yellow when regex mode is active, regardless of whether the pattern is valid. +::: ## Help and exit diff --git a/docs/usage/filtering.md b/docs/usage/filtering.md index 430e9db..b63349b 100644 --- a/docs/usage/filtering.md +++ b/docs/usage/filtering.md @@ -75,8 +75,28 @@ github-code-search "useFeatureFlag" --org fulll \ --exclude-extracts billing-api:src/flags.ts:0 ``` -## In-TUI filtering (file path filter) +## In-TUI filtering -In addition to pre-query exclusions, the [interactive mode](/usage/interactive-mode) offers a live **path filter** (press `f`) to narrow the displayed extracts by file path substring without permanently excluding anything. +In addition to pre-query exclusions, the [interactive mode](/usage/interactive-mode) offers a live **filter bar** (press `f`) to narrow the displayed results without permanently excluding anything. -Use the path filter for **exploration** (no side effects), and `--exclude-extracts` / `--exclude-repositories` for **reproducible** exclusions in replay commands. +### Three filter targets + +Press `t` (outside filter mode) or `Shift+Tab` (inside filter mode) to cycle between matching modes: + +| Target | Filters on | Unit visible/hidden | +| --------- | ---------------------------------------------------------- | ------------------- | +| `path` | File path substring (default, case-insensitive) | Individual file | +| `content` | Code fragment returned by GitHub Search (case-insensitive) | Individual file | +| `repo` | Full repository name `org/repo` (case-insensitive) | Entire repo | + +With `repo` mode the matching portion of the repository name is highlighted in yellow in the result list. + +### Regex mode + +Press `Tab` inside the filter bar to enable regex matching. The badge updates to `[path·regex]` (or `[content·regex]`, etc.). If you enter an invalid regular expression, it is treated as matching nothing: the filter will show zero results but the UI remains responsive. + +### Filter vs. exclusions: when to use which + +Use the **TUI filter** for **exploration** — it has no side effects and can be reset instantly with `r`. + +Use `--exclude-repositories` / `--exclude-extracts` for **reproducible** exclusions that should be encoded in the replay command (e.g. for CI pipelines). diff --git a/docs/usage/interactive-mode.md b/docs/usage/interactive-mode.md index 1cb75b2..e33fb85 100644 --- a/docs/usage/interactive-mode.md +++ b/docs/usage/interactive-mode.md @@ -41,7 +41,8 @@ github-code-search "useFeatureFlag" --org fulll | `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 a path substring to narrow visible files | +| `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) | @@ -56,29 +57,46 @@ github-code-search "useFeatureFlag" --org fulll ## Filter mode -Press `f` to enter filter mode. A prompt appears at the top of the results: +Press `f` to enter filter mode. A two-line bar appears at the top of the results: ```text -🔍 Filter: src/ ▌ Enter confirm · Esc cancel +🔍 [path] src/▌ 3 repos · 5 files + ←→ move · ⌥←→ word · ⌥⌫ del word · Tab regex · Shift+Tab target · ↵ OK · Esc cancel ``` -Type any path substring (case-insensitive). The view updates live as you type. Press: +- **Line 1**: the filter input field with a text cursor (`▌`), plus live stats on the right (how many repos and files are currently visible). +- **Line 2**: available shortcuts, indented to align with the input text. -- **Enter** — confirm the filter -- **Esc** — cancel without applying +### Filter targets -When a filter is active, the prompt is replaced by a stats line: +Press `t` (outside filter mode) or `Shift+Tab` (inside filter mode) to cycle through three matching modes: + +| Badge | Target | What is matched | Unit shown/hidden | +| ----------- | --------- | --------------------------------------------------- | ----------------- | +| `[path]` | `path` | File path — default, case-insensitive substring | Individual file | +| `[content]` | `content` | Code fragment text returned by GitHub Search | Individual file | +| `[repo]` | `repo` | Full repository name (`org/repo`), case-insensitive | Entire repo | + +The matching part is highlighted in **yellow** in the result list so you can instantly see why a row is visible. + +### Regex mode + +Press `Tab` in filter mode to toggle regular-expression matching. The badge updates to `[path·regex]` (or `[content·regex]`, etc.) while regex is active. If the expression is invalid, it matches nothing (no results are shown until you correct the pattern). + +### Confirmed filter + +After pressing `Enter` the filter is locked and the bar shows a compact summary: ```text -🔍 filter: src/ 3 matches in 2 repos shown · 4 hidden in 1 repo r to reset +🔍 [repo] billing 3 matches in 1 repo shown · 2 hidden in 2 repos r to reset ``` +Press `r` at any time to clear the filter and show all results again. + ::: info -`a` (select all) and `n` (select none) always operate only on the **currently visible** repos and extracts when a filter is active. +`a` (select all) and `n` (select none) always operate only on the **currently visible** repos and extracts when a filter is active. The filter target is taken into account: with `filterTarget=repo` only repos whose name matches are affected. ::: -Press `r` at any time to clear the filter and show all results again. - ## Full workflow example **1 — Run the search:** diff --git a/package.json b/package.json index d6df46a..557897d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "github-code-search", - "version": "1.4.0", + "version": "1.5.0", "description": "Interactive GitHub code search with per-repo aggregation", "keywords": [ "bun", diff --git a/src/render.test.ts b/src/render.test.ts index 53c968f..3e33c48 100644 --- a/src/render.test.ts +++ b/src/render.test.ts @@ -402,11 +402,44 @@ describe("isCursorVisible", () => { expect(isCursorVisible(rows, groups, 3, 0, 4)).toBe(false); }); + it("returns false when cursor row h does not fit in remaining space", () => { + // Regression: isCursorVisible must mirror renderGroups break condition. + // viewportHeight=2, row 0 (repo h=1) consumes 1 line, cursor row 1 + // (extract with fragment h=2) would need 1+2=3 > 2 → must NOT be visible. + // Before the fix this returned true, causing scroll to stop 1 step early + // which meant the last unfolded extract was never rendered. + const groups = [makeGroup("org/repo", ["a.ts"], false, true)]; + const rows = buildRows(groups); + // rows: [repo(0), extract(0,0)] extract has fragment → h=2 + expect(isCursorVisible(rows, groups, 1, 0, 2)).toBe(false); + // but first row (repo) is always "visible" even as sole row + expect(isCursorVisible(rows, groups, 0, 0, 2)).toBe(true); + }); + it("returns true when cursor is exactly at the start of the viewport", () => { const groups = [makeGroup("org/repo", ["a.ts"], false, false)]; const rows = buildRows(groups); expect(isCursorVisible(rows, groups, 0, 0, 10)).toBe(true); }); + + it("returns false when scrollOffset is stale (greater than cursor after filter shrinks rows)", () => { + // Regression guard for Bug 2: in filter-mode edit handlers (Backspace, Del, + // word-delete, paste) tui.ts clamps `cursor` to the new rows length but does + // NOT clamp `scrollOffset`. When the filter reduces rows, cursor is clamped + // down but scrollOffset can remain larger than cursor, causing the cursor to + // appear "above the viewport" — isCursorVisible returns false and the while + // loop `while (scrollOffset < cursor ...)` never runs (already false), so the + // tui gets stuck showing an empty groups area. + // + // Scenario: 4 rows, user was at cursor=3, scrollOffset=2. Filter reduces rows + // to 2. Cursor clamped to 1. scrollOffset NOT clamped → still 2 > cursor=1. + const groups = [makeGroup("org/repo", ["a.ts", "b.ts", "c.ts"], false)]; + const rows = buildRows(groups); // [repo, ext0, ext1, ext2] + // cursor=1 but scrollOffset=2: cursor is *above* the viewport window + expect(isCursorVisible(rows, groups, 1, 2, 10)).toBe(false); + // The fix: clamp scrollOffset = Math.min(scrollOffset, cursor) = Math.min(2,1) = 1 + expect(isCursorVisible(rows, groups, 1, 1, 10)).toBe(true); + }); }); // ─── buildSummaryFull ────────────────────────────────────────────────────────────── @@ -579,6 +612,27 @@ describe("renderGroups", () => { expect(stripped).toContain("org/repoA"); // repo name shown in sticky line }); + it("does NOT show sticky repo header when cursor is on a repo row (even with scrollOffset > 0)", () => { + // Regression guard for Bug 1: getViewportHeight() in tui.ts used to subtract + // a sticky-header line whenever scrollOffset > 0, but renderGroups only shows + // the sticky header when the cursor is on an *extract* row whose repo header + // has scrolled above the viewport. When cursor is on a repo row, no sticky + // header is emitted and the subtracted line makes isCursorVisible() return + // false prematurely (the cursor appears invisible one step too early). + // + // rows: [repo(0), ext(0,0), repo(1), ext(1,0)] + // cursor=2 (repo1), scrollOffset=1 → repo(0) < scrollOffset, but cursor is on + // repo(1), not an extract → sticky header must NOT be shown. + const groups = [ + makeGroup("org/repoA", ["a.ts"], false), + makeGroup("org/repoB", ["b.ts"], false), + ]; + const rows = buildRows(groups); + const out = renderGroups(groups, 2, rows, 40, 1, "q", "org"); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + expect(stripped).not.toContain("▲"); + }); + it("stops rendering rows when viewport is filled (overflow break)", () => { // Extract rows with fragments have h=2; with termHeight=9 viewportHeight=2 // row0(repo,h=1): fits. row1(ext,h=2): 1+2=3 > 2 AND usedLines=1>0 → break @@ -749,6 +803,87 @@ describe("applySelectNone with filterPath", () => { }); }); +// ─── applySelectAll / applySelectNone with filterTarget="repo" ─────────────── + +describe("applySelectAll with filterTarget=repo", () => { + it("selects all groups whose repoFullName matches (repo row context)", () => { + const groups = [ + makeGroup("org/alpha", ["a.ts"], false, false), + makeGroup("org/beta", ["b.ts"], false, false), + ]; + // start unselected so we can verify what applySelectAll selects + groups[0].repoSelected = false; + groups[0].extractSelected = [false]; + groups[1].repoSelected = false; + groups[1].extractSelected = [false]; + const row: Row = { type: "repo", repoIndex: 0 }; + applySelectAll(groups, row, "alpha", "repo"); + expect(groups[0].repoSelected).toBe(true); + expect(groups[0].extractSelected[0]).toBe(true); + expect(groups[1].repoSelected).toBe(false); // "beta" unaffected + expect(groups[1].extractSelected[0]).toBe(false); + }); + + it("selects the current repo when its name matches (extract row context)", () => { + const groups = [ + makeGroup("org/alpha", ["a.ts", "b.ts"], false, false), + makeGroup("org/beta", ["c.ts"], false, false), + ]; + groups[0].repoSelected = false; + groups[0].extractSelected = [false, false]; + groups[1].repoSelected = false; + groups[1].extractSelected = [false]; + const row: Row = { type: "extract", repoIndex: 0, extractIndex: 0 }; + applySelectAll(groups, row, "alpha", "repo"); + expect(groups[0].repoSelected).toBe(true); + expect(groups[0].extractSelected[0]).toBe(true); + expect(groups[0].extractSelected[1]).toBe(true); + // repo 1 must be untouched (extract-row scope) + expect(groups[1].repoSelected).toBe(false); + }); + + it("does not select the current repo when its name does not match (extract row context)", () => { + const groups = [makeGroup("org/beta", ["b.ts"], false, false)]; + groups[0].repoSelected = false; + groups[0].extractSelected = [false]; + const row: Row = { type: "extract", repoIndex: 0, extractIndex: 0 }; + applySelectAll(groups, row, "alpha", "repo"); + expect(groups[0].repoSelected).toBe(false); + expect(groups[0].extractSelected[0]).toBe(false); + }); +}); + +describe("applySelectNone with filterTarget=repo", () => { + it("deselects all groups whose repoFullName matches (repo row context)", () => { + const groups = [makeGroup("org/alpha", ["a.ts"]), makeGroup("org/beta", ["b.ts"])]; + const row: Row = { type: "repo", repoIndex: 0 }; + applySelectNone(groups, row, "alpha", "repo"); + expect(groups[0].repoSelected).toBe(false); + expect(groups[0].extractSelected[0]).toBe(false); + expect(groups[1].repoSelected).toBe(true); // "beta" not matched — untouched + expect(groups[1].extractSelected[0]).toBe(true); + }); + + it("deselects the current repo when its name matches (extract row context)", () => { + const groups = [makeGroup("org/alpha", ["a.ts", "b.ts"]), makeGroup("org/beta", ["c.ts"])]; + const row: Row = { type: "extract", repoIndex: 0, extractIndex: 0 }; + applySelectNone(groups, row, "alpha", "repo"); + expect(groups[0].repoSelected).toBe(false); + expect(groups[0].extractSelected[0]).toBe(false); + expect(groups[0].extractSelected[1]).toBe(false); + // repo 1 must be untouched (extract-row scope) + expect(groups[1].repoSelected).toBe(true); + }); + + it("does not deselect the current repo when its name does not match (extract row context)", () => { + const groups = [makeGroup("org/beta", ["b.ts"])]; + const row: Row = { type: "extract", repoIndex: 0, extractIndex: 0 }; + applySelectNone(groups, row, "alpha", "repo"); + expect(groups[0].repoSelected).toBe(true); // unaffected — no match + expect(groups[0].extractSelected[0]).toBe(true); + }); +}); + // ─── renderHelpOverlay ──────────────────────────────────────────────────────── describe("renderHelpOverlay", () => { @@ -791,9 +926,9 @@ describe("renderGroups filter opts", () => { filterInput: "src", }); const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); - expect(stripped).toContain("Filter:"); - expect(stripped).toContain("src"); - expect(stripped).toContain("Enter confirm"); + expect(stripped).toContain("src"); // typed text visible in input field + expect(stripped).toContain("↵ OK"); // line-2 hints + expect(stripped).toContain("Esc cancel"); // line-2 hints }); it("live-filters rows by filterInput when filterMode=true", () => { @@ -832,4 +967,194 @@ describe("renderGroups filter opts", () => { expect(stripped).not.toContain("filter:"); expect(stripped).not.toContain("Filter:"); }); + + it("shows mode badge [content] when filterTarget=content", () => { + const groups = [makeGroup("org/repo", ["a.ts"], false, true)]; + const rows = buildRows(groups, "code", "content"); + const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { + filterPath: "code", + filterTarget: "content", + }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + expect(stripped).toContain("[content]"); + }); + + it("shows mode badge [repo] when filterTarget=repo", () => { + const groups = [makeGroup("org/service", ["a.ts"])]; + const rows = buildRows(groups, "service", "repo"); + const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { + filterPath: "service", + filterTarget: "repo", + }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + expect(stripped).toContain("[repo]"); + }); + + it("shows mode badge [path·regex] when filterTarget=path and filterRegex=true", () => { + const groups = [makeGroup("org/repo", ["src/a.ts"])]; + const rows = buildRows(groups, "src", "path", true); + const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { + filterPath: "src", + filterTarget: "path", + filterRegex: true, + }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + expect(stripped).toContain("[path"); + expect(stripped).toContain("regex]"); + }); + + it("always shows mode badge [path] when filterTarget=path and filterRegex=false", () => { + const groups = [makeGroup("org/repo", ["a.ts"])]; + const rows = buildRows(groups, "a"); + const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { + filterPath: "a", + }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + expect(stripped).toContain("[path]"); + expect(stripped).not.toContain("[content]"); + expect(stripped).not.toContain("[repo]"); + }); + + it("renders inline cursor (inverse block) in filter mode", () => { + const groups = [makeGroup("org/repo", ["a.ts"])]; + const rows = buildRows(groups, "src"); + // filterCursor=2 means caret is at position 2 in "src" + const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { + filterMode: true, + filterInput: "src", + filterCursor: 2, + }); + // The character at cursor position should be wrapped in an inverse ANSI sequence (\x1b[7m) + expect(out).toMatch(/\x1b\[7m/); + }); + + it("shows '…' live stats placeholder while debounce pending (filterLiveStats=null, filterInput set)", () => { + const groups = [makeGroup("org/repo", ["a.ts"])]; + const rows = buildRows(groups, "a"); + const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { + filterMode: true, + filterInput: "a", + filterLiveStats: null, + }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + expect(stripped).toContain("…"); + }); + + it("shows live stats counts when filterLiveStats is provided", () => { + const groups = [makeGroup("org/repo", ["a.ts", "b.ts"], false)]; + const rows = buildRows(groups, "a"); + const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { + filterMode: true, + filterInput: "a", + filterLiveStats: { + visibleMatches: 1, + visibleRepos: 1, + hiddenMatches: 1, + hiddenRepos: 0, + visibleFiles: 1, + }, + }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + expect(stripped).toContain("1 repo · 1 file"); + }); +}); + +// ─── buildRows — filterTarget + filterRegex ─────────────────────────────────── + +describe("buildRows (filterTarget + filterRegex)", () => { + it("filters by content when filterTarget=content", () => { + const groups = [makeGroup("org/repo", ["a.ts", "b.ts"], false, true)]; + // makeGroup with withFragments=true generates fragment: "some code with " + const rows = buildRows(groups, "a.ts", "content"); + const paths = rows + .filter((r) => r.type === "extract") + .map((r) => groups[r.repoIndex].matches[r.extractIndex!].path); + expect(paths).toContain("a.ts"); + expect(paths).not.toContain("b.ts"); + }); + + it("filters by repo name when filterTarget=repo", () => { + const g1 = makeGroup("org/service-auth", ["a.ts"]); + const g2 = makeGroup("org/service-payments", ["b.ts"]); + g1.folded = false; + g2.folded = false; + const rows = buildRows([g1, g2], "auth", "repo"); + const extractRows = rows.filter((r) => r.type === "extract"); + const repoNames = extractRows.map((r) => [g1, g2][r.repoIndex].repoFullName); + expect(repoNames.some((n) => n.includes("auth"))).toBe(true); + expect(repoNames.some((n) => n.includes("payments"))).toBe(false); + }); + + it("uses regex when filterRegex=true", () => { + const groups = [makeGroup("org/repo", ["src/foo.ts", "lib/bar.ts"], false)]; + const rows = buildRows(groups, "^src/", "path", true); + const paths = rows + .filter((r) => r.type === "extract") + .map((r) => groups[r.repoIndex].matches[r.extractIndex!].path); + expect(paths).toContain("src/foo.ts"); + expect(paths).not.toContain("lib/bar.ts"); + }); + + it("shows no extracts for invalid regex (no crash)", () => { + const groups = [makeGroup("org/repo", ["src/foo.ts"], false)]; + const rows = buildRows(groups, "[broken", "path", true); + const extracts = rows.filter((r) => r.type === "extract"); + expect(extracts.length).toBe(0); + }); + + it("toggling regex to invalid pattern collapses rows to 0 — cursor must be clamped (regression guard for Bug 3)", () => { + // Regression guard for Bug 3: the Tab key in tui.ts toggles filterRegex but + // did not rebuild rows or clamp cursor/scrollOffset afterward. When the new + // regex is invalid (or simply more restrictive), the row list shrinks. If + // cursor was pointing at a now-removed row, isCursorVisible returns false yet + // the scroll-adjust while-loop doesn't fire (cursor is already 0 or the + // condition `scrollOffset < cursor` is already false), leaving cursor + // pointing at an invalid index — renderGroups skips the cursor highlight. + // + // The fix: after `filterRegex = !filterRegex`, rebuild rows and clamp: + // const newRows = buildRows(groups, filterInput, filterTarget, filterRegex); + // cursor = Math.min(cursor, Math.max(0, newRows.length - 1)); + // scrollOffset = Math.min(scrollOffset, cursor); + const groups = [makeGroup("org/repo", ["src/foo.ts", "src/bar.ts"], false)]; + // Before toggle: regex=false, pattern="src" → 2 extract rows visible + const rowsBefore = buildRows(groups, "src", "path", false); + expect(rowsBefore.filter((r) => r.type === "extract")).toHaveLength(2); + let cursor = 2; // cursor on second extract + // After toggle to regex=true with an invalid pattern: + const rowsAfter = buildRows(groups, "[invalid", "path", true); + expect(rowsAfter).toHaveLength(0); // invalid regex → no matches, no rows + // isCursorVisible with stale cursor (still 2, rows=[]) must return false + expect(isCursorVisible(rowsAfter, groups, cursor, 0, 10)).toBe(false); + // The required clamp: + cursor = Math.min(cursor, Math.max(0, rowsAfter.length - 1)); + expect(cursor).toBe(0); + }); +}); + +// ─── buildFilterStats — filterTarget + filterRegex ─────────────────────────── + +describe("buildFilterStats (filterTarget + filterRegex)", () => { + it("counts visible/hidden by content filter", () => { + const groups = [makeGroup("org/repo", ["a.ts", "b.ts"], false, true)]; + const stats = buildFilterStats(groups, "a.ts", "content"); + expect(stats.visibleMatches).toBe(1); + expect(stats.hiddenMatches).toBe(1); + }); + + it("counts by repo filter", () => { + const g1 = makeGroup("org/service-auth", ["a.ts"]); + const g2 = makeGroup("org/service-payments", ["b.ts"]); + g1.folded = false; + g2.folded = false; + const stats = buildFilterStats([g1, g2], "auth", "repo"); + expect(stats.visibleRepos).toBe(1); + expect(stats.hiddenRepos).toBe(1); + }); + + it("counts with regex filter", () => { + const groups = [makeGroup("org/repo", ["src/foo.ts", "lib/bar.ts"], false)]; + const stats = buildFilterStats(groups, "^src/", "path", true); + expect(stats.visibleMatches).toBe(1); + expect(stats.hiddenMatches).toBe(1); + }); }); diff --git a/src/render.ts b/src/render.ts index 254aaf6..0e96728 100644 --- a/src/render.ts +++ b/src/render.ts @@ -1,7 +1,7 @@ import pc from "picocolors"; -import type { RepoGroup, Row } from "./types.ts"; +import type { FilterTarget, RepoGroup, Row, TextMatchSegment } from "./types.ts"; import { highlightFragment } from "./render/highlight.ts"; -import { buildFilterStats } from "./render/filter.ts"; +import { buildFilterStats, type FilterStats } from "./render/filter.ts"; import { rowTerminalLines, buildRows, isCursorVisible } from "./render/rows.ts"; import { buildMatchCountLabel, buildSummaryFull } from "./render/summary.ts"; import { applySelectAll, applySelectNone } from "./render/selection.ts"; @@ -10,7 +10,7 @@ import { applySelectAll, applySelectNone } from "./render/selection.ts"; // Consumers (tui.ts, output.ts, tests) continue to import from render.ts. export { highlightFragment } from "./render/highlight.ts"; -export { buildFilterStats } from "./render/filter.ts"; +export { buildFilterStats, type FilterStats } from "./render/filter.ts"; export { rowTerminalLines, buildRows, isCursorVisible } from "./render/rows.ts"; export { buildMatchCountLabel, @@ -23,6 +23,9 @@ export { applySelectAll, applySelectNone } from "./render/selection.ts"; // ─── Help overlay ───────────────────────────────────────────────────────────── export function renderHelpOverlay(): string { + const IS_MAC = process.platform === "darwin"; + const optStr = IS_MAC ? "⌥" : "Alt+"; + const optBs = IS_MAC ? "⌥⌫" : "Ctrl+W"; const bar = pc.dim("─".repeat(62)); const rows = [ bar, @@ -34,9 +37,12 @@ export function renderHelpOverlay(): string { ` ${pc.yellow("a")} select all ${pc.yellow("n")} select none`, ` ${pc.dim("(respects active filter)")}`, ` ${pc.yellow("f")} enter filter mode ${pc.yellow("r")} reset filter`, + ` ${pc.yellow("t")} cycle filter target ${pc.dim("(path → content → repo)")}`, ` ${pc.yellow("h")} / ${pc.yellow("?")} toggle this help ${pc.yellow("q")} / Ctrl+C quit`, bar, - ` ${pc.dim("Filter mode:")} type to filter by path · Enter confirm · Esc cancel`, + ` ${pc.dim("Filter mode:")}`, + ` type to filter · ${pc.yellow("←→")} cursor · ${pc.yellow(`${optStr}←→ / Ctrl+←→`)} word jump · ${pc.yellow(optBs)} del word`, + ` ${pc.yellow("Tab")} regex · ${pc.yellow("Shift+Tab")} target · ${pc.yellow("↵")} confirm · ${pc.yellow("Esc")} cancel`, bar, pc.dim(` press ${pc.yellow("h")} or ${pc.yellow("?")} to close`), ]; @@ -48,24 +54,119 @@ export function renderHelpOverlay(): string { const INDENT = " "; const HEADER_LINES = 4; // title + summaryFull + hints + blank +/** + * Compute flat-offset segments for all occurrences of `pattern` in `fragment`. + * Returns fake TextMatchSegment entries (line/col unused by highlightFragment). + * Used to overlay filter-term highlights when filterTarget === "content". + */ +function contentPatternSegments( + fragment: string, + pattern: string, + isRegex: boolean, +): TextMatchSegment[] { + let re: RegExp; + try { + re = isRegex + ? new RegExp(pattern, "gi") + : new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"); + } catch { + return []; + } + const segs: TextMatchSegment[] = []; + let m: RegExpExecArray | null; + while ((m = re.exec(fragment)) !== null) { + segs.push({ text: m[0], indices: [m.index, m.index + m[0].length], line: 0, col: 0 }); + if (m[0].length === 0) re.lastIndex++; + } + return segs; +} + +/** + * Sort and merge overlapping or adjacent TextMatchSegments before passing them + * to highlightFragment. GitHub query matches and content-filter matches can share + * character ranges; without merging, overlapping segments cause double-rendering. + */ +function mergeSegments(segs: TextMatchSegment[]): TextMatchSegment[] { + if (segs.length <= 1) return segs; + const sorted = segs.toSorted((a, b) => a.indices[0] - b.indices[0]); + const merged: TextMatchSegment[] = [{ ...sorted[0] }]; + for (let i = 1; i < sorted.length; i++) { + const last = merged[merged.length - 1]; + const cur = sorted[i]; + if (cur.indices[0] <= last.indices[1]) { + // Overlapping or adjacent — extend the previous segment's end if needed. + if (cur.indices[1] > last.indices[1]) last.indices[1] = cur.indices[1]; + } else { + merged.push({ ...cur }); + } + } + return merged; +} + /** Strip ANSI escape sequences to measure the visible character width of a string. */ function stripAnsi(str: string): string { // eslint-disable-next-line no-control-regex return str.replace(/\x1b\[[\d;]*[mGKHF]/g, ""); } +/** + * Returns a text-highlight function compiled once per renderGroups call. + * The returned function applies bold-yellow highlighting to every occurrence of + * `pattern` in the given text — but only when `filterTarget === target`. + * Compiling the regex here avoids recompiling on every row during the render loop. + * Matching is case-insensitive; invalid regex silently falls back to plain style. + */ +function makeTextHighlighter( + pattern: string, + filterTarget: FilterTarget, + filterRegex: boolean, +): (text: string, target: FilterTarget, baseStyle: (s: string) => string) => string { + if (!pattern) return (_text, _target, style) => style(_text); + let re: RegExp; + try { + re = filterRegex + ? new RegExp(pattern, "gi") + : new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"); + } catch { + return (_text, _target, style) => style(_text); + } + return (text, target, baseStyle) => { + if (filterTarget !== target) return baseStyle(text); + re.lastIndex = 0; // reset for each new text (g flag retains state across calls) + const parts: string[] = []; + let last = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + if (m.index > last) parts.push(baseStyle(text.slice(last, m.index))); + parts.push(pc.bold(pc.yellow(m[0]))); + last = m.index + m[0].length; + if (m[0].length === 0) re.lastIndex++; // guard zero-length match + } + if (last < text.length) parts.push(baseStyle(text.slice(last))); + return parts.length > 0 ? parts.join("") : baseStyle(text); + }; +} + /** Options bag for renderGroups — all fields optional. */ interface RenderOptions { - /** Currently active file-path filter (empty = no filter). */ + /** Currently active confirmed filter (empty = no filter). */ filterPath?: string; /** Whether the filter input bar is in edit mode. */ filterMode?: boolean; /** Text being typed in filter mode (may differ from confirmed filterPath). */ filterInput?: string; + /** Caret position within filterInput (for cursor rendering). */ + filterCursor?: number; + /** Pre-computed live stats for filterMode display (null = computing / not yet available). */ + filterLiveStats?: FilterStats | null; /** Whether to show the help overlay instead of the normal view. */ showHelp?: boolean; /** Terminal column width used to right-align match counts (default: 80). */ termWidth?: number; + /** Which field to match against (default: "path"). */ + filterTarget?: FilterTarget; + /** When true, filterPath is treated as a regular expression (default: false). */ + filterRegex?: boolean; } export function renderGroups( @@ -82,8 +183,12 @@ export function renderGroups( filterPath = "", filterMode = false, filterInput = "", + filterCursor, + filterLiveStats = null, showHelp = false, termWidth = 80, + filterTarget = "path", + filterRegex = false, } = opts; // ── Help overlay ────────────────────────────────────────────────────────── @@ -98,26 +203,101 @@ export function renderGroups( ); lines.push(buildSummaryFull(groups)); + // Active filter text used for in-row highlighting (filterInput while typing, filterPath once confirmed) + const activeFilter = filterMode ? filterInput : filterPath; + + // Compile text highlighter once for this render call — avoids regex recompilation per row. + const highlightText = makeTextHighlighter(activeFilter, filterTarget, filterRegex); + // ── Filter bar (sticky, shown when active or typing) ────────────────────── + const IS_MAC = process.platform === "darwin"; + const optStr = IS_MAC ? "⌥" : "Alt+"; + const optBs = IS_MAC ? "⌥⌫" : "Ctrl+W"; + + // Mode badge: always shown so the active target is always explicit — [path], [content], [repo], + // [path·regex], [content·regex], [repo·regex]. + const targetBadge = ` ${pc.dim("[")}${pc.yellow(filterTarget)}${filterRegex ? pc.dim("·") + pc.yellow("regex") : ""}${pc.dim("]")} `; + let filterBarLines = 0; if (filterMode) { - lines.push( - `🔍 ${pc.bold("Filter:")} ${filterInput}${pc.inverse(" ")} ${pc.dim("Enter confirm · Esc cancel")}`, - ); - filterBarLines = 1; + // ── Line 1: underlined input field + stats right-aligned ─────────────── + const cur = filterCursor ?? filterInput.length; + const before = filterInput.slice(0, cur); + const atCursor = filterInput.length > cur ? filterInput[cur] : " "; + const after = filterInput.slice(cur + 1); + + let statsStr = ""; + let statsVisLen = 0; + if (filterInput) { + if (filterLiveStats) { + const r = filterLiveStats.visibleRepos; + const f = filterLiveStats.visibleFiles; + const m2 = filterLiveStats.visibleMatches; + // Show matches only when cross-repo duplicates inflate the count + const parts = [ + `${r} repo${r !== 1 ? "s" : ""}`, + `${f} file${f !== 1 ? "s" : ""}`, + ...(m2 !== f ? [`${m2} match${m2 !== 1 ? "es" : ""}`] : []), + ]; + statsStr = pc.dim(parts.join(" \u00b7 ")); + statsVisLen = stripAnsi(statsStr).length; + } else { + statsStr = pc.dim("…"); + statsVisLen = 1; + } + } + const statsRight = statsVisLen > 0 ? ` ${statsStr}` : ""; + const statsRightVisLen = statsVisLen > 0 ? 2 + statsVisLen : 0; + + // 🔍 is 2 cols wide in most terminals; targetBadge is pure ASCII + const prefixVisLen = 2 + stripAnsi(targetBadge).length; + const fieldWidth = Math.max(8, termWidth - prefixVisLen - statsRightVisLen); + const padWidth = Math.max(0, fieldWidth - filterInput.length - 1); + const pad = " ".repeat(padWidth); + + // Underline the whole field; cursor char gets inverse video on top + const inputLine = + `🔍${targetBadge}` + + `\x1b[4m${before}\x1b[7m${atCursor}\x1b[27m${after}${pad}\x1b[24m` + + statsRight; + lines.push(inputLine); + + // ── Line 2: OS-aware shortcuts (indented to align with input text) ────── + // prefixVisLen = width of "🔍" (2) + targetBadge, so hints start exactly + // under the first character of the typed filter input. + const hintsIndent = " ".repeat(prefixVisLen); + const hints = [ + `${pc.yellow("←→")} move`, + `${pc.yellow(`${optStr}←→`)} word`, + `${pc.yellow(optBs)} del word`, + `${pc.yellow("Tab")} regex${filterRegex ? pc.green(" ✓") : ""}`, + `${pc.yellow("Shift+Tab")} target`, + `${pc.yellow("↵")} OK`, + `${pc.yellow("Esc")} cancel`, + ].join(" · "); + lines.push(pc.dim(`${hintsIndent}${hints}`)); + + filterBarLines = 2; } else if (filterPath) { - const stats = buildFilterStats(groups, filterPath); + const stats = buildFilterStats(groups, filterPath, filterTarget, filterRegex); const statsStr = pc.dim( - `${stats.visibleMatches} match${stats.visibleMatches !== 1 ? "es" : ""} in ${stats.visibleRepos} repo${stats.visibleRepos !== 1 ? "s" : ""} shown · ` + - `${stats.hiddenMatches} hidden in ${stats.hiddenRepos} repo${stats.hiddenRepos !== 1 ? "s" : ""} r to reset`, + `${stats.visibleMatches} match${stats.visibleMatches !== 1 ? "es" : ""} in ${ + stats.visibleRepos + } repo${stats.visibleRepos !== 1 ? "s" : ""} shown · ${ + stats.hiddenMatches + } hidden in ${stats.hiddenRepos} repo${stats.hiddenRepos !== 1 ? "s" : ""} r to reset`, ); - lines.push(`🔍 ${pc.bold("filter:")} ${pc.yellow(filterPath)} ${statsStr}`); + lines.push(`🔍${targetBadge}${pc.bold("filter:")} ${pc.yellow(filterPath)} ${statsStr}`); + filterBarLines = 1; + } else if (filterTarget !== "path" || filterRegex) { + // No active filter text, but non-default mode selected — remind the user. + lines.push(`🔍${targetBadge}${pc.dim("f to filter")}`); filterBarLines = 1; } lines.push( pc.dim( - "← / → fold/unfold ↑ / ↓ navigate spc select a all n none f filter h help ↵ confirm q quit\n", + "← / → fold/unfold ↑ / ↓ navigate spc select a all n none f filter t target h help ↵ confirm q quit\n", ), ); @@ -174,9 +354,15 @@ export function renderGroups( // green checkmark clearly signals selection. The space preserves column // alignment so the repo name always starts at the same offset. const checkbox = group.repoSelected ? pc.green("✓") : " "; + // On cursor rows, keep the magenta background but still highlight the + // matching chars in yellow so the pattern remains visible. + // Each segment is individually styled (bold+white or bold+yellow) so + // nested ANSI resets do not bleed into neighbouring segments. const repoName = isCursor - ? pc.bgMagenta(pc.bold(pc.white(` ${group.repoFullName} `))) - : pc.bold(group.repoFullName); + ? pc.bgMagenta( + ` ${highlightText(group.repoFullName, "repo", (s) => pc.bold(pc.white(s)))} `, + ) + : highlightText(group.repoFullName, "repo", pc.bold); const count = pc.dim(buildMatchCountLabel(group)); // Right-align the match count flush to the terminal edge const leftPart = `${arrow} ${checkbox} ${repoName}`; @@ -193,13 +379,24 @@ export function renderGroups( const seg = match.textMatches[0]?.matches[0]; const locSuffix = seg ? `:${seg.line}:${seg.col}` : ""; const filePath = isCursor - ? pc.bgMagenta(pc.bold(pc.white(` ${match.path}${locSuffix} `))) - : `${pc.cyan(match.path)}${pc.dim(locSuffix)}`; + ? pc.bgMagenta( + ` ${highlightText(match.path, "path", (s) => pc.bold(pc.white(s)))}${pc.dim(locSuffix)} `, + ) + : `${highlightText(match.path, "path", pc.cyan)}${pc.dim(locSuffix)}`; lines.push(`${INDENT}${INDENT}${checkbox} ${filePath}`); if (match.textMatches.length > 0) { const tm = match.textMatches[0]; - const fragmentLines = highlightFragment(tm.fragment, tm.matches, match.path); + // When filtering by content, overlay the typed pattern on the fragment. + const extraSegs = + filterTarget === "content" && activeFilter + ? contentPatternSegments(tm.fragment, activeFilter, filterRegex) + : []; + const fragmentLines = highlightFragment( + tm.fragment, + mergeSegments([...tm.matches, ...extraSegs]), + match.path, + ); for (const fl of fragmentLines) { lines.push(`${INDENT}${INDENT}${INDENT}${fl}`); } diff --git a/src/render/filter-match.test.ts b/src/render/filter-match.test.ts new file mode 100644 index 0000000..47aab70 --- /dev/null +++ b/src/render/filter-match.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "bun:test"; +import { makeExtractMatcher, makeRepoMatcher } from "./filter-match.ts"; +import type { CodeMatch, RepoGroup } from "../types.ts"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeMatch(path: string, fragments: string[] = []): CodeMatch { + return { + path, + repoFullName: "org/repo", + htmlUrl: `https://github.com/org/repo/blob/main/${path}`, + archived: false, + textMatches: fragments.map((f) => ({ fragment: f, matches: [] })), + }; +} + +function makeGroup(repoFullName: string): RepoGroup { + return { + repoFullName, + matches: [], + folded: false, + repoSelected: true, + extractSelected: [], + }; +} + +// ─── makeExtractMatcher — path target ───────────────────────────────────────── + +describe("makeExtractMatcher (path)", () => { + it("empty pattern always matches", () => { + const fn = makeExtractMatcher("", "path", false); + expect(fn(makeMatch("src/foo.ts"))).toBe(true); + }); + + it("substring match is case-insensitive", () => { + const fn = makeExtractMatcher("FOO", "path", false); + expect(fn(makeMatch("src/foo.ts"))).toBe(true); + expect(fn(makeMatch("src/bar.ts"))).toBe(false); + }); + + it("regex match works", () => { + const fn = makeExtractMatcher("^src/", "path", true); + expect(fn(makeMatch("src/foo.ts"))).toBe(true); + expect(fn(makeMatch("lib/foo.ts"))).toBe(false); + }); + + it("regex match is case-insensitive", () => { + const fn = makeExtractMatcher("FOO\\.ts$", "path", true); + expect(fn(makeMatch("src/foo.ts"))).toBe(true); + }); + + it("invalid regex → always false (no crash)", () => { + const fn = makeExtractMatcher("[broken", "path", true); + expect(fn(makeMatch("src/foo.ts"))).toBe(false); + }); +}); + +// ─── makeExtractMatcher — content target ───────────────────────────────────── + +describe("makeExtractMatcher (content)", () => { + it("empty pattern always matches", () => { + const fn = makeExtractMatcher("", "content", false); + expect(fn(makeMatch("src/foo.ts", ["hello world"]))).toBe(true); + }); + + it("matches against fragment text", () => { + const fn = makeExtractMatcher("hello", "content", false); + expect(fn(makeMatch("src/foo.ts", ["hello world"]))).toBe(true); + expect(fn(makeMatch("src/foo.ts", ["goodbye world"]))).toBe(false); + }); + + it("matches any fragment when multiple present", () => { + const fn = makeExtractMatcher("secret", "content", false); + const m = makeMatch("file.ts", ["first fragment", "second with secret"]); + expect(fn(m)).toBe(true); + }); + + it("returns false when no fragments", () => { + const fn = makeExtractMatcher("hello", "content", false); + expect(fn(makeMatch("src/foo.ts", []))).toBe(false); + }); + + it("regex match against fragment", () => { + const fn = makeExtractMatcher("const \\w+", "content", true); + expect(fn(makeMatch("f.ts", ["const x = 1"]))).toBe(true); + expect(fn(makeMatch("f.ts", ["let y = 2"]))).toBe(false); + }); + + it("invalid regex → always false", () => { + const fn = makeExtractMatcher("[broken", "content", true); + expect(fn(makeMatch("f.ts", ["hello"]))).toBe(false); + }); +}); + +// ─── makeRepoMatcher ───────────────────────────────────────────────────────── + +describe("makeRepoMatcher", () => { + it("empty pattern always matches", () => { + const fn = makeRepoMatcher("", false); + expect(fn(makeGroup("org/service-a"))).toBe(true); + }); + + it("substring match on repoFullName", () => { + const fn = makeRepoMatcher("service", false); + expect(fn(makeGroup("org/service-a"))).toBe(true); + expect(fn(makeGroup("org/other"))).toBe(false); + }); + + it("case-insensitive substring match", () => { + const fn = makeRepoMatcher("SERVICE", false); + expect(fn(makeGroup("org/service-a"))).toBe(true); + }); + + it("regex match on repoFullName", () => { + const fn = makeRepoMatcher("^org/service", true); + expect(fn(makeGroup("org/service-a"))).toBe(true); + expect(fn(makeGroup("other/service-a"))).toBe(false); + }); + + it("invalid regex → always false", () => { + const fn = makeRepoMatcher("[broken", true); + expect(fn(makeGroup("org/repo"))).toBe(false); + }); + + it("matches short repo name without org prefix", () => { + const fn = makeRepoMatcher("service-a", false); + expect(fn(makeGroup("org/service-a"))).toBe(true); + }); +}); diff --git a/src/render/filter-match.ts b/src/render/filter-match.ts new file mode 100644 index 0000000..dab94f4 --- /dev/null +++ b/src/render/filter-match.ts @@ -0,0 +1,52 @@ +import type { CodeMatch, FilterTarget, RepoGroup } from "../types.ts"; + +// ─── Filter matchers ────────────────────────────────────────────────────────── + +/** + * Returns a function that tests a string against `pattern`. + * - `regex = true` → compiled as case-insensitive RegExp; invalid pattern → always false. + * - `regex = false` → case-insensitive substring check. + * - Empty `pattern` → always true. + */ +function makePatternTest(pattern: string, regex: boolean): (s: string) => boolean { + if (!pattern) return () => true; + if (regex) { + try { + const re = new RegExp(pattern, "i"); + return (s) => re.test(s); + } catch { + // Fix: invalid regex → no-match predicate so the TUI shows zero results without crashing. + return () => false; + } + } + const lower = pattern.toLowerCase(); + return (s) => s.toLowerCase().includes(lower); +} + +/** + * Returns a predicate that tests a `CodeMatch` against a filter pattern. + * - `target === "path"` → tests `match.path` + * - `target === "content"` → tests any `TextMatch.fragment` + */ +export function makeExtractMatcher( + pattern: string, + target: Exclude, + regex: boolean, +): (m: CodeMatch) => boolean { + if (!pattern) return () => true; + const test = makePatternTest(pattern, regex); + if (target === "content") { + return (m) => m.textMatches.some((tm) => test(tm.fragment)); + } + return (m) => test(m.path); +} + +/** + * Returns a predicate that tests a `RepoGroup` against a filter pattern, + * matching on `group.repoFullName` (e.g. `"org/my-service"`). + */ +export function makeRepoMatcher(pattern: string, regex: boolean): (g: RepoGroup) => boolean { + if (!pattern) return () => true; + const test = makePatternTest(pattern, regex); + return (g) => test(g.repoFullName); +} diff --git a/src/render/filter.ts b/src/render/filter.ts index 02081e3..de54e4e 100644 --- a/src/render/filter.ts +++ b/src/render/filter.ts @@ -1,30 +1,71 @@ -import type { RepoGroup } from "../types.ts"; +import type { FilterTarget, RepoGroup } from "../types.ts"; +import { makeExtractMatcher, makeRepoMatcher } from "./filter-match.ts"; // ─── Filter stats ───────────────────────────────────────────────────────────── -interface FilterStats { +export interface FilterStats { visibleRepos: number; hiddenRepos: number; + /** Number of matching CodeMatch entries (one per file-in-repo). */ visibleMatches: number; hiddenMatches: number; + /** Number of unique file paths among visible matches. */ + visibleFiles: number; } /** - * Given a confirmed filterPath, counts how many repos/matches are visible - * (path contains the filter string) vs hidden. + * Counts visible/hidden repos and matches according to the active filter. + * Supports all filter targets (path, content, repo) and regex mode. */ -export function buildFilterStats(groups: RepoGroup[], filterPath: string): FilterStats { - const filter = filterPath.toLowerCase(); +export function buildFilterStats( + groups: RepoGroup[], + filterPath: string, + filterTarget: FilterTarget = "path", + filterRegex = false, +): FilterStats { let visibleRepos = 0; let hiddenRepos = 0; let visibleMatches = 0; let hiddenMatches = 0; - for (const g of groups) { - const matching = g.matches.filter((m) => m.path.toLowerCase().includes(filter)).length; - if (matching > 0) visibleRepos++; - else hiddenRepos++; - visibleMatches += matching; - hiddenMatches += g.matches.length - matching; + // Collect visible file paths in the same pass to avoid recomputing matchers. + const visibleFileSet = new Set(); + + if (filterTarget === "repo") { + const repoMatcher = makeRepoMatcher(filterPath, filterRegex); + for (const g of groups) { + if (repoMatcher(g)) { + visibleRepos++; + visibleMatches += g.matches.length; + for (const m of g.matches) visibleFileSet.add(`${g.repoFullName}:${m.path}`); + } else { + hiddenRepos++; + hiddenMatches += g.matches.length; + } + } + } else { + const extractMatcher = makeExtractMatcher( + filterPath, + filterTarget as Exclude, + filterRegex, + ); + for (const g of groups) { + const matching = g.matches.filter(extractMatcher); + if (matching.length > 0) { + visibleRepos++; + for (const m of matching) visibleFileSet.add(`${g.repoFullName}:${m.path}`); + } else { + hiddenRepos++; + } + visibleMatches += matching.length; + hiddenMatches += g.matches.length - matching.length; + } } - return { visibleRepos, hiddenRepos, visibleMatches, hiddenMatches }; + + return { + visibleRepos, + hiddenRepos, + visibleMatches, + hiddenMatches, + visibleFiles: visibleFileSet.size, + }; } diff --git a/src/render/rows.ts b/src/render/rows.ts index 306b312..150b8fb 100644 --- a/src/render/rows.ts +++ b/src/render/rows.ts @@ -1,5 +1,6 @@ -import type { RepoGroup, Row } from "../types.ts"; +import type { FilterTarget, RepoGroup, Row } from "../types.ts"; import { MAX_FRAGMENT_LINES } from "./highlight.ts"; +import { makeExtractMatcher, makeRepoMatcher } from "./filter-match.ts"; // ─── Row helpers ────────────────────────────────────────────────────────────── @@ -15,32 +16,69 @@ export function rowTerminalLines(group: RepoGroup | undefined, row: Row): number } /** - * Build the flat list of rows from a group array, optionally filtered by a - * case-insensitive path substring. Repos with no visible extracts are omitted - * when a filter is active. + * Build the flat list of rows from a group array, optionally filtered. * - * When a `RepoGroup` has a `sectionLabel` field, a preceding `"section"` row - * is emitted so the TUI and viewport logic can handle it uniformly. + * - `filterTarget === "path"` (default) — case-insensitive substring/regex on file path. + * - `filterTarget === "content"` — matches against code fragment text. + * - `filterTarget === "repo"` — matches on the repository full name; the entire + * repo is shown or hidden as a unit (all its extracts remain visible). + * + * When `filterRegex` is true the pattern is treated as a case-insensitive RegExp. + * An invalid regex produces zero results without throwing. */ -export function buildRows(groups: RepoGroup[], filterPath = ""): Row[] { - const filter = filterPath.toLowerCase(); +export function buildRows( + groups: RepoGroup[], + filterPath = "", + filterTarget: FilterTarget = "path", + filterRegex = false, +): Row[] { const rows: Row[] = []; + + 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; + if (!repoMatcher(group)) continue; + const sectionToEmit = group.sectionLabel ?? pendingSectionLabel; + if (sectionToEmit !== undefined && sectionToEmit !== lastEmittedSectionLabel) { + rows.push({ type: "section", repoIndex: -1, sectionLabel: sectionToEmit }); + lastEmittedSectionLabel = sectionToEmit; + } + rows.push({ type: "repo", repoIndex: ri }); + if (!group.folded) { + group.matches.forEach((_, ei) => { + rows.push({ type: "extract", repoIndex: ri, extractIndex: ei }); + }); + } + } + return rows; + } + + const extractMatcher = makeExtractMatcher( + filterPath, + 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; const visibleExtractIndices = group.matches - .map((m, i) => (!filter || m.path.toLowerCase().includes(filter) ? i : -1)) + .map((m, i) => (extractMatcher(m) ? i : -1)) .filter((i) => i !== -1); - if (filter && visibleExtractIndices.length === 0) continue; + if (filterPath && visibleExtractIndices.length === 0) continue; - // Emit a section-header row when this group starts a new section - if (group.sectionLabel !== undefined) { - rows.push({ - type: "section", - repoIndex: -1, - sectionLabel: group.sectionLabel, - }); + const sectionToEmit = group.sectionLabel ?? pendingSectionLabel; + if (sectionToEmit !== undefined && sectionToEmit !== lastEmittedSectionLabel) { + rows.push({ type: "section", repoIndex: -1, sectionLabel: sectionToEmit }); + lastEmittedSectionLabel = sectionToEmit; } - rows.push({ type: "repo", repoIndex: ri }); if (!group.folded) { for (const ei of visibleExtractIndices) { @@ -51,7 +89,13 @@ export function buildRows(groups: RepoGroup[], filterPath = ""): Row[] { return rows; } -/** Returns true if the cursor row is currently within the visible viewport. */ +/** Returns true if the cursor row is currently within the visible viewport. + * + * Mirrors the renderGroups break condition exactly: + * a row renders only if `usedLines === 0 || usedLines + h <= viewportHeight`. + * Without this check a multi-line cursor row (fragment extract) can appear + * "visible" to the scroll adjuster while renderGroups would break before it. + */ export function isCursorVisible( rows: Row[], groups: RepoGroup[], @@ -62,10 +106,15 @@ export function isCursorVisible( let usedLines = 0; for (let i = scrollOffset; i < rows.length; i++) { if (usedLines >= viewportHeight) return false; - if (i === cursor) return true; const row = rows[i]; const group = row.repoIndex >= 0 ? groups[row.repoIndex] : undefined; - usedLines += rowTerminalLines(group, row); + const h = rowTerminalLines(group, row); + if (i === cursor) { + // The row is visible only if it actually fits in the remaining space + // (same rule as renderGroups: first row always shows, others need room). + return usedLines === 0 || usedLines + h <= viewportHeight; + } + usedLines += h; } return false; } diff --git a/src/render/selection.ts b/src/render/selection.ts index 935b589..36ae3cd 100644 --- a/src/render/selection.ts +++ b/src/render/selection.ts @@ -1,36 +1,67 @@ -import type { RepoGroup, Row } from "../types.ts"; +import type { FilterTarget, RepoGroup, Row } from "../types.ts"; +import { makeExtractMatcher, makeRepoMatcher } from "./filter-match.ts"; // ─── Selection mutations ────────────────────────────────────────────────────── /** * Select all repos+extracts (repo row context) or all extracts in the current - * repo (extract row context), respecting an optional file-path filter. + * repo (extract row context), respecting the active filter. * Mutates groups in-place. */ -export function applySelectAll(groups: RepoGroup[], contextRow: Row, filterPath = ""): void { - const filter = filterPath.toLowerCase(); - if (contextRow.type === "repo") { - for (const g of groups) { - if (filter) { - const anyMatch = g.matches.some((m) => m.path.toLowerCase().includes(filter)); - if (!anyMatch) continue; - g.matches.forEach((m, i) => { - if (m.path.toLowerCase().includes(filter)) g.extractSelected[i] = true; - }); - g.repoSelected = g.extractSelected.some(Boolean); - } else { +export function applySelectAll( + groups: RepoGroup[], + contextRow: Row, + filterPath = "", + filterTarget: FilterTarget = "path", + filterRegex = false, +): void { + if (filterTarget === "repo") { + const repoMatcher = makeRepoMatcher(filterPath, filterRegex); + if (contextRow.type === "repo") { + for (const g of groups) { + if (!repoMatcher(g)) continue; + g.repoSelected = true; + g.extractSelected.fill(true); + } + } else { + const g = groups[contextRow.repoIndex]; + if (repoMatcher(g)) { g.repoSelected = true; g.extractSelected.fill(true); } } - } else { - const g = groups[contextRow.repoIndex]; - if (filter) { + return; + } + + if (filterPath) { + const extractMatcher = makeExtractMatcher( + filterPath, + filterTarget as Exclude, + filterRegex, + ); + if (contextRow.type === "repo") { + for (const g of groups) { + if (!g.matches.some(extractMatcher)) continue; + g.matches.forEach((m, i) => { + if (extractMatcher(m)) g.extractSelected[i] = true; + }); + g.repoSelected = g.extractSelected.some(Boolean); + } + } else { + const g = groups[contextRow.repoIndex]; g.matches.forEach((m, i) => { - if (m.path.toLowerCase().includes(filter)) g.extractSelected[i] = true; + if (extractMatcher(m)) g.extractSelected[i] = true; }); g.repoSelected = g.extractSelected.some(Boolean); + } + } else { + if (contextRow.type === "repo") { + for (const g of groups) { + g.repoSelected = true; + g.extractSelected.fill(true); + } } else { + const g = groups[contextRow.repoIndex]; g.repoSelected = true; g.extractSelected.fill(true); } @@ -39,33 +70,63 @@ export function applySelectAll(groups: RepoGroup[], contextRow: Row, filterPath /** * Deselect all repos+extracts (repo row context) or all extracts in the - * current repo (extract row context), respecting an optional file-path filter. + * current repo (extract row context), respecting the active filter. * Mutates groups in-place. */ -export function applySelectNone(groups: RepoGroup[], contextRow: Row, filterPath = ""): void { - const filter = filterPath.toLowerCase(); - if (contextRow.type === "repo") { - for (const g of groups) { - if (filter) { - const anyMatch = g.matches.some((m) => m.path.toLowerCase().includes(filter)); - if (!anyMatch) continue; - g.matches.forEach((m, i) => { - if (m.path.toLowerCase().includes(filter)) g.extractSelected[i] = false; - }); - g.repoSelected = g.extractSelected.some(Boolean); - } else { +export function applySelectNone( + groups: RepoGroup[], + contextRow: Row, + filterPath = "", + filterTarget: FilterTarget = "path", + filterRegex = false, +): void { + if (filterTarget === "repo") { + const repoMatcher = makeRepoMatcher(filterPath, filterRegex); + if (contextRow.type === "repo") { + for (const g of groups) { + if (!repoMatcher(g)) continue; + g.repoSelected = false; + g.extractSelected.fill(false); + } + } else { + const g = groups[contextRow.repoIndex]; + if (repoMatcher(g)) { g.repoSelected = false; g.extractSelected.fill(false); } } - } else { - const g = groups[contextRow.repoIndex]; - if (filter) { + return; + } + + if (filterPath) { + const extractMatcher = makeExtractMatcher( + filterPath, + filterTarget as Exclude, + filterRegex, + ); + if (contextRow.type === "repo") { + for (const g of groups) { + if (!g.matches.some(extractMatcher)) continue; + g.matches.forEach((m, i) => { + if (extractMatcher(m)) g.extractSelected[i] = false; + }); + g.repoSelected = g.extractSelected.some(Boolean); + } + } else { + const g = groups[contextRow.repoIndex]; g.matches.forEach((m, i) => { - if (m.path.toLowerCase().includes(filter)) g.extractSelected[i] = false; + if (extractMatcher(m)) g.extractSelected[i] = false; }); g.repoSelected = g.extractSelected.some(Boolean); + } + } else { + if (contextRow.type === "repo") { + for (const g of groups) { + g.repoSelected = false; + g.extractSelected.fill(false); + } } else { + const g = groups[contextRow.repoIndex]; g.repoSelected = false; g.extractSelected.fill(false); } diff --git a/src/tui.ts b/src/tui.ts index fa5022e..8f269a7 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -3,12 +3,14 @@ import * as readline from "readline"; import { applySelectAll, applySelectNone, + buildFilterStats, buildRows, isCursorVisible, renderGroups, + type FilterStats, } from "./render.ts"; import { buildOutput } from "./output.ts"; -import type { OutputFormat, OutputType, RepoGroup } from "./types.ts"; +import type { FilterTarget, OutputFormat, OutputType, RepoGroup } from "./types.ts"; // ─── Key binding constants ──────────────────────────────────────────────────── @@ -20,6 +22,45 @@ const ANSI_ARROW_RIGHT = "\x1b[C"; const KEY_CTRL_C = "\u0003"; const KEY_ENTER_CR = "\r"; const KEY_ENTER_LF = "\n"; +// Cursor / word navigation (filter mode) +const KEY_TAB = "\t"; +const KEY_HOME = "\x1b[H"; +const KEY_END = "\x1b[F"; +const KEY_CTRL_A = "\x01"; +const KEY_CTRL_E = "\x05"; +const KEY_CTRL_W = "\x17"; +const KEY_ALT_BACKSPACE = "\x1b\x7f"; +const KEY_CTRL_ARROW_LEFT = "\x1b[1;5D"; +const KEY_CTRL_ARROW_RIGHT = "\x1b[1;5C"; +const KEY_ALT_ARROW_LEFT = "\x1b[1;3D"; // Alt/Option+← (xterm, iTerm2 with Use Option as Meta key) +const KEY_ALT_ARROW_RIGHT = "\x1b[1;3C"; // Alt/Option+→ +const KEY_ALT_B = "\x1bb"; +const KEY_ALT_F = "\x1bf"; +const KEY_DELETE = "\x1b[3~"; +const KEY_SHIFT_TAB = "\x1b[Z"; // Shift+Tab — cycle filter target in filter mode + +// ─── Word-boundary helpers ──────────────────────────────────────────────────── + +/** Returns the start of the word immediately before position `pos`. */ +function prevWordBoundary(s: string, pos: number): number { + let i = pos; + // skip trailing spaces + while (i > 0 && s[i - 1] === " ") i--; + // skip word chars + while (i > 0 && s[i - 1] !== " ") i--; + return i; +} + +/** Returns the start of the next word after position `pos`. */ +function nextWordBoundary(s: string, pos: number): number { + let i = pos; + const len = s.length; + // skip current word + while (i < len && s[i] !== " ") i++; + // skip spaces + while (i < len && s[i] === " ") i++; + return i; +} // ─── Interactive TUI ───────────────────────────────────────────────────────── @@ -45,23 +86,57 @@ export async function runInteractive( let cursor = 0; let scrollOffset = 0; const termHeight = process.stdout.rows ?? 40; - const viewportHeight = termHeight - 5; + // HEADER_LINES (4) + position indicator (2) = 6 fixed lines consumed by renderGroups. + // filterBarLines (0–2) and the sticky repo line (0–1) are added dynamically below. + // Use getViewportHeight() for scroll decisions so they match what renderGroups actually renders. + const getViewportHeight = () => { + let barLines = 0; + if (filterMode) barLines = 2; + else if (filterPath || filterTarget !== "path" || filterRegex) barLines = 1; + // When scrolled past the top and the cursor is within the visible window, + // renderGroups may show a sticky repo header that consumes one extra line. + // Mirror the condition precisely: sticky only appears when the cursor row is + // an extract whose repo row has scrolled above the viewport (repoRowIndex < + // scrollOffset). `cursor >= scrollOffset` is the necessary pre-condition. + const stickyHeaderLines = scrollOffset > 0 && cursor >= scrollOffset ? 1 : 0; + return termHeight - 6 - barLines - stickyHeaderLines; + }; // ─── Filter + help state ───────────────────────────────────────────────── let filterPath = ""; let filterMode = false; let filterInput = ""; + let filterCursor = 0; + let filterTarget: FilterTarget = "path"; + let filterRegex = false; + let filterLiveStats: FilterStats | null = null; + let statsDebounceTimer: ReturnType | null = null; let showHelp = false; + /** Schedule a debounced stats recompute (while typing in filter bar). */ + const scheduleStatsUpdate = () => { + if (statsDebounceTimer !== null) clearTimeout(statsDebounceTimer); + filterLiveStats = null; // show "…" while typing fast + statsDebounceTimer = setTimeout(() => { + filterLiveStats = buildFilterStats(groups, filterInput, filterTarget, filterRegex); + statsDebounceTimer = null; + redraw(); + }, 150); + }; + const redraw = () => { const activeFilter = filterMode ? filterInput : filterPath; - const rows = buildRows(groups, activeFilter); + const rows = buildRows(groups, activeFilter, filterTarget, filterRegex); const rendered = renderGroups(groups, cursor, rows, termHeight, scrollOffset, query, org, { filterPath, filterMode, filterInput, + filterCursor, + filterLiveStats, showHelp, termWidth: process.stdout.columns ?? 80, + filterTarget, + filterRegex, }); process.stdout.write(ANSI_CLEAR); process.stdout.write(rendered); @@ -79,35 +154,115 @@ export async function runInteractive( process.stdout.write(ANSI_CLEAR); process.stdin.setRawMode(false); process.exit(0); - } else if (key === "\x1b") { - // ESC — cancel filter input + } else if (key === "\x1b" && !key.startsWith("\x1b[") && !key.startsWith("\x1b\x1b")) { + // ESC (bare) — cancel filter input filterMode = false; filterInput = ""; + filterCursor = 0; + if (statsDebounceTimer !== null) { + clearTimeout(statsDebounceTimer); + statsDebounceTimer = null; + } + filterLiveStats = null; } else if (key === KEY_ENTER_CR || key === KEY_ENTER_LF) { // Enter — confirm filter filterPath = filterInput; filterMode = false; + if (statsDebounceTimer !== null) { + clearTimeout(statsDebounceTimer); + statsDebounceTimer = null; + } + filterLiveStats = null; // Clamp cursor to new row list - const newRows = buildRows(groups, filterPath); + const newRows = buildRows(groups, filterPath, filterTarget, filterRegex); cursor = Math.min(cursor, Math.max(0, newRows.length - 1)); scrollOffset = Math.min(scrollOffset, cursor); - } else if (key === "\x7f" || key === "\b") { - // Backspace — trim and clamp cursor to new live-filtered row list - filterInput = filterInput.slice(0, -1); - const newRows = buildRows(groups, filterInput); + } else if (key === KEY_TAB) { + // Tab — toggle regex mode; rebuilds rows immediately and clamps + // cursor/scrollOffset so the current position stays valid. + filterRegex = !filterRegex; + const newRows = buildRows(groups, filterInput, filterTarget, filterRegex); cursor = Math.min(cursor, Math.max(0, newRows.length - 1)); - } else if (key.length === 1 && key >= " ") { - // Printable character — clamp cursor to new live-filtered row list - filterInput += key; - const newRows = buildRows(groups, filterInput); + scrollOffset = Math.min(scrollOffset, cursor); + scheduleStatsUpdate(); + } else if (key === ANSI_ARROW_LEFT) { + // ← — move cursor left + filterCursor = Math.max(0, filterCursor - 1); + } else if (key === ANSI_ARROW_RIGHT) { + // → — move cursor right + filterCursor = Math.min(filterInput.length, filterCursor + 1); + } else if (key === KEY_HOME || key === KEY_CTRL_A) { + // Home / Ctrl+A — jump to start + filterCursor = 0; + } else if (key === KEY_END || key === KEY_CTRL_E) { + // End / Ctrl+E — jump to end + filterCursor = filterInput.length; + } else if (key === KEY_CTRL_ARROW_LEFT || key === KEY_ALT_ARROW_LEFT || key === KEY_ALT_B) { + // Ctrl+← / Alt+← / Alt+b — word left + filterCursor = prevWordBoundary(filterInput, filterCursor); + } else if (key === KEY_CTRL_ARROW_RIGHT || key === KEY_ALT_ARROW_RIGHT || key === KEY_ALT_F) { + // Ctrl+→ / Alt+→ / Alt+f — word right + filterCursor = nextWordBoundary(filterInput, filterCursor); + } else if (key === KEY_CTRL_W || key === KEY_ALT_BACKSPACE) { + // Ctrl+W / Alt+Backspace — delete word before cursor + const newPos = prevWordBoundary(filterInput, filterCursor); + filterInput = filterInput.slice(0, newPos) + filterInput.slice(filterCursor); + filterCursor = newPos; + const newRows = buildRows(groups, filterInput, filterTarget, filterRegex); cursor = Math.min(cursor, Math.max(0, newRows.length - 1)); + scrollOffset = Math.min(scrollOffset, cursor); + scheduleStatsUpdate(); + } else if ((key === "\x7f" || key === "\b") && filterCursor > 0) { + // Backspace — delete char before cursor + filterInput = filterInput.slice(0, filterCursor - 1) + filterInput.slice(filterCursor); + filterCursor--; + const newRows2 = buildRows(groups, filterInput, filterTarget, filterRegex); + cursor = Math.min(cursor, Math.max(0, newRows2.length - 1)); + scrollOffset = Math.min(scrollOffset, cursor); + scheduleStatsUpdate(); + } else if (key === KEY_DELETE && filterCursor < filterInput.length) { + // Del — delete char at cursor + filterInput = filterInput.slice(0, filterCursor) + filterInput.slice(filterCursor + 1); + const newRows3 = buildRows(groups, filterInput, filterTarget, filterRegex); + cursor = Math.min(cursor, Math.max(0, newRows3.length - 1)); + scrollOffset = Math.min(scrollOffset, cursor); + scheduleStatsUpdate(); + } else if (key === KEY_SHIFT_TAB) { + // Shift+Tab — cycle filter target (path → content → repo → path) + // Uses Shift+Tab instead of 't' so the letter t can still be typed in the filter. + filterTarget = + filterTarget === "path" ? "content" : filterTarget === "content" ? "repo" : "path"; + const newRows = buildRows(groups, filterInput, filterTarget, filterRegex); + cursor = Math.min(cursor, Math.max(0, newRows.length - 1)); + scrollOffset = Math.min(scrollOffset, cursor); + scheduleStatsUpdate(); + } else if (!key.startsWith("\x1b")) { + // Printable character(s) — insert at cursor. + // Handles both single keystrokes and paste (multi-char chunk). + // Discard control chars (code < 32 or DEL 127) without using a regex + // literal so as not to trigger the no-control-regex lint rule. + const printable = Array.from(key) + .filter((c) => { + const code = c.charCodeAt(0); + return code >= 32 && code !== 127; + }) + .join(""); + if (printable.length > 0) { + filterInput = + filterInput.slice(0, filterCursor) + printable + filterInput.slice(filterCursor); + filterCursor += printable.length; + const newRows = buildRows(groups, filterInput, filterTarget, filterRegex); + cursor = Math.min(cursor, Math.max(0, newRows.length - 1)); + scrollOffset = Math.min(scrollOffset, cursor); + scheduleStatsUpdate(); + } } redraw(); continue; } // ── Normal mode ───────────────────────────────────────────────────────── - const rows = buildRows(groups, filterPath); + const rows = buildRows(groups, filterPath, filterTarget, filterRegex); const row = rows[cursor]; if (key === KEY_CTRL_C || key === "q") { @@ -145,6 +300,27 @@ export async function runInteractive( if (key === "f") { filterMode = true; filterInput = filterPath; // pre-fill with current filter + filterCursor = filterInput.length; // cursor at end + // Show current stats immediately if there's already a filter + if (filterInput) { + filterLiveStats = buildFilterStats(groups, filterInput, filterTarget, filterRegex); + } else { + filterLiveStats = null; + } + redraw(); + continue; + } + + // `t` — cycle filter target: path → content → repo → path + 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); + } redraw(); continue; } @@ -153,8 +329,14 @@ export async function runInteractive( if (key === "r") { filterPath = ""; filterInput = ""; + filterCursor = 0; filterMode = false; - const newRows = buildRows(groups, ""); + if (statsDebounceTimer !== null) { + clearTimeout(statsDebounceTimer); + statsDebounceTimer = null; + } + filterLiveStats = null; + const newRows = buildRows(groups, "", filterTarget, filterRegex); cursor = Math.min(cursor, Math.max(0, newRows.length - 1)); scrollOffset = Math.min(scrollOffset, cursor); redraw(); @@ -182,7 +364,7 @@ export async function runInteractive( cursor = next; while ( scrollOffset < cursor && - !isCursorVisible(rows, groups, cursor, scrollOffset, viewportHeight) + !isCursorVisible(rows, groups, cursor, scrollOffset, getViewportHeight()) ) { scrollOffset++; } @@ -222,12 +404,12 @@ export async function runInteractive( // `a` — select all (respects active filter) if (key === "a" && row && row.type !== "section") { - applySelectAll(groups, row, filterPath); + applySelectAll(groups, row, filterPath, filterTarget, filterRegex); } // `n` — select none (respects active filter) if (key === "n" && row && row.type !== "section") { - applySelectNone(groups, row, filterPath); + applySelectNone(groups, row, filterPath, filterTarget, filterRegex); } redraw(); diff --git a/src/types.ts b/src/types.ts index 7d9ca80..4a90939 100644 --- a/src/types.ts +++ b/src/types.ts @@ -55,3 +55,11 @@ export interface TeamSection { export type OutputFormat = "markdown" | "json"; export type OutputType = "repo-only" | "repo-and-matches"; + +/** + * Which field the TUI filter bar matches against. + * - "path" — file path of the extract (default) + * - "content" — code fragment text (`TextMatch.fragment`) + * - "repo" — repository full name (`RepoGroup.repoFullName`) + */ +export type FilterTarget = "path" | "content" | "repo";