mirror of
https://github.com/peco/peco.git
synced 2026-09-10 15:26:29 -04:00
Compare commits
69 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4368da0fbf | ||
|
|
d473d30d3a | ||
|
|
ea8d6f064d | ||
|
|
578b59851e | ||
|
|
beaf90d759 | ||
|
|
ed3e39d9a2 | ||
|
|
2e25a62fcd | ||
|
|
e7ffb77baa | ||
|
|
ed3929e8c4 | ||
|
|
3752f7fc38 | ||
|
|
40bcfa25d1 | ||
|
|
f730b08b5d | ||
|
|
e947b755d7 | ||
|
|
a45e60b285 | ||
|
|
a4afa02128 | ||
|
|
c3f424413e | ||
|
|
51ff07275e | ||
|
|
49d9793ea1 | ||
|
|
7966368c66 | ||
|
|
baf8773274 | ||
|
|
f4fbedefa2 | ||
|
|
d9c865b45f | ||
|
|
78dc0ea123 | ||
|
|
55667855d4 | ||
|
|
08e3c514de | ||
|
|
8762bd7da0 | ||
|
|
41a8422b22 | ||
|
|
a66446d2e2 | ||
|
|
5f6ec1b21c | ||
|
|
643ad7881d | ||
|
|
886f02bdee | ||
|
|
ac77b9f2ff | ||
|
|
7e657cfd9f | ||
|
|
35ea2ba494 | ||
|
|
b819e73236 | ||
|
|
70243267cd | ||
|
|
4008f2e7bd | ||
|
|
adc039f3a8 | ||
|
|
003bf6b456 | ||
|
|
45c4bf09ac | ||
|
|
78c41943e8 | ||
|
|
68be2eeb7f | ||
|
|
69c9ca8cd7 | ||
|
|
b461fd28cc | ||
|
|
3df3d9735f | ||
|
|
45dae94e47 | ||
|
|
8c7dd793d9 | ||
|
|
d842d4124e | ||
|
|
cb1c747fe6 | ||
|
|
0188a6534c | ||
|
|
8c9aca356b | ||
|
|
dcd727a5b2 | ||
|
|
8e925b8870 | ||
|
|
93de8d0469 | ||
|
|
778ae4368e | ||
|
|
c2d3f453bf | ||
|
|
db69779cf1 | ||
|
|
3efedc3128 | ||
|
|
082d12b1f4 | ||
|
|
df830dc80d | ||
|
|
16d700eb28 | ||
|
|
1fbf855884 | ||
|
|
6bc81c893f | ||
|
|
b1734bbc34 | ||
|
|
42c39e38c8 | ||
|
|
1eab243c5e | ||
|
|
d232db1e32 | ||
|
|
14ed0ef01e | ||
|
|
3ed0b3015e |
50
.claude/docs/cli.md
Normal file
50
.claude/docs/cli.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<!-- Agent-consumed file. Keep terse, unambiguous, machine-parseable. -->
|
||||
|
||||
# CLI
|
||||
|
||||
## Entry Point
|
||||
|
||||
`cmd/peco/peco.go` — parses flags, creates `peco.New()`, calls `Run(ctx)`
|
||||
|
||||
## Flags (CLIOptions)
|
||||
|
||||
| Flag | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `--help` | bool | Show help |
|
||||
| `--version` | bool | Show version |
|
||||
| `--query` | string | Initial query string |
|
||||
| `--rcfile` | string | Config file path |
|
||||
| `--buffer-size` | int | Max lines to read (0=unlimited) |
|
||||
| `--null` | bool | Use NUL as line separator |
|
||||
| `--initial-index` | int | Initial cursor position |
|
||||
| `--initial-filter` | string | Initial filter name |
|
||||
| `--prompt` | string | Prompt string |
|
||||
| `--layout` | string | Layout type: top-down, bottom-up, top-down-query-bottom |
|
||||
| `--select-1` | bool | Auto-select if single match |
|
||||
| `--exit-zero` | bool | Exit immediately with status 1 if input is empty |
|
||||
| `--select-all` | bool | Select all lines and exit immediately |
|
||||
| `--on-cancel` | string | Cancel behavior: success/error |
|
||||
| `--selection-prefix` | string | Prefix for selected lines |
|
||||
| `--exec` | string | Command to execute with selection |
|
||||
| `--print-query` | bool | Print query as first output line |
|
||||
| `--color` | ColorMode | Color mode: auto, none |
|
||||
| `--height` | string | Terminal height spec |
|
||||
|
||||
## Exit Codes
|
||||
|
||||
- 0 — success (lines selected)
|
||||
- 0 — cancel (default, `--on-cancel success`)
|
||||
- 1 — cancel with `--on-cancel error`
|
||||
- Custom — from `--exec` command exit status
|
||||
|
||||
## Input
|
||||
|
||||
- Reads from stdin by default
|
||||
- Positional arg → read from file
|
||||
- Supports streaming (infinite) input
|
||||
|
||||
## Output
|
||||
|
||||
- Selected lines to stdout, one per line
|
||||
- With `--print-query`: query string as first line
|
||||
- With `--exec`: pipes selection to command
|
||||
57
.claude/docs/dependencies.md
Normal file
57
.claude/docs/dependencies.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<!-- Agent-consumed file. Keep terse, unambiguous, machine-parseable. -->
|
||||
|
||||
# Internal Dependency Graph
|
||||
|
||||
```
|
||||
cmd/peco → peco (root), internal/util
|
||||
cmd/filterbench → peco (root), filter, line, pipeline
|
||||
|
||||
peco (root) → config, filter, hub, line, pipeline, query, selection, sig
|
||||
→ internal/ansi, internal/keyseq, internal/util, internal/buffer
|
||||
|
||||
config → internal/util
|
||||
|
||||
filter → line, pipeline, internal/util
|
||||
|
||||
selection → line
|
||||
|
||||
line → internal/ansi
|
||||
|
||||
pipeline → line
|
||||
|
||||
internal/buffer → line
|
||||
```
|
||||
|
||||
## Layer Grouping
|
||||
|
||||
### Leaf (no internal deps)
|
||||
- `hub` — message bus, no imports
|
||||
- `query` — query text/caret, no imports
|
||||
- `sig` — signal handling, no imports
|
||||
- `internal/ansi` — ANSI parser, no imports
|
||||
- `internal/keyseq` — key matching, no imports
|
||||
- `internal/util` — platform utils, no imports
|
||||
|
||||
### Core
|
||||
- `line` → internal/ansi
|
||||
- `pipeline` → line
|
||||
- `internal/buffer` → line
|
||||
|
||||
### Processing
|
||||
- `config` → internal/util
|
||||
- `filter` → line, pipeline, internal/util
|
||||
- `selection` → line
|
||||
|
||||
### Application
|
||||
- `peco` (root) → all above
|
||||
- `cmd/peco` → peco, internal/util
|
||||
- `cmd/filterbench` → peco (root), filter, line, pipeline
|
||||
|
||||
## External Dependencies
|
||||
- `github.com/gdamore/tcell/v2` — terminal UI (screen.go)
|
||||
- `github.com/goccy/go-yaml` — config parsing
|
||||
- `github.com/google/btree` — ordered selection storage
|
||||
- `github.com/jessevdk/go-flags` — CLI flag parsing
|
||||
- `github.com/lestrrat-go/pdebug` — debug logging
|
||||
- `github.com/mattn/go-runewidth` — Unicode width calculation
|
||||
- `github.com/stretchr/testify` — test assertions
|
||||
102
.claude/docs/internals.md
Normal file
102
.claude/docs/internals.md
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<!-- Agent-consumed file. Keep terse, unambiguous, machine-parseable. -->
|
||||
|
||||
# Internals
|
||||
|
||||
## Concurrency Model
|
||||
|
||||
Three main goroutines coordinated via context cancellation:
|
||||
|
||||
1. **Input loop** (`input.go`) — reads terminal events, resolves key sequences via Keymap, dispatches actions
|
||||
2. **View loop** (`view.go`) — renders screen in response to hub messages (draw, paging, status)
|
||||
3. **Filter loop** (`filter.go`) — executes query against line buffer when query changes
|
||||
|
||||
Communication → **Hub** (`hub/`), central message bus with typed generic channels.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
stdin/file → Source → MemoryBuffer
|
||||
↓
|
||||
User keystroke → Input → Hub.SendQuery() → Filter loop
|
||||
↓
|
||||
Filter.Apply() → FilteredBuffer → Hub.SendDraw() → View loop
|
||||
↓
|
||||
View → Layout → Screen (tcell) → terminal
|
||||
```
|
||||
|
||||
## Hub Message Types
|
||||
|
||||
| Channel | Payload | Sender | Receiver |
|
||||
|---------|---------|--------|----------|
|
||||
| `QueryCh` | `string` | Input (action) | Filter loop |
|
||||
| `DrawCh` | `*DrawOptions` | Filter, actions | View loop |
|
||||
| `PagingCh` | `PagingRequest` | Input (action) | View loop |
|
||||
| `StatusMsgCh` | `StatusMsg` | Various | View loop |
|
||||
|
||||
Hub supports **batch mode** — multiple sends within `Batch()` callback are processed together.
|
||||
|
||||
## Buffer Architecture
|
||||
|
||||
- `MemoryBuffer` — stores all input lines, grows as Source reads
|
||||
- `FilteredBuffer` — wraps any Buffer with index range (page slice)
|
||||
- `Source` — implements `pipeline.Source`, reads input lines into MemoryBuffer
|
||||
- `ContextBuffer` — adds surrounding context lines for zoom view
|
||||
- `CurrentLineBuffer()` returns raw MemoryBuffer (no filter) or FilteredBuffer (after filter)
|
||||
|
||||
## Filter Pipeline
|
||||
|
||||
1. Query change arrives via Hub
|
||||
2. Filter loop creates pipeline: `MemoryBufferSource → filter.Apply → MemoryBuffer`
|
||||
3. Filter.Apply runs in parallel chunks (if `SupportsParallel()`)
|
||||
4. Results collected into new MemoryBuffer → set as CurrentLineBuffer
|
||||
5. Hub.SendDraw() triggers View redraw
|
||||
|
||||
## Screen Abstraction
|
||||
|
||||
- `Screen` interface wraps terminal operations
|
||||
- `TcellScreen` — production impl using tcell/v2
|
||||
- `InlineScreen` — wraps TcellScreen for height-limited display
|
||||
- `SimScreen` — test mock with event injection
|
||||
|
||||
## Layout System
|
||||
|
||||
- `BasicLayout` composes: `UserPrompt` + `ListArea` + `StatusBar`
|
||||
- Layout variants registered via `RegisterLayout(name, LayoutBuilder)`
|
||||
- Built-in: `top-down` (default), `bottom-up`, `top-down-query-bottom`
|
||||
- `AnchorSettings` controls vertical positioning (top/bottom anchor)
|
||||
|
||||
## Key Sequence Resolution
|
||||
|
||||
- `internal/keyseq.Keyseq` uses AhoCorasick matcher
|
||||
- Supports multi-key sequences (e.g., C-x,C-c)
|
||||
- Longest-match-wins semantics
|
||||
- `InMiddleOfChain()` indicates partial match in progress
|
||||
|
||||
## Selection Model
|
||||
|
||||
- `selection.Set` uses `google/btree` for ordered storage by line ID
|
||||
- Supports: single select, multi-select (toggle), range select, select-all
|
||||
- Sticky selection — persists across query changes (configurable)
|
||||
|
||||
## Action System
|
||||
|
||||
- ~40 built-in actions in `action.go`
|
||||
- Actions implement `Action` interface: `Execute(ctx, *Peco, Event)`
|
||||
- `ActionFunc` — function adapter with `Register()` for key binding
|
||||
- Combined actions — multiple actions bound to single key sequence
|
||||
- `Keymap.LookupAction(Event) → Action` — resolve event to action
|
||||
|
||||
## State Objects
|
||||
|
||||
| State | Purpose |
|
||||
|-------|---------|
|
||||
| `Location` | Current page, line number, offset, per-page count |
|
||||
| `SingleKeyJumpState` | Single-key jump mode toggle and prefix map |
|
||||
| `ZoomState` | Zoom view buffer and line reference |
|
||||
| `FrozenState` | Frozen source buffer for suspend/resume |
|
||||
| `QueryExecState` | Query execution delay timer |
|
||||
|
||||
## Object Pools
|
||||
|
||||
- `line.GetMatched/ReleaseMatched` — pool for Matched line wrappers
|
||||
- `internal/buffer.GetLineListBuf/ReleaseLineListBuf` — pool for line slices
|
||||
189
.claude/docs/packages.md
Normal file
189
.claude/docs/packages.md
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
<!-- Agent-consumed file. Keep terse, unambiguous, machine-parseable. -->
|
||||
|
||||
# Package Map
|
||||
|
||||
## peco (root)
|
||||
|
||||
Interactive filtering tool core. Holds global state, goroutine loops, UI components.
|
||||
|
||||
- **New() → *Peco** — create new instance
|
||||
- **(*Peco).Setup() → error** — initialize from config/options
|
||||
- **(*Peco).Run(ctx) → error** — main event loop
|
||||
- **(*Peco).ApplyConfig(CLIOptions) → error** — apply CLI flags to config
|
||||
- **(*Peco).SetupSource(ctx) → (*Source, error)** — initialize input source
|
||||
- **(*Peco).PrintResults()** — output selected lines to stdout
|
||||
- **(*Peco).CurrentLineBuffer() → Buffer** — active line buffer (raw or filtered)
|
||||
- **(*Peco).ExecQuery(ctx, func()) → bool** — execute filter with debounce
|
||||
- Key types: `Peco`, `Buffer`, `FilteredBuffer`, `MemoryBuffer`, `Source`, `Screen`, `Layout`, `Action`, `Keymap`, `Event`, `CLIOptions`, `Location`, `PageCrop`
|
||||
- Key interfaces: `MessageHub`, `Screen`, `Layout`, `Action`, `ActionMap`, `Buffer`, `Keyseq`, `ConfigReader`
|
||||
- Screen impls: `TcellScreen` (production), `InlineScreen` (height-limited), `SimScreen` (tests)
|
||||
- Layout impls: `BasicLayout` with builders: `DefaultLayout`, `BottomUpLayout`, `TopDownQueryBottomLayout`
|
||||
- Files: `peco.go`, `action.go`, `buffer.go`, `event.go`, `filter.go`, `input.go`, `keymap.go`, `layout.go`, `layout_any.go`, `layout_windows.go`, `options.go`, `page.go`, `screen.go`, `screen_inline.go`, `source.go`, `state.go`, `view.go`, `vertical_anchor_gen.go`
|
||||
- Imports: config, filter, hub, line, pipeline, query, selection, sig, internal/ansi, internal/buffer, internal/keyseq, internal/util
|
||||
|
||||
## cmd/peco
|
||||
|
||||
CLI entry point.
|
||||
|
||||
- Parses flags via `go-flags`, creates `peco.New()`, calls `Run(ctx)`
|
||||
- Files: `peco.go`
|
||||
- Imports: peco (root), internal/util
|
||||
|
||||
## cmd/filterbench
|
||||
|
||||
Benchmark tool for filter performance.
|
||||
|
||||
- Files: `main.go`
|
||||
- Imports: peco (root), filter, line, pipeline
|
||||
|
||||
## config/
|
||||
|
||||
Configuration loading and types.
|
||||
|
||||
- **Config** — main config struct (Keymap, Action, Style, Layout, CustomFilter, SingleKeyJump, Height, etc.)
|
||||
- **(*Config).Init() → error** — set defaults
|
||||
- **(*Config).ReadFilename(string) → error** — load YAML config file
|
||||
- **LocateRcfile(Locator) → (string, error)** — find config file path
|
||||
- Key types: `Config`, `StyleSet`, `Style`, `Attribute`, `OnCancelBehavior`, `ColorMode`, `CustomFilterConfig`, `SingleKeyJumpConfig`, `HeightSpec`
|
||||
- Color constants: `ColorDefault`, `ColorBlack`..`ColorWhite`, `AttrBold`, `AttrUnderline`, `AttrReverse`, `AttrTrueColor`
|
||||
- Layout constants: `LayoutTypeTopDown`, `LayoutTypeBottomUp`, `LayoutTypeTopDownQueryBottom`
|
||||
- Files: `config.go`, `style.go`, `height.go`, `layout.go`
|
||||
- Imports: internal/util
|
||||
|
||||
## filter/
|
||||
|
||||
Filter algorithm implementations.
|
||||
|
||||
- **Filter** interface — `Apply(ctx, []line.Line, ChanOutput) → error`, `BufSize() → int`, `NewContext(ctx, string) → ctx`, `String() → string`, `SupportsParallel() → bool`
|
||||
- **Collector** interface (optional) — `ApplyCollect(ctx, []line.Line) → ([]line.Line, error)`
|
||||
- **Set** — filter collection with rotation: `Add(Filter)`, `Rotate()`, `Current() → Filter`, `SetCurrentByName(string) → error`
|
||||
- Implementations: `NewIgnoreCase()`, `NewCaseSensitive()`, `NewSmartCase()`, `NewRegexp()`, `NewIRegexp()`, `NewFuzzy(sortLongest bool)`, `NewExternalCmd(name, cmd string, args []string, threshold int, idgen IDGenerator, enableSep bool)`
|
||||
- Files: `filter.go`, `base.go`, `regexp.go`, `fuzzy.go`, `external.go`, `set.go`
|
||||
- Imports: line, pipeline, internal/util
|
||||
|
||||
## hub/
|
||||
|
||||
Central message bus for goroutine communication.
|
||||
|
||||
- **New(bufsize int) → *Hub** — create hub with channel buffer size
|
||||
- **(*Hub).SendDraw(ctx, *DrawOptions)** — trigger screen redraw
|
||||
- **(*Hub).SendQuery(ctx, string)** — send query change
|
||||
- **(*Hub).SendPaging(ctx, PagingRequest)** — send paging command
|
||||
- **(*Hub).SendStatusMsg(ctx, string, time.Duration)** — show status message
|
||||
- **(*Hub).Batch(ctx, func(ctx))** — batch multiple sends atomically
|
||||
- Channel accessors: `DrawCh()`, `PagingCh()`, `QueryCh()`, `StatusMsgCh()`
|
||||
- Key types: `Hub`, `Payload[T]`, `DrawOptions`, `PagingRequest`, `PagingRequestType`, `StatusMsg`
|
||||
- Paging types: `ToLineAbove`, `ToLineBelow`, `ToScrollPageDown`, `ToScrollPageUp`, `ToScrollLeft`, `ToScrollRight`, `ToScrollFirstItem`, `ToScrollLastItem`, `ToLineInPage`
|
||||
- Files: `hub.go`, `draw.go`, `paging.go`, `paging_request_type_gen.go`
|
||||
- Imports: (none internal)
|
||||
|
||||
## line/
|
||||
|
||||
Line data types for display and selection.
|
||||
|
||||
- **Line** interface — `ID() → uint64`, `Buffer() → string`, `DisplayString() → string`, `Output() → string`, `IsDirty() → bool`, `SetDirty(bool)`, implements `btree.Item`
|
||||
- **NewRaw(id uint64, s string, enableSep bool, stripANSI bool) → *Raw** — create raw line
|
||||
- **NewMatched(Line, [][]int) → *Matched** — wrap line with match indices
|
||||
- **GetMatched(Line, [][]int) → *Matched** — pooled allocation
|
||||
- **ReleaseMatched(*Matched)** — return to pool
|
||||
- **IDGenerator** interface — `Next() → uint64`
|
||||
- Files: `raw.go`, `matched.go`
|
||||
- Imports: internal/ansi, btree
|
||||
|
||||
## pipeline/
|
||||
|
||||
Generic source→acceptor→destination pipeline.
|
||||
|
||||
- **New() → *Pipeline** — create pipeline
|
||||
- **(*Pipeline).SetSource(Source)** — set data source
|
||||
- **(*Pipeline).Add(Acceptor)** — add processing stage
|
||||
- **(*Pipeline).SetDestination(Destination)** — set terminal stage
|
||||
- **(*Pipeline).Run(ctx) → error** — execute pipeline
|
||||
- Key interfaces: `Source` (`Start`, `Reset`), `Acceptor` (`Accept`), `Destination` (`Accept`, `Reset`, `Done`), `Suspender` (optional `Suspend`/`Resume`)
|
||||
- **ChanOutput** (chan line.Line) — `Send(ctx, line.Line) → error`, `OutCh() → <-chan line.Line`
|
||||
- **NewQueryContext(ctx, string) → ctx** / **QueryFromContext(ctx) → string** — pass query through context
|
||||
- Files: `pipeline.go`
|
||||
- Imports: line
|
||||
|
||||
## query/
|
||||
|
||||
Query text and caret management.
|
||||
|
||||
- **Text** — query string with save/restore: `Set(string)`, `Reset()`, `SaveQuery()`, `RestoreSavedQuery()`, `DeleteRange(int, int)`, `InsertAt(rune, int)`, `String()`, `Len()`, `RuneSlice()`, `RuneAt(int)`
|
||||
- **Caret** — cursor position: `Pos() → int`, `SetPos(int)`, `Move(int)`
|
||||
- Files: `query.go`
|
||||
- Imports: (none internal)
|
||||
|
||||
## selection/
|
||||
|
||||
Ordered selection storage using btree.
|
||||
|
||||
- **New() → *Set** — create selection set
|
||||
- **(*Set).Add(line.Line)** — add to selection
|
||||
- **(*Set).Remove(line.Line)** — remove from selection
|
||||
- **(*Set).Has(line.Line) → bool** — check membership
|
||||
- **(*Set).Len() → int** — count selected
|
||||
- **(*Set).Ascend(func(line.Line) bool)** — iterate in order
|
||||
- **(*Set).Copy(dst *Set)** — copy all items
|
||||
- **RangeStart** — range selection start marker: `Valid()`, `Value()`, `SetValue(int)`, `Reset()`
|
||||
- Files: `selection.go`
|
||||
- Imports: line, btree
|
||||
|
||||
## sig/
|
||||
|
||||
OS signal handling.
|
||||
|
||||
- **New(handler ReceivedHandler, sigs ...os.Signal) → *Handler**
|
||||
- **(*Handler).Loop(ctx, func()) → error** — signal listening loop
|
||||
- **ReceivedHandler** interface — `Handle(os.Signal)`
|
||||
- Files: `sig.go`
|
||||
- Imports: (none internal)
|
||||
|
||||
## internal/ansi
|
||||
|
||||
ANSI escape sequence parser.
|
||||
|
||||
- **Parse(string) → ParseResult** — strip ANSI, extract color spans
|
||||
- **ExtractSegment([]AttrSpan, start, end int) → []AttrSpan** — slice attr spans for substring
|
||||
- Key types: `ParseResult` (`Stripped string`, `Attrs []AttrSpan`), `AttrSpan` (`Fg, Bg Attribute`, `Length int`)
|
||||
- Files: `parser.go`
|
||||
- Imports: (none internal)
|
||||
|
||||
## internal/buffer
|
||||
|
||||
Line list buffer pool.
|
||||
|
||||
- **GetLineListBuf() → []line.Line** — get from pool
|
||||
- **ReleaseLineListBuf([]line.Line)** — return to pool
|
||||
- Files: `line.go`
|
||||
- Imports: line
|
||||
|
||||
## internal/keyseq
|
||||
|
||||
Key sequence matching (multi-key bindings).
|
||||
|
||||
- **New() → *Keyseq** — create matcher (uses AhoCorasick internally)
|
||||
- **(*Keyseq).Add(KeyList, any)** — register key sequence → action
|
||||
- **(*Keyseq).Compile() → error** — build matcher
|
||||
- **(*Keyseq).AcceptKey(Key) → (any, error)** — feed key, get action if matched
|
||||
- **ToKeyList(string) → (KeyList, error)** — parse "C-x,C-c" → KeyList
|
||||
- **KeyEventToString(KeyType, rune, ModifierKey) → (string, error)** — event → name
|
||||
- Key types: `Key`, `KeyList`, `KeyType`, `ModifierKey`
|
||||
- Matcher impls: Trie, TernarySearch, AhoCorasick (AhoCorasick used by default)
|
||||
- Files: `keyseq.go`, `keys.go`, `trie.go`, `ternary.go`, `ahocorasick.go`
|
||||
- Imports: (none internal)
|
||||
|
||||
## internal/util
|
||||
|
||||
Platform utilities.
|
||||
|
||||
- **IsTty(io.Reader) → bool** — check if reader is terminal
|
||||
- **Homedir() → (string, error)** — user home directory
|
||||
- **Shell(ctx, string) → *exec.Cmd** — create shell command
|
||||
- **StripANSISequence(string) → string** — remove ANSI escapes (deprecated, use internal/ansi)
|
||||
- **IsCollectResultsError(error) → bool** — check for collect-results sentinel
|
||||
- **IsIgnorableError(error) → bool** — check for ignorable errors
|
||||
- **GetExitStatus(error) → (int, bool)** — extract exit code
|
||||
- Platform files: `tty_posix.go`, `tty_bsd.go`, `tty_windows.go`, `shell_unix.go`, `shell_windows.go`, `homedir_posix.go`, `homedir_darwin.go`, `homedir_windows.go`
|
||||
- Files: `util.go`
|
||||
- Imports: (none internal)
|
||||
58
.claude/docs/testing.md
Normal file
58
.claude/docs/testing.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<!-- Agent-consumed file. Keep terse, unambiguous, machine-parseable. -->
|
||||
|
||||
# Testing
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
make test # go test -v -race ./...
|
||||
go test -v -run TestFoo ./... # single test, all packages
|
||||
go test -v -run TestFoo ./filter/ # single test, specific package
|
||||
go test -race -coverprofile=coverage.out ./... # coverage
|
||||
```
|
||||
|
||||
## Test Package Convention
|
||||
|
||||
- Tests use same package name (not `_test` suffix) — white-box testing
|
||||
- Exception: some packages use `_test` suffix for external testing
|
||||
|
||||
## Key Test Helpers
|
||||
|
||||
- `newPeco() → *Peco` — creates test instance with SimScreen, default config
|
||||
- `NewDummyScreen() → *SimScreen` — mock terminal; supports `SendEvent(Event)` for input injection
|
||||
- SimScreen has fixed size, no-op rendering, collects events
|
||||
|
||||
## Test Patterns
|
||||
|
||||
- Table-driven tests with `t.Run()` subtests
|
||||
- Regression tests for GitHub issues in `issues_test.go`
|
||||
- Filter tests in `filter/filter_test.go`, `filter/base_test.go`
|
||||
- Hub tests in `hub/hub_test.go`
|
||||
- Key sequence tests in `internal/keyseq/trie_test.go`, `ahocorasick_test.go`, `ternary_test.go`
|
||||
- Pipeline tests in `pipeline/pipeline_test.go`
|
||||
- Selection tests in `selection/selection_test.go`
|
||||
- Query tests in `query/query_test.go`
|
||||
|
||||
## Benchmark Tests
|
||||
|
||||
- `filter/bench_test.go` — filter algorithm benchmarks
|
||||
- `hub/bench_test.go` — hub message passing benchmarks
|
||||
- `line/bench_test.go` — line allocation benchmarks
|
||||
- `internal/ansi/bench_test.go` — ANSI parsing benchmarks
|
||||
- `internal/util/bench_test.go` — utility benchmarks
|
||||
- `cmd/filterbench/` — standalone filter benchmark CLI
|
||||
|
||||
## No Test Data Directory
|
||||
|
||||
- No `testdata/` or golden files
|
||||
- Tests use inline data and programmatic setup
|
||||
|
||||
## Build Tags
|
||||
|
||||
- Platform-specific files: `_posix.go`, `_bsd.go`, `_windows.go`, `_darwin.go`
|
||||
- No custom build tags for testing
|
||||
|
||||
## Code Generation
|
||||
|
||||
- `go generate ./...` — runs `stringer` for enum types
|
||||
- Generated files: `vertical_anchor_gen.go`, `hub/paging_request_type_gen.go`
|
||||
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
|
|
@ -10,9 +10,9 @@ jobs:
|
|||
name: Go ${{ matrix.go }} test
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
|
|
@ -21,14 +21,14 @@ jobs:
|
|||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
check-latest: true
|
||||
- name: Test
|
||||
run: make test
|
||||
- name: Install GoReleaser
|
||||
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
|
||||
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
|
||||
with:
|
||||
install-only: true
|
||||
- name: Build
|
||||
|
|
@ -39,9 +39,9 @@ jobs:
|
|||
name: Check generated code
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
|
|
@ -50,7 +50,7 @@ jobs:
|
|||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
|
|
|
|||
8
.github/workflows/codeql.yml
vendored
8
.github/workflows/codeql.yml
vendored
|
|
@ -24,14 +24,14 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
uses: github/codeql-action/init@v4.37.9
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
uses: github/codeql-action/autobuild@v4.37.9
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
uses: github/codeql-action/analyze@v4.37.9
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
|
|
|||
6
.github/workflows/lint.yml
vendored
6
.github/workflows/lint.yml
vendored
|
|
@ -5,11 +5,11 @@ jobs:
|
|||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
|
||||
- uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
|
||||
with:
|
||||
version: v2.8.0
|
||||
- name: Run go vet
|
||||
|
|
|
|||
4
.github/workflows/manpage.yml
vendored
4
.github/workflows/manpage.yml
vendored
|
|
@ -9,13 +9,13 @@ jobs:
|
|||
update-manpage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install pandoc
|
||||
run: sudo apt-get update && sudo apt-get install -y pandoc
|
||||
- name: Generate man page
|
||||
run: bash contrib/man/generate.sh
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
commit-message: "docs: regenerate man page from README"
|
||||
title: "docs: update man page"
|
||||
|
|
|
|||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
|
@ -13,17 +13,17 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
|
||||
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
|
||||
with:
|
||||
version: "~> v2"
|
||||
args: release --clean
|
||||
|
|
|
|||
104
CLAUDE.md
Normal file
104
CLAUDE.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<!-- Agent-consumed file. Keep terse, unambiguous, machine-parseable. -->
|
||||
|
||||
# CLAUDE.md
|
||||
|
||||
peco is an interactive filtering tool for the terminal, written in Go.
|
||||
|
||||
## Pre-Read Rules
|
||||
|
||||
Read the linked doc BEFORE working in that area. No exceptions.
|
||||
|
||||
| Trigger | Doc |
|
||||
|---------|-----|
|
||||
| Working with any package API or adding imports | `.claude/docs/packages.md` |
|
||||
| Modifying cross-package dependencies | `.claude/docs/dependencies.md` |
|
||||
| Writing or running tests | `.claude/docs/testing.md` |
|
||||
| Modifying CLI flags, entry point, or output | `.claude/docs/cli.md` |
|
||||
| Modifying concurrency, hub, buffers, filters, layout, screen, actions | `.claude/docs/internals.md` |
|
||||
|
||||
## Build & Test Commands
|
||||
|
||||
```bash
|
||||
make # Build binary via goreleaser (default target)
|
||||
make build # Build binary to dist/peco_<os>_<arch>/peco
|
||||
make test # Run all tests: go test -v -race ./...
|
||||
make deps # Download Go module dependencies
|
||||
make clean # Remove build artifacts
|
||||
```
|
||||
|
||||
Run a single test:
|
||||
```bash
|
||||
go test -v -run TestFunctionName ./...
|
||||
go test -v -run TestFunctionName ./filter/ # for a specific package
|
||||
```
|
||||
|
||||
The entry point is `cmd/peco/peco.go`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Concurrency Model
|
||||
|
||||
peco runs three main goroutines coordinated via context cancellation:
|
||||
|
||||
- **Input loop** (`input.go`) — reads tcell key events, resolves key sequences via Keymap, dispatches actions
|
||||
- **View loop** (`view.go`) — renders screen in response to draw/paging/status messages
|
||||
- **Filter loop** (`filter.go`) — executes queries against the line buffer when query text changes
|
||||
|
||||
These goroutines communicate through the **Hub** (`hub/`), a central message bus with typed channels: `QueryCh`, `DrawCh`, `PagingCh`, `StatusMsgCh`.
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. **Source** (`source.go`) reads input lines (stdin or file), implements `pipeline.Source`
|
||||
2. User keystrokes trigger actions that modify the query
|
||||
3. Query changes are sent to the Filter loop via Hub
|
||||
4. **Filter** applies the active filter algorithm to produce matched lines
|
||||
5. Results flow through the **Pipeline** (`pipeline/`) as `Source → Acceptor → Destination`
|
||||
6. **View** receives draw messages and delegates to **Layout** (`layout.go`) which composes `UserPrompt`, `ListArea`, and `StatusBar`
|
||||
7. **Screen** (`screen.go`) wraps tcell/v2 for terminal cell rendering
|
||||
|
||||
### Key Interfaces
|
||||
|
||||
- **`Buffer`** — line storage (`LineAt`, `Size`); implemented by `MemoryBuffer`, `FilteredBuffer`, `Source`
|
||||
- **`Filter`** (in `filter/`) — `Apply(ctx, []line.Line, ChanOutput)` for each filter algorithm (IgnoreCase, CaseSensitive, SmartCase, Regexp, IRegexp, Fuzzy, ExternalCmd)
|
||||
- **`Line`** (`line/`) — represents a single line with `ID`, `Buffer`, `DisplayString`, `Output`
|
||||
- **`Screen`** — terminal abstraction (`Init`, `SetCell`, `Flush`, `PollEvent`); `SimScreen` used in tests
|
||||
- **`Layout`** — screen composition (`DrawScreen`, `DrawPrompt`, `MovePage`)
|
||||
- **`Action`** — user actions bound to keys (`action.go`); ~40 built-in actions, supports combined action sequences
|
||||
|
||||
### Selection
|
||||
|
||||
Uses `google/btree` for ordered selection storage. Supports multi-select, range mode, and sticky selection (persists across query changes).
|
||||
|
||||
### Key Sequence Resolution
|
||||
|
||||
`internal/keyseq/` implements Trie, TernarySearch, and AhoCorasick for matching multi-key sequences to actions (longest-match-wins).
|
||||
|
||||
### Platform-Specific Code
|
||||
|
||||
Platform-specific behavior lives in `layout_any.go` / `layout_windows.go` (handling the `extraOffset` layout constant) and in files suffixed `_posix.go` / `_windows.go` under `internal/util/` (TTY detection, shell integration, home directory resolution).
|
||||
|
||||
### Code Generation
|
||||
|
||||
Uses `go:generate` with `stringer` for enum string representations.
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
- `newPeco()` helper creates a test instance with `SimScreen` (mock terminal)
|
||||
- `NewDummyScreen()` returns a `SimScreen` that supports event injection for simulating user input
|
||||
- Table-driven tests with `t.Run()` subtests are the common pattern
|
||||
- Regression tests for specific GitHub issues in `issues_test.go`
|
||||
|
||||
## Cache Maintenance
|
||||
|
||||
These docs cache repository state. Still read source before modifying code.
|
||||
|
||||
1. When your changes affect a doc below, update it in the same commit.
|
||||
2. If you notice any doc is wrong or stale — even on an unrelated task — fix it immediately.
|
||||
|
||||
| Doc | Update trigger |
|
||||
|-----|----------------|
|
||||
| `packages.md` | Add/remove/rename exported functions, types, or packages |
|
||||
| `dependencies.md` | Add/remove internal package imports |
|
||||
| `testing.md` | Change test infrastructure, helpers, or test commands |
|
||||
| `cli.md` | Add/remove CLI flags, change exit codes or output format |
|
||||
| `internals.md` | Change concurrency model, hub channels, buffer types, layout system, action system |
|
||||
21
README.md
21
README.md
|
|
@ -437,6 +437,26 @@ Without `--height`, peco uses the full terminal screen (default behavior, unchan
|
|||
|
||||
**Note:** In inline mode, peco sets the environment variable `TCELL_ALTSCREEN=disable` to prevent tcell from using the alternate screen buffer, and restores the original value on exit. If peco is killed abnormally (e.g. `SIGKILL`), you may need to unset this variable manually: `unset TCELL_ALTSCREEN`.
|
||||
|
||||
### -f, --follow
|
||||
|
||||
When specified, peco follows streaming input, automatically scrolling to keep the newest lines visible at the bottom of the list, just like `tail -f`. This is useful for live log streams:
|
||||
|
||||
```
|
||||
journalctl -f -n 1000 | peco --follow --layout top-down-query-bottom
|
||||
```
|
||||
|
||||
Moving the cursor manually (e.g. with the arrow keys) turns follow mode off so you can scroll back through the history. The `ToggleFollow` action turns it back on; bind it to a key in your configuration file, for example:
|
||||
|
||||
```json
|
||||
{
|
||||
"Keymap": {
|
||||
"C-f": "peco.ToggleFollow"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Follow mode can also be enabled by default with the `Follow` configuration variable. The `--follow` command line option takes precedence over the configuration file.
|
||||
|
||||
# Configuration File
|
||||
|
||||
peco by default consults a few locations for the config files.
|
||||
|
|
@ -737,6 +757,7 @@ Some keys just... don't map correctly / too easily for various reasons. Here, we
|
|||
| peco.SelectVisible | Selects the all visible line, and save it |
|
||||
| peco.ToggleQuery | Toggle list between filtered by query and not filtered |
|
||||
| peco.ToggleRangeMode | Start selecting by range, or append selecting range to selections |
|
||||
| peco.ToggleFollow | Toggle follow mode (auto-scroll to the newest lines, like tail -f) |
|
||||
| peco.ToggleSelectMode | (DEPRECATED) Alias to ToggleRangeMode |
|
||||
| peco.ToggleSelection | Selects the current line, and saves it |
|
||||
| peco.ToggleSelectionAndSelectNext | Selects the current line, saves it, and proceeds to the next line |
|
||||
|
|
|
|||
23
action.go
23
action.go
|
|
@ -172,6 +172,8 @@ func init() {
|
|||
ActionFunc(doFreezeResults).Register("FreezeResults")
|
||||
ActionFunc(doUnfreezeResults).Register("UnfreezeResults")
|
||||
|
||||
ActionFunc(doToggleFollow).Register("ToggleFollow")
|
||||
|
||||
ActionFunc(doZoomIn).Register("ZoomIn")
|
||||
ActionFunc(doZoomOut).Register("ZoomOut")
|
||||
|
||||
|
|
@ -966,6 +968,27 @@ func doUnfreezeResults(ctx context.Context, state *Peco, _ Event) {
|
|||
state.Hub().SendDrawPrompt(ctx)
|
||||
}
|
||||
|
||||
// doToggleFollow turns follow mode on or off. In follow mode peco auto-scrolls
|
||||
// to keep the newest input lines visible, like "tail -f". Enabling it redraws
|
||||
// so the viewport jumps to the tail immediately.
|
||||
func doToggleFollow(ctx context.Context, state *Peco, _ Event) {
|
||||
if pdebug.Enabled {
|
||||
g := pdebug.Marker("doToggleFollow")
|
||||
defer g.End()
|
||||
}
|
||||
|
||||
follow := state.Follow()
|
||||
enabled := !follow.Enabled()
|
||||
follow.Set(enabled)
|
||||
|
||||
if enabled {
|
||||
state.Hub().SendStatusMsg(ctx, "Follow mode on", 0)
|
||||
state.Hub().SendDraw(ctx, &hub.DrawOptions{DisableCache: true})
|
||||
return
|
||||
}
|
||||
state.Hub().SendStatusMsg(ctx, "Follow mode off", 0)
|
||||
}
|
||||
|
||||
// doZoomIn expands the view to show context lines around matched lines by building
|
||||
// a ContextBuffer from the current filter results and the original source.
|
||||
func doZoomIn(ctx context.Context, state *Peco, _ Event) {
|
||||
|
|
|
|||
16
buffer.go
16
buffer.go
|
|
@ -52,13 +52,23 @@ type ContextLine struct {
|
|||
// NewFilteredBuffer creates a FilteredBuffer containing one page of lines from
|
||||
// the source buffer, computing the maximum column width for horizontal scrolling.
|
||||
func NewFilteredBuffer(src Buffer, page, perPage int) *FilteredBuffer {
|
||||
return newFilteredBufferRange(src, perPage*(page-1), perPage)
|
||||
}
|
||||
|
||||
// newFilteredBufferRange creates a FilteredBuffer holding up to count lines
|
||||
// from the source buffer starting at the given index. This is the shared core
|
||||
// behind both page-aligned cropping (NewFilteredBuffer) and the sliding-window
|
||||
// crop used by follow mode (WindowCrop), where start is not page-aligned.
|
||||
func newFilteredBufferRange(src Buffer, start, count int) *FilteredBuffer {
|
||||
fb := FilteredBuffer{
|
||||
src: src,
|
||||
}
|
||||
|
||||
start := perPage * (page - 1)
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
|
||||
// if for whatever reason we wanted a page that goes over the
|
||||
// if for whatever reason we wanted a range that goes over the
|
||||
// capacity of the original buffer, we don't need to do any more
|
||||
// calculations. bail out
|
||||
if start > src.Size() {
|
||||
|
|
@ -66,7 +76,7 @@ func NewFilteredBuffer(src Buffer, page, perPage int) *FilteredBuffer {
|
|||
}
|
||||
|
||||
// Copy over the selections that are applicable to this filtered buffer.
|
||||
end := min(start+perPage, src.Size())
|
||||
end := min(start+count, src.Size())
|
||||
selection := make([]int, 0, end-start)
|
||||
|
||||
lines := src.linesInRange(start, end)
|
||||
|
|
|
|||
|
|
@ -96,6 +96,11 @@ type Config struct {
|
|||
// Height specifies the display height in lines or percentage (e.g. "10", "50%").
|
||||
// When set, peco renders inline without using the alternate screen buffer.
|
||||
Height string `json:"Height" yaml:"Height"`
|
||||
|
||||
// Follow enables follow mode at startup: peco auto-scrolls to keep the
|
||||
// newest input lines visible, like "tail -f". The --follow CLI flag
|
||||
// overrides this value.
|
||||
Follow bool `json:"Follow" yaml:"Follow"`
|
||||
}
|
||||
|
||||
// SingleKeyJumpConfig holds configuration for single key jump mode.
|
||||
|
|
|
|||
|
|
@ -741,6 +741,38 @@ behavior, unchanged).
|
|||
alternate screen buffer, and restores the original value on exit.
|
||||
If peco is killed abnormally (e.g.\ \f[V]SIGKILL\f[R]), you may need to
|
||||
unset this variable manually: \f[V]unset TCELL_ALTSCREEN\f[R].
|
||||
.SS -f, \[en]follow
|
||||
.PP
|
||||
When specified, peco follows streaming input, automatically scrolling to
|
||||
keep the newest lines visible at the bottom of the list, just like
|
||||
\f[V]tail -f\f[R].
|
||||
This is useful for live log streams:
|
||||
.IP
|
||||
.nf
|
||||
\f[C]
|
||||
journalctl -f -n 1000 | peco --follow --layout top-down-query-bottom
|
||||
\f[R]
|
||||
.fi
|
||||
.PP
|
||||
Moving the cursor manually (e.g.\ with the arrow keys) turns follow mode
|
||||
off so you can scroll back through the history.
|
||||
The \f[V]ToggleFollow\f[R] action turns it back on; bind it to a key in
|
||||
your configuration file, for example:
|
||||
.IP
|
||||
.nf
|
||||
\f[C]
|
||||
{
|
||||
\[dq]Keymap\[dq]: {
|
||||
\[dq]C-f\[dq]: \[dq]peco.ToggleFollow\[dq]
|
||||
}
|
||||
}
|
||||
\f[R]
|
||||
.fi
|
||||
.PP
|
||||
Follow mode can also be enabled by default with the \f[V]Follow\f[R]
|
||||
configuration variable.
|
||||
The \f[V]--follow\f[R] command line option takes precedence over the
|
||||
configuration file.
|
||||
.SH Configuration File
|
||||
.PP
|
||||
peco by default consults a few locations for the config files.
|
||||
|
|
@ -1399,6 +1431,11 @@ T}@T{
|
|||
Start selecting by range, or append selecting range to selections
|
||||
T}
|
||||
T{
|
||||
peco.ToggleFollow
|
||||
T}@T{
|
||||
Toggle follow mode (auto-scroll to the newest lines, like tail -f)
|
||||
T}
|
||||
T{
|
||||
peco.ToggleSelectMode
|
||||
T}@T{
|
||||
(DEPRECATED) Alias to ToggleRangeMode
|
||||
|
|
|
|||
194
follow_test.go
Normal file
194
follow_test.go
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
package peco
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/peco/peco/filter"
|
||||
"github.com/peco/peco/hub"
|
||||
"github.com/peco/peco/line"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// makeFollowBuffer builds a MemoryBuffer with n lines labelled "lineN".
|
||||
func makeFollowBuffer(n int) *MemoryBuffer {
|
||||
mb := NewMemoryBuffer(0)
|
||||
for i := range n {
|
||||
mb.lines = append(mb.lines, line.NewRaw(uint64(i), fmt.Sprintf("line%d", i), false, false))
|
||||
}
|
||||
return mb
|
||||
}
|
||||
|
||||
// readScreenRow returns the text rendered on row y of the simulation screen,
|
||||
// trailing blanks trimmed.
|
||||
func readScreenRow(t *testing.T, s *SimScreen, y int) string {
|
||||
t.Helper()
|
||||
cells, w, _ := s.screen.GetContents()
|
||||
var b strings.Builder
|
||||
for x := range w {
|
||||
c := cells[y*w+x]
|
||||
if len(c.Runes) == 0 || c.Runes[0] == 0 {
|
||||
b.WriteRune(' ')
|
||||
continue
|
||||
}
|
||||
b.WriteRune(c.Runes[0])
|
||||
}
|
||||
return strings.TrimRight(b.String(), " ")
|
||||
}
|
||||
|
||||
func newFollowState(t *testing.T, buf Buffer) (*Peco, *SimScreen) {
|
||||
t.Helper()
|
||||
screen := NewDummyScreen()
|
||||
state := New()
|
||||
state.screen = screen
|
||||
state.Filters().Add(filter.NewIgnoreCase())
|
||||
state.currentLineBuffer = buf
|
||||
return state, screen
|
||||
}
|
||||
|
||||
func TestWindowCrop(t *testing.T) {
|
||||
buf := makeFollowBuffer(100)
|
||||
|
||||
t.Run("tail window", func(t *testing.T) {
|
||||
fb := WindowCrop{offset: 90, perPage: 10}.Crop(buf)
|
||||
require.Equal(t, 10, fb.Size())
|
||||
first, err := fb.LineAt(0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "line90", first.DisplayString())
|
||||
last, err := fb.LineAt(9)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "line99", last.DisplayString())
|
||||
})
|
||||
|
||||
t.Run("partial window smaller than perPage", func(t *testing.T) {
|
||||
small := makeFollowBuffer(5)
|
||||
fb := WindowCrop{offset: 0, perPage: 10}.Crop(small)
|
||||
require.Equal(t, 5, fb.Size())
|
||||
})
|
||||
|
||||
t.Run("negative offset clamps to zero", func(t *testing.T) {
|
||||
fb := WindowCrop{offset: -5, perPage: 3}.Crop(buf)
|
||||
require.Equal(t, 3, fb.Size())
|
||||
first, err := fb.LineAt(0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "line0", first.DisplayString())
|
||||
})
|
||||
|
||||
t.Run("offset past end yields empty", func(t *testing.T) {
|
||||
fb := WindowCrop{offset: 200, perPage: 10}.Crop(buf)
|
||||
require.Equal(t, 0, fb.Size())
|
||||
})
|
||||
}
|
||||
|
||||
// TestGHIssue820_FollowKeepsNewestVisible verifies that with follow mode on,
|
||||
// DrawScreen pins the viewport to the tail of the buffer so the newest line
|
||||
// is rendered on the last visible row (like tail -f), regardless of page
|
||||
// alignment.
|
||||
func TestGHIssue820_FollowKeepsNewestVisible(t *testing.T) {
|
||||
buf := makeFollowBuffer(50)
|
||||
state, screen := newFollowState(t, buf)
|
||||
state.Follow().Set(true)
|
||||
|
||||
// top-down-query-bottom is the layout from the issue: list anchored to
|
||||
// the top (row 0), query at the bottom.
|
||||
layout, err := TopDownQueryBottomLayout(state)
|
||||
require.NoError(t, err)
|
||||
|
||||
layout.DrawScreen(state, nil)
|
||||
|
||||
loc := state.Location()
|
||||
perPage := layout.linesPerPage()
|
||||
|
||||
require.Equal(t, 49, loc.LineNumber(), "cursor should be pinned to the newest line")
|
||||
require.Equal(t, 50, loc.Total())
|
||||
require.Equal(t, max(50-perPage, 0), loc.Offset(),
|
||||
"offset should be a sliding window over the tail, not page-aligned")
|
||||
|
||||
// The list area is anchored at row 0; the newest line sits on the last
|
||||
// visible row.
|
||||
lastRow := perPage - 1
|
||||
require.Equal(t, "line49", readScreenRow(t, screen, lastRow),
|
||||
"newest line should be on the bottom row of the list area")
|
||||
}
|
||||
|
||||
// TestFollowDefaultShowsHead verifies that without follow mode the viewport
|
||||
// stays at the head of the buffer (unchanged behavior).
|
||||
func TestFollowDefaultShowsHead(t *testing.T) {
|
||||
buf := makeFollowBuffer(50)
|
||||
state, screen := newFollowState(t, buf)
|
||||
require.False(t, state.IsFollowing())
|
||||
|
||||
layout, err := TopDownQueryBottomLayout(state)
|
||||
require.NoError(t, err)
|
||||
|
||||
layout.DrawScreen(state, nil)
|
||||
|
||||
loc := state.Location()
|
||||
require.Equal(t, 0, loc.LineNumber())
|
||||
require.Equal(t, 0, loc.Offset())
|
||||
require.Equal(t, "line0", readScreenRow(t, screen, 0),
|
||||
"oldest line should be on the top row when not following")
|
||||
}
|
||||
|
||||
// TestFollowDisabledByManualScroll verifies that any manual vertical
|
||||
// navigation turns follow mode off.
|
||||
func TestFollowDisabledByManualScroll(t *testing.T) {
|
||||
buf := makeFollowBuffer(50)
|
||||
state, _ := newFollowState(t, buf)
|
||||
state.Follow().Set(true)
|
||||
|
||||
layout, err := TopDownQueryBottomLayout(state)
|
||||
require.NoError(t, err)
|
||||
// Pin to the tail first.
|
||||
layout.DrawScreen(state, nil)
|
||||
require.True(t, state.IsFollowing())
|
||||
|
||||
moved := layout.MovePage(state, hub.ToLineAbove)
|
||||
require.True(t, moved)
|
||||
require.False(t, state.IsFollowing(),
|
||||
"manual vertical scroll should disable follow mode")
|
||||
}
|
||||
|
||||
// TestToggleFollowAction verifies the ToggleFollow action flips follow state.
|
||||
func TestToggleFollowAction(t *testing.T) {
|
||||
state := New()
|
||||
state.hub = nullHub{}
|
||||
require.False(t, state.IsFollowing())
|
||||
|
||||
doToggleFollow(t.Context(), state, Event{})
|
||||
require.True(t, state.IsFollowing(), "follow should be on after first toggle")
|
||||
|
||||
doToggleFollow(t.Context(), state, Event{})
|
||||
require.False(t, state.IsFollowing(), "follow should be off after second toggle")
|
||||
}
|
||||
|
||||
// TestFollowConfigAndFlag verifies that --follow and the Follow config field
|
||||
// both enable follow mode, with the CLI flag taking precedence.
|
||||
func TestFollowConfigAndFlag(t *testing.T) {
|
||||
t.Run("CLI flag", func(t *testing.T) {
|
||||
p := newPeco()
|
||||
require.NoError(t, p.ApplyConfig(CLIOptions{OptFollow: true}))
|
||||
require.True(t, p.IsFollowing())
|
||||
})
|
||||
|
||||
t.Run("config field", func(t *testing.T) {
|
||||
p := newPeco()
|
||||
p.config.Follow = true
|
||||
require.NoError(t, p.ApplyConfig(CLIOptions{}))
|
||||
require.True(t, p.IsFollowing())
|
||||
})
|
||||
|
||||
t.Run("default off", func(t *testing.T) {
|
||||
p := newPeco()
|
||||
require.NoError(t, p.ApplyConfig(CLIOptions{}))
|
||||
require.False(t, p.IsFollowing())
|
||||
})
|
||||
}
|
||||
|
||||
// TestToggleFollowRegistered verifies the action is wired into the registry so
|
||||
// it can be bound from a keymap.
|
||||
func TestToggleFollowRegistered(t *testing.T) {
|
||||
_, ok := nameToActions["peco.ToggleFollow"]
|
||||
require.True(t, ok, "peco.ToggleFollow should be registered")
|
||||
}
|
||||
9
go.mod
9
go.mod
|
|
@ -3,13 +3,13 @@ module github.com/peco/peco
|
|||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/gdamore/tcell/v2 v2.13.8
|
||||
github.com/gdamore/tcell/v2 v2.13.10
|
||||
github.com/goccy/go-yaml v1.19.2
|
||||
github.com/google/btree v1.1.3
|
||||
github.com/jessevdk/go-flags v1.6.1
|
||||
github.com/lestrrat-go/pdebug v0.0.0-20180220043849-39f9a71bcabe
|
||||
github.com/mattn/go-runewidth v0.0.20
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/mattn/go-runewidth v0.0.28
|
||||
github.com/stretchr/testify v1.12.1
|
||||
)
|
||||
|
||||
require (
|
||||
|
|
@ -17,10 +17,9 @@ require (
|
|||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/gdamore/encoding v1.0.1 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/term v0.37.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
|
|||
20
go.sum
20
go.sum
|
|
@ -4,8 +4,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
|||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
||||
github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
|
||||
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
||||
github.com/gdamore/tcell/v2 v2.13.10 h1:Afs3JKt83HnhuUKdZ3MnxUgOqQRWftj5JyDqv1LLynA=
|
||||
github.com/gdamore/tcell/v2 v2.13.10/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
||||
|
|
@ -16,15 +16,15 @@ github.com/lestrrat-go/pdebug v0.0.0-20180220043849-39f9a71bcabe h1:S7XSBlgc/eI2
|
|||
github.com/lestrrat-go/pdebug v0.0.0-20180220043849-39f9a71bcabe/go.mod h1:zvUY6gZZVL2nu7NM+/3b51Z/hxyFZCZxV0hvfZ3NJlg=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
|
||||
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/mattn/go-runewidth v0.0.28 h1:rPyg2ybwEKPebvpzVWe1gKBkH8EQFkxO4Y0hjBeLaBU=
|
||||
github.com/mattn/go-runewidth v0.0.28/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
|
|
@ -61,7 +61,3 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
|
|||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
|
|||
66
layout.go
66
layout.go
|
|
@ -328,6 +328,9 @@ func (u UserPrompt) Draw(state *Peco) {
|
|||
|
||||
loc := state.Location()
|
||||
pmsg := fmt.Sprintf("%s [%d (%d/%d)]", state.Filters().Current().String(), loc.Total(), loc.Page(), loc.MaxPage())
|
||||
if state.IsFollowing() {
|
||||
pmsg = "FOLLOW " + pmsg
|
||||
}
|
||||
u.screen.Print(PrintArgs{
|
||||
X: width - runewidth.StringWidth(pmsg),
|
||||
Y: location,
|
||||
|
|
@ -581,16 +584,26 @@ func (l *ListArea) Draw(state *Peco, parent Layout, perPage int, options *hub.Dr
|
|||
|
||||
loc := state.Location()
|
||||
linebuf := state.CurrentLineBuffer()
|
||||
following := state.IsFollowing()
|
||||
|
||||
if options != nil && options.RunningQuery {
|
||||
// While following, the viewport is pinned to the tail of the buffer
|
||||
// (see calculateFollowPage), so the running-query page adjustment must
|
||||
// not move it.
|
||||
if !following && options != nil && options.RunningQuery {
|
||||
adjustPageForRunningQuery(loc, linebuf, parent, state)
|
||||
}
|
||||
|
||||
pf := loc.PageCrop()
|
||||
if pdebug.Enabled {
|
||||
pdebug.Printf("Cropping linebuf which contains %d lines at page %d (%d entries per page)", linebuf.Size(), pf.currentPage, pf.perPage)
|
||||
var buf *FilteredBuffer
|
||||
if following {
|
||||
// Sliding window over the tail so the newest line is on the last row.
|
||||
buf = WindowCrop{offset: loc.Offset(), perPage: loc.PerPage()}.Crop(linebuf)
|
||||
} else {
|
||||
pf := loc.PageCrop()
|
||||
if pdebug.Enabled {
|
||||
pdebug.Printf("Cropping linebuf which contains %d lines at page %d (%d entries per page)", linebuf.Size(), pf.currentPage, pf.perPage)
|
||||
}
|
||||
buf = pf.Crop(linebuf)
|
||||
}
|
||||
buf := pf.Crop(linebuf)
|
||||
bufsiz := buf.Size()
|
||||
|
||||
// This protects us from losing the selected line in case our selected
|
||||
|
|
@ -910,6 +923,39 @@ func (l *BasicLayout) CalculatePage(state *Peco, perPage int) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// calculateFollowPage pins the viewport to the tail of the buffer for follow
|
||||
// mode. Unlike CalculatePage, the offset is a sliding window (Size-perPage)
|
||||
// rather than a page boundary, so the newest line always lands on the last
|
||||
// visible row. The cursor is pinned to the newest line.
|
||||
func (l *BasicLayout) calculateFollowPage(state *Peco, perPage int) error {
|
||||
if pdebug.Enabled {
|
||||
g := pdebug.Marker("BasicLayout.calculateFollowPage %d", perPage)
|
||||
defer g.End()
|
||||
}
|
||||
buf := state.CurrentLineBuffer()
|
||||
loc := state.Location()
|
||||
|
||||
total := buf.Size()
|
||||
loc.SetPerPage(perPage)
|
||||
loc.SetTotal(total)
|
||||
|
||||
if total == 0 {
|
||||
loc.SetOffset(0)
|
||||
loc.SetLineNumber(0)
|
||||
loc.SetPage(1)
|
||||
loc.SetMaxPage(1)
|
||||
// wait for targets
|
||||
return errors.New("no targets or query. nothing to do")
|
||||
}
|
||||
|
||||
loc.SetOffset(max(total-perPage, 0))
|
||||
loc.SetLineNumber(total - 1)
|
||||
loc.SetMaxPage((total + perPage - 1) / perPage)
|
||||
loc.SetPage(loc.MaxPage())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DrawPrompt draws the prompt to the terminal
|
||||
func (l *BasicLayout) DrawPrompt(state *Peco) {
|
||||
l.prompt.Draw(state)
|
||||
|
|
@ -924,7 +970,11 @@ func (l *BasicLayout) DrawScreen(state *Peco, options *hub.DrawOptions) {
|
|||
|
||||
perPage := l.linesPerPage()
|
||||
|
||||
if err := l.CalculatePage(state, perPage); err != nil {
|
||||
calculate := l.CalculatePage
|
||||
if state.IsFollowing() {
|
||||
calculate = l.calculateFollowPage
|
||||
}
|
||||
if err := calculate(state, perPage); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -1097,6 +1147,10 @@ func updateRangeSelection(state *Peco, buf Buffer, loc *Location, lineBefore, lc
|
|||
|
||||
// verticalScroll moves the cursor position vertically
|
||||
func verticalScroll(state *Peco, l *BasicLayout, p hub.PagingRequest) bool {
|
||||
// Manual vertical navigation cancels follow mode so the user can scroll
|
||||
// back through history. Re-enable it with the ToggleFollow action.
|
||||
state.Follow().Set(false)
|
||||
|
||||
loc := state.Location()
|
||||
lineBefore := loc.LineNumber()
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ type CLIOptions struct {
|
|||
OptPrintQuery bool `long:"print-query" description:"print out the current query as first line of output"`
|
||||
OptColor config.ColorMode `long:"color" description:"color mode: 'auto' (default, parse ANSI codes) or 'none' (disable)" default:"auto"`
|
||||
OptHeight string `long:"height" description:"display height in lines or percentage (e.g. '10', '50%')"`
|
||||
OptFollow bool `long:"follow" short:"f" description:"follow streaming input, auto-scrolling to keep the newest lines\nvisible (like 'tail -f'). Manual cursor movement turns this off"`
|
||||
}
|
||||
|
||||
// parse parses command-line arguments and validates the resulting options.
|
||||
|
|
|
|||
14
page.go
14
page.go
|
|
@ -120,3 +120,17 @@ func (l *Location) PageCrop() PageCrop {
|
|||
func (pf PageCrop) Crop(in Buffer) *FilteredBuffer {
|
||||
return NewFilteredBuffer(in, pf.currentPage, pf.perPage)
|
||||
}
|
||||
|
||||
// WindowCrop crops a fixed-size window of lines starting at an explicit
|
||||
// offset rather than a page boundary. Follow mode uses it to show the tail
|
||||
// of the buffer (the newest lines) regardless of page alignment.
|
||||
type WindowCrop struct {
|
||||
offset int
|
||||
perPage int
|
||||
}
|
||||
|
||||
// Crop returns a new Buffer containing up to perPage lines starting at the
|
||||
// window's offset.
|
||||
func (wc WindowCrop) Crop(in Buffer) *FilteredBuffer {
|
||||
return newFilteredBufferRange(in, wc.offset, wc.perPage)
|
||||
}
|
||||
|
|
|
|||
14
peco.go
14
peco.go
|
|
@ -96,6 +96,8 @@ type Peco struct {
|
|||
|
||||
frozen FrozenState
|
||||
|
||||
follow FollowState
|
||||
|
||||
zoom ZoomState
|
||||
|
||||
// cancelFunc is called for Exit()
|
||||
|
|
@ -291,6 +293,16 @@ func (p *Peco) Frozen() *FrozenState {
|
|||
return &p.frozen
|
||||
}
|
||||
|
||||
// Follow returns the follow-mode state.
|
||||
func (p *Peco) Follow() *FollowState {
|
||||
return &p.follow
|
||||
}
|
||||
|
||||
// IsFollowing reports whether follow mode is currently active.
|
||||
func (p *Peco) IsFollowing() bool {
|
||||
return p.follow.Enabled()
|
||||
}
|
||||
|
||||
func (p *Peco) Zoom() *ZoomState {
|
||||
return &p.zoom
|
||||
}
|
||||
|
|
@ -794,6 +806,8 @@ func (p *Peco) ApplyConfig(opts CLIOptions) error {
|
|||
p.selectOneAndExit = opts.OptSelect1
|
||||
p.exitZeroAndExit = opts.OptExitZero
|
||||
p.selectAllAndExit = opts.OptSelectAll
|
||||
// Follow mode: CLI flag overrides config.
|
||||
p.follow.Set(opts.OptFollow || p.config.Follow)
|
||||
p.printQuery = opts.OptPrintQuery
|
||||
p.initialQuery = opts.OptQuery
|
||||
p.initialFilter = opts.OptInitialFilter
|
||||
|
|
|
|||
24
screen.go
24
screen.go
|
|
@ -110,8 +110,20 @@ func tcellEventToEvent(tev tcell.Event) Event {
|
|||
}
|
||||
}
|
||||
|
||||
// Special case: space must be sent as KeySpace with Ch=0
|
||||
// to match the convention expected by doAcceptChar
|
||||
// Ctrl+Space via CSI u: tcell delivers KeyRune with
|
||||
// rune=' ' and ModCtrl. Convert to KeyCtrlSpace (0x00).
|
||||
if r == ' ' && mod&keyseq.ModCtrl != 0 {
|
||||
mod &^= keyseq.ModCtrl
|
||||
return Event{
|
||||
Type: EventKey,
|
||||
Key: keyseq.KeyCtrlSpace,
|
||||
Ch: 0,
|
||||
Mod: mod,
|
||||
}
|
||||
}
|
||||
|
||||
// Plain space must be sent as KeySpace with Ch=0
|
||||
// to match the convention expected by doAcceptChar.
|
||||
if r == ' ' {
|
||||
return Event{
|
||||
Type: EventKey,
|
||||
|
|
@ -138,17 +150,17 @@ func tcellEventToEvent(tev tcell.Event) Event {
|
|||
}
|
||||
}
|
||||
|
||||
// Ctrl+letter keys: tcell.KeyCtrlA(65)..KeyCtrlZ(90).
|
||||
// Ctrl keys: tcell.KeyCtrlSpace(64)..KeyCtrlZ(90).
|
||||
// On terminals with enhanced keyboard protocols (CSI u /
|
||||
// fixterms), tcell normalizes Ctrl+letter to these constants
|
||||
// with ModCtrl set. Peco's keyseq system encodes the ctrl
|
||||
// nature in the key value (0x01-0x1A), not in the modifier,
|
||||
// nature in the key value (0x00-0x1A), not in the modifier,
|
||||
// so strip the redundant ModCtrl. (issue #715)
|
||||
if key >= tcell.KeyCtrlA && key <= tcell.KeyCtrlZ {
|
||||
if key >= tcell.KeyCtrlSpace && key <= tcell.KeyCtrlZ {
|
||||
mod &^= keyseq.ModCtrl
|
||||
return Event{
|
||||
Type: EventKey,
|
||||
Key: keyseq.KeyType(key - tcell.KeyCtrlA + 1),
|
||||
Key: keyseq.KeyType(key - tcell.KeyCtrlSpace),
|
||||
Ch: 0,
|
||||
Mod: mod,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -538,6 +538,62 @@ func TestTcellEventToEventCtrlKeysStripModCtrl(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestTcellEventToEventCtrlSpace verifies that Ctrl+Space is correctly
|
||||
// converted to KeyCtrlSpace (0x00) regardless of how the terminal reports it.
|
||||
//
|
||||
// Traditional terminals send NUL (0x00); tcell's input handler
|
||||
// delivers this as KeyCtrlSpace(64) with ModCtrl.
|
||||
// The KeyCtrlSpace..KeyCtrlZ path handles this.
|
||||
//
|
||||
// CSI u / enhanced terminals report Ctrl+Space as KeyRune with rune=' '
|
||||
// and ModCtrl. This must also produce Key=KeyCtrlSpace, Mod=ModNone
|
||||
// so that the ToggleSelectionAndSelectNext action fires correctly.
|
||||
func TestTcellEventToEventCtrlSpace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tcellEv *tcell.EventKey
|
||||
wantKey keyseq.KeyType
|
||||
wantCh rune
|
||||
wantMod keyseq.ModifierKey
|
||||
}{
|
||||
{
|
||||
// Traditional terminal: NUL byte (0x00).
|
||||
// tcell's input handler calls
|
||||
// NewEventKey(KeyCtrlSpace+Key(r), 0, ModCtrl)
|
||||
// which yields key=KeyCtrlSpace(64), mod=ModCtrl.
|
||||
// The KeyCtrlSpace..KeyCtrlZ path strips ModCtrl and
|
||||
// maps to keyseq.KeyCtrlSpace(0x00).
|
||||
name: "traditional terminal: NUL byte via KeyCtrlSpace+ModCtrl",
|
||||
tcellEv: tcell.NewEventKey(tcell.KeyCtrlSpace, 0, tcell.ModCtrl),
|
||||
wantKey: keyseq.KeyCtrlSpace,
|
||||
wantCh: 0,
|
||||
wantMod: keyseq.ModNone,
|
||||
},
|
||||
{
|
||||
// CSI u / enhanced terminal: Ctrl+Space reported as
|
||||
// KeyRune with rune=' ' and ModCtrl.
|
||||
name: "CSI u terminal: Ctrl+Space as rune with ModCtrl",
|
||||
tcellEv: tcell.NewEventKey(tcell.KeyRune, ' ', tcell.ModCtrl),
|
||||
wantKey: keyseq.KeyCtrlSpace,
|
||||
wantCh: 0,
|
||||
wantMod: keyseq.ModNone,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := tcellEventToEvent(tt.tcellEv)
|
||||
require.Equal(t, EventKey, got.Type, "event type")
|
||||
require.Equal(t, tt.wantKey, got.Key, "key")
|
||||
require.Equal(t, tt.wantCh, got.Ch, "ch")
|
||||
require.Equal(t, tt.wantMod, got.Mod, "modifier")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTcellEventToEventCtrlWithAltPreservesAlt verifies that Ctrl+Alt
|
||||
// combinations strip ModCtrl but preserve ModAlt.
|
||||
func TestTcellEventToEventCtrlWithAltPreservesAlt(t *testing.T) {
|
||||
|
|
|
|||
71
source.go
71
source.go
|
|
@ -26,11 +26,16 @@ type Source struct {
|
|||
inClosed bool
|
||||
isInfinite bool
|
||||
lines []line.Line
|
||||
name string
|
||||
mutex sync.RWMutex
|
||||
ready chan struct{}
|
||||
setupDone chan struct{}
|
||||
setupOnce sync.Once
|
||||
// start is the index of the oldest live line within lines. When a
|
||||
// capacity is set, Append advances start instead of reallocating on
|
||||
// every line; the dead prefix lines[:start] is reclaimed in bulk by a
|
||||
// periodic compaction. The live window is always lines[start:].
|
||||
start int
|
||||
name string
|
||||
mutex sync.RWMutex
|
||||
ready chan struct{}
|
||||
setupDone chan struct{}
|
||||
setupOnce sync.Once
|
||||
}
|
||||
|
||||
// drawRefreshInterval is the interval at which the screen is redrawn while
|
||||
|
|
@ -225,7 +230,12 @@ func (s *Source) Start(ctx context.Context, out pipeline.ChanOutput) {
|
|||
|
||||
if !resume {
|
||||
// no fancy resume handling needed. Send individual lines.
|
||||
for _, l := range s.lines {
|
||||
// setupDone is closed, so the buffer is stable; snapshot the live
|
||||
// window (lines[start:]) under the lock and iterate it.
|
||||
s.mutex.RLock()
|
||||
live := s.lines[s.start:]
|
||||
s.mutex.RUnlock()
|
||||
for _, l := range live {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if pdebug.Enabled {
|
||||
|
|
@ -314,40 +324,65 @@ func (s *Source) SetupDone() <-chan struct{} {
|
|||
return s.setupDone
|
||||
}
|
||||
|
||||
// linesInRange returns a slice of lines between start and end indices from the buffer.
|
||||
func (s *Source) linesInRange(start, end int) []line.Line {
|
||||
// linesInRange returns the lines between from and to (indices into the live
|
||||
// window) from the buffer. The returned slice is a copy: Append may compact
|
||||
// the backing array in place after the lock is released, which would
|
||||
// otherwise overwrite or clear the slice the caller is still iterating.
|
||||
func (s *Source) linesInRange(from, to int) []line.Line {
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
return s.lines[start:end]
|
||||
src := s.lines[s.start+from : s.start+to]
|
||||
out := make([]line.Line, len(src))
|
||||
copy(out, src)
|
||||
return out
|
||||
}
|
||||
|
||||
// LineAt returns the line at the given index from the buffer.
|
||||
// LineAt returns the line at the given index (within the live window) from the buffer.
|
||||
func (s *Source) LineAt(n int) (line.Line, error) {
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
return bufferLineAt(s.lines, n)
|
||||
return bufferLineAt(s.lines[s.start:], n)
|
||||
}
|
||||
|
||||
// Size returns the number of lines currently in the buffer.
|
||||
func (s *Source) Size() int {
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
return len(s.lines)
|
||||
return len(s.lines) - s.start
|
||||
}
|
||||
|
||||
// Append adds a new line to the source buffer. If a capacity is set and
|
||||
// exceeded, the oldest lines are discarded to maintain the limit.
|
||||
//
|
||||
// Discarding is amortized O(1): rather than reallocating and copying the
|
||||
// whole window on every line once saturated (which is O(capacity) per
|
||||
// Append), we advance the logical start index and only compact — copying
|
||||
// the live window to the front and releasing the discarded lines — once the
|
||||
// dead prefix has grown to a full window. Compaction therefore happens once
|
||||
// every capacity appends, so the per-line cost is O(1) amortized while peak
|
||||
// memory stays bounded at ~2*capacity lines.
|
||||
func (s *Source) Append(l line.Line) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
s.lines = append(s.lines, l)
|
||||
if s.capacity > 0 && len(s.lines) > s.capacity {
|
||||
diff := len(s.lines) - s.capacity
|
||||
if s.capacity <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Copy to a new slice to allow GC of discarded lines
|
||||
newLines := make([]line.Line, s.capacity)
|
||||
copy(newLines, s.lines[diff:])
|
||||
s.lines = newLines
|
||||
// Drop the oldest line logically once we are over capacity. len-start
|
||||
// is the live size, so this keeps Size() pinned at exactly capacity.
|
||||
if len(s.lines)-s.start > s.capacity {
|
||||
s.start++
|
||||
}
|
||||
|
||||
// Once the dead prefix is as large as the live window (len == 2*capacity),
|
||||
// compact: slide the live window to the front, clear the freed tail so the
|
||||
// discarded lines can be GC'd, and reset start.
|
||||
if s.start >= s.capacity {
|
||||
n := copy(s.lines, s.lines[s.start:])
|
||||
clear(s.lines[n:])
|
||||
s.lines = s.lines[:n]
|
||||
s.start = 0
|
||||
}
|
||||
}
|
||||
|
|
|
|||
29
source_capacity_bench_test.go
Normal file
29
source_capacity_bench_test.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package peco
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/peco/peco/line"
|
||||
)
|
||||
|
||||
// BenchmarkSourceAppendSaturated measures Append cost on a capacity-bounded
|
||||
// source that is already saturated, which is the steady state for a long-lived
|
||||
// streaming input (e.g. `tail -f | peco -b N`).
|
||||
func BenchmarkSourceAppendSaturated(b *testing.B) {
|
||||
const capacity = 10000
|
||||
ig := newIDGen()
|
||||
go ig.Run(b.Context())
|
||||
|
||||
s := NewSource("-", strings.NewReader(""), false, ig, capacity, false, false)
|
||||
// Pre-fill to capacity so every benchmarked Append discards an old line.
|
||||
for i := range capacity {
|
||||
s.Append(line.NewRaw(uint64(i), strconv.Itoa(i), false, false))
|
||||
}
|
||||
|
||||
l := line.NewRaw(uint64(capacity), "payload", false, false)
|
||||
for b.Loop() {
|
||||
s.Append(l)
|
||||
}
|
||||
}
|
||||
123
source_capacity_test.go
Normal file
123
source_capacity_test.go
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
package peco
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/peco/peco/line"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestSourceCapacity verifies that a capacity-bounded Source keeps exactly the
|
||||
// most recent `capacity` lines as new lines stream in, across many compaction
|
||||
// boundaries, and that the backing storage stays bounded (amortized O(1)
|
||||
// trimming rather than a full reallocation per append).
|
||||
func TestSourceCapacity(t *testing.T) {
|
||||
const capacity = 4
|
||||
ig := newIDGen()
|
||||
go ig.Run(t.Context())
|
||||
|
||||
s := NewSource("-", strings.NewReader(""), false, ig, capacity, false, false)
|
||||
|
||||
const total = 25 // crosses several capacity-sized compaction windows
|
||||
for i := range total {
|
||||
s.Append(line.NewRaw(uint64(i), fmt.Sprintf("line%d", i), false, false))
|
||||
|
||||
want := min(i+1, capacity)
|
||||
require.Equal(t, want, s.Size(), "Size must stay pinned at capacity once saturated")
|
||||
|
||||
// The live window must be exactly the most-recently-appended lines.
|
||||
oldest := (i + 1) - s.Size()
|
||||
for j := range s.Size() {
|
||||
l, err := s.LineAt(j)
|
||||
require.NoError(t, err, "LineAt(%d) at append %d", j, i)
|
||||
require.Equal(t, fmt.Sprintf("line%d", oldest+j), l.DisplayString())
|
||||
}
|
||||
|
||||
// Backing storage must not grow without bound.
|
||||
require.LessOrEqual(t, len(s.lines), 2*capacity,
|
||||
"backing window should stay bounded at ~2*capacity")
|
||||
}
|
||||
|
||||
// linesInRange over the live window still returns a correct contiguous slice
|
||||
// after many compactions.
|
||||
rng := s.linesInRange(0, capacity)
|
||||
require.Len(t, rng, capacity)
|
||||
require.Equal(t, fmt.Sprintf("line%d", total-capacity), rng[0].DisplayString())
|
||||
require.Equal(t, fmt.Sprintf("line%d", total-1), rng[capacity-1].DisplayString())
|
||||
|
||||
// Out-of-range access is rejected.
|
||||
_, err := s.LineAt(capacity)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// TestSourceUnlimitedRetainsAll verifies that the default (capacity 0) keeps
|
||||
// every appended line — the start-offset machinery must not kick in.
|
||||
func TestSourceUnlimitedRetainsAll(t *testing.T) {
|
||||
ig := newIDGen()
|
||||
go ig.Run(t.Context())
|
||||
|
||||
s := NewSource("-", strings.NewReader(""), false, ig, 0, false, false)
|
||||
|
||||
const total = 50
|
||||
for i := range total {
|
||||
s.Append(line.NewRaw(uint64(i), fmt.Sprintf("line%d", i), false, false))
|
||||
}
|
||||
|
||||
require.Equal(t, total, s.Size())
|
||||
require.Equal(t, 0, s.start, "start must stay 0 when capacity is unlimited")
|
||||
first, err := s.LineAt(0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "line0", first.DisplayString())
|
||||
last, err := s.LineAt(total - 1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fmt.Sprintf("line%d", total-1), last.DisplayString())
|
||||
}
|
||||
|
||||
// TestSourceLinesInRangeConcurrentAppend verifies that the slice returned by
|
||||
// linesInRange remains stable for the caller even when Append concurrently
|
||||
// compacts the backing array. Run under -race, this catches any future
|
||||
// regression where linesInRange returns a slice aliased into the live storage.
|
||||
func TestSourceLinesInRangeConcurrentAppend(t *testing.T) {
|
||||
const capacity = 32
|
||||
ig := newIDGen()
|
||||
go ig.Run(t.Context())
|
||||
|
||||
s := NewSource("-", strings.NewReader(""), false, ig, capacity, false, false)
|
||||
for i := range capacity {
|
||||
s.Append(line.NewRaw(uint64(i), fmt.Sprintf("seed%d", i), false, false))
|
||||
}
|
||||
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
i := uint64(capacity)
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
s.Append(line.NewRaw(i, fmt.Sprintf("hot%d", i), false, false))
|
||||
i++
|
||||
}
|
||||
})
|
||||
|
||||
// Repeatedly grab a window and read every element. If linesInRange
|
||||
// returned a slice aliased into s.lines, a concurrent compaction would
|
||||
// either nil out entries (post-clear) or rewrite them, producing a race
|
||||
// detector hit and/or a nil DisplayString() deref.
|
||||
for range 2000 {
|
||||
rng := s.linesInRange(0, capacity)
|
||||
require.Len(t, rng, capacity)
|
||||
for _, l := range rng {
|
||||
require.NotNil(t, l)
|
||||
_ = l.DisplayString()
|
||||
}
|
||||
}
|
||||
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
}
|
||||
19
state.go
19
state.go
|
|
@ -2,6 +2,7 @@ package peco
|
|||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
|
@ -75,6 +76,24 @@ func (z *ZoomState) Clear() {
|
|||
z.lineNo = 0
|
||||
}
|
||||
|
||||
// FollowState tracks whether follow mode is active. In follow mode peco
|
||||
// auto-scrolls to keep the newest input lines visible, like "tail -f".
|
||||
// It is toggled at runtime: manual cursor movement turns it off, and the
|
||||
// ToggleFollow action turns it back on.
|
||||
type FollowState struct {
|
||||
enabled atomic.Bool
|
||||
}
|
||||
|
||||
// Enabled reports whether follow mode is currently active.
|
||||
func (f *FollowState) Enabled() bool {
|
||||
return f.enabled.Load()
|
||||
}
|
||||
|
||||
// Set turns follow mode on or off.
|
||||
func (f *FollowState) Set(enabled bool) {
|
||||
f.enabled.Store(enabled)
|
||||
}
|
||||
|
||||
// FrozenState holds a snapshot of filter results when the user
|
||||
// "freezes" the current results to filter on top of them.
|
||||
type FrozenState struct {
|
||||
|
|
|
|||
Loading…
Reference in a new issue