Skip to content

refactor(widgets): share one base class across the Git count widgets - #22

Closed
eric-engberg wants to merge 15 commits into
developfrom
refactor/git-count-widget-duplication
Closed

eric-engberg wants to merge 15 commits into
developfrom
refactor/git-count-widget-duplication

Conversation

@eric-engberg

Copy link
Copy Markdown
Owner

Standalone, cut from main. Upstream sirmalloc#601 and sirmalloc#499 also touch these files; happy to rebase either way. A sibling PR, #19, does the same for the Git status indicator widgets; the two touch different files, so either can go first.

Demo

After the refactor: in the line editor, r switches Git Staged Files to its raw value and h hides it at zero, then g sets Git Insertions' glyph and both of Git Changes' glyphs. Every case also renders byte-for-byte the same as before; see Testing.

Powerline

Raw value, zero hiding and glyphs on the Git count widgets in Powerline mode

Plain

Raw value, zero hiding and glyphs on the Git count widgets in plain mode

The same six widgets piped from a real repository with staged, unstaged and untracked changes, a clean one, and a directory outside any repository:

Powerline

The Git count widgets reading a dirty repository, a clean one and a non-repository in Powerline mode

Plain

The Git count widgets reading a dirty repository, a clean one and a non-repository in plain mode

Summary

Git Staged, Unstaged and Untracked Files, Insertions, Deletions and Changes each carried their own copy of:

  • the editor label, and the "when not in a git repo" and "zero" hide states;
  • render: the preview sample, then (no git) outside a work tree, or nothing when that state is hidden, then nothing for a zero count when the zero state is hidden.

The three file-count widgets were also one class written three times, differing only in the count they show, its label, name and color. Insertions and Deletions were nearly the same class too.

GitCountWidgetBase (widgets/shared/git-count-widget.ts) now holds that render and the hide states. Each widget says when its counts are zero and how they show; the preview sample goes through the same formatting. On top of it:

  • GitFileCountWidget holds the file counts' git query, the raw-or-labeled format and the numeric value, so Staged, Unstaged and Untracked Files only name their count and label.
  • GitLineCountWidget holds the line counts' git query, the preview sample, and the glyph key and editor. Insertions, Deletions and Changes each keep their own zero rule and format.

7 source files, 173 lines added and 330 removed, plus a test file. No visual change.

Testing

  • The existing tests for these widgets cover each one's counts and placeholders. The first commit adds widgets/__tests__/GitCountWidgets.test.ts (63 tests) for the rest, checked against the current code before the refactor:

    • all six against the same repositories (dirty, clean, only removed lines, only added lines, only an untracked file, and none), labeled, raw and with each hide state;
    • Git Changes staying visible with the zero state hidden while only one of its counts is zero;
    • the editor label and hide-state labels, the file counts' numeric value, and that a raw item on Insertions, Deletions or Changes still shows its glyph;
    • glyph overrides in a repository, and the glyph editor's rows.

    It passes at both commits under Bun and under Node. Breaking Git Changes' zero rule, a zero-state label, Git Untracked Files' numeric value, Git Deletions' glyph editor, Git Insertions' no-git hide state or Git Staged Files' raw value each makes it fail.

  • bun run lint and bun test (2,421 pass).

  • Before/after render check: all six widgets rendered on main and on this branch, in every state their own keys reach, raw and labeled, at all three color levels. It ran in the TUI preview and against seven real repositories: staged, unstaged and untracked changes; only staged; only untracked; a merge conflict; clean; no commits yet; and a directory outside any repository. All 420 cases (60 per repository) are byte-identical, as are each widget's editor label and keys. A wider sweep over the same repositories also varied the hide states, glyph overrides (set and emptied) and raw value (on, off, unset): all 4,320 renders are identical, as are each widget's numeric value, hide states, keys and glyph-editor rows.

  • Built dist/ and piped a status line with all six widgets through it, from the dirty and the clean repository, in plain and Powerline mode under Bun and Node 16, 18, 20, 22, 24 and 26: every runtime agrees, and the output matches main. The TUI opens and exits cleanly on each.

  • Under Node with Vitest, the full suite fails in the same 17 files as main (all from spying on Node built-ins), and the new tests pass.

The existing tests for Git Staged, Unstaged and Untracked Files, Insertions,
Deletions and Changes cover each widget's counts and placeholders, but not
the editor label and hide-state labels, the file counts' numeric value, glyph
overrides outside the preview, a raw item on the widgets without a raw mode,
or Git Changes staying visible with the zero state hidden while only one of
its counts is zero. Cover all six against the same repositories: dirty,
clean, only removed lines, only added lines, only an untracked file, and none.
Git is answered by a mocked execFileSync, like the other Git widget tests.
Git Staged, Unstaged and Untracked Files, Insertions, Deletions and Changes
each carried the same editor label, "no git" and "zero" hide states and
render: the preview sample, then "(no git)" outside a work tree, or nothing
when that state is hidden, then nothing for a zero count when the zero state
is hidden. The three file-count widgets were also one class written three
times, and Insertions and Deletions nearly so.

