diff --git a/CLAUDE.md b/CLAUDE.md index 6c940f5..a0219f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,9 +9,7 @@ Operating modes, lightest to heaviest: 1. **Default** — `git fetch --all` per repo. `--prune` is opt-in. Precedence per repo: CLI (`--prune` / `--no-prune`) → local `git config rain.fetchprune` → registry `fetch_prune` → global `fetch_prune`. 2. **`--fetch-mainline`** — targeted fetches for mainline branches only (incompatible with `--sync` / full-sync triggers; CLI returns an error if combined). 3. **`--sync` + `branch_mode`** — hydrate local branches from remotes. -4. **`--risky`** — on the sync path only; allows hard reset to upstream after backup refs. - -Extracted from the `git-fire` codebase and promoted to a first-class tool. +4. **`--risky`** — on the full branch-hydration path; allows hard reset to upstream after backup refs. Module: `github.com/git-rain/git-rain` Go version: 1.24.2 @@ -58,8 +56,8 @@ main.go **Key design decisions:** - Uses native `git` binary via `exec.Command` — not go-git. - Default run: `git fetch --all` with optional `--prune` (resolved per repo: CLI → `rain.fetchprune` → registry `fetch_prune` → `global.fetch_prune`) and optional `--tags`. Mainline-only: `internal/git.MainlineFetchRemotes` when `--fetch-mainline`. Local hydrate: `RainRepository` when `--sync`, non-mainline `branch_mode`, or risky-only config forces full sync. -- Interactive picker: `--rain` (mirrors `git-fire --fire`). -- Backup branch prefix: `git-rain-backup-` (was `git-fire-rain-backup-` in git-fire). +- Interactive picker: `--rain` (streaming scan + Bubble Tea UI). Panel width math lives in `internal/ui/panel_layout.go` (`PanelBlockWidth` / `PanelTextWidth`); keep it aligned with `boxStyle` horizontal padding. On exit, `runRainTUIStream` cancels the scan context before draining channels so in-flight `git` from `ScanRepositoriesStream` can abort; OS SIGINT maps to the same cancel path as `q` via `tea.ErrInterrupted` → `ErrCancelled`. +- Backup branch prefix: `git-rain-backup-`. - Config env prefix: `GIT_RAIN_`. - Safe mode (default): never rewrites local-only commits (applies to `--sync` path). - Risky mode (`--risky` / `config: global.risky_mode`): allows hard reset to upstream after creating a `git-rain-backup-*` ref (implies full sync). diff --git a/README.md b/README.md index 250c667..ca2cc1c 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,25 @@ git-fire → commit + push everything out git-rain → fetch all remotes by default, or hydrate locals with --sync ``` -`git-rain` discovers git repositories under your scan path (and known registry entries). **Default:** `git fetch --all` per repo (no `--prune` unless you opt in) so **remote-tracking refs** update without moving local branches. **Lighter fetch:** `--fetch-mainline`. **Local updates:** `--sync` with `--branch-mode` / config. **Destructive realignment:** `--risky` on the sync path only. +`git-rain` discovers git repositories under your scan path (and known registry entries). From lightest to heaviest: -> **Warning: `--prune` is opt-in.** Passing `--prune` on `git fetch` deletes **stale remote-tracking branch refs** (for example `refs/remotes/origin/old-feature` after that branch was removed on the server). That is usually what you want for a tidy clone, but it **removes those ref names locally** until the next fetch brings them back if the branch reappears. Enable pruning only when you intend it, in this order of override: **`--prune`** / **`--no-prune`** for this run, per-repo **`git config --local --bool rain.fetchprune`**, **`fetch_prune`** on a registry entry, or **`global.fetch_prune`** in config. Effective precedence for each repo is: **CLI** → **`rain.fetchprune`** → **registry `fetch_prune`** → **global `fetch_prune`**. +| Mode | What it does | +| --- | --- | +| **Default** | `git fetch --all` per repo. `--prune` is opt-in (see below). Updates **remote-tracking refs**; does not move local branches. | +| **Lighter fetch** | `--fetch-mainline` — mainline remote-tracking refs only. | +| **Local updates** | `--sync` — hydrate locals; scope from `--branch-mode` or config. | +| **Destructive realignment** | `--risky` or config `risky_mode` on the **full branch-hydration** path (same machinery as `--sync`). That path also runs without `--sync` when you pass **`--risky`**, set **`risky_mode`**, use a **non-mainline `branch_mode`** in config, or pass **any `--branch-mode` value** on the CLI (even `mainline`). Hard-reset to upstream only after backup refs. | + +> **Warning: `--prune` is opt-in.** On `git fetch`, `--prune` deletes **stale remote-tracking branch refs** (for example `refs/remotes/origin/old-feature` after that branch was removed on the server). That is usually what you want for a tidy clone, but it **removes those ref names locally** until a later fetch brings them back if the branch reappears. Turn pruning on only when you mean to. + +**Where `--prune` is decided** (per repo, first applicable source wins): + +1. **CLI** — `--prune` or `--no-prune` for this run +2. **Local git config** — `git config --local --bool rain.fetchprune` +3. **Registry** — `fetch_prune` on the repo entry +4. **User config** — `global.fetch_prune` + +Precedence chain: **CLI** → **`rain.fetchprune`** → **registry `fetch_prune`** → **global `fetch_prune`**. Invocation note: `git-rain` and `git rain` are equivalent when `git-rain` is on your PATH. @@ -51,7 +67,8 @@ Invocation note: `git-rain` and `git rain` are equivalent when `git-rain` is on ## Quick Start ```bash -# preview first — shows what would be synced without touching anything +# preview first — lists repos and whether each would get fetch-only vs branch hydration, without running git +# (still does a filesystem scan; the flag name is a little ironic — "dry" rain that still kicks up dust) git-rain --dry-run # default: scan repos, then git fetch --all per repo (no --prune unless configured or --prune) @@ -167,7 +184,7 @@ Requires Go 1.24.2+. 4. **`--sync`** — hydrates **local** branches: `git fetch --all` (same prune/tags rules), then updates eligible locals toward upstream. Scope comes from **`--branch-mode`** or config `branch_mode`: `mainline`, `checked-out`, `all-local`, or **`all-branches`** (creates local tracking branches for remotes you do not have yet — can be many branches). -5. **`--risky`** — does not change fetch behavior by itself; on the **`--sync`** path it allows hard reset to upstream after creating `git-rain-backup-*` refs when you would otherwise skip local-only commits. +5. **`--risky`** — does not change fetch behavior by itself; on the **full branch-hydration** path (see `--sync` above — entered by `--sync`, **`--risky`**, config **`risky_mode`**, a **non-mainline `branch_mode`**, or **any `--branch-mode` flag** on the CLI) it allows hard reset to upstream after creating `git-rain-backup-*` refs when you would otherwise skip local-only commits. 6. **Report** — one summary line per repo on the default full fetch; per-branch lines on `--fetch-mainline` and `--sync`. @@ -177,7 +194,7 @@ Requires Go 1.24.2+. - **Safety-first defaults** — never rewrites local-only commits; dirty worktrees are skipped, not clobbered - **Risky mode** — opt-in destructive realignment: creates a `git-rain-backup-*` ref, then hard-resets to upstream - **Non-checked-out branches** — updated directly without touching the worktree -- **Interactive TUI (`--rain`)** — streaming repo picker (mirrors `git-fire --fire`), then the same default fetch, `--fetch-mainline`, or `--sync` behavior +- **Interactive TUI (`--rain`)** — streaming repo picker, then the same default fetch, `--fetch-mainline`, or `--sync` behavior - **Registry** — discovered repos persist across runs; mark repos ignored to skip them permanently - **Dry run** — preview all repos that would be fetched or synced without making any changes - **`--fetch-mainline`** — mainline-only remote-tracking ref refresh instead of the default full `git fetch --all` @@ -185,7 +202,7 @@ Requires Go 1.24.2+. ## Core Commands ```bash -# dry run — preview repos, no changes +# dry run — preview repos, no changes (still scans disk unless you add --no-scan) git-rain --dry-run # default run — scan repos, git fetch --all per repo @@ -220,11 +237,11 @@ git-rain --init | Flag | Description | |---|---| -| `--dry-run` | Show what would run without making changes | -| `--rain` | Interactive TUI repo picker before running (like `git-fire --fire`) | +| `--dry-run` | No `git fetch` / branch updates — still scans disk to list repos **unless `--no-scan`** (then only registry-known paths are considered). The name is weather-themed irony: no “wet” git work, but not a no-op. | +| `--rain` | Interactive TUI repo picker before running | | `--sync` | Update local branches from remotes (after `git fetch --all`; default run does not sync locals) | | `--fetch-mainline` | Mainline-only remote `git fetch` per remote instead of default `git fetch --all` (not with `--sync` or other full-sync triggers) | -| `--branch-mode` | With `--sync`: `mainline`, `checked-out`, `all-local`, or `all-branches` (overrides config for this run) | +| `--branch-mode` | On the **full branch-hydration** path (same triggers as `--sync` — see table above): `mainline`, `checked-out`, `all-local`, or `all-branches` (overrides config `branch_mode` for this run) | | `--prune` | Pass `--prune` on fetch for this run (highest precedence; cannot combine with `--no-prune`) | | `--no-prune` | Never pass `--prune` on fetch for this run (overrides `--prune`, config, registry, and `rain.fetchprune`) | | `--tags` | Also pass `--tags` on fetch operations | @@ -251,13 +268,17 @@ Key options: ```toml [global] scan_path = "/home/you/projects" # root to discover repos under -scan_depth = 5 # max directory depth -scan_workers = 8 # parallel scan workers -risky_mode = false # destructive realignment on --sync path only -branch_mode = "mainline" # used with --sync: mainline | checked-out | all-local | all-branches -fetch_prune = false # pass --prune on fetch when true (default off; see README warning) -default_mode = "safe" # "safe" or "risky" -disable_scan = false # skip scan; use registry only +scan_depth = 5 # max directory depth (default in app: 10) +scan_workers = 8 # parallel scan workers +fetch_workers = 4 # parallel per-repo operations (default in app: 4) +risky_mode = false # allow destructive realignment on full hydration path +branch_mode = "mainline" # full hydration: mainline | checked-out | all-local | all-branches +fetch_prune = false # pass --prune on fetch when true (default off; see README warning) +sync_tags = false # pass --tags on fetch when true; CLI --tags still forces tags for the run +# Registry default for new repos (TUI / opt-out): leave-untouched | sync-default | sync-all | sync-current-branch +default_mode = "sync-default" +disable_scan = false # skip scan; use registry only +mainline_patterns = [] # extra mainline names/prefixes when branch_mode = mainline scan_exclude = [ "node_modules", @@ -266,6 +287,8 @@ scan_exclude = [ ] ``` +`global.default_mode` must be exactly one of the four values listed above; anything else fails config load. + All options can be overridden with environment variables using the `GIT_RAIN_` prefix: ```bash @@ -273,9 +296,15 @@ GIT_RAIN_GLOBAL_RISKY_MODE=true git-rain GIT_RAIN_GLOBAL_SCAN_PATH=/tmp/repos git-rain ``` +### Config file, locks, and crashes + +**Registry (`repos.toml`)** — Writes use a cross-process lock file (`repos.toml.lock`), atomic replace, and stale-lock detection (owner PID). If a process dies mid-run you may still see a leftover lock: the CLI prompts to remove it when safe, or you can use **`--force-unlock-registry`** in scripts. This is the same class of “stale lock / don’t corrupt the database” problem as other multi-repo tools; treat lock removal like any other forced unlock — only when you are sure no other `git-rain` is running. + +**User config (`config.toml`)** — Writes use **`config.toml.lock`** with a **bounded wait** (so the `--rain` settings UI does not hang forever if another process holds the lock), then an **atomic replace** (PID-scoped temp file + rename). If the lock cannot be acquired in time, the TUI shows a save error and keeps in-memory settings. Avoid hand-editing `config.toml` while a session is saving; you might leave an orphan `*.tmp` after a crash — safe to delete if present. + ## Interactive TUI -`git-rain --rain` opens an interactive picker. Repositories stream in as the filesystem scan finds them — no waiting for the full scan to complete before you can start picking. After you confirm, the tool runs the **default full fetch** (`git fetch --all`, prune opt-in) unless you passed **`--fetch-mainline`**, or **`--sync`** / config implies full branch hydration. +`git-rain --rain` opens an interactive picker. Repositories stream in as the filesystem scan finds them — no waiting for the full scan to complete before you can start picking. After you confirm, the tool runs the **default full fetch** (`git fetch --all`, prune opt-in) unless you passed **`--fetch-mainline`**, or **full branch hydration** is implied by **`--sync`**, **`--risky`**, **`risky_mode`** in config, a **non-mainline `branch_mode`**, or **any `--branch-mode`** on the CLI. Quitting (**`q`** or **`ctrl+c`**) cancels the in-progress scan (in-flight `git` subprocesses are aborted via the scan context); **`ctrl+c`** outside raw TTY mode is treated like cancel. **Key bindings:** @@ -284,7 +313,8 @@ GIT_RAIN_GLOBAL_SCAN_PATH=/tmp/repos git-rain | `space` | Toggle repo selection | | `a` | Select all / deselect all | | `enter` | Confirm selection and begin fetch or sync | -| `q` / `esc` | Abort | +| `q` / `ctrl+c` | Abort picker | +| `c` / `Esc` | Back from settings (ignored list uses `Esc` / `i` / `b`) | | `↑` / `↓` | Navigate | ## Safe Mode vs Risky Mode @@ -296,7 +326,7 @@ GIT_RAIN_GLOBAL_SCAN_PATH=/tmp/repos git-rain | Checked-out branch, dirty worktree | ⊘ Skipped | ⊘ Skipped | | No upstream tracked | ⊘ Skipped | ⊘ Skipped | -In risky mode, a `git-rain-backup--` ref is created before any hard reset so local work is always recoverable. +In risky mode, a backup ref named like `git-rain-backup---` is created before any hard reset so local work is always recoverable. ## Registry diff --git a/cmd/root.go b/cmd/root.go index 5a8a626..4f1e492 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -53,7 +53,8 @@ Modes (from lightest fetch to full local updates): use --prune, config fetch_prune, registry fetch_prune, or git config rain.fetchprune. 2. --fetch-mainline — targeted fetches for mainline branches only (faster when - you do not need every remote ref). + you do not need every remote ref). Cannot be combined with --sync, --risky, + non-mainline --branch-mode, or global risky_mode (full-sync triggers). 3. --sync — hydrate local branches from remotes. Use --branch-mode for scope (mainline, checked-out, all-local, all-branches); all-branches can create @@ -91,7 +92,7 @@ func init() { rootCmd.Flags().BoolVar(&rainFetchMainline, "fetch-mainline", false, "Fetch only mainline remote-tracking refs per remote (lighter than the default git fetch --all)") rootCmd.Flags().BoolVar(&rainInit, "init", false, "Generate example ~/.config/git-rain/config.toml") rootCmd.Flags().StringVar(&rainConfigFile, "config", "", "Use an explicit config file path") - rootCmd.Flags().BoolVar(&rainRain, "rain", false, "Interactive TUI repo picker before running (mirrors git-fire --fire)") + rootCmd.Flags().BoolVar(&rainRain, "rain", false, "Interactive TUI repo picker before running") rootCmd.Flags().BoolVar(&rainSync, "sync", false, "Update local branches from remotes (default is git fetch --all only)") rootCmd.Flags().StringVar(&rainBranchMode, "branch-mode", "", `Branch sync mode: mainline (default), checked-out, all-local, all-branches`) rootCmd.Flags().BoolVar(&rainSyncTags, "tags", false, "Fetch all tags from remotes (default: off)") @@ -510,7 +511,8 @@ func runRainTUIStream(cfg *config.Config, reg *registry.Registry, regPath string opts.FolderProgress = folderProgress scanChan := make(chan git.Repository, opts.Workers) - tuiRepoChan := make(chan git.Repository, opts.Workers) + // Large buffer so the bridge rarely blocks on send if the TUI stops consuming. + tuiRepoChan := make(chan git.Repository, 256) var scanErr error scanDone := make(chan struct{}) @@ -521,7 +523,9 @@ func runRainTUIStream(cfg *config.Config, reg *registry.Registry, regPath string now := time.Now() defaultMode := git.ParseMode(cfg.Global.DefaultMode) + bridgeDone := make(chan struct{}) go func() { + defer close(bridgeDone) defer close(tuiRepoChan) for repo := range scanChan { repo, include := upsertRepoIntoRegistry(reg, repo, now, defaultMode) @@ -547,7 +551,10 @@ func runRainTUIStream(cfg *config.Config, reg *registry.Registry, regPath string regPath, ) - // Drain channels before cancelling so goroutines can't block on sends. + // Cancel scan first so filepath walk and in-flight git subprocesses unwind. + cancelScan() + + // Drain the TUI channel so the bridge never blocks on send while the scanner closes. go func() { for range tuiRepoChan { } @@ -556,8 +563,8 @@ func runRainTUIStream(cfg *config.Config, reg *registry.Registry, regPath string for range folderProgress { } }() - cancelScan() <-scanDone + <-bridgeDone if err != nil { if errors.Is(err, ui.ErrCancelled) { diff --git a/cmd/root_test.go b/cmd/root_test.go index acb644f..8c3a05a 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -102,6 +102,36 @@ func TestComputeFullSync(t *testing.T) { } } +func TestRunRain_DryRunSyncShowsPruneResolution(t *testing.T) { + tmpHome := t.TempDir() + setTestUserDirs(t, tmpHome) + + scenario := testutil.NewScenario(t) + repo := scenario.CreateRepo("dry-sync-prune"). + AddFile("a.txt", "x\n"). + Commit("init") + + resetFlags() + rainPath = filepath.Dir(repo.Path()) + rainDryRun = true + rainSync = true + rainPrune = true + + var runErr error + output := captureStdout(t, func() { + runErr = runRain(rootCmd, []string{}) + }) + if runErr != nil { + t.Fatalf("runRain() dry-run sync error = %v", runErr) + } + if !strings.Contains(output, "Fetch --prune: on for this run (--prune)") { + t.Fatalf("expected dry-run to show prune on for --sync path, got:\n%s", output) + } + if !strings.Contains(output, "Would hydrate") { + t.Fatalf("expected dry-run hydrate wording for --sync, got:\n%s", output) + } +} + func TestRunRain_DefaultFetchAllDoesNotMoveLocalBranch(t *testing.T) { tmpHome := t.TempDir() setTestUserDirs(t, tmpHome) diff --git a/go.mod b/go.mod index 638800f..d834c05 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gofrs/flock v0.12.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-localereader v0.0.1 // indirect diff --git a/go.sum b/go.sum index 61bb3e7..9b4c475 100644 --- a/go.sum +++ b/go.sum @@ -33,6 +33,8 @@ github.com/git-fire/git-testkit v0.2.0 h1:IFzOxMdNTE5A4lnzbFz62h2R44+3qVy27Xj1KW github.com/git-fire/git-testkit v0.2.0/go.mod h1:YlJlkY9JfGdYTe9o9W3l+gv9BPj05FGu6HK36Z5jwVA= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= +github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -98,5 +100,6 @@ golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4b37c60..1c973c6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,6 +3,7 @@ package config_test import ( "os" "path/filepath" + "sync" "testing" "github.com/git-rain/git-rain/internal/config" @@ -109,6 +110,37 @@ func TestLoad_MissingExplicitConfigFile_Error(t *testing.T) { } } +func TestSaveConfig_ConcurrentWrites(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + c := config.DefaultConfig() + if i%2 == 0 { + c.Global.BranchMode = "mainline" + } else { + c.Global.BranchMode = "all-local" + } + if err := config.SaveConfig(&c, cfgPath); err != nil { + t.Errorf("SaveConfig %d: %v", i, err) + } + }(i) + } + wg.Wait() + + loaded, err := config.LoadWithOptions(config.LoadOptions{ConfigFile: cfgPath}) + if err != nil { + t.Fatalf("Load after concurrent saves: %v", err) + } + if loaded.Global.BranchMode != "mainline" && loaded.Global.BranchMode != "all-local" { + t.Fatalf("unexpected branch mode %q", loaded.Global.BranchMode) + } +} + func TestSaveConfig_RoundTrip(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "config.toml") @@ -148,11 +180,30 @@ func TestValidate_ZeroFetchWorkers_Fixed(t *testing.T) { } } +func TestValidate_DefaultMode_EmptyBecomesDefault(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Global.DefaultMode = "" + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } + if cfg.Global.DefaultMode != "sync-default" { + t.Fatalf("DefaultMode = %q, want sync-default", cfg.Global.DefaultMode) + } +} + +func TestValidate_DefaultMode_Invalid(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Global.DefaultMode = "push-known-branches" + if err := cfg.Validate(); err == nil { + t.Fatal("Validate() should reject invalid default_mode") + } +} + func TestExampleConfigTOML_ContainsBranchMode(t *testing.T) { toml := config.ExampleConfigTOML() - for _, want := range []string{"branch_mode", "sync_tags", "fetch_prune", "mainline_patterns"} { + for _, want := range []string{"branch_mode", "sync_tags", "fetch_prune", "mainline_patterns", `default_mode = "sync-default"`} { if !contains(toml, want) { - t.Errorf("ExampleConfigTOML missing key %q", want) + t.Errorf("ExampleConfigTOML missing fragment %q", want) } } } diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 036f584..57cca20 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -77,7 +77,7 @@ scan_workers = 8 fetch_workers = 4 # Default mode for repos (used by registry opt-out model) -default_mode = "push-known-branches" +default_mode = "sync-default" # Re-scan known repos for new submodules rescan_submodules = false diff --git a/internal/config/loader.go b/internal/config/loader.go index 0687036..4d8b3aa 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -1,11 +1,14 @@ package config import ( + "context" "fmt" "os" "path/filepath" "strings" + "time" + "github.com/gofrs/flock" "github.com/pelletier/go-toml/v2" "github.com/spf13/viper" ) @@ -96,24 +99,63 @@ func setDefaults(v *viper.Viper) { v.SetDefault("ui.color_profile", defaults.UI.ColorProfile) } +// Bounded lock acquisition for config.toml: SaveConfig runs from the TUI on +// every settings keypress — a blocking flock.Lock() can freeze the UI if the +// lock file is stale or another git-rain holds it. TryLockContext retries until +// ctx expires; callers surface errors (e.g. TUI configSaveErr). +const ( + configFileLockTimeout = 2 * time.Second + configFileLockRetry = 50 * time.Millisecond +) + +func acquireConfigLock(lock *flock.Flock) error { + ctx, cancel := context.WithTimeout(context.Background(), configFileLockTimeout) + defer cancel() + locked, err := lock.TryLockContext(ctx, configFileLockRetry) + if err != nil { + return fmt.Errorf("config file lock: %w", err) + } + if !locked { + return fmt.Errorf("config file lock: timeout waiting for %s", lock.Path()) + } + return nil +} + +// writeAtomicReplacing writes data to path via a PID-scoped temp file and rename. +// Removes the temp file if write or rename fails. +func writeAtomicReplacing(path string, data []byte) error { + tmp := fmt.Sprintf("%s.%d.tmp", path, os.Getpid()) + if err := os.WriteFile(tmp, data, 0o600); err != nil { + _ = os.Remove(tmp) + return err + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return err + } + return nil +} + // SaveConfig writes cfg to path as TOML. Intermediate directories are created -// if needed. Existing file content is replaced atomically. +// if needed. Uses an exclusive lock (path + ".lock") and a PID-scoped temp file +// so concurrent writers or interrupted renames cannot corrupt the live config. func SaveConfig(cfg *Config, path string) error { dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o700); err != nil { return fmt.Errorf("creating config directory: %w", err) } + lock := flock.New(path + ".lock") + if err := acquireConfigLock(lock); err != nil { + return err + } + defer func() { _ = lock.Unlock() }() + data, err := toml.Marshal(cfg) if err != nil { return fmt.Errorf("marshalling config: %w", err) } - tmp := path + ".tmp" - if err := os.WriteFile(tmp, data, 0o600); err != nil { - return fmt.Errorf("writing temp config: %w", err) - } - if err := os.Rename(tmp, path); err != nil { - _ = os.Remove(tmp) - return fmt.Errorf("replacing config file: %w", err) + if err := writeAtomicReplacing(path, data); err != nil { + return fmt.Errorf("writing config file: %w", err) } return nil } @@ -130,6 +172,16 @@ func LoadOrDefault() *Config { // Validate checks if the configuration is valid. func (c *Config) Validate() error { + dm := strings.TrimSpace(c.Global.DefaultMode) + if dm == "" { + dm = DefaultConfig().Global.DefaultMode + } + switch dm { + case "leave-untouched", "sync-default", "sync-all", "sync-current-branch": + c.Global.DefaultMode = dm + default: + return fmt.Errorf("global.default_mode must be one of leave-untouched, sync-default, sync-all, sync-current-branch, got %q", c.Global.DefaultMode) + } if c.Global.FetchWorkers <= 0 { c.Global.FetchWorkers = DefaultFetchWorkers } @@ -193,13 +245,20 @@ func resolvedUserConfigDir() (string, string) { } // WriteExampleConfig writes an example config file to the specified path. +// Same locking and atomic replace semantics as SaveConfig. func WriteExampleConfig(path string) error { dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o700); err != nil { return fmt.Errorf("failed to create config directory: %w", err) } + lock := flock.New(path + ".lock") + if err := acquireConfigLock(lock); err != nil { + return err + } + defer func() { _ = lock.Unlock() }() + content := ExampleConfigTOML() - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + if err := writeAtomicReplacing(path, []byte(content)); err != nil { return fmt.Errorf("failed to write config file: %w", err) } return nil diff --git a/internal/git/scanner.go b/internal/git/scanner.go index 1c98b02..f6b4cbe 100644 --- a/internal/git/scanner.go +++ b/internal/git/scanner.go @@ -46,7 +46,7 @@ func ScanRepositoriesStream(opts ScanOptions, out chan<- Repository) error { if err != nil || !fi.IsDir() { return } - repo, err := analyzeRepository(p) + repo, err := analyzeRepository(ctx, p) if err != nil { return } @@ -146,31 +146,42 @@ func ScanRepositories(opts ScanOptions) ([]Repository, error) { // AnalyzeRepository extracts metadata from a git repository at repoPath. func AnalyzeRepository(repoPath string) (Repository, error) { - return analyzeRepository(repoPath) + return analyzeRepository(context.Background(), repoPath) } -func analyzeRepository(repoPath string) (Repository, error) { +func analyzeRepository(ctx context.Context, repoPath string) (Repository, error) { + if err := ctx.Err(); err != nil { + return Repository{}, err + } repo := Repository{ Path: repoPath, Name: filepath.Base(repoPath), Selected: true, } - remotes, err := getRemotes(repoPath) + remotes, err := getRemotes(ctx, repoPath) if err == nil { repo.Remotes = remotes + } else if ctx.Err() != nil { + return Repository{}, ctx.Err() + } + + if err := ctx.Err(); err != nil { + return Repository{}, err } - dirty, err := isDirty(repoPath) + dirty, err := isDirty(ctx, repoPath) if err == nil { repo.IsDirty = dirty + } else if ctx.Err() != nil { + return Repository{}, ctx.Err() } return repo, nil } -func getRemotes(repoPath string) ([]Remote, error) { - cmd := exec.Command("git", "remote", "-v") +func getRemotes(ctx context.Context, repoPath string) ([]Remote, error) { + cmd := exec.CommandContext(ctx, "git", "remote", "-v") cmd.Dir = repoPath output, err := cmd.Output() @@ -210,8 +221,8 @@ func getRemotes(repoPath string) ([]Remote, error) { return remotes, nil } -func isDirty(repoPath string) (bool, error) { - cmd := exec.Command("git", "status", "--porcelain") +func isDirty(ctx context.Context, repoPath string) (bool, error) { + cmd := exec.CommandContext(ctx, "git", "status", "--porcelain") cmd.Dir = repoPath output, err := cmd.Output() diff --git a/internal/git/scanner_test.go b/internal/git/scanner_test.go new file mode 100644 index 0000000..6eff92e --- /dev/null +++ b/internal/git/scanner_test.go @@ -0,0 +1,41 @@ +package git + +import ( + "context" + "testing" + + testutil "github.com/git-fire/git-testkit" +) + +// TestScanRepositoriesStream_PreCancelledDrain verifies that when the scan +// context is already cancelled, ScanRepositoriesStream still closes the output +// channel so callers never block on range. This guards the --rain quit path +// where cancelScan runs while the picker exits. +func TestScanRepositoriesStream_PreCancelledDrain(t *testing.T) { + scenario := testutil.NewScenario(t) + repo := scenario.CreateRepo("scan-pre-cancel"). + AddFile("README.md", "x\n"). + Commit("init") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + out := make(chan Repository, 8) + opts := ScanOptions{ + RootPath: repo.Path(), + Workers: 2, + Ctx: ctx, + MaxDepth: 4, + DisableScan: true, + KnownPaths: map[string]bool{repo.Path(): false}, + FolderProgress: nil, + } + + err := ScanRepositoriesStream(opts, out) + if err != nil { + t.Fatalf("ScanRepositoriesStream: %v", err) + } + + for range out { + } +} diff --git a/internal/registry/types.go b/internal/registry/types.go index d5fe3f5..af8862b 100644 --- a/internal/registry/types.go +++ b/internal/registry/types.go @@ -21,7 +21,7 @@ type RegistryEntry struct { // Status: "active", "missing", or "ignored" Status string `toml:"status"` - // Last-used mode (e.g. "push-known-branches") + // Last-used per-repo disposition: leave-untouched | sync-default | sync-all | sync-current-branch Mode string `toml:"mode,omitempty"` // Per-repo override for submodule re-scanning. diff --git a/internal/ui/config_view.go b/internal/ui/config_view.go index c4469cb..9373312 100644 --- a/internal/ui/config_view.go +++ b/internal/ui/config_view.go @@ -27,10 +27,10 @@ const ( var configRows = []configRow{ {label: "Default mode", kind: configRowEnum, options: []string{ - "push-known-branches", - "push-all", + "sync-default", + "sync-all", + "sync-current-branch", "leave-untouched", - "push-current-branch", }}, {label: "Disable scan", kind: configRowBool}, {label: "Fetch workers", kind: configRowEnum, options: []string{ @@ -244,10 +244,10 @@ func (m RepoSelectorModel) saveConfig() RepoSelectorModel { func (m RepoSelectorModel) viewConfig() string { var s strings.Builder + cw := m.contentWidth() if m.rainVisible() { - cw := m.contentWidth() - rainW := min(cw, 70) + rainW := RainDisplayWidth(m.windowWidth) s.WriteString(m.rainBg.Render()) s.WriteString("\n") s.WriteString(RenderRainWave(rainW, m.frameIndex, m.rainAnimationMode)) @@ -259,7 +259,12 @@ func (m RepoSelectorModel) viewConfig() string { Foreground(activeProfile().titleFg). Background(activeProfile().titleBg). Padding(0, 2) - s.WriteString(titleGradient.Render("🌧️ GIT RAIN — SETTINGS")) + title := "🌧️ GIT RAIN — SETTINGS" + if cw <= 0 { + s.WriteString(titleGradient.Render(title)) + } else { + s.WriteString(titleGradient.MaxWidth(cw).Render(title)) + } s.WriteString("\n\n") cursorStyle := lipgloss.NewStyle().Foreground(activeProfile().configCursor).Bold(true) @@ -283,29 +288,34 @@ func (m RepoSelectorModel) viewConfig() string { case configRowComingSoon: hintStr = dimStyle.Render(" coming soon") default: - hintStr = dimStyle.Render(" ←/→ to change") + if cw >= 88 { + hintStr = dimStyle.Render(" ←/→ to change") + } else if cw >= 64 { + hintStr = dimStyle.Render(" ←/→") + } } } - line := fmt.Sprintf("%s %-32s %s%s", + // Explicit space after ":" so label and value never abut (lipgloss Width + // on styled segments does not insert separators; Bugbot: "Default mode:sync-default"). + line := fmt.Sprintf("%s %s %s%s", cursorStyle.Render(cur), - labelStyle.Render(row.label+":"), + labelStyle.Render(row.label+": "), valueStyle.Render(val), hintStr, ) - s.WriteString(line) + s.WriteString(clampCellWidth(line, cw)) s.WriteString("\n") } s.WriteString("\n") if m.configSaveErr != nil { errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6666")) - s.WriteString(errStyle.Render("⚠️ Save failed: " + m.configSaveErr.Error())) + s.WriteString(clampCellWidth(errStyle.Render("⚠️ Save failed: "+m.configSaveErr.Error()), cw)) s.WriteString("\n") - s.WriteString(helpStyle.Render( - "In-memory settings updated; fix the error above to persist to disk.\n" + - "Controls: ↑/k, ↓/j Navigate | space/→ Next value | ← Prev value | c/Esc Back | q Quit", - )) + helpText := "In-memory settings updated; fix the error above to persist to disk.\n" + + "Controls: ↑/k, ↓/j Navigate | space/→ Next value | ← Prev value | c/Esc Back | q Quit" + s.WriteString(helpStyle.MaxWidth(cw).Render(helpText)) } else { cfgPathStr := m.cfgPath if cfgPathStr == "" { @@ -313,13 +323,13 @@ func (m RepoSelectorModel) viewConfig() string { } else { cfgPathStr = AbbreviateUserHome(cfgPathStr) } - s.WriteString(helpStyle.Render( - "Changes saved immediately to " + cfgPathStr + "\n" + - "Controls: ↑/k, ↓/j Navigate | space/→ Next value | ← Prev value | c/Esc Back | q Quit", - )) + helpText := "Changes saved immediately to " + cfgPathStr + "\n" + + "Controls: ↑/k, ↓/j Navigate | space/→ Next value | ← Prev value | c/Esc Back | q Quit" + s.WriteString(helpStyle.MaxWidth(cw).Render(helpText)) } - return boxStyle.Render(s.String()) + innerW := PanelBlockWidth(m.windowWidth) + return renderMainPanelBox(innerW, s.String()) } func (m RepoSelectorModel) syncRuntimeFromConfig(cmds []tea.Cmd) (RepoSelectorModel, []tea.Cmd) { diff --git a/internal/ui/panel_layout.go b/internal/ui/panel_layout.go new file mode 100644 index 0000000..33f8ce6 --- /dev/null +++ b/internal/ui/panel_layout.go @@ -0,0 +1,71 @@ +package ui + +import "github.com/charmbracelet/lipgloss" + +// Horizontal layout for the main Bubble Tea panel (must stay consistent across +// repo list, ignored list, settings, rain banner, and PathWidthFor). + +// panelOuterMarginTotal is the number of terminal columns reserved outside the +// bordered panel so the frame does not touch the left/right edge. +const panelOuterMarginTotal = 6 + +// panelBoxHorizontalPadding is the sum of left and right padding on boxStyle +// (Padding(1, 2) => 2 + 2). Keep in sync with boxStyle in repo_selector.go and +// applyColorProfile in color_profiles.go. +const panelBoxHorizontalPadding = 4 + +// PanelBlockWidth returns the lipgloss Width passed to boxStyle for the main panel. +func PanelBlockWidth(terminalWidth int) int { + w := terminalWidth - panelOuterMarginTotal + if w < 0 { + return 0 + } + return w +} + +// PanelTextWidth is the maximum cell width for one line of content inside the +// panel after horizontal padding (use for clamping, PathWidthFor, MaxWidth). +func PanelTextWidth(terminalWidth int) int { + w := PanelBlockWidth(terminalWidth) - panelBoxHorizontalPadding + if w < 0 { + return 0 + } + return w +} + +// RainDisplayWidth is the cell width for the rain background and wave strip. +// It must equal PanelTextWidth so every line inside the bordered panel matches +// the inner content width; otherwise lipgloss rounded borders show gaps when +// the first rows are shorter than later rows (main menu and settings). +func RainDisplayWidth(terminalWidth int) int { + return PanelTextWidth(terminalWidth) +} + +// panelInnerLipglossWidth is the lipgloss "width" setting for the block *inside* +// the main panel's border and horizontal padding. alignTextHorizontal pads each +// line to this width before applyBorder measures line widths — if any physical +// line is wider in the terminal (e.g. emoji wcwidth 2 vs ansi width 1), the +// border row becomes shorter than the content and corners look like gaps. +func panelInnerLipglossWidth(innerBlockWidth int) int { + if innerBlockWidth <= 0 { + return 0 + } + w := innerBlockWidth - panelBoxHorizontalPadding + if w < 1 { + return 1 + } + return w +} + +// renderMainPanelBox renders `inner` (no outer border) inside boxStyle with a +// consistent inner width. A pre-pass forces every line to exactly +// panelInnerLipglossWidth(innerBlockWidth) lipgloss cells so border segments +// match the terminal-rendered content on desktops with wide emoji. +func renderMainPanelBox(innerBlockWidth int, inner string) string { + if innerBlockWidth <= 0 { + return boxStyle.Render(inner) + } + cells := panelInnerLipglossWidth(innerBlockWidth) + normalized := lipgloss.NewStyle().Width(cells).Render(inner) + return boxStyle.Width(innerBlockWidth).Render(normalized) +} diff --git a/internal/ui/panel_layout_test.go b/internal/ui/panel_layout_test.go new file mode 100644 index 0000000..e3c76ae --- /dev/null +++ b/internal/ui/panel_layout_test.go @@ -0,0 +1,63 @@ +package ui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" +) + +// TestPanelLayoutMatchesBoxStylePadding keeps panelBoxHorizontalPadding in sync +// with the main panel lipgloss style (Border + Padding(1,2)). +func TestPanelLayoutMatchesBoxStylePadding(t *testing.T) { + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + Padding(1, 2) + if got := box.GetHorizontalPadding(); got != panelBoxHorizontalPadding { + t.Fatalf("lipgloss horizontal padding = %d, panelBoxHorizontalPadding = %d — update one to match", + got, panelBoxHorizontalPadding) + } +} + +func TestPanelBlockAndTextWidth(t *testing.T) { + if got := PanelBlockWidth(120); got != 114 { + t.Fatalf("PanelBlockWidth(120) = %d, want 114", got) + } + if got := PanelTextWidth(120); got != 110 { + t.Fatalf("PanelTextWidth(120) = %d, want 110", got) + } + if got := PanelTextWidth(6); got != 0 { + t.Fatalf("PanelTextWidth(6) = %d, want 0", got) + } + if got := RainDisplayWidth(200); got != 190 { + t.Fatalf("RainDisplayWidth(200) = %d, want 190 (same as PanelTextWidth)", got) + } + if got := RainDisplayWidth(80); got != 70 { + t.Fatalf("RainDisplayWidth(80) = %d, want 70 (same as PanelTextWidth)", got) + } + if got := panelInnerLipglossWidth(114); got != 110 { + t.Fatalf("panelInnerLipglossWidth(114) = %d, want 110", got) + } +} + +func TestRenderMainPanelBoxWithEmojiLine(t *testing.T) { + // Title uses emoji; pre-normalize so border width matches lipgloss line width. + inner := "🌧️ GIT RAIN — SETTINGS\nsecond line" + out := renderMainPanelBox(40, inner) + lines := strings.Split(out, "\n") + if len(lines) < 3 { + t.Fatalf("expected bordered output with multiple lines, got %d lines", len(lines)) + } + // All non-empty lines should share the same lipgloss-measured width (border alignment). + widths := make(map[int]bool) + for _, line := range lines { + if strings.TrimSpace(line) == "" { + continue + } + w := lipgloss.Width(line) + widths[w] = true + } + if len(widths) != 1 { + t.Fatalf("inconsistent line widths (border gaps on some terminals): %v", widths) + } +} diff --git a/internal/ui/path_display.go b/internal/ui/path_display.go index c8bd91f..e25e7fc 100644 --- a/internal/ui/path_display.go +++ b/internal/ui/path_display.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" + "github.com/charmbracelet/lipgloss" "github.com/git-rain/git-rain/internal/git" ) @@ -60,18 +61,32 @@ func TruncatePath(path string, maxWidth, offset int) (visible string, hasLeft, h return string(runes[offset : offset+maxWidth]), offset > 0, offset+maxWidth < total } -// PathWidthFor returns the number of rune columns available for the scrollable path -// portion inside a repo list row, given the current terminal width and the repo's -// other fixed-width fields. -func PathWidthFor(windowWidth int, repo git.Repository) int { +// PathWidthFor returns the number of terminal cells available for the scrollable +// path segment in a repo list row. Uses PanelTextWidth and lipgloss cell widths +// so the composed line fits inside the padded panel. +func PathWidthFor(terminalWidth int, repo git.Repository) int { + inner := PanelTextWidth(terminalWidth) + if inner < 16 { + if inner < 10 { + return 4 + } + return inner / 3 + } remotesInfo := fmt.Sprintf("(%d remotes)", len(repo.Remotes)) if len(repo.Remotes) == 0 { remotesInfo = "(no remotes!)" } - overhead := 26 + len([]rune(repo.Name)) + len([]rune(repo.Mode.String())) + len([]rune(remotesInfo)) - w := windowWidth - overhead - if w < 8 { - w = 8 + // Row layout: "> [✓] name (‹PATH›) [mode] remotes 💧 …scroll-hint" + prefixW := lipgloss.Width(fmt.Sprintf("> [✓] %s (‹", repo.Name)) + if w := lipgloss.Width(fmt.Sprintf("> [ ] %s (‹", repo.Name)); w > prefixW { + prefixW = w + } + suffixW := lipgloss.Width(fmt.Sprintf("›) [%s] %s", repo.Mode.String(), remotesInfo)) + // Space for cursor, brackets, separators, optional dirty icon + path scroll hint (ANSI width). + const pathRowReserveCells = 34 + pw := inner - prefixW - suffixW - pathRowReserveCells + if pw < 8 { + pw = 8 } - return w + return pw } diff --git a/internal/ui/repo_selector.go b/internal/ui/repo_selector.go index 7d9c274..7aa7b68 100644 --- a/internal/ui/repo_selector.go +++ b/internal/ui/repo_selector.go @@ -146,19 +146,19 @@ type RepoSelectorModel struct { pathScrollDir int pathScrollPause int - scanChan <-chan git.Repository - progressChan <-chan string - scanDone bool - progDone bool - scanDisabled bool - scanDisabledRunOnly bool - scanCurrentPath string + scanChan <-chan git.Repository + progressChan <-chan string + scanDone bool + progDone bool + scanDisabled bool + scanDisabledRunOnly bool + scanCurrentPath string scanNewRegistryCount int scanKnownRegistryCount int - showRain bool - rainTick time.Duration - rainAnimationMode string + showRain bool + rainTick time.Duration + rainAnimationMode string showStartupQuote bool startupQuoteBehavior string @@ -186,7 +186,7 @@ func NewRepoSelectorModel(repos []git.Repository, reg *registry.Registry, regPat s.Style = lipgloss.NewStyle().Foreground(activeProfile().boxBorder) animMode := config.UIRainAnimationBasic - rainBg := NewRainBackground(70, 5, animMode) + rainBg := NewRainBackground(resolveRainBackgroundWidth(80), 5, animMode) return RepoSelectorModel{ repos: repos, @@ -255,7 +255,7 @@ func NewRepoSelectorModelStream( } } - rainBg := NewRainBackground(70, 5, animMode) + rainBg := NewRainBackground(resolveRainBackgroundWidth(80), 5, animMode) return RepoSelectorModel{ repos: nil, @@ -335,7 +335,7 @@ func (m RepoSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.windowWidth = msg.Width m.windowHeight = msg.Height - bgW := min(msg.Width-4, 70) + bgW := resolveRainBackgroundWidth(msg.Width) m.rainBg = NewRainBackground(bgW, 5, m.rainAnimationMode) m = m.withClampedPathScroll() m.scrollOffset = m.clampScroll(m.scrollOffset, m.cursor, m.repoListVisibleCount(), len(m.repos)) @@ -380,6 +380,10 @@ func (m RepoSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.spinner, cmd = m.spinner.Update(msg) cmds = append(cmds, cmd) + case tea.InterruptMsg: + m.quitting = true + return m, tea.Quit + case tea.KeyMsg: if m.view == repoViewConfig { return m.updateConfigView(msg, cmds) @@ -551,7 +555,12 @@ func (m RepoSelectorModel) renderStartupQuote() string { quoteStyle := lipgloss.NewStyle(). Foreground(activeProfile().scrollHint). Italic(true) - return quoteStyle.Render(" ☁ " + m.currentStartupQuote) + cw := m.contentWidth() + line := " ☁ " + m.currentStartupQuote + if cw <= 0 { + return quoteStyle.Render(line) + } + return quoteStyle.MaxWidth(cw).Render(line) } func (m RepoSelectorModel) renderIgnoredViewTitle() string { @@ -560,58 +569,33 @@ func (m RepoSelectorModel) renderIgnoredViewTitle() string { Foreground(activeProfile().titleFg). Background(activeProfile().titleBg). Padding(0, 2) - return titleGradient.Render("🌧️ GIT RAIN — IGNORED REPOSITORIES") + cw := m.contentWidth() + title := "🌧️ GIT RAIN — IGNORED REPOSITORIES" + if cw <= 0 { + return titleGradient.Render(title) + } + return titleGradient.MaxWidth(cw).Render(title) } -func renderIgnoredViewHelp() string { - return helpStyle.Render( - "\nControls:\n" + - " ↑/k, ↓/j Navigate | u Restore (un-ignore) | b/i/Esc Back | q Quit", - ) +func renderIgnoredViewHelp(cw int) string { + text := "\nControls:\n" + + " ↑/k, ↓/j Navigate | u Restore (un-ignore) | b/i/Esc Back | q Quit" + if cw <= 0 { + return helpStyle.Render(text) + } + return helpStyle.MaxWidth(cw).Render(text) } // repoListVisibleCount returns how many repo rows can fit in the viewport. +// Uses measured lipgloss height (wrapped help, bordered scan panel) so the +// bordered panel never exceeds window height — avoids the top border cropping +// when many repos are listed in a short terminal. func (m RepoSelectorModel) repoListVisibleCount() int { - // Box overhead: 2 border + 2 padding top + 2 padding bottom = 6 - // Rain area: 7 rows (bg 5 + wave 1 + blank 1) when visible - // Title: 1 - // Blank after title: 1 - // Quote: 1 (when visible) - // Blank after quote: 1 (when visible) - // Scan panel: ~3 rows - // Help: ~5 rows - // Scroll indicators: up to 2 - overhead := 6 + 1 + 1 // box + title + blank - if m.rainVisible() { - overhead += 7 - } - if m.quoteVisible() { - overhead += 2 - } - if m.scanChan != nil || m.scanDisabled { - overhead += 3 - } - overhead += 5 // help text - visible := m.windowHeight - overhead - if visible < 1 { - visible = 1 - } - return visible + return m.mainViewMeasuredRepoListCapacity() } func (m RepoSelectorModel) ignoredListVisibleCount() int { - overhead := 6 + 1 + 1 + 3 - if m.rainVisible() { - overhead += 7 - } - if m.quoteVisible() { - overhead += 2 - } - visible := m.windowHeight - overhead - if visible < 1 { - visible = 1 - } - return visible + return m.ignoredMeasuredListCapacity() } // advancePathScroll advances the path scroll for the currently focused row. @@ -717,13 +701,27 @@ func (m RepoSelectorModel) clampScroll(offset, cursor, visible, total int) int { } func (m RepoSelectorModel) contentWidth() int { - w := m.windowWidth - 6 - if w < 0 { - w = 0 + return PanelTextWidth(m.windowWidth) +} + +func resolveRainBackgroundWidth(terminalWidth int) int { + w := RainDisplayWidth(terminalWidth) + if w < 1 { + w = 1 } return w } +// clampCellWidth keeps one screen row within maxCells using lipgloss truncation. +// Degeneracy: maxCells < 1 means "no usable width" — return s unchanged; empty s +// is also a no-op. For maxCells == 1, truncation still runs (single visible cell). +func clampCellWidth(s string, maxCells int) string { + if maxCells < 1 || s == "" { + return s + } + return lipgloss.NewStyle().MaxWidth(maxCells).Inline(true).Render(s) +} + func viewportWarningRows(contentWidth int, warning string) int { if warning == "" { return 1 @@ -814,176 +812,34 @@ func (m RepoSelectorModel) View() string { return m.viewConfig() } - cw := m.contentWidth() - rainW := min(cw, 70) - - var s strings.Builder - - if m.rainVisible() { - s.WriteString(m.rainBg.Render()) - s.WriteString("\n") - s.WriteString(RenderRainWave(rainW, m.frameIndex, m.rainAnimationMode)) - s.WriteString("\n\n") - } - - titleGradient := lipgloss.NewStyle(). - Bold(true). - Foreground(activeProfile().titleFg). - Background(activeProfile().titleBg). - Padding(0, 2) - s.WriteString(titleGradient.Render("🌧️ GIT RAIN — SELECT REPOSITORIES 🌧️")) - s.WriteString("\n\n") - - if m.quoteVisible() { - s.WriteString(m.renderStartupQuote()) - s.WriteString("\n\n") - } - - if len(m.repos) == 0 && !m.scanDone { - s.WriteString(unselectedStyle.Render(" Waiting for repositories...")) - s.WriteString("\n") - } - visible := m.repoListVisibleCount() - scrollOffset := m.clampScroll(m.scrollOffset, m.cursor, visible, len(m.repos)) - - hasAbove := scrollOffset > 0 - hasBelow := len(m.repos) > scrollOffset+visible - indicators := 0 - if hasAbove { - indicators++ - } - if hasBelow { - indicators++ - } - itemVisible := visible - indicators - hadHiddenRows := hasAbove || hasBelow - indicatorsSuppressed := false - viewportWarning := " ⚠ More repos exist, but ↑/↓ indicators are hidden in this terminal size (enlarge window or press r)." - warningRows := viewportWarningRows(cw, viewportWarning) - if itemVisible < 1 { - hasAbove = false - hasBelow = false - itemVisible = visible - if hadHiddenRows && visible-warningRows >= 1 { - indicatorsSuppressed = true - itemVisible = visible - warningRows - } - if itemVisible < 1 { - itemVisible = 1 - } - } - end := scrollOffset + itemVisible - if end > len(m.repos) { - end = len(m.repos) - } - - if hasAbove { - s.WriteString(unselectedStyle.Render(fmt.Sprintf(" ↑ %d more", scrollOffset))) - s.WriteString("\n") - } - - for i := scrollOffset; i < end; i++ { - repo := m.repos[i] - cur := " " - if m.cursor == i { - cur = ">" - } - - checked := "[ ]" - style := unselectedStyle - if m.selected[i] { - checked = "[✓]" - style = selectedStyle - } - - dirtyIndicator := "" - if repo.IsDirty { - dirtyIndicator = " 💧" - } - - remotesInfo := fmt.Sprintf("(%d remotes)", len(repo.Remotes)) - if len(repo.Remotes) == 0 { - remotesInfo = "(no remotes!)" - } - - parentPath := AbbreviateUserHome(filepath.Dir(repo.Path)) - pWidth := PathWidthFor(m.windowWidth, repo) - scrollOff := 0 - if m.cursor == i { - scrollOff = m.pathScrollOffset - } - visPath, hasLeft, hasRight := TruncatePath(parentPath, pWidth, scrollOff) - leftInd, rightInd := " ", " " - if hasLeft { - leftInd = "‹" - } - if hasRight { - rightInd = "›" - } - - scrollHint := "" - if m.cursor == i && (hasLeft || hasRight) { - scrollHint = " " + scrollHintStyle.Render("<< SCROLL PATH >>") - } - - line := fmt.Sprintf("%s %s %s (%s%s%s) [%s] %s%s%s", - cur, checked, - style.Render(repo.Name), - leftInd, visPath, rightInd, - repo.Mode.String(), - remotesInfo, - dirtyIndicator, - scrollHint, - ) - s.WriteString(line) - s.WriteString("\n") - } - - if hasBelow { - below := len(m.repos) - end - s.WriteString(unselectedStyle.Render(fmt.Sprintf(" ↓ %d more", below))) - s.WriteString("\n") - } - if indicatorsSuppressed { - s.WriteString(viewportWarningStyle.Render(viewportWarning)) - s.WriteString("\n") - } - - configHint := "" - if m.cfg != nil { - configHint = " c Settings | " - } - help := helpStyle.Render( - "\n" + - "Controls:\n" + - " ↑/k, ↓/j Navigate | ←/→ Scroll path | space Toggle selection\n" + - " m Change mode | x Ignore | a Select all | n Select none | r Toggle rain\n" + - " i View ignored | " + configHint + "enter Confirm | q Quit\n\n" + - "Icons:\n" + - " 💧 = Has uncommitted changes\n" + - " [✓] = Selected | [ ] = Not selected | ‹› = path scrollable", - ) - s.WriteString(help) - - if m.scanChan != nil || m.scanDisabled { - s.WriteString("\n") - s.WriteString(m.renderScanStatus()) - } + var s strings.Builder + s.WriteString(m.mainViewHeaderBlock()) + s.WriteString(m.mainViewRepoListBlock(visible)) + s.WriteString(m.mainViewFooterBlock()) - innerW := m.windowWidth - 6 - if innerW < 0 { - innerW = 0 - } - return boxStyle.Width(innerW).Render(s.String()) + innerW := PanelBlockWidth(m.windowWidth) + return renderMainPanelBox(innerW, s.String()) } func (m RepoSelectorModel) renderScanStatus() string { + cw := m.contentWidth() + if cw < 1 { + cw = 1 + } scanStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(activeProfile().scanBorder). Padding(0, 1) + // Full-width, left-aligned row inside the main panel. Do not use clampCellWidth + // here — it uses Inline(true) and flattens multiline bordered blocks, which + // breaks the scan box and leaves the label floating toward the center. + scanRow := func(inner string) string { + box := scanStyle.Render(inner) + return lipgloss.NewStyle().Width(cw).Align(lipgloss.Left).Render(box) + } + switch { case m.scanDisabled: var label string @@ -992,7 +848,7 @@ func (m RepoSelectorModel) renderScanStatus() string { } else { label = "⚠️ Scanning Disabled" } - return scanStyle.Render(lipgloss.NewStyle().Foreground(activeProfile().scanWarn).Render(label)) + return scanRow(lipgloss.NewStyle().Foreground(activeProfile().scanWarn).Render(label)) case m.scanDone: total := m.scanNewRegistryCount + m.scanKnownRegistryCount @@ -1003,7 +859,7 @@ func (m RepoSelectorModel) renderScanStatus() string { msg = fmt.Sprintf("✅ Scan Complete (%d in list: %d new to registry, %d known)", total, m.scanNewRegistryCount, m.scanKnownRegistryCount) } - return scanStyle.Render(lipgloss.NewStyle().Foreground(activeProfile().scanDone).Render(msg)) + return scanRow(lipgloss.NewStyle().Foreground(activeProfile().scanDone).Render(msg)) default: folder := m.scanCurrentPath @@ -1011,6 +867,12 @@ func (m RepoSelectorModel) renderScanStatus() string { folder = "..." } maxLen := 50 + if cw > 24 { + maxLen = cw - 16 + if maxLen < 24 { + maxLen = 24 + } + } if len(folder) > maxLen { folder = "..." + folder[len(folder)-maxLen+3:] } @@ -1018,111 +880,19 @@ func (m RepoSelectorModel) renderScanStatus() string { total := m.scanNewRegistryCount + m.scanKnownRegistryCount line2 := fmt.Sprintf(" In list: %d (%d new to registry, %d known)", total, m.scanNewRegistryCount, m.scanKnownRegistryCount) - return scanStyle.Render(line1 + "\n" + line2) + return scanRow(line1 + "\n" + line2) } } func (m RepoSelectorModel) viewIgnoredMain() string { - cw := m.contentWidth() - rainW := min(cw, 70) - + visible := m.ignoredListVisibleCount() var s strings.Builder - if m.rainVisible() { - s.WriteString(m.rainBg.Render()) - s.WriteString("\n") - s.WriteString(RenderRainWave(rainW, m.frameIndex, m.rainAnimationMode)) - s.WriteString("\n\n") - } + s.WriteString(m.ignoredViewHeaderBlock()) + s.WriteString(m.ignoredViewListBlock(visible)) + s.WriteString(m.ignoredViewFooterBlock()) - s.WriteString(m.renderIgnoredViewTitle()) - s.WriteString("\n\n") - if m.quoteVisible() { - s.WriteString(m.renderStartupQuote()) - s.WriteString("\n\n") - } - - if len(m.ignoredEntries) == 0 { - s.WriteString(unselectedStyle.Render("No ignored repositories.")) - s.WriteString("\n") - } else { - visible := m.ignoredListVisibleCount() - scrollOffset := m.clampScroll(m.ignoredScrollOffset, m.ignoredCursor, visible, len(m.ignoredEntries)) - - hasAbove := scrollOffset > 0 - hasBelow := len(m.ignoredEntries) > scrollOffset+visible - indicators := 0 - if hasAbove { - indicators++ - } - if hasBelow { - indicators++ - } - - maxPathCols := cw - 4 - if maxPathCols < 0 { - maxPathCols = 0 - } - - itemVisible := visible - indicators - hadHiddenRows := hasAbove || hasBelow - indicatorsSuppressed := false - viewportWarning := " ⚠ More ignored repos exist, but ↑/↓ indicators are hidden in this terminal size." - warningRows := viewportWarningRows(cw, viewportWarning) - if itemVisible < 1 { - hasAbove = false - hasBelow = false - itemVisible = visible - if hadHiddenRows && visible-warningRows >= 1 { - indicatorsSuppressed = true - itemVisible = visible - warningRows - } - if itemVisible < 1 { - itemVisible = 1 - } - } - end := scrollOffset + itemVisible - if end > len(m.ignoredEntries) { - end = len(m.ignoredEntries) - } - - if hasAbove { - s.WriteString(unselectedStyle.Render(fmt.Sprintf(" ↑ %d more", scrollOffset))) - s.WriteString("\n") - } - - for i := scrollOffset; i < end; i++ { - e := m.ignoredEntries[i] - cur := " " - if m.ignoredCursor == i { - cur = ">" - } - displayPath := AbbreviateUserHome(e.Path) - if maxPathCols == 0 { - displayPath = "" - } else if len([]rune(displayPath)) > maxPathCols { - displayPath = string([]rune(displayPath)[:maxPathCols-1]) + "…" - } - fmt.Fprintf(&s, "%s %s\n", cur, displayPath) - } - - if hasBelow { - below := len(m.ignoredEntries) - end - s.WriteString(unselectedStyle.Render(fmt.Sprintf(" ↓ %d more", below))) - s.WriteString("\n") - } - if indicatorsSuppressed { - s.WriteString(viewportWarningStyle.Render(viewportWarning)) - s.WriteString("\n") - } - } - - s.WriteString(renderIgnoredViewHelp()) - - innerW := m.windowWidth - 6 - if innerW < 0 { - innerW = 0 - } - return boxStyle.Width(innerW).Render(s.String()) + innerW := PanelBlockWidth(m.windowWidth) + return renderMainPanelBox(innerW, s.String()) } func (m RepoSelectorModel) persistMode(repoPath string, mode git.RepoMode) { @@ -1145,6 +915,9 @@ func RunRepoSelector(repos []git.Repository, reg *registry.Registry, regPath str finalModel, err := p.Run() if err != nil { + if errors.Is(err, tea.ErrInterrupted) { + return nil, ErrCancelled + } return nil, err } @@ -1172,6 +945,9 @@ func RunRepoSelectorStream( finalModel, err := p.Run() if err != nil { + if errors.Is(err, tea.ErrInterrupted) { + return nil, ErrCancelled + } return nil, err } @@ -1182,10 +958,3 @@ func RunRepoSelectorStream( return m.GetSelectedRepos(), nil } - -func min(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/internal/ui/view_layout.go b/internal/ui/view_layout.go new file mode 100644 index 0000000..b9981c4 --- /dev/null +++ b/internal/ui/view_layout.go @@ -0,0 +1,358 @@ +package ui + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// mainViewHeaderBlock returns inner content before the repo list (rain, title, quote, waiting line). +func (m RepoSelectorModel) mainViewHeaderBlock() string { + cw := m.contentWidth() + rainW := RainDisplayWidth(m.windowWidth) + var s strings.Builder + if m.rainVisible() { + s.WriteString(m.rainBg.Render()) + s.WriteString("\n") + s.WriteString(RenderRainWave(rainW, m.frameIndex, m.rainAnimationMode)) + s.WriteString("\n\n") + } + titleGradient := lipgloss.NewStyle(). + Bold(true). + Foreground(activeProfile().titleFg). + Background(activeProfile().titleBg). + Padding(0, 2) + title := "🌧️ GIT RAIN — SELECT REPOSITORIES 🌧️" + if cw <= 0 { + s.WriteString(titleGradient.Render(title)) + } else { + s.WriteString(titleGradient.MaxWidth(cw).Render(title)) + } + s.WriteString("\n\n") + if m.quoteVisible() { + s.WriteString(m.renderStartupQuote()) + s.WriteString("\n\n") + } + if len(m.repos) == 0 && !m.scanDone { + s.WriteString(clampCellWidth(unselectedStyle.Render(" Waiting for repositories..."), cw)) + s.WriteString("\n") + } + return s.String() +} + +// mainViewFooterBlock returns help text plus optional scan panel (same as View tail). +func (m RepoSelectorModel) mainViewFooterBlock() string { + cw := m.contentWidth() + configHint := "" + if m.cfg != nil { + configHint = " c Settings | " + } + helpText := "\n" + + "Controls:\n" + + " ↑/k, ↓/j Navigate | ←/→ Scroll path | space Toggle selection\n" + + " m Change mode | x Ignore | a Select all | n Select none | r Toggle rain\n" + + " i View ignored | " + configHint + "enter Confirm | q Quit\n\n" + + "Icons:\n" + + " 💧 = Has uncommitted changes\n" + + " [✓] = Selected | [ ] = Not selected | ‹› = path scrollable" + var s strings.Builder + s.WriteString(helpStyle.MaxWidth(cw).Render(helpText)) + if m.scanChan != nil || m.scanDisabled { + s.WriteString("\n") + s.WriteString(m.renderScanStatus()) + } + return s.String() +} + +// mainViewRepoListBlock builds the scrollable repo list for a given list *capacity* +// (same meaning as legacy repoListVisibleCount: passed to clampScroll as visible). +func (m RepoSelectorModel) mainViewRepoListBlock(capacity int) string { + cw := m.contentWidth() + if len(m.repos) == 0 { + return "" + } + if capacity < 1 { + capacity = 1 + } + scrollOffset := m.clampScroll(m.scrollOffset, m.cursor, capacity, len(m.repos)) + hasAbove := scrollOffset > 0 + hasBelow := len(m.repos) > scrollOffset+capacity + indicators := 0 + if hasAbove { + indicators++ + } + if hasBelow { + indicators++ + } + itemVisible := capacity - indicators + hadHiddenRows := hasAbove || hasBelow + indicatorsSuppressed := false + viewportWarning := " ⚠ More repos exist, but ↑/↓ indicators are hidden in this terminal size (enlarge window or press r)." + warningRows := viewportWarningRows(cw, viewportWarning) + if itemVisible < 1 { + hasAbove = false + hasBelow = false + itemVisible = capacity + if hadHiddenRows && capacity-warningRows >= 1 { + indicatorsSuppressed = true + itemVisible = capacity - warningRows + } + if itemVisible < 1 { + itemVisible = 1 + } + } + end := scrollOffset + itemVisible + if end > len(m.repos) { + end = len(m.repos) + } + var s strings.Builder + if hasAbove { + s.WriteString(clampCellWidth(unselectedStyle.Render(fmt.Sprintf(" ↑ %d more", scrollOffset)), cw)) + s.WriteString("\n") + } + for i := scrollOffset; i < end; i++ { + repo := m.repos[i] + cur := " " + if m.cursor == i { + cur = ">" + } + checked := "[ ]" + style := unselectedStyle + if m.selected[i] { + checked = "[✓]" + style = selectedStyle + } + dirtyIndicator := "" + if repo.IsDirty { + dirtyIndicator = " 💧" + } + remotesInfo := fmt.Sprintf("(%d remotes)", len(repo.Remotes)) + if len(repo.Remotes) == 0 { + remotesInfo = "(no remotes!)" + } + parentPath := AbbreviateUserHome(filepath.Dir(repo.Path)) + pWidth := PathWidthFor(m.windowWidth, repo) + scrollOff := 0 + if m.cursor == i { + scrollOff = m.pathScrollOffset + } + visPath, hasLeft, hasRight := TruncatePath(parentPath, pWidth, scrollOff) + leftInd, rightInd := " ", " " + if hasLeft { + leftInd = "‹" + } + if hasRight { + rightInd = "›" + } + scrollHint := "" + if m.cursor == i && (hasLeft || hasRight) { + scrollHint = " " + scrollHintStyle.Render("<< SCROLL PATH >>") + } + line := fmt.Sprintf("%s %s %s (%s%s%s) [%s] %s%s%s", + cur, checked, + style.Render(repo.Name), + leftInd, visPath, rightInd, + repo.Mode.String(), + remotesInfo, + dirtyIndicator, + scrollHint, + ) + s.WriteString(clampCellWidth(line, cw)) + s.WriteString("\n") + } + if hasBelow { + below := len(m.repos) - end + s.WriteString(clampCellWidth(unselectedStyle.Render(fmt.Sprintf(" ↓ %d more", below)), cw)) + s.WriteString("\n") + } + if indicatorsSuppressed { + s.WriteString(clampCellWidth(viewportWarningStyle.Render(viewportWarning), cw)) + s.WriteString("\n") + } + return s.String() +} + +// mainViewPanelOuterHeight returns total terminal rows for the bordered main panel +// when the repo list uses the given scroll *capacity* (see clampScroll visible). +func (m RepoSelectorModel) mainViewPanelOuterHeight(capacity int) int { + innerW := PanelBlockWidth(m.windowWidth) + body := m.mainViewHeaderBlock() + m.mainViewRepoListBlock(capacity) + m.mainViewFooterBlock() + return lipgloss.Height(renderMainPanelBox(innerW, body)) +} + +// mainViewMeasuredRepoListCapacity finds the largest capacity such that the full +// panel (header + list + footer with wrapped help and scan box) fits in the window. +// Fixes top border scrolling away when repo count is large and help text wraps. +func (m RepoSelectorModel) mainViewMeasuredRepoListCapacity() int { + h := m.windowHeight + if h < 1 { + return 1 + } + if len(m.repos) == 0 { + return 1 + } + innerW := PanelBlockWidth(m.windowWidth) + header := m.mainViewHeaderBlock() + footer := m.mainViewFooterBlock() + outerHeight := func(capacity int) int { + body := header + m.mainViewRepoListBlock(capacity) + footer + return lipgloss.Height(renderMainPanelBox(innerW, body)) + } + // Binary search largest capacity that fits; best defaults to 1 when even a + // single row overflows the terminal (still show one row + scroll). + best := 1 + lo, hi := 1, len(m.repos) + for lo <= hi { + mid := (lo + hi) / 2 + if mid < 1 { + mid = 1 + } + if outerHeight(mid) <= h { + best = mid + lo = mid + 1 + } else { + hi = mid - 1 + } + } + return best +} + +// --- Ignored repositories view (same height measurement as main view) --- + +func (m RepoSelectorModel) ignoredViewHeaderBlock() string { + rainW := RainDisplayWidth(m.windowWidth) + var s strings.Builder + if m.rainVisible() { + s.WriteString(m.rainBg.Render()) + s.WriteString("\n") + s.WriteString(RenderRainWave(rainW, m.frameIndex, m.rainAnimationMode)) + s.WriteString("\n\n") + } + s.WriteString(m.renderIgnoredViewTitle()) + s.WriteString("\n\n") + if m.quoteVisible() { + s.WriteString(m.renderStartupQuote()) + s.WriteString("\n\n") + } + return s.String() +} + +func (m RepoSelectorModel) ignoredViewFooterBlock() string { + return renderIgnoredViewHelp(m.contentWidth()) +} + +func (m RepoSelectorModel) ignoredViewListBlock(capacity int) string { + cw := m.contentWidth() + if len(m.ignoredEntries) == 0 { + return clampCellWidth(unselectedStyle.Render("No ignored repositories."), cw) + "\n" + } + if capacity < 1 { + capacity = 1 + } + scrollOffset := m.clampScroll(m.ignoredScrollOffset, m.ignoredCursor, capacity, len(m.ignoredEntries)) + hasAbove := scrollOffset > 0 + hasBelow := len(m.ignoredEntries) > scrollOffset+capacity + indicators := 0 + if hasAbove { + indicators++ + } + if hasBelow { + indicators++ + } + maxPathCols := cw - 4 + if maxPathCols < 0 { + maxPathCols = 0 + } + itemVisible := capacity - indicators + hadHiddenRows := hasAbove || hasBelow + indicatorsSuppressed := false + viewportWarning := " ⚠ More ignored repos exist, but ↑/↓ indicators are hidden in this terminal size." + warningRows := viewportWarningRows(cw, viewportWarning) + if itemVisible < 1 { + hasAbove = false + hasBelow = false + itemVisible = capacity + if hadHiddenRows && capacity-warningRows >= 1 { + indicatorsSuppressed = true + itemVisible = capacity - warningRows + } + if itemVisible < 1 { + itemVisible = 1 + } + } + end := scrollOffset + itemVisible + if end > len(m.ignoredEntries) { + end = len(m.ignoredEntries) + } + var s strings.Builder + if hasAbove { + s.WriteString(clampCellWidth(unselectedStyle.Render(fmt.Sprintf(" ↑ %d more", scrollOffset)), cw)) + s.WriteString("\n") + } + for i := scrollOffset; i < end; i++ { + e := m.ignoredEntries[i] + cur := " " + if m.ignoredCursor == i { + cur = ">" + } + displayPath := AbbreviateUserHome(e.Path) + if maxPathCols == 0 { + displayPath = "" + } else if len([]rune(displayPath)) > maxPathCols { + displayPath = string([]rune(displayPath)[:maxPathCols-1]) + "…" + } + line := fmt.Sprintf("%s %s", cur, displayPath) + s.WriteString(clampCellWidth(line, cw)) + s.WriteString("\n") + } + if hasBelow { + below := len(m.ignoredEntries) - end + s.WriteString(clampCellWidth(unselectedStyle.Render(fmt.Sprintf(" ↓ %d more", below)), cw)) + s.WriteString("\n") + } + if indicatorsSuppressed { + s.WriteString(clampCellWidth(viewportWarningStyle.Render(viewportWarning), cw)) + s.WriteString("\n") + } + return s.String() +} + +func (m RepoSelectorModel) ignoredViewPanelOuterHeight(capacity int) int { + innerW := PanelBlockWidth(m.windowWidth) + body := m.ignoredViewHeaderBlock() + m.ignoredViewListBlock(capacity) + m.ignoredViewFooterBlock() + return lipgloss.Height(renderMainPanelBox(innerW, body)) +} + +func (m RepoSelectorModel) ignoredMeasuredListCapacity() int { + h := m.windowHeight + if h < 1 { + return 1 + } + if len(m.ignoredEntries) == 0 { + return 1 + } + innerW := PanelBlockWidth(m.windowWidth) + header := m.ignoredViewHeaderBlock() + footer := m.ignoredViewFooterBlock() + outerHeight := func(capacity int) int { + body := header + m.ignoredViewListBlock(capacity) + footer + return lipgloss.Height(renderMainPanelBox(innerW, body)) + } + best := 1 + lo, hi := 1, len(m.ignoredEntries) + for lo <= hi { + mid := (lo + hi) / 2 + if mid < 1 { + mid = 1 + } + if outerHeight(mid) <= h { + best = mid + lo = mid + 1 + } else { + hi = mid - 1 + } + } + return best +} diff --git a/internal/ui/view_layout_test.go b/internal/ui/view_layout_test.go new file mode 100644 index 0000000..b05296e --- /dev/null +++ b/internal/ui/view_layout_test.go @@ -0,0 +1,88 @@ +package ui + +import ( + "testing" + + "github.com/git-rain/git-rain/internal/git" + "github.com/git-rain/git-rain/internal/registry" +) + +// assertMeasuredCapacityInvariants checks binary-search layout: chosen capacity +// is at least 1, full panel fits windowHeight, and capacity+1 would overflow +// (when list is long enough to test). +func assertMeasuredCapacityInvariants(t *testing.T, windowHeight, capacity, maxList int, panelOuterHeight func(int) int) { + t.Helper() + if capacity < 1 { + t.Fatalf("capacity = %d, want >= 1", capacity) + } + h := panelOuterHeight(capacity) + if h > windowHeight { + t.Fatalf("panel outer height %d > window %d for capacity %d", h, windowHeight, capacity) + } + if capacity+1 <= maxList { + hNext := panelOuterHeight(capacity + 1) + if hNext <= windowHeight { + t.Fatalf("binary search not maximal: capacity=%d height=%d but capacity+1 height=%d also fits", capacity, h, hNext) + } + } +} + +func TestMeasuredListCapacityFitsWindow_table(t *testing.T) { + repos := make([]git.Repository, 50) + for i := range repos { + repos[i] = git.Repository{ + Name: "repo", + Path: "/tmp/r" + string(rune('a'+i%26)), + Remotes: []git.Remote{{Name: "origin"}}, + Mode: git.ModeSyncDefault, + } + } + ignored := make([]registry.RegistryEntry, 35) + for i := range ignored { + ignored[i] = registry.RegistryEntry{ + Path: "/tmp/ignored-" + string(rune('0'+i%10)), + } + } + + cases := []struct { + name string + model RepoSelectorModel + measure func(RepoSelectorModel) int + height func(RepoSelectorModel, int) int + maxList int + }{ + { + name: "main view", + model: RepoSelectorModel{ + repos: repos, + windowWidth: 100, + windowHeight: 28, + showRain: false, + scanDone: true, + }, + measure: func(m RepoSelectorModel) int { return m.mainViewMeasuredRepoListCapacity() }, + height: func(m RepoSelectorModel, capacity int) int { return m.mainViewPanelOuterHeight(capacity) }, + maxList: len(repos), + }, + { + name: "ignored view", + model: RepoSelectorModel{ + ignoredEntries: ignored, + windowWidth: 90, + windowHeight: 24, + showRain: false, + }, + measure: func(m RepoSelectorModel) int { return m.ignoredMeasuredListCapacity() }, + height: func(m RepoSelectorModel, capacity int) int { return m.ignoredViewPanelOuterHeight(capacity) }, + maxList: len(ignored), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + capacity := tc.measure(tc.model) + panelH := func(c int) int { return tc.height(tc.model, c) } + assertMeasuredCapacityInvariants(t, tc.model.windowHeight, capacity, tc.maxList, panelH) + }) + } +}