Skip to content

Commit e616b2a

Browse files
verttidlvhdr
andauthored
feat: add watch mode for auto-refreshing diffs (#96)
## Summary When working on a branch, it's useful to leave diffnav open in a terminal tab and have it automatically reflect the latest changes. This adds `--watch` mode, which periodically re-runs a diff command and refreshes the TUI. ```sh diffnav --watch diffnav --watch --watch-cmd "git diff main..." --watch-interval 5s ``` ## Details - New flags: `--watch` (`-w`), `--watch-cmd` (default: `git diff`), `--watch-interval` (default: `2s`) - Watch tick/result message loop with in-flight guard to prevent overlapping fetches - Stderr discarded in watch command execution to avoid corrupting the TUI - `ClearCache` on diffviewer for clean re-renders on refresh - Active watch command shown in footer bar - README updated with watch mode flags and usage examples --------- Co-authored-by: Dolev Hadar <dolevc2@gmail.com>
1 parent dc735a5 commit e616b2a

15 files changed

Lines changed: 357 additions & 73 deletions

File tree

.github/workflows/build.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: build
2+
3+
on:
4+
pull_request:
5+
6+
permissions:
7+
contents: read
8+
9+
jobs:
10+
build:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
- uses: actions/setup-go@v5
15+
with:
16+
go-version: stable
17+
- name: test
18+
run: |
19+
go test -v -failfast -race -coverpkg=./... -covermode=atomic -coverprofile=coverage.txt ./...
20+
- uses: codecov/codecov-action@v5
21+
if: matrix.os == 'ubuntu-latest'
22+
with:
23+
token: ${{ secrets.CODECOV_TOKEN }}
24+
file: ./coverage.txt

.github/workflows/lint.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: golangci-lint
2+
on:
3+
pull_request:
4+
5+
permissions:
6+
contents: read
7+
8+
jobs:
9+
golangci:
10+
name: lint
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
- uses: actions/setup-go@v5
15+
with:
16+
go-version: stable
17+
- name: golangci-lint
18+
uses: golangci/golangci-lint-action@v9
19+
with:
20+
version: v2.11.3

README.md

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,13 @@ git config --global pager.diff diffnav
5858

5959
## Flags
6060

61-
| Flag | Description |
62-
| -------------------- | ---------------------------- |
63-
| `--side-by-side, -s` | Force side-by-side diff view |
64-
| `--unified, -u` | Force unified diff view |
61+
| Flag | Description |
62+
| -------------------- | ------------------------------------------------ |
63+
| `--side-by-side, -s` | Force side-by-side diff view |
64+
| `--unified, -u` | Force unified diff view |
65+
| `--watch, -w` | Watch mode: periodically re-run a command and refresh |
66+
| `--watch-cmd` | Command to run in watch mode (implies `--watch`, default: `git diff`) |
67+
| `--watch-interval` | Interval between watch refreshes (default: `2s`) |
6568

6669
Example:
6770

@@ -70,6 +73,21 @@ git diff | diffnav --unified
7073
git diff | diffnav -u
7174
```
7275

76+
### Watch Mode
77+
78+
Watch mode lets diffnav periodically re-run a diff command and refresh the display automatically. This is useful for monitoring changes as you work.
79+
80+
```sh
81+
# watch unstaged changes (default: git diff, every 2s)
82+
diffnav --watch
83+
84+
# watch staged changes with a custom interval
85+
diffnav --watch-cmd "git diff --cached" --watch-interval 5s
86+
87+
# watch changes against a specific branch
88+
diffnav --watch-cmd "git diff main..."
89+
```
90+
7391
## Configuration
7492

7593
The config file is searched in this order:

cmd/root.go

Lines changed: 69 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/dlvhdr/diffnav/pkg/config"
2424
"github.com/dlvhdr/diffnav/pkg/ui"
2525
"github.com/dlvhdr/diffnav/pkg/version"
26+
"github.com/dlvhdr/diffnav/pkg/watch"
2627
)
2728

2829
//go:embed logo-diff-part.txt
@@ -48,6 +49,10 @@ gh pr diff https://github.com/dlvhdr/gh-dash/pull/447 | diffnav
4849
4950
# set up as the global git diff pager
5051
git config --global pager.diff diffnav
52+
53+
# watch mode: auto-refresh a diff command
54+
diffnav --watch
55+
diffnav --watch --watch-cmd "git diff HEAD" --watch-interval 5s
5156
`,
5257
}
5358

