Skip to content

feat: Repos タブにリポジトリ一覧のサイドバーを足す - #71

Merged
kukv merged 19 commits into
mainfrom
worktree-fluttering-cuddling-papert
Sep 11, 2026
Merged

feat: Repos タブにリポジトリ一覧のサイドバーを足す#71
kukv merged 19 commits into
mainfrom
worktree-fluttering-cuddling-papert

Conversation

@kukv

@kukv kukv commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Phase 4 スライス 2-2。Repos タブを常設にし、設定ファイルの repositories
左ペインのサイドバーとして描き、選択した行のリポジトリで右ペインを引き直せるようにする。

設計: docs/superpowers/specs/2026-09-08-phase4-design.md §4・§8・§9
画面: docs/superpowers/specs/2026-09-05-octoscope-standalone-design.md §4.0・§4.2・§4.6
実装計画: docs/superpowers/plans/2026-09-09-phase4-repos-sidebar.md

入ったもの

  • Repos タブが常設になった。 app.Options.HasRepoRepo / Repositories に解体。
    カレントのリポジトリが一覧に無ければ一時行として先頭に出て、設定ファイルには書かない
  • サイドバー。 設定ファイルの一覧を左ペインに描く。大文字小文字を無視して重複を畳む。
    20〜50 件でもスクロールする
  • 件数バッジ。 open な PR / Issue を 1 リクエストでまとめて引く。位置で対応させ、
    GitHub が解決した綴りを採用する。引けなかった行は「—」で残る
  • 選択行のリポジトリを引く。 行を移ると右ペインが切り替わり、古い応答は世代カウンタで捨てる
  • h / l でペイン移動、j / k でフォーカス側のカーソル移動。 マウスも対応
    (サイドバーのクリック・ホイール、右ペインの X オフセット)
  • 100 桁未満でサイドバーを畳む。 ja の 80 桁でもキーバーが 1 行に収まる
  • 一覧が空でカレントも無いときは repos.none 初期投入の導線は 2-3

入っていないもの(2-3)

追加ダイアログ(a)、削除(x)、初期投入の導線、config.Save

実測(設計 §2 に追記)

自分の公開リポジトリ 30 件を buildRepoCountsQuery と同じ形(alias を並べて 1 リクエスト)で
組み、gh api graphql で 1 回計測。8.13 秒、30 alias 全件解決・errors 無し。
1 リクエストで足りる。30 件超は未計測。

レビューで見つけて直したもの

タスクごとのレビュー 8 回に加えて全ブランチレビューを 1 回。後者でしか出なかったもの:

  • 行が 0 件のとき r を押す、またはレビュー提出後の自動 refresh で全画面エラーに落ちる
    退行(Refresh() / showTab() に行数ガードが無かった)
  • SetCurrent がバッジを全消しして引き直さない ため、--repo 無しの既定の起動で
    バッジが常に「—」だった
  • 設定ファイルの綴りの揺れ(KUKV/Octoscope)で飛行中の一覧が破棄され、
    スピナーが止まらなくなる問題。名前の完全一致を世代カウンタに置き換えて解決
  • errMsg にガードが無く、離れた行の失敗が全画面エラーを起こす問題(同じ世代カウンタで解決)
  • 空振りテスト 2 本(loading がスピナーを出すため View() ベースの主張が落ちなかった)を
    SelectedRef() ベースに書き直し

積み残し

docs/superpowers/2026-09-09-phase4-repos-sidebar-followups.md に 9 件、すべて理由つき。

実端末での確認が要る(TTY の無い環境では代行できない):
サイドバー付きの Repos タブ、--lang ja の 80 桁、h/lj/k
マウスでのサイドバー選択、設定が空のときの repos.none

検証

make check 緑(1044 tests、lint 0 issues)。golden は en / ja × 80 / 120 / 160。

🤖 Generated with Claude Code

kukv and others added 19 commits September 9, 2026 22:06
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
golangci-lint flags unused fields and funcs; row.prs/issues/counted and
Model.rowNames belong to the tasks that will read them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Repos tab now shows a left-hand sidebar listing every configured
repository with an open-item badge, alongside the existing table. The
sidebar folds away under 100 columns, keeping the current repository's
name in the header. h/l move focus between the sidebar and the table;
j/k drive whichever pane has focus. The table's columns now measure
against the width left over once the sidebar has taken its share.

Regenerates the app package's golden files: the Repos tab they render
now includes the sidebar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The counts fetch that Refresh triggers had a test; the one Init triggers
did not, and the doc comment claiming otherwise sat on a model with no
sidebar rows to fetch counts for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sidebarRowAt checked only the top and the total repository count, not
sidebarRows(): with more repositories than fit on screen, a click on
the footer (or lower) mapped to a row past what was actually drawn and
selected it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Refresh() and showTab() set loading and called fetchList regardless of
whether there were any rows. With no rows, fetchList asks for "" and gh
pr list falls back to the working directory's repository; when that call
fails, the errMsg becomes repo.ErrorMsg and app.go drops the whole UI onto
its error screen. This hit two real paths: pressing r on an empty Repos
tab, and app.refreshLists calling repo.Refresh unconditionally after any
review submission or merge.

