Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude-plugin/skills/revdiff/references/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Then uncomment and edit the values you want to change.
| `--tab-width` | `REVDIFF_TAB_WIDTH` | Spaces per tab character | `4` |
| `--no-colors` | `REVDIFF_NO_COLORS` | Disable all colors including syntax highlighting | `false` |
| `--no-status-bar` | `REVDIFF_NO_STATUS_BAR` | Hide the status bar | `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 | `monokai` |
| `-o`, `--output` | `REVDIFF_OUTPUT` | Write annotations to file instead of stdout | |
| `--config` | `REVDIFF_CONFIG` | Path to config file | `~/.config/revdiff/config` |
Expand Down
1 change: 1 addition & 0 deletions .claude-plugin/skills/revdiff/references/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ revdiff HEAD~1 # review last commit
|-----|--------|
| `f` | Toggle filter: all files / annotated only |
| `q` | Quit, output annotations to stdout |
| `Q` | Discard all annotations and quit (confirms if annotations exist) |

## Output Format

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ revdiff [OPTIONS] [ref]
| `--tab-width` | Number of spaces per tab character, env: `REVDIFF_TAB_WIDTH` | `4` |
| `--no-colors` | Disable all colors including syntax highlighting, env: `REVDIFF_NO_COLORS` | `false` |
| `--no-status-bar` | Hide the status bar, env: `REVDIFF_NO_STATUS_BAR` | `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` | `monokai` |
| `-o`, `--output` | Write annotations to file instead of stdout, env: `REVDIFF_OUTPUT` | |
| `--config` | Path to config file, env: `REVDIFF_CONFIG` | `~/.config/revdiff/config` |
Expand Down Expand Up @@ -228,6 +229,7 @@ revdiff HEAD~1
|-----|--------|
| `f` | Toggle filter: all files / annotated only (shown when annotations exist) |
| `q` | Quit, output annotations to stdout |
| `Q` | Discard all annotations and quit (confirms if annotations exist) |

### Output Format

Expand Down
37 changes: 21 additions & 16 deletions cmd/revdiff/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,17 @@ type options struct {
Ref string `positional-arg-name:"ref" description:"git ref to diff against (default: uncommitted changes)"`
} `positional-args:"yes"`

Staged bool `long:"staged" ini-name:"staged" env:"REVDIFF_STAGED" description:"show staged changes"`
TreeWidth int `long:"tree-width" ini-name:"tree-width" env:"REVDIFF_TREE_WIDTH" default:"2" description:"file tree panel width in units (1-10, default 2 of 10)"`
TabWidth int `long:"tab-width" ini-name:"tab-width" env:"REVDIFF_TAB_WIDTH" default:"4" description:"number of spaces per tab character"`
NoColors bool `long:"no-colors" ini-name:"no-colors" env:"REVDIFF_NO_COLORS" description:"disable all colors including syntax highlighting"`
NoStatusBar bool `long:"no-status-bar" ini-name:"no-status-bar" env:"REVDIFF_NO_STATUS_BAR" description:"hide the status bar"`
ChromaStyle string `long:"chroma-style" ini-name:"chroma-style" env:"REVDIFF_CHROMA_STYLE" default:"monokai" 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"`
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"`
Staged bool `long:"staged" ini-name:"staged" env:"REVDIFF_STAGED" description:"show staged changes"`
TreeWidth int `long:"tree-width" ini-name:"tree-width" env:"REVDIFF_TREE_WIDTH" default:"2" description:"file tree panel width in units (1-10, default 2 of 10)"`
TabWidth int `long:"tab-width" ini-name:"tab-width" env:"REVDIFF_TAB_WIDTH" default:"4" description:"number of spaces per tab character"`
NoColors bool `long:"no-colors" ini-name:"no-colors" env:"REVDIFF_NO_COLORS" description:"disable all colors including syntax highlighting"`
NoStatusBar bool `long:"no-status-bar" ini-name:"no-status-bar" env:"REVDIFF_NO_STATUS_BAR" description:"hide the status bar"`
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"`
ChromaStyle string `long:"chroma-style" ini-name:"chroma-style" env:"REVDIFF_CHROMA_STYLE" default:"monokai" 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"`
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"`

