diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad7a674..d3d0fd0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: '1.22' + go-version-file: go.mod cache: true - name: gofmt @@ -36,3 +36,53 @@ jobs: # The plugin entry point is shell; catch syntax errors before users do. - name: shellcheck kaku-tab.tmux run: bash -n kaku-tab.tmux + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - uses: golangci/golangci-lint-action@v9 + with: + # Pinned: an unpinned linter turns an unrelated upstream release into + # a red build on a PR that changed nothing. + version: v2.12.2 + + coverage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: test with coverage + run: go test -covermode=atomic -coverprofile=cover.out ./... + + # Thresholds live in .testcoverage.yml, set just under where the tree + # stands so this is a ratchet rather than a wall. + - uses: vladopajic/go-test-coverage@v2 + with: + config: ./.testcoverage.yml + + # The release config is only exercised on a tag push, which is the worst + # possible time to discover it no longer parses. + goreleaser-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - uses: goreleaser/goreleaser-action@v6 + with: + version: '~> v2' + args: check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 72e144f..b42e5e2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,26 +8,27 @@ permissions: contents: write jobs: - build: + goreleaser: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 with: - go-version: '1.22' + # goreleaser builds the changelog from git history, which a shallow + # clone does not have. + fetch-depth: 0 - - name: Build binaries - run: | - mkdir -p dist - for target in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64; do - os=${target%/*}; arch=${target#*/} - GOOS=$os GOARCH=$arch go build \ - -ldflags "-s -w -X main.version=${GITHUB_REF_NAME}" \ - -o "dist/kaku-tab-$os-$arch" ./cmd/kaku-tab - done - cd dist && sha256sum * > SHA256SUMS + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true - - uses: softprops/action-gh-release@v2 + - uses: goreleaser/goreleaser-action@v6 with: - files: dist/* - generate_release_notes: true + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Optional. Without it the release still publishes and the Homebrew + # cask is skipped — see the skip_upload guard in .goreleaser.yaml. + # Needs a PAT with write access to dsaad68/homebrew-tap. + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} diff --git a/.gitignore b/.gitignore index 6150bd8..dd2f907 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ bin/ dist/ +cover.out # Local agent/editor state .claude/settings.local.json diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..e4459c1 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,54 @@ +version: "2" + +linters: + default: none + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - unused + - bodyclose + - copyloopvar + - misspell + - nilerr + - revive + - unconvert + + settings: + errcheck: + # A deliberate `_ =` is how this codebase marks "best effort, and the + # caller cannot do anything useful with the failure" — reaping a + # satellite, focusing a pane. Flagging those would just add noise. + check-blank: false + revive: + # Deliberately without the `exported` rule. This codebase comments why + # something is the way it is, not what it is; that rule would demand a + # line of restated signature above every thin tmux wrapper and every + # bubbletea interface method, and a gate full of noise is a gate people + # learn to skip. + rules: + - name: indent-error-flow + - name: var-declaration + - name: errorf + - name: context-as-argument + - name: increment-decrement + - name: unreachable-code + staticcheck: + checks: ['all'] + + exclusions: + generated: lax + rules: + # Test helpers are allowed to be undocumented and to ignore errors. + - path: _test\.go + linters: [errcheck, revive] + +formatters: + enable: + - gofmt + +issues: + # A lint gate that only reports the first few problems is not a gate. + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..aee622c --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,85 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +version: 2 + +project_name: kaku-tab + +before: + hooks: + - go mod tidy + +builds: + - id: kaku-tab + main: ./cmd/kaku-tab + binary: kaku-tab + env: + - CGO_ENABLED=0 + goos: [linux, darwin] + goarch: [amd64, arm64] + # Matches the Makefile: main.version is what `kaku-tab version` prints. + ldflags: + - -s -w -X main.version={{ .Version }} + mod_timestamp: '{{ .CommitTimestamp }}' + +archives: + # The binary keeps its plain name inside the archive. kaku-tab.tmux looks for + # `kaku-tab` on PATH when there is no local build, so an archive that unpacks + # to kaku-tab_darwin_arm64 would install something the plugin cannot find. + - id: default + formats: [tar.gz] + name_template: >- + {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }} + files: + - README.md + - LICENSE + - kaku-tab.tmux + - docs/* + +checksum: + name_template: checksums.txt + +changelog: + use: github + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' + - '^chore:' + - Merge pull request + +# A cask, not a formula: goreleaser deprecated `brews`, and a cask also strips +# the macOS quarantine attribute from the downloaded binary. `brew install +# --cask` is macOS-only, which is where Kaku runs; Linux users have `go install` +# and the tarballs above. +homebrew_casks: + - name: kaku-tab + binaries: [kaku-tab] + repository: + owner: dsaad68 + name: homebrew-tap + token: '{{ .Env.HOMEBREW_TAP_TOKEN }}' + # Publishing needs the tap repo and a token with write access to it. Until + # both exist the release still succeeds and simply skips the cask, rather + # than failing after the archives are already uploaded. + skip_upload: '{{ if index .Env "HOMEBREW_TAP_TOKEN" }}false{{ else }}true{{ end }}' + homepage: https://github.com/dsaad68/kaku-tab + description: A tmux plugin that maps one tmux window to one terminal tab + caveats: | + This installs the binary only. tmux still needs the plugin entry point, + from TPM: + + set -g @plugin 'dsaad68/kaku-tab' + + or from a checkout: + + run-shell '~/.tmux/plugins/kaku-tab/kaku-tab.tmux' + + Either way it finds kaku-tab on PATH, so no build step runs. + +release: + draft: false + prerelease: auto + footer: | + --- + Installing: `brew install dsaad68/tap/kaku-tab`, or unpack the archive for + your platform and put `kaku-tab` on your PATH. diff --git a/.testcoverage.yml b/.testcoverage.yml new file mode 100644 index 0000000..f9f07d5 --- /dev/null +++ b/.testcoverage.yml @@ -0,0 +1,34 @@ +# Thresholds for github.com/vladopajic/go-test-coverage. +# +# These are a ratchet set just under where the tree stands today, not an +# aspiration. The point is that a change cannot quietly delete coverage from +# internal/resolve — the package the invariants in CLAUDE.md live in — on its +# way to fixing something else. + +profile: cover.out + +exclude: + paths: + # Thin wrappers around an external process: the tmux CLI, the Kaku mux CLI, + # and argument parsing in main. A unit test of these can only assert the + # arguments back at itself, so they are excluded rather than pinned at 0 + # and dragging the total into meaninglessness. They are covered instead by + # running the real thing — see "Verifying changes" in CLAUDE.md. + - ^cmd/kaku-tab/ + - ^internal/tmux/ + - ^internal/kaku/ + +threshold: + file: 0 + package: 0 + total: 40 + +override: + # The join. Everything else is presentation or plumbing around the table it + # produces, so this is the number that matters most. + - path: ^internal/resolve$ + threshold: 85 + - path: ^internal/ui$ + threshold: 40 + - path: ^internal/mru$ + threshold: 80 diff --git a/CLAUDE.md b/CLAUDE.md index 47ca5f6..ab7091f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,11 +8,16 @@ Go binary + a thin `kaku-tab.tmux` plugin entry point. ```sh make build # bin/kaku-tab make test # go vet + go test -make lint # gofmt + vet +make lint # golangci-lint, same .golangci.yml as CI +make cover # tests + coverage thresholds from .testcoverage.yml ``` Tests use recorded fixtures — no tmux server or terminal needed. +CI runs test / lint / coverage / goreleaser-check. The coverage thresholds are +a ratchet set just under the current numbers: if a change drops +`internal/resolve`, that is the signal, not a nuisance. + ## Where to start `internal/resolve` is the core: it joins the terminal's pane list to tmux's diff --git a/Makefile b/Makefile index 8d03d78..d713af6 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,13 @@ PKG := ./cmd/kaku-tab VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) LDFLAGS := -s -w -X main.version=$(VERSION) -.PHONY: all build install test lint fmt clean +# Pinned to match .github/workflows/ci.yml. A local lint that disagrees with CI +# is worse than no local lint. +GOLANGCI := github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 +COVCHECK := github.com/vladopajic/go-test-coverage/v2@v2.19.0 +GORELEASER := github.com/goreleaser/goreleaser/v2@latest + +.PHONY: all build install test lint fmt cover release-snapshot clean all: build @@ -21,9 +27,23 @@ test: fmt: gofmt -w cmd internal -lint: fmt - go vet ./... - @test -z "$$(gofmt -l cmd internal)" || (echo "unformatted files"; exit 1) +## lint: the full linter set from .golangci.yml, same as CI. Uses an installed +## golangci-lint when there is one, since `go run` rebuilds it from scratch. +lint: + @if command -v golangci-lint >/dev/null 2>&1; then \ + golangci-lint run; \ + else \ + go run $(GOLANGCI) run; \ + fi + +## cover: run tests and check the thresholds in .testcoverage.yml +cover: + go test -covermode=atomic -coverprofile=cover.out ./... + @go run $(COVCHECK) --config=.testcoverage.yml + +## release-snapshot: build the release artifacts locally, publishing nothing +release-snapshot: + go run $(GORELEASER) release --snapshot --clean --skip=publish clean: - rm -rf bin + rm -rf bin dist cover.out diff --git a/README.md b/README.md index 44dbb7f..27357cd 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Works with [Kaku](https://github.com/tw93/Kaku) and [WezTerm](https://wezterm.or ╭── tmux ⇄ kaku ──────────────────────────────────────────────────────────────────╮ │ kaku-tab ❯ 17/17 │ ├──────────────────────────────────────────────────────────────────────────────────┤ -│ ▸ ▾ api 3 windows ⟦kaku 15⟧ │ +│➤ ▾ api 3 windows ⟦kaku 15⟧ │ │ ├ ◍ 1 nvim 2p ~/src/api ⟦hidden 15⟧ │ │ ├ ◍ 2 zsh 2p ~/src/api/cmd ⟦hidden 15⟧ │ │ └ ● 3 just 2p ! ~/src/api ⟦kaku 15⟧ │ @@ -29,7 +29,7 @@ Works with [Kaku](https://github.com/tw93/Kaku) and [WezTerm](https://wezterm.or │ └ ○ 2 htop 1p ~ ⟦ new tab ⟧ │ │ │ │ enter switch · ^/ show preview · ^t new tab · tab fold (S-tab all) · ^p panes │ -│ ^r rename · ^x kill · ^d detach · ^u clear │ +│ ^e hide detached · ^r rename · ^x kill · ^d detach · ^u clear │ ╰──────────────────────────────────────────────────────────────────────────────────╯ ``` @@ -72,6 +72,16 @@ set -g @plugin 'dsaad68/kaku-tab' Then prefix+I. The plugin builds its binary on first load if Go is available. +### Homebrew + +```sh +brew install --cask dsaad68/tap/kaku-tab +``` + +That installs the binary only; tmux still needs the plugin entry point, so pair +it with the TPM line above or a checkout. Nothing gets built — the plugin finds +`kaku-tab` on `PATH`. + ### Manual ```sh @@ -100,11 +110,14 @@ and press Alt+L. | Key | Action | |---|---| +| | move (Ctrl+K / Ctrl+J too) | +| PgUp PgDn Home End | move by a screenful, or to either end | | Enter | switch to that window, reusing the session's existing tab | | Ctrl+T | force a **new** tab, so two windows of one session show at once | | Tab | fold/unfold a session (works from a child row too) | | Shift+Tab | fold or unfold every session | | Ctrl+P | toggle window ⇄ pane rows | +| Ctrl+E | hide/show detached sessions — leaves only what's on screen | | Ctrl+/ | show/hide the preview — the popup resizes with it | | Ctrl+R | rename: the **window** on a child row, the **session** on a header | | Ctrl+X | kill window | @@ -115,6 +128,17 @@ and press Alt+L. Typing filters. A session header matches on behalf of its windows, so `api` shows the session *and* everything under it. +When there are more rows than fit, a scrollbar appears down the right edge — +otherwise a list that continues below the frame looks exactly like one that +ends there. Shift+Tab folds every session, and +Ctrl+E drops the detached ones, which are usually the +faster ways to get a long list back onto one screen. + +Ctrl+E is a per-invocation toggle: it resets each time you +open the picker, because a filter that quietly persisted would one day hide half +your sessions with nothing on screen to say why. Set +`@kaku-tab-detached 'off'` if you want it on by default. + ### Enter vs Ctrl-T For a window that is hidden — its session has a tab, but that tab is showing a @@ -138,9 +162,16 @@ Every option, with defaults, is in set -g @kaku-tab-key 'M-l' # picker binding set -g @kaku-tab-search-key 'M-p' # optional: scrollback search set -g @kaku-tab-preview 'off' # ^/ toggles it +set -g @kaku-tab-sort 'tabs' # or 'mru' / 'name' +set -g @kaku-tab-detached 'on' # 'off' starts with detached hidden; ^e toggles set -g @kaku-tab-ignore 'popup' # sessions to hide, comma-separated ``` +With `@kaku-tab-sort 'mru'` the list is ordered by what you most recently +switched to, and the window you are in now is pushed one place down — so +Alt+L Enter toggles back to where you just +were, alt-tab style. + ## Scrollback search Set `@kaku-tab-search-key` to get a live grep over every pane's scrollback in @@ -172,9 +203,11 @@ brings the sessions back, this brings the tabs back. ## Development ```sh -make build # bin/kaku-tab -make test # go vet + go test -make lint # gofmt + vet +make build # bin/kaku-tab +make test # go vet + go test +make lint # golangci-lint, same config as CI +make cover # tests + the thresholds in .testcoverage.yml +make release-snapshot # build the release artifacts, publish nothing ``` Tests run against recorded fixtures — no tmux server or terminal required. diff --git a/cmd/kaku-tab/main.go b/cmd/kaku-tab/main.go index 8e4c741..dc321a6 100644 --- a/cmd/kaku-tab/main.go +++ b/cmd/kaku-tab/main.go @@ -26,6 +26,7 @@ import ( "github.com/dsaad68/kaku-tab/internal/action" "github.com/dsaad68/kaku-tab/internal/kaku" "github.com/dsaad68/kaku-tab/internal/model" + "github.com/dsaad68/kaku-tab/internal/mru" "github.com/dsaad68/kaku-tab/internal/resolve" "github.com/dsaad68/kaku-tab/internal/tmux" "github.com/dsaad68/kaku-tab/internal/ui" @@ -51,6 +52,26 @@ func opts(selfSession string, withPanes bool) resolve.Options { } } +// sortOption reads @kaku-tab-sort. An unrecognised value falls back to the +// default rather than failing the picker over a typo in tmux.conf. +func sortOption() string { + switch s := tmux.Option("@kaku-tab-sort", ui.SortTabs); s { + case ui.SortMRU, ui.SortName: + return s + default: + return ui.SortTabs + } +} + +// mruList is only read for the sort mode that uses it — every tmux.Option is a +// subprocess, and the picker runs one on every keypress-triggered popup. +func mruList(sortMode string) []string { + if sortMode != ui.SortMRU { + return nil + } + return mru.List(mru.Tmux{}) +} + // ignored lists sessions to hide, e.g. a throwaway popup session bound to a // key. Comma-separated in @kaku-tab-ignore; empty by default. func ignored() []string { @@ -139,8 +160,8 @@ func popup(selfTTY, selfSession string) error { return err } path := stateFile.Name() - stateFile.Close() - defer os.Remove(path) + _ = stateFile.Close() + defer func() { _ = os.Remove(path) }() self, _ := os.Executable() preview := tmux.Option("@kaku-tab-preview", "off") == "on" @@ -249,9 +270,18 @@ func pick(selfTTY, selfSession string) error { preview = restore.Preview } + // Deliberately not written back the way @kaku-tab-preview is. A filter that + // silently persisted would have you reopen the picker one day, find half + // your sessions gone, and have no idea why. + hideDetached := tmux.Option("@kaku-tab-detached", "on") == "off" + if resumed { + hideDetached = restore.HideDetached + } + self, _ := os.Executable() ctx := action.Ctx{SelfTTY: selfTTY, Suffix: suffix, AttachSh: self} + sortMode := sortOption() m := ui.New(ws, ui.Options{ Suffix: suffix, SelfTab: selfTab, @@ -262,6 +292,10 @@ func pick(selfTTY, selfSession string) error { Reload: reload, Ctx: ctx, Restore: restore.State, + Sort: sortMode, + MRU: mruList(sortMode), + + HideDetached: hideDetached, }) // The picker owns the popup's terminal; Kaku's own alt-screen is untouched. @@ -275,6 +309,10 @@ func pick(selfTTY, selfSession string) error { if res.Relaunch || !res.Chosen { return nil } + // Recorded unconditionally, not just under @kaku-tab-sort 'mru': the list + // has to already be there for the option to do anything the first time it + // is switched on. + _ = mru.Record(mru.Tmux{}, res.Window.ID) return action.Go(res.Window, res.PaneID, res.Mode, ctx) } @@ -305,6 +343,7 @@ func search(selfTTY, selfSession, query string) error { if !res.Chosen { return nil } + _ = mru.Record(mru.Tmux{}, res.Window.ID) return action.Go(res.Window, res.PaneID, res.Mode, ctx) } diff --git a/docs/configuration.md b/docs/configuration.md index d8bd7bd..56792f9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -44,12 +44,43 @@ config.send_composed_key_when_right_alt_is_pressed = false | Option | Default | Meaning | |---|---|---| | `@kaku-tab-open-mode` | `reuse` | what Enter does for a hidden window: `reuse` retargets the session's existing tab, `go` opens a new one | +| `@kaku-tab-sort` | `tabs` | list order: `tabs`, `mru`, or `name` — see below | +| `@kaku-tab-detached` | `on` | `off` starts with detached sessions hidden; Ctrl+E toggles | | `@kaku-tab-scope` | `all` | `all`, `session` (current session only), or `group` (current session group) | | `@kaku-tab-ignore` | *(empty)* | comma-separated session names to hide, e.g. a throwaway popup session | | `@kaku-tab-satellite-suffix` | `~kaku` | naming for grouped satellite sessions | | `@kaku-tab-mux-cli` | *(auto)* | force `kaku` or `wezterm` instead of auto-detecting | | `@kaku-tab-search-depth` | `2000` | scrollback lines per pane indexed by search | +### Sort order + +| Value | Order | +|---|---| +| `tabs` | sessions that have a terminal tab first, then the rest; alphabetical within each. The default. | +| `mru` | whatever you most recently switched to, first — sessions and windows both. | +| `name` | plain alphabetical, ignoring whether a session has a tab. | + +`mru` is the one to reach for if you bounce between the same two or three +windows: the window you were in *before* this one sits at the top of the list, +so Alt+L Enter is a straight toggle. + +The window you are currently in is deliberately pushed one place down. It heads +the history — switching here is what recorded it — and leaving it at the top +would put the cursor on a row whose Enter does nothing. + +Only windows you have switched to *through kaku-tab* are ranked; everything +else falls in behind them in the `tabs` order. So a freshly started tmux server +looks exactly like the default until the history fills in. + +The history is kept in a tmux option rather than a file, because tmux window +ids (`@42`) mean nothing outside the server that issued them and a server +option lives exactly that long: + +```sh +tmux show-option -gqv @kaku-tab-mru # most recent first +tmux set-option -gu @kaku-tab-mru # forget it +``` + ## Tab titles Off by default, because it takes over the tab title — which you may already be diff --git a/internal/action/titles.go b/internal/action/titles.go index 7bbd87d..9472eab 100644 --- a/internal/action/titles.go +++ b/internal/action/titles.go @@ -11,7 +11,8 @@ import ( "github.com/dsaad68/kaku-tab/internal/tmux" ) -// TitleFormat placeholders: +// DefaultTitleFormat is the tab title template used when @kaku-tab-title-format +// is unset. Placeholders: // // %s session name (a satellite keeps its own name) // %g base session name, with any satellite suffix stripped diff --git a/internal/mru/mru.go b/internal/mru/mru.go new file mode 100644 index 0000000..289ee31 --- /dev/null +++ b/internal/mru/mru.go @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT + +// Package mru remembers which tmux windows you most recently switched to. +// +// tmux exposes no per-window "last used" timestamp. #{window_activity} is +// output-driven, so a window running top or a build is permanently "most +// recent" whether or not you have looked at it, and #{session_last_attached} +// only moves when a client attaches — which is exactly what kaku-tab avoids +// doing when it retargets an existing tab. Neither answers "where was I?", so +// kaku-tab records its own picks. +// +// The list lives in a tmux server option rather than a state file. Window ids +// (@42) are only meaningful for the life of one tmux server, which is precisely +// how long a server option survives: nothing to migrate, nothing to garbage +// collect, and `tmux show-option -gqv @kaku-tab-mru` shows the whole thing when +// the order looks wrong. +package mru + +import ( + "strings" + + "github.com/dsaad68/kaku-tab/internal/tmux" +) + +// Option is the tmux server option holding the list, most recent first. +const Option = "@kaku-tab-mru" + +// Cap bounds the stored list. Past a few dozen entries the tail is noise, and +// this keeps the option from growing without limit on a long-lived server. +const Cap = 64 + +// Store is where the list is kept. Production uses Tmux; tests use their own. +type Store interface { + Get() string + Set(string) error +} + +// Tmux stores the list in a tmux server option. +type Tmux struct{} + +func (Tmux) Get() string { return tmux.Option(Option, "") } + +func (Tmux) Set(v string) error { return tmux.SetOption(Option, v) } + +// List returns recorded window ids, most recent first. +func List(s Store) []string { + return split(s.Get()) +} + +// Record pushes a window id to the front, removing any earlier occurrence so an +// id appears exactly once. +func Record(s Store, id string) error { + if id == "" { + return nil + } + out := []string{id} + for _, prev := range split(s.Get()) { + if prev != id && len(out) < Cap { + out = append(out, prev) + } + } + return s.Set(strings.Join(out, ",")) +} + +// Ranks maps window id to position, 0 being the most recent. Ids absent from +// the list are absent from the map; callers order those by their usual rule. +// +// current — the window the picker was invoked from — is demoted one place when +// it heads the list. It always does head it, because switching here is what +// recorded it. Leaving it at 0 would put "where you already are" under the +// cursor and make Enter a no-op, which is the one thing an MRU order exists to +// avoid; alt-tab has the same rule for the same reason. +func Ranks(list []string, current string) map[string]int { + order := append([]string(nil), list...) + if current != "" && len(order) > 1 && order[0] == current { + order[0], order[1] = order[1], order[0] + } + m := make(map[string]int, len(order)) + for i, id := range order { + if _, dup := m[id]; !dup { + m[id] = i + } + } + return m +} + +func split(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + var out []string + for _, s := range strings.Split(raw, ",") { + if s = strings.TrimSpace(s); s != "" { + out = append(out, s) + } + } + return out +} diff --git a/internal/mru/mru_test.go b/internal/mru/mru_test.go new file mode 100644 index 0000000..73343e0 --- /dev/null +++ b/internal/mru/mru_test.go @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: MIT + +package mru + +import ( + "strings" + "testing" +) + +type memStore struct{ v string } + +func (s *memStore) Get() string { return s.v } + +func (s *memStore) Set(v string) error { s.v = v; return nil } + +func TestRecordPushesToFrontAndDeduplicates(t *testing.T) { + s := &memStore{} + for _, id := range []string{"@1", "@2", "@3", "@2"} { + if err := Record(s, id); err != nil { + t.Fatal(err) + } + } + want := "@2,@3,@1" + if s.v != want { + t.Errorf("got %q want %q", s.v, want) + } +} + +// The option lives on a long-running tmux server, so an unbounded list would +// grow for as long as that server does. +func TestRecordCapsTheList(t *testing.T) { + s := &memStore{} + for i := 0; i < Cap*2; i++ { + if err := Record(s, "@"+itoa(i)); err != nil { + t.Fatal(err) + } + } + if got := len(List(s)); got != Cap { + t.Errorf("stored %d entries, want %d", got, Cap) + } + if got := List(s)[0]; got != "@"+itoa(Cap*2-1) { + t.Errorf("most recent is %q, want the last recorded", got) + } +} + +func TestListIgnoresBlanksAndEmptyOption(t *testing.T) { + if got := List(&memStore{}); got != nil { + t.Errorf("empty option gave %v, want nil", got) + } + got := List(&memStore{v: " @1 , ,@2, "}) + if strings.Join(got, ",") != "@1,@2" { + t.Errorf("got %v", got) + } +} + +// The head of the list is always the window you just switched to, i.e. where +// you are now. Ranking it first would put the cursor on a row whose Enter does +// nothing — the one outcome an MRU order exists to prevent. +func TestRanksDemotesTheCurrentWindow(t *testing.T) { + r := Ranks([]string{"@1", "@2", "@3"}, "@1") + if r["@2"] != 0 { + t.Errorf("@2 rank %d, want 0", r["@2"]) + } + if r["@1"] != 1 { + t.Errorf("@1 rank %d, want 1", r["@1"]) + } + if r["@3"] != 2 { + t.Errorf("@3 rank %d, want 2", r["@3"]) + } +} + +func TestRanksLeavesListAloneWhenCurrentIsNotHead(t *testing.T) { + list := []string{"@1", "@2"} + r := Ranks(list, "@2") + if r["@1"] != 0 || r["@2"] != 1 { + t.Errorf("got %v, want @1=0 @2=1", r) + } + // Ranks must not reorder the caller's slice: the picker rebuilds rows on + // every reload from the same list. + if list[0] != "@1" || list[1] != "@2" { + t.Errorf("input slice mutated: %v", list) + } +} + +func TestRanksHandlesDegenerateInput(t *testing.T) { + if got := Ranks(nil, "@1"); len(got) != 0 { + t.Errorf("nil list gave %v", got) + } + if got := Ranks([]string{"@1"}, "@1"); got["@1"] != 0 { + t.Errorf("single entry gave %v, want @1=0", got) + } +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var b []byte + for n > 0 { + b = append([]byte{byte('0' + n%10)}, b...) + n /= 10 + } + return string(b) +} diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index c71ec02..0a6eaad 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -213,3 +213,10 @@ func Option(name, def string) string { } return strings.TrimSpace(out) } + +// SetOption writes a global tmux option. Used for the small amount of state +// kaku-tab keeps on the server rather than on disk. +func SetOption(name, value string) error { + _, err := Run("set-option", "-g", name, value) + return err +} diff --git a/internal/ui/detached_test.go b/internal/ui/detached_test.go new file mode 100644 index 0000000..4d7a18b --- /dev/null +++ b/internal/ui/detached_test.go @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: MIT + +package ui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/dsaad68/kaku-tab/internal/model" +) + +func toggleDetached(m *Model) { + m.Update(tea.KeyMsg{Type: tea.KeyCtrlE}) +} + +// sample() has "termdown" detached and "api" attached. +func TestHideDetachedDropsWholeSessions(t *testing.T) { + m := New(sample(), Options{Tree: true, SelfTab: "8", HideDetached: true}) + + if got := sessions(m); strings.Join(got, ",") != "api" { + t.Errorf("got %v, want only the attached session", got) + } + for _, r := range m.rows { + if r.status == model.Detached { + t.Errorf("detached row survived: %s", r.search) + } + } +} + +// The header would be left behind pointing at nothing. +func TestHideDetachedRemovesTheHeaderToo(t *testing.T) { + m := New(sample(), Options{Tree: true, SelfTab: "8", HideDetached: true}) + for _, r := range m.rows { + if r.kind == kindHeader && r.group == "termdown" { + t.Error("header for a fully hidden session is still listed") + } + } +} + +func TestToggleDetachedIsReversible(t *testing.T) { + m := New(sample(), Options{Tree: true, SelfTab: "8"}) + before := len(m.rows) + + toggleDetached(m) + if len(m.rows) >= before { + t.Errorf("hiding changed nothing: %d -> %d rows", before, len(m.rows)) + } + if !m.opt.HideDetached { + t.Error("flag not set") + } + + toggleDetached(m) + if len(m.rows) != before { + t.Errorf("unhiding did not restore the list: %d -> %d rows", before, len(m.rows)) + } +} + +// Unhiding inserts whole sessions, which can be above the cursor. Without +// holding the row, the selection lands on something unrelated and Enter goes +// somewhere the user did not choose. +func TestToggleDetachedKeepsTheCursorOnItsRow(t *testing.T) { + m := New(sample(), Options{Tree: true, SelfTab: "8", HideDetached: true}) + m.width, m.height = 150, 30 + + // Land on a window row of "api", the one session that survives hiding. + for i, vi := range m.view { + if m.rows[vi].kind == kindWindow { + m.cursor = i + break + } + } + want, _ := m.current() + + toggleDetached(m) + + got, ok := m.current() + if !ok { + t.Fatal("cursor left the view") + } + if got.win.ID != want.win.ID { + t.Errorf("cursor moved from window %s to %s", want.win.ID, got.win.ID) + } +} + +// "no matches" reads as "you have no sessions" when the real cause is a filter +// the user may have forgotten they turned on. +func TestEmptyListExplainsTheFilter(t *testing.T) { + ws := []model.Window{ + {RawWindow: model.RawWindow{Session: "api", ID: "@1", Index: "1"}, Status: model.Detached}, + } + m := New(ws, Options{Tree: true, HideDetached: true}) + m.width, m.height = 150, 30 + + if got := m.View(); !strings.Contains(got, "^e shows detached") { + t.Error("empty list does not say the filter is why") + } +} + +func TestHelpBarNamesTheDirectionOfTheToggle(t *testing.T) { + shown := New(sample(), Options{Tree: true}) + if !hasPair(shown.helpPairs(), "^e", "hide detached") { + t.Errorf("with detached shown, help says %v", shown.helpPairs()) + } + + hidden := New(sample(), Options{Tree: true, HideDetached: true}) + if !hasPair(hidden.helpPairs(), "^e", "show detached") { + t.Errorf("with detached hidden, help says %v", hidden.helpPairs()) + } +} + +func hasPair(pairs [][2]string, key, label string) bool { + for _, p := range pairs { + if p[0] == key && p[1] == label { + return true + } + } + return false +} diff --git a/internal/ui/panerow_test.go b/internal/ui/panerow_test.go new file mode 100644 index 0000000..aaedf41 --- /dev/null +++ b/internal/ui/panerow_test.go @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT + +package ui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + "github.com/mattn/go-runewidth" + + "github.com/dsaad68/kaku-tab/internal/model" +) + +func paneSample() []model.Window { + return []model.Window{ + { + RawWindow: model.RawWindow{Session: "api", ID: "@1", Index: "1", Name: "nvim", Panes: 2, Path: "/home/u/api"}, + Status: model.Visible, TabID: "8", GUIWin: "0", ClientSession: "api", + Panes_: []model.Pane{ + {ID: "%1", Index: "1", Cmd: "nvim", Path: "/home/u/api", Active: true}, + {ID: "%2", Index: "2", Cmd: "zsh", Path: "/home/u/api"}, + }, + }, + } +} + +func paneModel(t *testing.T) *Model { + t.Helper() + m := New(paneSample(), Options{Tree: true, PaneMode: true, SelfTab: "8"}) + m.width, m.height = 150, 30 + return m +} + +// The marker used to hang directly off the glyph — "●*" read as one smudged +// symbol rather than a status and a flag. +func TestActivePaneMarkerIsNotFlushAgainstTheGlyph(t *testing.T) { + m := paneModel(t) + for _, r := range m.rows { + if r.kind != kindPane || !r.pane.Active { + continue + } + plain := ansi.Strip(m.renderRow(r, false)) + if strings.Contains(plain, "●*") || strings.Contains(plain, "◍*") || strings.Contains(plain, "○*") { + t.Errorf("marker is flush against the glyph: %q", strings.TrimRight(plain, " ")) + } + return + } + t.Fatal("no active pane row") +} + +// Reserving the marker column on every pane row is what keeps the columns +// straight: when only the active row carried it, that row's label, command and +// path all sat one cell right of its neighbours'. +func TestPaneRowColumnsAlignRegardlessOfTheMarker(t *testing.T) { + m := paneModel(t) + + var cols []int + for _, r := range m.rows { + if r.kind != kindPane { + continue + } + plain := ansi.Strip(m.renderRow(r, false)) + i := strings.Index(plain, "1.") + if i < 0 { + t.Fatalf("no pane label in %q", plain) + } + cols = append(cols, runewidth.StringWidth(plain[:i])) + } + if len(cols) < 2 { + t.Fatal("need at least two pane rows") + } + for _, c := range cols[1:] { + if c != cols[0] { + t.Errorf("label column drifts across pane rows: %v", cols) + break + } + } +} + +// The cursor bar and a collapsed session's fold arrow used to be the same +// glyph, sitting two columns apart and meaning different things. +func TestCursorGlyphIsNotTheFoldArrow(t *testing.T) { + m := newTestModel(t) + for _, r := range m.rows { + sel := ansi.Strip(m.renderRow(r, true)) + if strings.HasPrefix(strings.TrimLeft(sel, " "), "▸") { + t.Errorf("cursor still renders as the fold arrow: %q", sel) + } + } +} diff --git a/internal/ui/scrollbar_test.go b/internal/ui/scrollbar_test.go new file mode 100644 index 0000000..ae6455b --- /dev/null +++ b/internal/ui/scrollbar_test.go @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MIT + +package ui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +func blanks(n, w int) []string { + out := make([]string, n) + for i := range out { + out[i] = strings.Repeat(" ", w) + } + return out +} + +// The gutter is what tells you the list did not end at the frame. +func TestScrollbarAppearsOnlyWhenTheListOverflows(t *testing.T) { + fits := withScrollbar(blanks(5, 10), 10, 5, 0) + for i, l := range fits { + if got := ansi.Strip(l); strings.ContainsAny(got, "│┃") { + t.Errorf("line %d drew a bar for a list that fits: %q", i, got) + } + } + + overflows := withScrollbar(blanks(5, 10), 10, 40, 0) + if !strings.ContainsAny(ansi.Strip(strings.Join(overflows, "")), "│┃") { + t.Error("no bar drawn for a list four times the viewport") + } +} + +// A gutter that came and went with the query would re-budget every column. +func TestScrollbarWidthIsConstant(t *testing.T) { + for _, total := range []int{1, 5, 6, 500} { + lines := withScrollbar(blanks(5, 10), 10, total, 0) + for i, l := range lines { + if got := ansi.StringWidth(l); got != 10+scrollbarCells { + t.Errorf("total=%d line %d width %d, want %d", total, i, got, 10+scrollbarCells) + } + } + } +} + +// The thumb has to reach the bottom at the bottom, or a list you have scrolled +// all the way through still looks like it continues. +func TestScrollbarThumbTracksTheOffset(t *testing.T) { + const h, total = 5, 40 + + top := thumbRows(withScrollbar(blanks(h, 4), 4, total, 0)) + if len(top) == 0 || top[0] != 0 { + t.Errorf("at offset 0 the thumb sits at rows %v, want it to start at 0", top) + } + + bottom := thumbRows(withScrollbar(blanks(h, 4), 4, total, total-h)) + if len(bottom) == 0 || bottom[len(bottom)-1] != h-1 { + t.Errorf("at the last offset the thumb sits at rows %v, want it to end at %d", bottom, h-1) + } +} + +// On a long list an exactly proportional thumb rounds to zero cells and the bar +// disappears at the size where it matters most. +func TestScrollbarThumbSurvivesAVeryLongList(t *testing.T) { + if got := thumbRows(withScrollbar(blanks(5, 4), 4, 100000, 0)); len(got) == 0 { + t.Error("thumb vanished on a 100k-row list") + } +} + +// An offset past the end must not index outside the gutter. +func TestScrollbarClampsAnOutOfRangeOffset(t *testing.T) { + lines := withScrollbar(blanks(5, 4), 4, 40, 999) + if got := thumbRows(lines); len(got) == 0 || got[len(got)-1] != 4 { + t.Errorf("thumb rows %v, want it pinned to the bottom", got) + } +} + +func thumbRows(lines []string) []int { + var out []int + for i, l := range lines { + if strings.Contains(ansi.Strip(l), "┃") { + out = append(out, i) + } + } + return out +} diff --git a/internal/ui/search.go b/internal/ui/search.go index 56c78dc..47110f1 100644 --- a/internal/ui/search.go +++ b/internal/ui/search.go @@ -262,6 +262,7 @@ func (m *SearchModel) View() string { prompt := promptLeft + strings.Repeat(" ", maxInt(1, gap)) + right + " " h := m.listHeight() + rw := maxInt(20, w-scrollbarCells) lines := make([]string, 0, h) switch { case m.indexing: @@ -277,11 +278,11 @@ func (m *SearchModel) View() string { loc := fmt.Sprintf("%s:%s.%s", hit.win.Session, hit.win.Index, hit.pane.Index) prefix := " " if i == m.cursor { - prefix = " " + cPrompt.Render("▸ ") + prefix = cCursor.Render("➤") + " " } line := prefix + cGroup.Render(pad(loc, 20)) + " " + m.badge(hit.win) + " " + cDim.Render(fmt.Sprintf("%5d ", hit.line)) + highlight(hit.text, m.query) - line = padToWidth(truncateANSI(line, w), w) + line = padToWidth(truncateANSI(line, rw), rw) if i == m.cursor { line = cSel.Render(line) } @@ -289,8 +290,11 @@ func (m *SearchModel) View() string { } } for len(lines) < h { - lines = append(lines, strings.Repeat(" ", w)) + lines = append(lines, strings.Repeat(" ", rw)) } + // A scrollback grep routinely returns hundreds of hits, so this list + // overflows far more often than the window list does. + lines = withScrollbar(lines, rw, len(m.view), m.offset) hl := helpBarLines([][2]string{ {"enter", "jump"}, {"^t", "new tab"}, {"^u", "clear"}, {"esc", "cancel"}, diff --git a/internal/ui/sort_test.go b/internal/ui/sort_test.go new file mode 100644 index 0000000..6e96dd0 --- /dev/null +++ b/internal/ui/sort_test.go @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: MIT + +package ui + +import ( + "strings" + "testing" + + "github.com/dsaad68/kaku-tab/internal/model" +) + +// sessions lists header groups in the order they were built. +func sessions(m *Model) []string { + var out []string + for _, r := range m.rows { + if r.kind == kindHeader { + out = append(out, r.group) + } + } + return out +} + +// windowIDs lists the window rows of one group, in order. +func windowIDs(m *Model, group string) []string { + var out []string + for _, r := range m.rows { + if r.kind == kindWindow && r.group == group { + out = append(out, r.win.ID) + } + } + return out +} + +func TestSortTabsIsTheDefault(t *testing.T) { + m := New(sample(), Options{Tree: true, SelfTab: "8"}) + if got := sessions(m); strings.Join(got, ",") != "api,termdown" { + t.Errorf("got %v, want attached session first", got) + } + if got := windowIDs(m, "api"); strings.Join(got, ",") != "@1,@2" { + t.Errorf("got %v, want resolve's index order", got) + } +} + +// sample() has api:@2 visible in the invoking tab, so @2 heads any MRU list and +// must be demoted: the session holding the next-most-recent window sorts first. +func TestSortMRUOrdersSessionsAndWindowsByRecency(t *testing.T) { + m := New(sample(), Options{ + Tree: true, SelfTab: "8", + Sort: SortMRU, + MRU: []string{"@2", "@3", "@1"}, + }) + + if got := sessions(m); strings.Join(got, ",") != "termdown,api" { + t.Errorf("session order %v, want termdown first — @3 outranks the demoted @2", got) + } + if got := windowIDs(m, "api"); strings.Join(got, ",") != "@2,@1" { + t.Errorf("window order %v, want @2 before @1", got) + } +} + +// Enter on the top header must not land on the window you are already in — the +// whole point of demoting the current window one place. +func TestSortMRUHeaderDoesNotTargetTheCurrentWindow(t *testing.T) { + m := New(sample(), Options{ + Tree: true, SelfTab: "8", + Sort: SortMRU, + MRU: []string{"@2", "@1"}, // only session "api" has been visited + }) + for _, r := range m.rows { + if r.kind == kindHeader && r.group == "api" { + if r.win.ID == "@2" { + t.Error("header targets @2, the window already showing in this tab") + } + return + } + } + t.Fatal("no api header") +} + +// Nothing recorded yet — a fresh tmux server — must look exactly like the +// default, or turning the option on would scramble the list until you had used +// the picker enough times to fill it. +func TestSortMRUWithNoHistoryMatchesDefault(t *testing.T) { + def := New(sample(), Options{Tree: true, SelfTab: "8"}) + byMRU := New(sample(), Options{Tree: true, SelfTab: "8", Sort: SortMRU}) + + if a, b := sessions(def), sessions(byMRU); strings.Join(a, ",") != strings.Join(b, ",") { + t.Errorf("sessions %v != %v", b, a) + } + if a, b := windowIDs(def, "api"), windowIDs(byMRU, "api"); strings.Join(a, ",") != strings.Join(b, ",") { + t.Errorf("windows %v != %v", b, a) + } +} + +// Windows that have never been picked keep their index order behind the ones +// that have, rather than being shuffled by map iteration. +func TestSortMRUKeepsUnvisitedWindowsInIndexOrder(t *testing.T) { + ws := []model.Window{ + {RawWindow: model.RawWindow{Session: "api", ID: "@1", Index: "1"}, Status: model.Detached}, + {RawWindow: model.RawWindow{Session: "api", ID: "@2", Index: "2"}, Status: model.Detached}, + {RawWindow: model.RawWindow{Session: "api", ID: "@3", Index: "3"}, Status: model.Detached}, + } + m := New(ws, Options{Tree: true, Sort: SortMRU, MRU: []string{"@3"}}) + if got := windowIDs(m, "api"); strings.Join(got, ",") != "@3,@1,@2" { + t.Errorf("got %v, want the visited window first then index order", got) + } +} + +func TestSortNameIgnoresWhetherASessionHasATab(t *testing.T) { + ws := append(sample(), + model.Window{RawWindow: model.RawWindow{Session: "aaa", ID: "@9", Index: "1"}, Status: model.Detached}) + + byTabs := New(ws, Options{Tree: true, SelfTab: "8"}) + if got := sessions(byTabs); got[0] != "api" { + t.Errorf("default put %q first, want the session with a tab", got[0]) + } + + byName := New(ws, Options{Tree: true, SelfTab: "8", Sort: SortName}) + if got := sessions(byName); strings.Join(got, ",") != "aaa,api,termdown" { + t.Errorf("got %v, want plain alphabetical", got) + } +} diff --git a/internal/ui/text.go b/internal/ui/text.go index bb6fd23..f7796c4 100644 --- a/internal/ui/text.go +++ b/internal/ui/text.go @@ -34,6 +34,52 @@ func padToWidth(s string, w int) string { return s + strings.Repeat(" ", w-n) } +// scrollbarCells is the gutter reserved at the right edge of a list. +// +// Reserved always, even when every row fits. A gutter that appeared only on +// overflow would re-budget every column the moment a query filtered a row away, +// and column widths are a property of the table, not of the query. +const scrollbarCells = 1 + +// withScrollbar pads each line to width and appends the gutter: a track with a +// thumb sized to the fraction of the list on screen. +// +// Without it a list that runs past the viewport looks exactly like a list that +// ends there. The rows simply stop at the frame, with nothing to say the next +// session is one keypress below. +func withScrollbar(lines []string, width, total, offset int) []string { + h := len(lines) + if h == 0 { + return lines + } + if total <= h { + for i := range lines { + lines[i] = padToWidth(lines[i], width) + " " + } + return lines + } + + // Never smaller than one cell: on a long enough list an exactly + // proportional thumb rounds to zero and vanishes where it is needed most. + size := maxInt(1, h*h/total) + start := offset * (h - size) / (total - h) + if start < 0 { + start = 0 + } + if start > h-size { + start = h - size + } + + for i := range lines { + cell := cBorder.Render("│") + if i >= start && i < start+size { + cell = cThumb.Render("┃") + } + lines[i] = padToWidth(lines[i], width) + cell + } + return lines +} + var ( homeOnce sync.Once home string diff --git a/internal/ui/theme.go b/internal/ui/theme.go index 0ff32ac..0e86861 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -37,6 +37,8 @@ var ( cHead = lipgloss.NewStyle().Foreground(colMuted) cPrompt = lipgloss.NewStyle().Foreground(colAccent).Bold(true) cBorder = lipgloss.NewStyle().Foreground(colBorder) + cThumb = lipgloss.NewStyle().Foreground(colAccent) + cCursor = lipgloss.NewStyle().Foreground(colAccent) cTitle = lipgloss.NewStyle().Foreground(colAccent).Bold(true) cKey = lipgloss.NewStyle().Foreground(colText).Bold(true) cText = lipgloss.NewStyle().Foreground(colText) diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 63e570d..6267ec1 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -29,9 +29,21 @@ import ( "github.com/dsaad68/kaku-tab/internal/action" "github.com/dsaad68/kaku-tab/internal/kaku" "github.com/dsaad68/kaku-tab/internal/model" + "github.com/dsaad68/kaku-tab/internal/mru" "github.com/dsaad68/kaku-tab/internal/tmux" ) +// Sort modes for the session list, from @kaku-tab-sort. +const ( + // SortTabs lists sessions that have a terminal tab first, then the rest, + // alphabetically within each. The default. + SortTabs = "tabs" + // SortMRU lists whatever you most recently switched to first. + SortMRU = "mru" + // SortName is plain alphabetical, ignoring whether a session has a tab. + SortName = "name" +) + type rowKind int const ( @@ -69,12 +81,13 @@ type Result struct { // State survives a relaunch so toggling the preview does not lose your place. type State struct { - Query string `json:"query"` - Cursor int `json:"cursor"` - Offset int `json:"offset"` - Preview bool `json:"preview"` - PaneMode bool `json:"pane_mode"` - Collapse map[string]bool `json:"collapse"` + Query string `json:"query"` + Cursor int `json:"cursor"` + Offset int `json:"offset"` + Preview bool `json:"preview"` + PaneMode bool `json:"pane_mode"` + HideDetached bool `json:"hide_detached"` + Collapse map[string]bool `json:"collapse"` } type Options struct { @@ -86,8 +99,13 @@ type Options struct { OpenMode action.Mode Reload func(panes bool) ([]model.Window, error) Ctx action.Ctx - Depth int // scrollback lines per pane for search - Restore State // carried across a preview-toggle relaunch + Depth int // scrollback lines per pane for search + Restore State // carried across a preview-toggle relaunch + Sort string // SortTabs (default), SortMRU, or SortName + MRU []string // window ids, most recent first; only read for SortMRU + + // HideDetached drops sessions with no terminal tab from the list. + HideDetached bool } type previewMsg struct { @@ -151,7 +169,8 @@ func New(ws []model.Window, opt Options) *Model { func (m *Model) State() State { return State{ Query: m.query, Cursor: m.cursor, Offset: m.offset, - Preview: m.opt.Preview, PaneMode: m.opt.PaneMode, Collapse: m.collapse, + Preview: m.opt.Preview, PaneMode: m.opt.PaneMode, + HideDetached: m.opt.HideDetached, Collapse: m.collapse, } } @@ -160,9 +179,23 @@ func (m *Model) Result() Result { return m.result } // build turns resolved windows into tree rows. func (m *Model) build() { m.rows = nil + + // tmux marks every window of a client-less session Detached, so this drops + // whole sessions rather than punching holes in one — which is the point: + // what is left is exactly what you can switch between right now. + windows := m.windows + if m.opt.HideDetached { + windows = make([]model.Window, 0, len(m.windows)) + for _, w := range m.windows { + if w.Status != model.Detached { + windows = append(windows, w) + } + } + } + groups := map[string][]model.Window{} var order []string - for _, w := range m.windows { + for _, w := range windows { g := w.Session if m.opt.PaneMode { g = w.Session + ":" + w.Index @@ -185,11 +218,41 @@ func (m *Model) build() { } } } + + // Under SortMRU the recorded order wins, and everything you have never + // switched to falls back to the rules above — so a fresh tmux server, with + // nothing recorded yet, looks exactly like SortTabs. + best := map[string]int{} // group -> best rank in it; absent = never picked + if m.opt.Sort == SortMRU { + ranks := mru.Ranks(m.opt.MRU, m.here()) + for g, ws := range groups { + sortByRank(ws, ranks) + for _, w := range ws { + if r, ok := ranks[w.ID]; ok { + if cur, seen := best[g]; !seen || r < cur { + best[g] = r + } + } + } + } + } + sort.SliceStable(order, func(i, j int) bool { - if attached[order[i]] != attached[order[j]] { - return attached[order[i]] + a, b := order[i], order[j] + if m.opt.Sort == SortMRU { + ra, oka := best[a] + rb, okb := best[b] + if oka != okb { + return oka + } + if oka && ra != rb { + return ra < rb + } + } + if m.opt.Sort != SortName && attached[a] != attached[b] { + return attached[a] } - return order[i] < order[j] + return a < b }) for _, g := range order { @@ -214,7 +277,7 @@ func (m *Model) build() { m.rows = append(m.rows, row{ kind: kindHeader, group: g, search: g, count: n, status: hstat, tabID: htab, - win: pickHeaderWindow(ws), + win: pickHeaderWindow(ws, m.opt.Sort == SortMRU), }) } for i, w := range ws { @@ -240,9 +303,48 @@ func (m *Model) build() { m.refilter() } +// here is the tmux window displayed in the tab the picker was invoked from, or +// "" when the picker cannot tell (no Kaku CLI, or a client outside a tab). +func (m *Model) here() string { + if m.opt.SelfTab == "" { + return "" + } + for _, w := range m.windows { + if w.Status == model.Visible && w.TabID == m.opt.SelfTab { + return w.ID + } + } + return "" +} + +// sortByRank puts recently switched-to windows first. Windows with no recorded +// rank keep resolve's index order behind them, which is why the comparison +// returns false rather than falling through to an index compare. +func sortByRank(ws []model.Window, ranks map[string]int) { + sort.SliceStable(ws, func(i, j int) bool { + ri, oki := ranks[ws[i].ID] + rj, okj := ranks[ws[j].ID] + if oki != okj { + return oki + } + if !oki { + return false + } + return ri < rj + }) +} + // pickHeaderWindow chooses what Enter on a header targets: the window that // session is currently showing, else its first. -func pickHeaderWindow(ws []model.Window) model.Window { +// +// Under an MRU order the group is already sorted by where you were, and the +// currently-showing window is the one place Enter must not land — so the first +// row wins instead. Without this the top header of an MRU list would target the +// window you are already in. +func pickHeaderWindow(ws []model.Window, byMRU bool) model.Window { + if byMRU { + return ws[0] + } for _, w := range ws { if w.Status == model.Visible { return w @@ -334,10 +436,14 @@ func (m *Model) helpPairs() [][2]string { if !m.opt.Preview { preview = "show preview" } + detached := "hide detached" + if m.opt.HideDetached { + detached = "show detached" + } pairs := [][2]string{ {"enter", "switch"}, {"^/", preview}, {"^t", "new tab"}, {"tab", "fold"}, - {"^p", "panes"}, {"^r", "rename"}, {"^x", "kill"}, {"^d", "detach"}, - {"^u", "clear"}, + {"^p", "panes"}, {"^e", detached}, {"^r", "rename"}, {"^x", "kill"}, + {"^d", "detach"}, {"^u", "clear"}, } if m.opt.Tree { pairs[3] = [2]string{"tab", "fold (S-tab all)"} @@ -575,6 +681,19 @@ func (m *Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.opt.PaneMode = !m.opt.PaneMode return m, m.reloadCmd() + case "ctrl+e": + // Hold the cursor on the same row across the toggle. Unhiding inserts + // whole sessions above it, so without this the selection lands on + // something unrelated and Enter does the wrong thing. + keepID, keepHeader := "", false + if r, ok := m.current(); ok { + keepID, keepHeader = r.win.ID, r.kind == kindHeader + } + m.opt.HideDetached = !m.opt.HideDetached + m.build() + m.focusRow(keepID, keepHeader) + return m, m.previewCmd() + case "ctrl+x": if r, ok := m.current(); ok && r.kind != kindHeader { if err := action.Kill(r.win, m.opt.Suffix); err != nil { @@ -637,6 +756,21 @@ func (m *Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +// focusRow puts the cursor back on a window's row after the rows are rebuilt. +// A no-op when that row is gone, leaving refilter's clamp to decide. +func (m *Model) focusRow(id string, header bool) { + if id == "" { + return + } + for i, vi := range m.view { + if r := m.rows[vi]; r.win.ID == id && (r.kind == kindHeader) == header { + m.cursor = i + m.ensureVisible() + return + } + } +} + func (m *Model) move(d int) { m.cursor += d if m.cursor < 0 { @@ -724,6 +858,8 @@ func glyph(st model.Status) string { } } +// listWidth is the whole list column, scrollbar gutter included. The preview +// is sized from what is left of innerW after it. func (m *Model) listWidth() int { if m.opt.Preview && m.sideBySide() { return m.innerW()*55/100 - 2 @@ -731,13 +867,23 @@ func (m *Model) listWidth() int { return m.innerW() } +// rowWidth is what a row's own content gets: the list column less the gutter. +func (m *Model) rowWidth() int { return maxInt(20, m.listWidth()-scrollbarCells) } + func (m *Model) renderRow(r row, selected bool) string { - lw := m.listWidth() - // Leading space keeps the cursor off the frame border. Both variants are - // exactly cursorCells wide on screen; the selected one just carries colour. + lw := m.rowWidth() + // ➤ (U+27A4), an arrowhead rather than the ▸ (U+25B8) triangle a collapsed + // session folds with. Different shape, not just a different weight, which + // is what keeps the two readable in the one place they meet — a selected, + // folded header — along with the two spaces between them. + // + // Both variants are exactly cursorCells wide on screen; the selected one + // just carries colour. Every candidate glyph was checked at 1 cell in tmux + // first: an ambiguous-width marker would shift every column on the selected + // row and nowhere else. cursor := " " if selected { - cursor = " " + cPrompt.Render("▸ ") + cursor = cCursor.Render("➤") + " " } if r.kind == kindHeader { @@ -780,7 +926,7 @@ func (m *Model) renderRow(r row, selected bool) string { badgeCol := strings.Repeat(" ", maxInt(0, m.badgeW-ansi.StringWidth(badge))) + badge fixed := cursorCells + ansi.StringWidth(indent) + m.badgeW + rightMargin if r.kind == kindPane { - fixed += 1 + 1 + 4 // glyph, active marker, four single spaces + fixed += 1 + 1 + 5 // glyph, active marker, five single spaces } else { fixed += 1 + 4 + 2 + 5 // glyph, "NNp ", flags, five single spaces } @@ -795,7 +941,7 @@ func (m *Model) renderRow(r row, selected bool) string { pathW = 8 } - var label, name, extra string + var label, name string if r.kind == kindPane { // In the tree the session is already on the header, so a pane row shows // only its own coordinates. @@ -804,10 +950,16 @@ func (m *Model) renderRow(r row, selected bool) string { label = r.win.Session + ":" + label } name = strings.TrimSpace(r.pane.Cmd) + + // The active-pane marker gets a column of its own, reserved on every + // pane row. Rendered flush against the glyph it read as one smudged + // symbol, and appearing only on the active row it shifted that row's + // every other column one cell right of its neighbours'. + marker := " " if r.pane.Active { - extra = "*" + marker = cFlag.Render("*") } - return truncateANSI(cursor+cDim.Render(indent)+glyph(r.status)+extra+" "+ + return truncateANSI(cursor+cDim.Render(indent)+glyph(r.status)+" "+marker+" "+ pad(label, labelW)+" "+cName.Render(pad(name, nameW))+" "+ cDim.Render(pad(padLeft(tilde(r.pane.Path), pathW), pathW))+" "+ badgeCol, lw) @@ -873,18 +1025,25 @@ func (m *Model) View() string { lines := make([]string, 0, h) for i := m.offset; i < len(m.view) && i < m.offset+h; i++ { r := m.rows[m.view[i]] - line := padToWidth(m.renderRow(r, i == m.cursor), m.listWidth()) + line := padToWidth(m.renderRow(r, i == m.cursor), m.rowWidth()) if i == m.cursor { line = cSel.Render(line) } lines = append(lines, line) } if len(lines) == 0 { - lines = append(lines, cDim.Render(padToWidth(" no matches", m.listWidth()))) + // An empty list with no query typed is the filter's doing, not the + // query's — say which, or it reads as "you have no sessions". + msg := " no matches" + if m.opt.HideDetached && strings.TrimSpace(m.query) == "" { + msg = " nothing attached — ^e shows detached sessions" + } + lines = append(lines, cDim.Render(padToWidth(msg, m.rowWidth()))) } for len(lines) < h { - lines = append(lines, strings.Repeat(" ", m.listWidth())) + lines = append(lines, strings.Repeat(" ", m.rowWidth())) } + lines = withScrollbar(lines, m.rowWidth(), len(m.view), m.offset) list := strings.Join(lines, "\n") // ── body: list, optionally beside the preview ───────────────────────── @@ -974,10 +1133,3 @@ func maxInt(a, b int) int { } return b } - -func minInt(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index 817429c..2b3e7db 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -62,12 +62,14 @@ func badgeCol(s string) int { return runewidth.StringWidth(plain[:i]) } -func TestRowsNeverExceedListWidth(t *testing.T) { +// Against rowWidth, not listWidth: a row that spilled into the scrollbar gutter +// would push the bar off the frame. +func TestRowsNeverExceedRowWidth(t *testing.T) { m := newTestModel(t) for _, r := range m.rows { for _, sel := range []bool{false, true} { - if got := ansi.StringWidth(m.renderRow(r, sel)); got > m.listWidth() { - t.Errorf("row %q width %d exceeds list width %d", r.group, got, m.listWidth()) + if got := ansi.StringWidth(m.renderRow(r, sel)); got > m.rowWidth() { + t.Errorf("row %q width %d exceeds row width %d", r.group, got, m.rowWidth()) } } }