Guard both the same way Init already does, on len(m.rows) == 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SetCurrent rebuilds m.rows from the settings file's strings, which resets
every row's badge to uncounted, but only returned selectRow's fetchList
command. On the ordinary --repo-less startup path, RepoCounts (one
GraphQL call) almost always answers before resolveRepo (gh repo view,
6+ seconds cold), so SetCurrent's rebuild landed after counts had already
arrived and wiped every badge back to "—" until the user pressed r.

Have SetCurrent batch a fresh fetchCounts alongside selectRow's fetch, and
add a test on the app side confirming RepoCounts is actually called once
the lookup resolves the current repository (fakeSource.RepoCounts
previously returned nil, nil unconditionally and recorded nothing).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
prListMsg/issueListMsg dropped an answer whose repo did not string-match
the selected row's name. repoCountsMsg rewrites a row's name to GitHub's
own spelling, and RepoCounts (one GraphQL call) usually answers before a
just-started ListPRs/ListIssues does, so a settings file spelled
"KUKV/Octoscope" got its row renamed to "kukv/octoscope" out from under
the fetch already in flight for the old spelling: the answer's repo no
longer matched, was dropped, and the spinner never stopped. A GitHub-side
rename (old/name -> new/name, which GitHub redirects) hits the same path.

errMsg carried no guard at all: a fetch for a row the cursor moved past
could fail seconds later and drop the whole UI onto app.fail's screen,
unrelated to whatever the cursor is on by then -- routine with the 20-50
rows a real settings file has (design §2).

Replace the name comparison with a generation counter selectRow bumps
each time the cursor moves to a different row. fetchList and openWeb take
the generation their caller was in; Update drops any prListMsg,
issueListMsg or errMsg whose generation no longer matches, which no
longer depends on a row's spelling and closes the errMsg gap the same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TestMovingTheSidebarClearsTheOldList and TestSetCurrentClearsAndRefetches
asserted "first pr" is absent from View() to guard selectRow's
m.prs, m.issues = nil, nil. That assertion cannot fail: the same
selectRow call also sets m.loading[m.tab] = true, and body() draws only a
spinner line while loading, so a stale m.prs never reaches View() whether
or not it was cleared.

What the clear actually protects is SelectedRef() and selectedURL(): they
read m.prs/m.issues and m.cursors directly, ignoring m.loading, and pair
whatever they find there with the newly selected row's name. Without the
clear, enter/o/d/s pressed while the new row is still loading would open
the previous repository's item under the new repository's name.

Assert on SelectedRef() instead, which does fail when the clear is
removed (verified by hand before writing this commit).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drop item 5 (Refresh/showTab fetching with no rows): fixed as C1 in this
fix wave, so it is no longer carried forward. Its old reasoning ("no real
harm") was itself wrong -- the failure reaches app.fail's full-screen
error via repo.ErrorMsg.

Rewrite item 4's reasoning: the rule column and its click handling did not
exist before this slice, so "an extension of pre-existing behavior" does
not hold. The real reason to carry it forward is that a miss on that one
column only nudges the selection to the table's first row, not a broken
or misdirected action.

Rewrite item 6's reasoning (startup flicker): "momentary" and "in theory"
understated it. With an empty settings file, repos.none is shown for the
lookup's entire span -- up to repoLookupTimeout (20s), 6+ seconds cold --
not a flash. Flag it for higher priority next slice.

Add re-evaluation notes to items 3 and 7 (renumbered from 3 and 7): I1
changed the assumption item 3's reasoning rests on (name matching is gone,
replaced by a generation counter), and C2 changes what item 7's "—" badge
can mean (never fetched vs. fetched and failed), so both need a fresh look
against the fixed code, not just repeating the old text.

Add two new items found while fixing this wave: sidebar navigation
starting one gh subprocess per row with no cancellation or debouncing
(the new generation counter gives a cancellation context something to key
off, left for the next slice's design call), and the pane-switch key hint
staying in the footer when the sidebar is folded away or empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Code Metrics Report

Coverage Test Execution Time
87.3% 41s

Code coverage of files in pull request scope (86.3%, patch 88.8%)

Files Coverage Patch Coverage
cmd/octoscope/main.go 0.0% 0.0%
internal/tui/app/app.go 92.7% 100.0%
internal/tui/app/render.go 98.0% 100.0%
internal/tui/repo/mouse.go 90.3% 81.2%
internal/tui/repo/render.go 96.2% 100.0%
internal/tui/repo/repo.go 87.1% 81.3%
internal/tui/repo/rows.go 100.0% 100.0%
internal/tui/repo/sidebar.go 97.2% 95.7%

Reported by octocov

@kukv
kukv merged commit c65c90f into main Sep 11, 2026
3 checks passed
@kukv
kukv deleted the worktree-fluttering-cuddling-papert branch September 11, 2026 01:21
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.

1 participant