Colors struct {
Accent string `long:"color-accent" ini-name:"color-accent" env:"REVDIFF_COLOR_ACCENT" default:"#5f87ff" description:"active pane borders and directory names"`
Expand Down Expand Up @@ -171,12 +172,13 @@ func run(opts options) error {
store := annotation.NewStore()
hl := highlight.New(opts.ChromaStyle, !opts.NoColors)
model := ui.NewModel(renderer, store, hl, ui.ModelConfig{
NoColors: opts.NoColors,
NoStatusBar: opts.NoStatusBar,
TabWidth: opts.TabWidth,
Ref: opts.Ref.Ref,
Staged: opts.Staged,
TreeWidthRatio: opts.TreeWidth,
NoColors: opts.NoColors,
NoStatusBar: opts.NoStatusBar,
NoConfirmDiscard: opts.NoConfirmDiscard,
TabWidth: opts.TabWidth,
Ref: opts.Ref.Ref,
Staged: opts.Staged,
TreeWidthRatio: opts.TreeWidth,
Colors: ui.Colors{
Accent: opts.Colors.Accent,
Border: opts.Colors.Border,
Expand Down Expand Up @@ -209,6 +211,9 @@ func run(opts options) error {
if !ok {
return nil
}
if m.Discarded() {
return nil
}
output := m.Store().FormatOutput()
if output == "" {
return nil
Expand Down
32 changes: 28 additions & 4 deletions cmd/revdiff/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,9 @@ import (

// noConfigArgs returns args that point to a nonexistent config file,
// isolating the test from user's real config.
func noConfigArgs(t *testing.T, extra ...string) []string {
func noConfigArgs(t *testing.T) []string {
t.Helper()
args := make([]string, 0, 2+len(extra))
args = append(args, "--config", filepath.Join(t.TempDir(), "none"))
return append(args, extra...)
return []string{"--config", filepath.Join(t.TempDir(), "none")}
}

func TestParseArgs_Defaults(t *testing.T) {
Expand All @@ -27,10 +25,36 @@ func TestParseArgs_Defaults(t *testing.T) {
assert.False(t, opts.Staged)
assert.False(t, opts.NoColors)
assert.False(t, opts.NoStatusBar)
assert.False(t, opts.NoConfirmDiscard)
assert.Empty(t, opts.Output)
assert.Empty(t, opts.Ref.Ref)
}

func TestParseArgs_NoConfirmDiscard(t *testing.T) {
t.Run("flag", func(t *testing.T) {
opts, err := parseArgs(append(noConfigArgs(t), "--no-confirm-discard"))
require.NoError(t, err)
assert.True(t, opts.NoConfirmDiscard)
})

t.Run("env", func(t *testing.T) {
t.Setenv("REVDIFF_NO_CONFIRM_DISCARD", "true")
opts, err := parseArgs(noConfigArgs(t))
require.NoError(t, err)
assert.True(t, opts.NoConfirmDiscard)
})

t.Run("config file", func(t *testing.T) {
cfgDir := t.TempDir()
cfgPath := filepath.Join(cfgDir, "config")
err := os.WriteFile(cfgPath, []byte("[Application Options]\nno-confirm-discard = true\n"), 0o600)
require.NoError(t, err)
opts, err := parseArgs([]string{"--config", cfgPath})
require.NoError(t, err)
assert.True(t, opts.NoConfirmDiscard)
})
}

func TestParseArgs_OutputFlag(t *testing.T) {
opts, err := parseArgs([]string{"-o", "/tmp/out.txt"})
require.NoError(t, err)
Expand Down
124 changes: 124 additions & 0 deletions docs/plans/completed/20260401-discard-quit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Discard and Quit (Q hotkey)

## Overview
- Add a "discard and quit" hotkey (`Q`) that exits revdiff without outputting any annotations
- When annotations exist, show an inline confirmation prompt in the status bar before discarding
- When no annotations exist, `Q` behaves identically to `q` (just exits, nothing to discard)
- The confirmation prompt is suppressible via `--no-confirm-discard` CLI flag / env / config
- Solves the problem of accidentally sending annotations back to the calling process when the user just wants to exit

## Context (from discovery)
- Key files: `ui/model.go` (Model struct, handleKey, statusBarText), `cmd/revdiff/main.go` (options, run)
- `Store.Count()` already exists for checking annotation count
- Existing accessor pattern: `Store()` returns private field, same pattern for `Discarded()`
- Status bar already has context-sensitive text via `statusBarText()`
- Existing `--no-*` flag pattern: `--no-colors`, `--no-status-bar`

## Solution Overview
- `Q` (shift+q) quits without annotations; `q` continues to quit with annotations
- If annotations exist and `--no-confirm-discard` is not set, status bar shows `"discard N annotations? [y/n]"`
- `y` or second `Q` confirms discard, `n`/`Esc` cancels back to normal mode
- If no annotations or `--no-confirm-discard` is set, `Q` quits immediately
- `main.go` checks `m.Discarded()` before calling `FormatOutput()`

## Technical Details
- New Model fields: `confirmingDiscard bool`, `discarded bool`, `noConfirmDiscard bool`
- New ModelConfig field: `NoConfirmDiscard bool`
- New CLI option: `--no-confirm-discard` (env: `REVDIFF_NO_CONFIRM_DISCARD`, ini: `no-confirm-discard`)
- New accessor: `func (m Model) Discarded() bool`
- Status bar in confirming state: `"discard N annotations? [y/n]"` (replaces normal hints)
- `Q` during annotation input mode is ignored (same as other navigation keys)

## Development Approach
- **testing approach**: Regular (code first, then tests)
- complete each task fully before moving to the next
- make small, focused changes
- **CRITICAL: every task MUST include new/updated tests** for code changes in that task
- **CRITICAL: all tests must pass before starting next task**
- **CRITICAL: update this plan file when scope changes during implementation**
- run tests after each change
- maintain backward compatibility

## Testing Strategy
- **unit tests**: required for every task
- Test Q with no annotations (immediate quit, discarded=true)
- Test Q with annotations and confirmation (y confirms, n/Esc cancels)
- Test Q with annotations and noConfirmDiscard (immediate quit)
- Test Q during annotation input (ignored)
- Test Discarded() accessor
- Test main.go skips output when discarded
- Test status bar text during confirmation

## Progress Tracking
- mark completed items with `[x]` immediately when done
- add newly discovered tasks with ➕ prefix
- document issues/blockers with ⚠️ prefix
- update plan if implementation deviates from original scope

## Implementation Steps

### Task 1: Add discard state to Model and CLI option

**Files:**
- Modify: `ui/model.go`
- Modify: `cmd/revdiff/main.go`

- [x] add `discarded bool`, `noConfirmDiscard bool` fields to Model struct (`confirmingDiscard` deferred to Task 2 to avoid unused-field lint error)
- [x] add `NoConfirmDiscard bool` to ModelConfig
- [x] add `Discarded() bool` accessor method on Model
- [x] wire `noConfirmDiscard` in NewModel from ModelConfig
- [x] add `NoConfirmDiscard` option to `options` struct in main.go (`--no-confirm-discard`, env `REVDIFF_NO_CONFIRM_DISCARD`, ini `no-confirm-discard`)
- [x] pass `NoConfirmDiscard` from options to ModelConfig in `run()`
- [x] write tests in `ui/model_test.go` for Discarded() accessor (default false, set true)
- [x] write test in `cmd/revdiff/main_test.go` for `--no-confirm-discard` flag parsing
- [x] run `go test ./...` - must pass before task 2

### Task 2: Handle Q keypress and confirmation flow

**Files:**
- Modify: `ui/model.go`

- [x] add `Q` case in handleKey: if no annotations or noConfirmDiscard, set `discarded=true` and return `tea.Quit`
- [x] if annotations exist and confirm required, set `confirmingDiscard=true` and return
- [x] ignore `Q` when `m.annotating` is true (annotation input mode)
- [x] add confirmation key handling: when `confirmingDiscard` is true, `y` or `Q` sets `discarded=true` and returns `tea.Quit`, `n`/`Esc` sets `confirmingDiscard=false`
- [x] block other keys while `confirmingDiscard` is true (only y/Q/n/Esc accepted); confirmation blocking applies only in `handleKey`, non-key messages (WindowSizeMsg etc.) are handled normally
- [x] write tests for Q with no annotations (immediate quit, discarded=true)
- [x] write tests for Q with annotations (enters confirming state)
- [x] write tests for y during confirmation (quits with discarded=true)
- [x] write tests for n and Esc during confirmation (cancels back to normal)
- [x] write test for second Q during confirmation (confirms discard)
- [x] write test for Q during annotation input (ignored)
- [x] write test for Q with noConfirmDiscard and annotations (immediate quit)
- [x] run `go test ./...` - must pass before task 3

### Task 3: Update status bar and output handling

**Files:**
- Modify: `ui/model.go`
- Modify: `cmd/revdiff/main.go`

- [x] update `statusBarText()` to show `"discard N annotations? [y/n]"` when `confirmingDiscard` is true
- [x] add `[Q] discard` hint to normal status bar text (both tree and diff pane hints)
- [x] in main.go `run()`, check `m.Discarded()` before `FormatOutput()` — if true, return nil (skip output)
- [x] write test for status bar text during confirmation state
- [x] write test for status bar showing Q hint in normal mode
- [x] write test verifying main.go skips output when discarded (if feasible with existing test patterns)
- [x] run `go test ./...` - must pass before task 4

### Task 4: Verify acceptance criteria
- [x] verify Q with no annotations exits silently (no output)
- [x] verify Q with annotations shows confirmation, y discards, n cancels
- [x] verify --no-confirm-discard skips prompt
- [x] verify q still works as before (outputs annotations)
- [x] verify Q is ignored during annotation text input
- [x] run full test suite: `go test ./...`
- [x] run linter: `golangci-lint run`
- [x] run formatters: `~/.claude/format.sh`

### Task 5: [Final] Update documentation
- [x] update README.md key bindings table (add Q)
- [x] update README.md options table (add --no-confirm-discard)
- [x] update CLAUDE.md if needed
- [x] update plugin reference docs (`.claude-plugin/skills/revdiff/references/usage.md`, `config.md`)
- [x] move this plan to `docs/plans/completed/`
Loading
Loading