GitCountWidgetBase now holds that render and the hide states; each widget
says when its counts are zero and how they show. GitFileCountWidget adds the
file counts' git query, raw-or-labeled format and numeric value, so those
three only name their count and label. GitLineCountWidget adds the line
counts' git query, preview sample, glyph key and editor for Insertions,
Deletions and Changes.

Behavior is unchanged.
@eric-engberg
eric-engberg marked this pull request as ready for review October 4, 2026 21:56
eric-engberg added a commit that referenced this pull request Oct 6, 2026
sirmalloc#601 lets Tokens Input, Output, Cached and Total, and Git Staged,
Unstaged and Untracked Files, offer their labels to the label editor
one widget at a time. #16 and #22 move those widgets onto shared base
classes, so the merges kept their shape and the labels went with it:
an edited label still drew, but the editor never offered (e).

Each base now offers its subclass's label, once for all of them.
effectustasi and others added 13 commits October 6, 2026 09:25
…sh style (sirmalloc#665)

Fish style replaced any cwd that merely started with the home path, so with
home /Users/al the sibling /Users/alex/project rendered as "~ex/project".
Reuse the boundary-checked abbreviateHomeDir that the plain ~ option already
uses.

Co-authored-by: effectustasi <[email protected]>
…ith 10 updates (sirmalloc#662)

Bumps the dev-dependencies group with 10 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@remotion/cli](https://github.com/remotion-dev/remotion) | `4.0.524` | `4.0.532` |
| [chalk](https://github.com/chalk/chalk) | `6.0.0` | `6.0.1` |
| [eslint](https://github.com/eslint/eslint) | `10.10.0` | `10.11.0` |
| [react](https://github.com/react/react/tree/HEAD/packages/react) | `19.2.8` | `19.3.0` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.2.18` | `19.3.0` |
| [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom) | `19.2.8` | `19.3.0` |
| [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) | `19.2.7` | `19.3.0` |
| [remotion](https://github.com/remotion-dev/remotion) | `4.0.524` | `4.0.532` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.70.0` | `8.70.1` |
| [zod](https://github.com/colinhacks/zod) | `4.5.4` | `4.6.5` |



Updates `@remotion/cli` from 4.0.524 to 4.0.532
- [Release notes](https://github.com/remotion-dev/remotion/releases)
- [Commits](remotion-dev/remotion@v4.0.524...v4.0.532)

Updates `chalk` from 6.0.0 to 6.0.1
- [Release notes](https://github.com/chalk/chalk/releases)
- [Commits](chalk/chalk@v6.0.0...v6.0.1)

Updates `eslint` from 10.10.0 to 10.11.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](eslint/eslint@v10.10.0...v10.11.0)

Updates `react` from 19.2.8 to 19.3.0
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.3.0/packages/react)

Updates `@types/react` from 19.2.18 to 19.3.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `react-dom` from 19.2.8 to 19.3.0
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.3.0/packages/react-dom)

Updates `@types/react-dom` from 19.2.7 to 19.3.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

Updates `remotion` from 4.0.524 to 4.0.532
- [Release notes](https://github.com/remotion-dev/remotion/releases)
- [Commits](remotion-dev/remotion@v4.0.524...v4.0.532)

Updates `typescript-eslint` from 8.70.0 to 8.70.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.70.1/packages/typescript-eslint)

Updates `zod` from 4.5.4 to 4.6.5
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](colinhacks/zod@v4.5.4...v4.6.5)

---
updated-dependencies:
- dependency-name: "@remotion/cli"
  dependency-version: 4.0.532
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: chalk
  dependency-version: 6.0.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: eslint
  dependency-version: 10.11.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: react
  dependency-version: 19.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: "@types/react"
  dependency-version: 19.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: react-dom
  dependency-version: 19.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: "@types/react-dom"
  dependency-version: 19.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: remotion
  dependency-version: 4.0.532
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.70.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: zod
  dependency-version: 4.6.5
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…c#661)

The status page fetch behind the Claude Status widget was only tested
for a response stream that aborts or errors. Its other outcomes had no
tests: a body split across chunks, a non-200 or empty response, a
request that errors or times out (and must be destroyed so the socket
doesn't outlive the render), and HTTPS_PROXY handling, where a blank
value must be ignored and a value that isn't a URL must settle as a
failed lookup rather than throw out of the render.

Also cover the 16-color fallback for incident colors (major drops from
orange to red, critical to bright red).

The fake response helper now takes the final stream event, so the
existing abort and error tests share it.
…irmalloc#660)

Powerline separator colors (same, single and missing backgrounds, both
normal and inverted), caps on widgets without a background, bold resets
at flex gaps and end caps, preserved command colors, and auto-align
padding for merged groups had no tests, and neither did inherited and
override colors on standard-mode separators and padding. These paths
decide whether a status line keeps a continuous color band or shows
gaps, wrong arrow colors and leaked bold.

The ANSI parser gets direct tests for 8-bit C1 CSI/OSC, unterminated
sequences, two-character escapes, flags, ZWJ emoji sequences and
hyperlink closing on truncation, so widths and truncation stay right
for custom command output.

Claude settings gain tests for loadClaudeSettingsSync, refresh-interval
reads and writes against unreadable settings, a failed .orig backup not
blocking install, invalid ccstatusline configs not syncing hooks, and
the getRemoteControlStatus manifest lookup.
The line picker behind Edit Lines and Edit Colors had no tests. These drive
it through Ink with one key per write: listing lines with their widget
counts, moving and selecting, going back by ESC or the Back entry, appending,
the delete confirmation (Yes, No, ESC), move mode with its wrap-around swap,
the Powerline theme warning, and new props from the parent.

Each key waits for its visible effect before the next one. Keys that must do
nothing wait until Ink has read them from stdin, then check that nothing was
redrawn.
Render UpdateCheckerMenu in each state it shows (checking, up to date,
registry failure, update available with runnable, missing and no
commands, auto-update) and drive its keys: Enter on each entry, Down to
Check again and Back, and Escape, checking which callback fires.
… busy machine (sirmalloc#655)

"returns successful output when a background job keeps stdout open" gave the
command 200ms to start, print and exit, and the background job 1.2s to live.
On a loaded machine starting node alone can take longer than 200ms, so the
command timed out and the test failed. It then waited a fixed 1.3s for the
job's file, which a slow start also outran.

The command now gets 1.5s and the job lives 3s, so the test still shows the
command returns without waiting for the job, with room to start. It polls
for the job's file instead of sleeping. The overflow-tree test gets a long
timeout too, so only the overflow can end it, which is what it asserts; the
timeout-tree test keeps its short one.
The config tests used a fixed /tmp/ccstatusline-config-test-home and deleted
it before every test. Two test runs at once (an editor's test runner plus a
terminal run, or parallel CI jobs on one machine) deleted each other's
settings files mid-test and failed at random. Run three at a time, the file
failed 128 of 345 tests on main. A directory from mkdtempSync is unique to
each run, so they no longer collide.
…loc#652)

The Ink component tests slept a fixed 25ms after rendering and after each
key, then asserted. Ink redraws, and React re-attaches Ink's input listener,
on their own schedule, so on a loaded machine the assertion ran before the
redraw, or the next key arrived before the listener was back and was dropped.
Under CPU load these tests failed intermittently: TerminalWidthMenu,
RefreshIntervalMenu and the timezone editor most often.

Each test now waits for the visible effect of its key (the selection moving,
a prompt appearing, a callback firing), with a timeout, through a small
shared helper in src/tui/__tests__/helpers/wait-for-ink.ts. After each
successful wait it yields two setImmediate turns so React can re-attach the
listener before the next key. No assertions change.
…ls (sirmalloc#645)

The codebase mixed the two spellings: 39 parseInt vs 6 Number.parseInt,
14 isNaN vs 14 Number.isNaN (Number.isFinite and Number.parseFloat were
already used exclusively).

parseInt -> Number.parseInt is a pure rename (they are the same
function). Number.isNaN differs from the global only for non-number
arguments, which it doesn't coerce; every one of the 14 calls checks the
result of parseInt(..., 10), a number, so behavior is unchanged.

ESLint's built-in no-restricted-globals now rejects global parseInt,
parseFloat, isNaN and isFinite with a message pointing at the Number.*
form, so the mix doesn't come back. No new dependency.
The codebase mixed two styles for Node's own modules: a handful of files
imported 'node:fs', 'node:os' and friends while the rest used bare ids
such as 'fs' and 'child_process'. Use the node: prefix everywhere, so
each import says plainly that it is Node's module and cannot resolve to
an npm package of the same name.

This covers static imports and re-exports, type-only imports, dynamic
import(), require() (including the script the Custom Command widget
hands to a child runtime and the scripts tests write to disk), and
vi.mock() ids in tests. Vitest and Bun resolve 'child_process' and
'node:child_process' to the same module, for both mocks and spies, so
every mock still applies to the code under test. ESLint's import order
moved five imports to keep builtins sorted.

No behavior change. The bundle already loads node: imports, and every
Node version that can load the bundle also supports require('node:...').

Co-authored-by: Matthew Breedlove <[email protected]>
@sonarqubecloud

sonarqubecloud Bot commented Oct 6, 2026

Copy link
Copy Markdown

@eric-engberg

Copy link
Copy Markdown
Owner Author

Merged upstream as sirmalloc#654.

@eric-engberg
eric-engberg deleted the refactor/git-count-widget-duplication branch October 9, 2026 21:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants