From b54c5a0fba5d574a90b7b1211a97b0778e5f6055 Mon Sep 17 00:00:00 2001 From: Umputun Date: Fri, 3 Apr 2026 18:50:24 -0500 Subject: [PATCH 1/2] feat: add --only flag for file filtering Add repeatable --only/-F flag to show only matching files in the diff view. Matches by exact path or path suffix. Shows message when no files match the filter. Bump plugin version to 0.2.2. --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .claude-plugin/skills/revdiff/SKILL.md | 2 +- .../skills/revdiff/references/config.md | 1 + .../skills/revdiff/references/usage.md | 2 ++ .../skills/revdiff/scripts/launch-revdiff.sh | 2 +- README.md | 4 +++ cmd/revdiff/main.go | 4 ++- ui/model.go | 32 +++++++++++++++-- ui/model_test.go | 36 +++++++++++++++++++ 10 files changed, 79 insertions(+), 8 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 0d37d6b..b7998ca 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "revdiff", "source": "./", "description": "Review git diffs with inline annotations in a TUI overlay", - "version": "0.2.1", + "version": "0.2.2", "author": { "name": "umputun" } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 7ce913c..1c30e67 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "revdiff", - "version": "0.2.1", + "version": "0.2.2", "description": "Review git diffs with inline annotations in a TUI overlay", "author": { "name": "umputun", diff --git a/.claude-plugin/skills/revdiff/SKILL.md b/.claude-plugin/skills/revdiff/SKILL.md index dc34541..ee1a9f9 100644 --- a/.claude-plugin/skills/revdiff/SKILL.md +++ b/.claude-plugin/skills/revdiff/SKILL.md @@ -71,7 +71,7 @@ The script outputs structured fields: Run the launcher script: ```bash -${CLAUDE_PLUGIN_ROOT}/.claude-plugin/skills/revdiff/scripts/launch-revdiff.sh [ref] [--staged] +${CLAUDE_PLUGIN_ROOT}/.claude-plugin/skills/revdiff/scripts/launch-revdiff.sh [ref] [--staged] [--only=file1 --only=file2] ``` The script: diff --git a/.claude-plugin/skills/revdiff/references/config.md b/.claude-plugin/skills/revdiff/references/config.md index 3d45a38..13fee42 100644 --- a/.claude-plugin/skills/revdiff/references/config.md +++ b/.claude-plugin/skills/revdiff/references/config.md @@ -26,6 +26,7 @@ Then uncomment and edit the values you want to change. | `--wrap` | `REVDIFF_WRAP` | Enable line wrapping in diff view | `false` | | `--no-confirm-discard` | `REVDIFF_NO_CONFIRM_DISCARD` | Skip confirmation when discarding annotations with Q | `false` | | `--chroma-style` | `REVDIFF_CHROMA_STYLE` | Chroma color theme for syntax highlighting | `catppuccin-macchiato` | +| `-F`, `--only` | | Show only matching files (may be repeated, matches by path or suffix) | | | `-o`, `--output` | `REVDIFF_OUTPUT` | Write annotations to file instead of stdout | | | `--config` | `REVDIFF_CONFIG` | Path to config file | `~/.config/revdiff/config` | | `--dump-config` | | Print default config to stdout and exit | | diff --git a/.claude-plugin/skills/revdiff/references/usage.md b/.claude-plugin/skills/revdiff/references/usage.md index 2300d9a..da71bc4 100644 --- a/.claude-plugin/skills/revdiff/references/usage.md +++ b/.claude-plugin/skills/revdiff/references/usage.md @@ -11,6 +11,8 @@ revdiff # review uncommitted changes revdiff main # review changes against a branch revdiff --staged # review staged changes revdiff HEAD~1 # review last commit +revdiff --only=model.go # review only files matching model.go +revdiff --only=ui/model.go --only=README.md # review specific files ``` ## Single-File Mode diff --git a/.claude-plugin/skills/revdiff/scripts/launch-revdiff.sh b/.claude-plugin/skills/revdiff/scripts/launch-revdiff.sh index b603d75..f58d1d0 100755 --- a/.claude-plugin/skills/revdiff/scripts/launch-revdiff.sh +++ b/.claude-plugin/skills/revdiff/scripts/launch-revdiff.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # launch revdiff in a terminal overlay (tmux/kitty/wezterm) and capture annotations. -# usage: launch-revdiff.sh [ref] [--staged] +# usage: launch-revdiff.sh [ref] [--staged] [--only=file1 ...] # output: annotation text from revdiff stdout (empty if no annotations) set -euo pipefail diff --git a/README.md b/README.md index 05bfd7c..e891086 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,7 @@ revdiff [OPTIONS] [ref] | `--wrap` | Enable line wrapping in diff view, env: `REVDIFF_WRAP` | `false` | | `--no-confirm-discard` | Skip confirmation when discarding annotations with Q, env: `REVDIFF_NO_CONFIRM_DISCARD` | `false` | | `--chroma-style` | Chroma color theme for syntax highlighting, env: `REVDIFF_CHROMA_STYLE` | `catppuccin-macchiato` | +| `-F`, `--only` | Show only these files, may be repeated (e.g. `--only=model.go --only=README.md`) | | | `-o`, `--output` | Write annotations to file instead of stdout, env: `REVDIFF_OUTPUT` | | | `--config` | Path to config file, env: `REVDIFF_CONFIG` | `~/.config/revdiff/config` | | `--dump-config` | Print default config to stdout and exit | | @@ -216,6 +217,9 @@ revdiff --staged # review last commit revdiff HEAD~1 + +# review only specific files +revdiff --only=model.go --only=README.md ``` ### Key Bindings diff --git a/cmd/revdiff/main.go b/cmd/revdiff/main.go index a37f72d..ffe6691 100644 --- a/cmd/revdiff/main.go +++ b/cmd/revdiff/main.go @@ -32,7 +32,8 @@ type options struct { NoConfirmDiscard bool `long:"no-confirm-discard" ini-name:"no-confirm-discard" env:"REVDIFF_NO_CONFIRM_DISCARD" description:"skip confirmation prompt when discarding annotations with Q"` Wrap bool `long:"wrap" ini-name:"wrap" env:"REVDIFF_WRAP" description:"enable line wrapping in diff view"` ChromaStyle string `long:"chroma-style" ini-name:"chroma-style" env:"REVDIFF_CHROMA_STYLE" default:"catppuccin-macchiato" description:"chroma style for syntax highlighting"` - Output string `long:"output" short:"o" env:"REVDIFF_OUTPUT" no-ini:"true" description:"write annotations to file instead of stdout"` + Only []string `long:"only" short:"F" no-ini:"true" description:"show only these files (may be repeated)"` + Output string `long:"output" short:"o" env:"REVDIFF_OUTPUT" no-ini:"true" description:"write annotations to file instead of stdout"` Config string `long:"config" env:"REVDIFF_CONFIG" no-ini:"true" description:"path to config file"` DumpConfig bool `long:"dump-config" no-ini:"true" description:"print default config to stdout and exit"` Version bool `short:"V" long:"version" no-ini:"true" description:"show version info"` @@ -185,6 +186,7 @@ func run(opts options) error { Ref: opts.Ref.Ref, Staged: opts.Staged, TreeWidthRatio: opts.TreeWidth, + Only: opts.Only, Colors: ui.Colors{ Accent: opts.Colors.Accent, Border: opts.Colors.Border, diff --git a/ui/model.go b/ui/model.go index ca6b7e1..bd7f5bd 100644 --- a/ui/model.go +++ b/ui/model.go @@ -50,6 +50,7 @@ type Model struct { ref string staged bool + only []string // filter to show only matching files noStatusBar bool focus pane width int @@ -115,7 +116,8 @@ type ModelConfig struct { NoColors bool // disable all colors including syntax highlighting NoStatusBar bool // hide the status bar NoConfirmDiscard bool // skip confirmation prompt when discarding annotations - Wrap bool // enable line wrapping + Wrap bool // enable line wrapping + Only []string // show only these files (filter by path suffix) Colors Colors } @@ -138,6 +140,7 @@ func NewModel(renderer Renderer, store *annotation.Store, highlighter SyntaxHigh highlighter: highlighter, ref: cfg.Ref, staged: cfg.Staged, + only: cfg.Only, noStatusBar: cfg.NoStatusBar, noConfirmDiscard: cfg.NoConfirmDiscard, wrapMode: cfg.Wrap, @@ -437,13 +440,36 @@ func (m Model) handleResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { return m, nil } +// filterOnly returns only files matching the --only patterns, or all files if no filter is set. +// matches by exact path or path suffix (e.g. "model.go" matches "ui/model.go"). +func (m Model) filterOnly(files []string) []string { + if len(m.only) == 0 { + return files + } + var filtered []string + for _, f := range files { + for _, pattern := range m.only { + if f == pattern || strings.HasSuffix(f, "/"+pattern) { + filtered = append(filtered, f) + break + } + } + } + return filtered +} + func (m Model) handleFilesLoaded(msg filesLoadedMsg) (tea.Model, tea.Cmd) { if msg.err != nil { m.viewport.SetContent(fmt.Sprintf("error loading files: %v", msg.err)) return m, nil } - m.tree = newFileTree(msg.files) - m.singleFile = len(msg.files) == 1 + files := m.filterOnly(msg.files) + if len(files) == 0 && len(m.only) > 0 { + m.viewport.SetContent("no files match --only filter") + return m, nil + } + m.tree = newFileTree(files) + m.singleFile = len(files) == 1 if m.singleFile { m.focus = paneDiff m.treeWidth = 0 diff --git a/ui/model_test.go b/ui/model_test.go index 5afbff2..24bdcc9 100644 --- a/ui/model_test.go +++ b/ui/model_test.go @@ -5162,6 +5162,42 @@ func TestModel_DiffContentWidthSingleFile(t *testing.T) { }) } +func TestModel_FilterOnly(t *testing.T) { + t.Run("no filter returns all files", func(t *testing.T) { + m := testModel(nil, nil) + files := []string{"ui/model.go", "diff/diff.go", "README.md"} + assert.Equal(t, files, m.filterOnly(files)) + }) + + t.Run("exact path match", func(t *testing.T) { + m := testModel(nil, nil) + m.only = []string{"ui/model.go"} + files := []string{"ui/model.go", "diff/diff.go", "README.md"} + assert.Equal(t, []string{"ui/model.go"}, m.filterOnly(files)) + }) + + t.Run("suffix match", func(t *testing.T) { + m := testModel(nil, nil) + m.only = []string{"model.go"} + files := []string{"ui/model.go", "diff/diff.go", "README.md"} + assert.Equal(t, []string{"ui/model.go"}, m.filterOnly(files)) + }) + + t.Run("multiple patterns", func(t *testing.T) { + m := testModel(nil, nil) + m.only = []string{"model.go", "README.md"} + files := []string{"ui/model.go", "diff/diff.go", "README.md"} + assert.Equal(t, []string{"ui/model.go", "README.md"}, m.filterOnly(files)) + }) + + t.Run("no matches returns empty", func(t *testing.T) { + m := testModel(nil, nil) + m.only = []string{"nonexistent.go"} + files := []string{"ui/model.go", "diff/diff.go"} + assert.Empty(t, m.filterOnly(files)) + }) +} + func TestModel_SingleFileKeysNoOp(t *testing.T) { lines := []diff.DiffLine{ {NewNum: 1, Content: "line one", ChangeType: diff.ChangeContext}, From c87ee15c69697f054d970edaab13a3e408084742 Mon Sep 17 00:00:00 2001 From: Umputun Date: Fri, 3 Apr 2026 18:57:14 -0500 Subject: [PATCH 2/2] fix: address Copilot review findings for --only flag --- README.md | 2 +- ui/model.go | 2 +- ui/model_test.go | 15 +++++++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e891086..5323f15 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ revdiff [OPTIONS] [ref] | `--wrap` | Enable line wrapping in diff view, env: `REVDIFF_WRAP` | `false` | | `--no-confirm-discard` | Skip confirmation when discarding annotations with Q, env: `REVDIFF_NO_CONFIRM_DISCARD` | `false` | | `--chroma-style` | Chroma color theme for syntax highlighting, env: `REVDIFF_CHROMA_STYLE` | `catppuccin-macchiato` | -| `-F`, `--only` | Show only these files, may be repeated (e.g. `--only=model.go --only=README.md`) | | +| `-F`, `--only` | Show only matching files by exact path or suffix, may be repeated (e.g. `--only=model.go`) | | | `-o`, `--output` | Write annotations to file instead of stdout, env: `REVDIFF_OUTPUT` | | | `--config` | Path to config file, env: `REVDIFF_CONFIG` | `~/.config/revdiff/config` | | `--dump-config` | Print default config to stdout and exit | | diff --git a/ui/model.go b/ui/model.go index bd7f5bd..f5b4c12 100644 --- a/ui/model.go +++ b/ui/model.go @@ -117,7 +117,7 @@ type ModelConfig struct { NoStatusBar bool // hide the status bar NoConfirmDiscard bool // skip confirmation prompt when discarding annotations Wrap bool // enable line wrapping - Only []string // show only these files (filter by path suffix) + Only []string // show only these files (match by exact path or path suffix) Colors Colors } diff --git a/ui/model_test.go b/ui/model_test.go index 24bdcc9..2c99740 100644 --- a/ui/model_test.go +++ b/ui/model_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" @@ -5198,6 +5199,20 @@ func TestModel_FilterOnly(t *testing.T) { }) } +func TestModel_FilterOnlyNoMatchShowsMessage(t *testing.T) { + m := testModel(nil, nil) + m.only = []string{"nonexistent.go"} + m.ready = true + m.width = 80 + m.height = 24 + m.viewport = viewport.New(76, 20) + + result, cmd := m.Update(filesLoadedMsg{files: []string{"ui/model.go", "diff/diff.go"}}) + model := result.(Model) + assert.Nil(t, cmd, "should not trigger file load when no files match") + assert.Contains(t, model.viewport.View(), "no files match --only filter") +} + func TestModel_SingleFileKeysNoOp(t *testing.T) { lines := []diff.DiffLine{ {NewNum: 1, Content: "line one", ChangeType: diff.ChangeContext},