@@ -79,6 +84,11 @@ func init() {
7984

8085
rootCmd.Flags().BoolP("unified", "u", false, "Force unified diff view")
8186

87+
rootCmd.Flags().
88+
BoolP("watch", "w", false, "Watch mode: periodically re-run a diff command and refresh")
89+
rootCmd.Flags().String("watch-cmd", "git diff", "Command to run in watch mode")
90+
rootCmd.Flags().Duration("watch-interval", 2*time.Second, "Interval between watch refreshes")
91+
8292
rootCmd.SetVersionTemplate("\n" + logo + "\n" + `{{printf "version %s\n" .Version}}`)
8393

8494
rootCmd.Run = func(cmd *cobra.Command, args []string) {
@@ -97,17 +107,23 @@ func init() {
97107
log.Fatal("Cannot parse the help flag", err)
98108
}
99109

100-
zone.NewGlobal()
101-
102-
stat, err := os.Stdin.Stat()
110+
watchFlag, err := cmd.Flags().GetBool("watch")
103111
if err != nil {
104-
panic(err)
112+
log.Fatal("Cannot parse the watch flag", err)
105113
}
106-
107-
if !helpFlag && stat.Mode()&os.ModeNamedPipe == 0 && stat.Size() == 0 {
108-
fmt.Println("No diff, exiting")
109-
os.Exit(0)
114+
watchCmd, err := cmd.Flags().GetString("watch-cmd")
115+
if err != nil {
116+
log.Fatal("Cannot parse the watch-cmd flag", err)
110117
}
118+
watchInterval, err := cmd.Flags().GetDuration("watch-interval")
119+
if err != nil {
120+
log.Fatal("Cannot parse the watch-interval flag", err)
121+
}
122+
if cmd.Flags().Changed("watch-cmd") {
123+
watchFlag = true
124+
}
125+
126+
zone.NewGlobal()
111127

112128
if os.Getenv("DEBUG") == "true" {
113129
var fileErr error
@@ -144,26 +160,50 @@ func init() {
144160
log.SetLevel(log.FatalLevel)
145161
}
146162

147-
reader := bufio.NewReader(os.Stdin)
148-
var b strings.Builder
163+
var input string
164+
if watchFlag {
165+
stat, sErr := os.Stdin.Stat()
166+
if sErr == nil && stat.Mode()&os.ModeNamedPipe != 0 {
167+
fmt.Fprintln(os.Stderr, "Warning: stdin input ignored in watch mode")
168+
}
169+
output, wErr := watch.RunCmd(watchCmd)
170+
if wErr != nil {
171+
log.Warn("initial watch command failed, starting with empty diff", "err", wErr)
172+
}
173+
input = output
174+
} else {
175+
stat, sErr := os.Stdin.Stat()
176+
if sErr != nil {
177+
panic(sErr)
178+
}
179+
180+
if !helpFlag && stat.Mode()&os.ModeNamedPipe == 0 && stat.Size() == 0 {
181+
fmt.Println("No diff, exiting")
182+
os.Exit(0)
183+
}
149184

150-
for {
151-
r, _, err := reader.ReadRune()
152-
if err != nil && err == io.EOF {
153-
break
185+
reader := bufio.NewReader(os.Stdin)
186+
var b strings.Builder
187+
188+
for {
189+
r, _, rErr := reader.ReadRune()
190+
if rErr != nil && rErr == io.EOF {
191+
break
192+
}
193+
_, rErr = b.WriteRune(r)
194+
if rErr != nil {
195+
fmt.Println("Error getting input:", rErr)
196+
os.Exit(1)
197+
}
154198
}
155-
_, err = b.WriteRune(r)
156-
if err != nil {
157-
fmt.Println("Error getting input:", err)
158-
os.Exit(1)
199+
200+
input = ansi.Strip(b.String())
201+
if strings.TrimSpace(input) == "" {
202+
fmt.Println("No input provided, exiting")
203+
os.Exit(0)
159204
}
160205
}
161206

162-
input := ansi.Strip(b.String())
163-
if strings.TrimSpace(input) == "" {
164-
fmt.Println("No input provided, exiting")
165-
os.Exit(0)
166-
}
167207
cfg := config.Load()
168208

169209
// Override config with CLI flags if specified
@@ -173,6 +213,12 @@ func init() {
173213
cfg.UI.SideBySide = true
174214
}
175215

216+
cfg.Watch = config.WatchConfig{
217+
Enabled: watchFlag,
218+
Cmd: watchCmd,
219+
Interval: watchInterval,
220+
}
221+
176222
ttyIn, _, err := tea.OpenTTY()
177223
if err != nil {
178224
log.Fatal(err)

devbox.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
{
22
"$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.14.0/.schema/devbox.schema.json",
33
"packages": {
4-
"go": "1.22.6",
5-
"gopls": "latest",
6-
"golangci-lint": "latest",
7-
"svu": "latest",
8-
"git": "latest",
9-
"go-task": "latest"
4+
"go": "1.22.6",
5+
"gopls": "latest",
6+
"golangci-lint": "2.11.3",
7+
"svu": "latest",
8+
"git": "latest",
9+
"go-task": "latest"
1010
},
1111
"shell": {
1212
"scripts": {

devbox.lock

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -172,51 +172,51 @@
172172
}
173173
}
174174
},
175-
"golangci-lint@latest": {
176-
"last_modified": "2025-03-25T17:32:05Z",
177-
"resolved": "github:NixOS/nixpkgs/25d1b84f5c90632a623c48d83a2faf156451e6b1#golangci-lint",
175+
"golangci-lint@2.11.3": {
176+
"last_modified": "2026-03-11T04:01:32Z",
177+
"resolved": "github:NixOS/nixpkgs/b6067cc0127d4db9c26c79e4de0513e58d0c40c9#golangci-lint",
178178
"source": "devbox-search",
179-
"version": "2.0.0",
179+
"version": "2.11.3",
180180
"systems": {
181181
"aarch64-darwin": {
182182
"outputs": [
183183
{
184184
"name": "out",
185-
"path": "/nix/store/idv9cyl4i6w9n4sgc29kvqhywm04n1rz-golangci-lint-2.0.0",
185+
"path": "/nix/store/zalxgqwyng4jdbv5cpsmm9xbmim9hjjj-golangci-lint-2.11.3",
186186
"default": true
187187
}
188188
],
189-
"store_path": "/nix/store/idv9cyl4i6w9n4sgc29kvqhywm04n1rz-golangci-lint-2.0.0"
189+
"store_path": "/nix/store/zalxgqwyng4jdbv5cpsmm9xbmim9hjjj-golangci-lint-2.11.3"
190190
},
191191
"aarch64-linux": {
192192
"outputs": [
193193
{
194194
"name": "out",
195-
"path": "/nix/store/r0jxwvqvk2999dx04v3j9jgd46jscqc4-golangci-lint-2.0.0",
195+
"path": "/nix/store/mbra9idrmw8f8a4sqv2d0bf6vw8gcmfp-golangci-lint-2.11.3",
196196
"default": true
197197
}
198198
],
199-
"store_path": "/nix/store/r0jxwvqvk2999dx04v3j9jgd46jscqc4-golangci-lint-2.0.0"
199+
"store_path": "/nix/store/mbra9idrmw8f8a4sqv2d0bf6vw8gcmfp-golangci-lint-2.11.3"
200200
},
201201
"x86_64-darwin": {
202202
"outputs": [
203203
{
204204
"name": "out",
205-
"path": "/nix/store/lsyy8arab3zvkpi8lr9303mf88y5k1rc-golangci-lint-2.0.0",
205+
"path": "/nix/store/ph64c01zwvbr9xvxydxa8ijvbdfax8fy-golangci-lint-2.11.3",
206206
"default": true
207207
}
208208
],
209-
"store_path": "/nix/store/lsyy8arab3zvkpi8lr9303mf88y5k1rc-golangci-lint-2.0.0"
209+
"store_path": "/nix/store/ph64c01zwvbr9xvxydxa8ijvbdfax8fy-golangci-lint-2.11.3"
210210
},
211211
"x86_64-linux": {
212212
"outputs": [
213213
{
214214
"name": "out",
215-
"path": "/nix/store/834gvbmhpwvy1d65r5x4xihkxm4g91ab-golangci-lint-2.0.0",
215+
"path": "/nix/store/i1xcg5f24w70llk4v6lzi0vi4jf3ma5k-golangci-lint-2.11.3",
216216
"default": true
217217
}
218218
],
219-
"store_path": "/nix/store/834gvbmhpwvy1d65r5x4xihkxm4g91ab-golangci-lint-2.0.0"
219+
"store_path": "/nix/store/i1xcg5f24w70llk4v6lzi0vi4jf3ma5k-golangci-lint-2.11.3"
220220
}
221221
}
222222
},

mise.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[tools]
2+
go = "1.24.11"

pkg/config/config.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"os"
55
"path/filepath"
66
"runtime"
7+
"time"
78

89
"gopkg.in/yaml.v3"
910
)
@@ -20,8 +21,15 @@ type UIConfig struct {
2021
SideBySide bool `yaml:"sideBySide"` // Side-by-side diff view (default: true)
2122
}
2223

24+
type WatchConfig struct {
25+
Enabled bool
26+
Cmd string
27+
Interval time.Duration
28+
}
29+
2330
type Config struct {
24-
UI UIConfig `yaml:"ui"`
31+
UI UIConfig `yaml:"ui"`
32+
Watch WatchConfig `yaml:"-"`
2533
}
2634

2735
func DefaultConfig() Config {

pkg/ui/panes/diffviewer/diffviewer.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ func (m *Model) SetSize(width, height int) tea.Cmd {
113113
m.Height = height
114114
m.vp.SetWidth(m.contentWidth())
115115
m.vp.SetHeight(m.Height - dirHeaderHeight)
116-
m.cache = make(nodeCache)
116+
m.ClearCache()
117117
return m.diff()
118118
}
119119

@@ -368,7 +368,10 @@ func renderPreamble(preamble string) string {
368368
for _, line := range strings.Split(preamble, "\n") {
369369
switch {
370370
case strings.HasPrefix(line, "commit "):
371-
out = append(out, dim.Render("commit ")+yellow.Render(strings.TrimPrefix(line, "commit ")))
371+
out = append(
372+
out,
373+
dim.Render("commit ")+yellow.Render(strings.TrimPrefix(line, "commit ")),
374+
)
372375
case strings.HasPrefix(line, "Author:"),
373376
strings.HasPrefix(line, "AuthorDate:"),
374377
strings.HasPrefix(line, "Date:"),
@@ -389,6 +392,10 @@ type diffContentMsg struct {
389392
text string
390393
}
391394

395+
func (m *Model) ClearCache() {
396+
m.cache = make(nodeCache)
397+
}
398+
392399
func (m *Model) RootDiffStats() (int64, int64) {
393400
if item, ok := m.cache[cacheKey("/", m.sideBySide)]; ok {
394401
return item.additions, item.deletions

pkg/ui/panes/filetree/filetree.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,6 @@ func collapseTree(t *tree.Node) *tree.Node {
312312
child := newChildren[0]
313313
// If the child is dir with one chlid that's also a dir -> collapse it
314314
if dir, ok := child.GivenValue().(*dirnode.DirNode); ok {
315-
316315
// if the only child is a tree and its parent is the root we don't want to collapse.
317316
// The root should always be visible
318317
if rootDir.Name == constants.RootName {

0 commit comments

Comments
 (0)