diff --git a/README.md b/README.md index 6003f8a..6b7c2f6 100644 --- a/README.md +++ b/README.md @@ -1139,6 +1139,11 @@ On a terminal: ⠹ [3/4] hash: [12345/98765], 92 files/s, elapsed 2m32s, remain 17m44s 12% |█ | ``` +- During `scan`'s `walk`, the line below the display shows the path of the last + file the walk found. Each draw shows the path of that moment, cut short before + the terminal's last column. The line is erased when the walk ends, and the + next phase is drawn on it. + - No line reaches the terminal's last column. A line too wide for the terminal first leaves out its rate, the words of a stalled phase, or what the phase is waiting for. It leaves out its bar next. It is cut short at the edge only when diff --git a/TODO.md b/TODO.md index 8424492..b59c383 100644 --- a/TODO.md +++ b/TODO.md @@ -29,6 +29,11 @@ Newest first. An entry ends with its date and its issue or branch, where those are known. +- On a terminal, the line below the `walk` display shows the path of the last + file the walk found, at each draw. The line is erased when the walk ends, and + the next phase is drawn on it (2026-11-10, + https://git.eeqj.de/sneak/sfdupes/issues/160) + - The Go code moved out of the repository root. `cmd/sfdupes/main.go` is the command, and it calls `Main` in the package `internal/sfdupes`. `main.go` became `internal/sfdupes/cli.go`. Every other file kept its name. The install diff --git a/go.mod b/go.mod index 5d7e17c..3cf9824 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module sneak.berlin/go/sfdupes go 1.25.7 require ( + github.com/rivo/uniseg v0.4.7 github.com/schollz/progressbar/v3 v3.19.1 github.com/spf13/cobra v1.10.2 golang.org/x/sys v0.46.0 @@ -18,7 +19,6 @@ require ( github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect modernc.org/libc v1.74.1 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/internal/sfdupes/progress.go b/internal/sfdupes/progress.go index 8d24bf4..6f372aa 100644 --- a/internal/sfdupes/progress.go +++ b/internal/sfdupes/progress.go @@ -11,6 +11,7 @@ import ( "time" "unicode/utf8" + "github.com/rivo/uniseg" "github.com/schollz/progressbar/v3" "golang.org/x/term" ) @@ -116,7 +117,8 @@ type drawnCount struct { // terminal's width. A phase with an exact total ends the line with its // percent and bar, at the terminal's right edge, when the terminal has // room for them. It is drawn again every redrawInterval, so its elapsed -// time keeps moving while the phase waits for its next item. When +// time keeps moving while the phase waits for its next item. Each draw +// of the walk shows the path it reached last on the line below. When // stderr is not a TTY it emits no ANSI redraws: it prints line as the // phase starts, every plainInterval while it runs, and as it ends. // @@ -141,6 +143,7 @@ type progress struct { estimate bool waitingFor string stalledOn string + path string // the path the walk reached last; "" for none count int64 start time.Time // when the phase started, for the elapsed time rateStart time.Time // when the count started, for the time remaining @@ -239,6 +242,48 @@ func (p *progress) draw() { // less the terminal's last column, which spinnerMargin keeps clear. A // bar's cells take one column each but more than one byte. p.widest = max(p.widest, utf8.RuneCountInString(text)+spinnerMargin-1) + + if p.path != "" { + // The library draws the path on the line below and returns to + // the phase's line. The path stays clear of the last column, so + // that no terminal wraps it. + _ = p.bar.AddDetail(cutToColumns(escapePath(p.path), width-1)) + } +} + +// cutToColumns returns s cut to at most columns terminal columns, at a +// character boundary. A wide character, as in Chinese, takes two +// columns. +func cutToColumns(s string, columns int) string { + used, end, state := 0, 0, -1 + + for rest := s; rest != ""; { + char, next, width, nextState := uniseg.FirstGraphemeClusterInString( + rest, state) + if used+width > columns { + break + } + + used += width + end += len(char) + rest, state = next, nextState + } + + return s[:end] +} + +// clear erases the display from the terminal: its line, and the path on +// the line below when one is shown. p.mu must be held. +func (p *progress) clear() { + if p.bar == nil { + return + } + + if p.path != "" { + _ = p.bar.AddDetail("") + } + + _ = p.bar.Clear() } // newBar has the library make what draw draws: its spinner, followed by @@ -263,6 +308,8 @@ func (p *progress) newBar(width int) { // The text carries the elapsed time and the time remaining. progressbar.OptionSetElapsedTime(false), progressbar.OptionSetPredictTime(false), + // The line below the phase's line, for the walk's path. + progressbar.OptionSetMaxDetailRow(1), ) p.widest = 0 } @@ -314,6 +361,19 @@ func (p *progress) increment() { p.lastItem = now } +// setPath records the path the walk reached last, which a display on a +// terminal shows at its next draw. +func (p *progress) setPath(path string) { + if p == nil { + return + } + + p.mu.Lock() + defer p.mu.Unlock() + + p.path = path +} + // warnf prints a one-line warning to stderr without corrupting the // display. The whole message is escaped like a report's path columns, // so a path holding a newline cannot split the warning. @@ -327,11 +387,9 @@ func (p *progress) warnf(format string, args ...any) { p.mu.Lock() defer p.mu.Unlock() - if p.bar != nil { - // The warning takes the display's line; the next draw shows - // the display again, on the line below. - _ = p.bar.Clear() - } + // The warning takes the display's line; the next draw shows the + // display again, on the line below. + p.clear() fmt.Fprintln(os.Stderr, msg) } @@ -392,9 +450,7 @@ func (w *progressWriter) Flush() error { w.p.mu.Lock() defer w.p.mu.Unlock() - if w.p.bar != nil { - _ = w.p.bar.Clear() - } + w.p.clear() } _, err := w.out.Write(w.buf) @@ -414,6 +470,13 @@ func (p *progress) finish() { close(p.stop) <-p.done + // The walk's path is only for someone watching the walk. Its line is + // erased, and the next phase is drawn on it. + p.mu.Lock() + p.clear() + p.path = "" + p.mu.Unlock() + p.draw() if p.width != nil { diff --git a/internal/sfdupes/progress_test.go b/internal/sfdupes/progress_test.go index 91fb275..ecd8362 100644 --- a/internal/sfdupes/progress_test.go +++ b/internal/sfdupes/progress_test.go @@ -6,6 +6,7 @@ import ( "math" "os" "path/filepath" + "regexp" "slices" "strings" "sync" @@ -1138,3 +1139,106 @@ func TestWarningAfterLastItemKeepsBar(t *testing.T) { t.Errorf("terminal shows %q, want a count of 2 of 2", shown) } } + +func TestCutToColumns(t *testing.T) { + t.Parallel() + + cases := []struct { + s string + columns int + want string + }{ + {"/srv/a", 10, "/srv/a"}, + {"/srv/photos", 8, "/srv/pho"}, + // Each of these two fullwidth letters takes two columns. + {"/AB/a", 4, "/A"}, + {"/AB/a", 5, "/AB"}, + // An accent that combines with the letter before stays with it. + {"/café/a", 5, "/café"}, + {"/srv", 0, ""}, + } + + for _, tc := range cases { + if got := cutToColumns(tc.s, tc.columns); got != tc.want { + t.Errorf("cutToColumns(%q, %d) = %q, want %q", + tc.s, tc.columns, got, tc.want) + } + } +} + +// pathLines returns the text of each line the library wrote below a +// display's line. It writes one as an erase to the end of the line, a +// carriage return, the text and a newline. +func pathLines(out string) []string { + matches := regexp.MustCompile("\x1b\\[K\r([^\n]*)\n"). + FindAllStringSubmatch(out, -1) + lines := make([]string, 0, len(matches)) + + for _, m := range matches { + lines = append(lines, m[1]) + } + + return lines +} + +// TestTerminalWalkShowsPathBelow checks that the walk's display on a +// terminal 30 columns wide shows the path it reached last on the line +// below, cut to 29 columns. That line is erased before a warning is +// printed, and again when the walk ends, so the next phase is drawn on +// an empty line. +// +//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr +func TestTerminalWalkShowsPathBelow(t *testing.T) { + stderr := captureStderr(t) + p := startProgress(progressParams{ + label: scanPhase("walk"), unit: scanUnit, total: -1, + }, func() int { return 30 }, testRedraw, time.Now) + + const shown = "/srv/photos/2026/holiday/beac" + + p.setPath("/srv/photos/2026/holiday/beach.jpg") + p.increment() + awaitOutput(t, stderr, shown, 1) + + p.warnf("warning") + + before, _, _ := strings.Cut(stderr(), "warning") + if lines := pathLines(before); lines[len(lines)-1] != "" { + t.Errorf("line below shows %q as the warning is printed, want it "+ + "empty", lines[len(lines)-1]) + } + + // Drawn again below the warning. + awaitOutput(t, stderr, shown, strings.Count(before, shown)+1) + p.finish() + + lines := pathLines(stderr()) + if !slices.Contains(lines, shown) { + t.Errorf("lines below the display = %q, want %q among them", + lines, shown) + } + + if last := lines[len(lines)-1]; last != "" { + t.Errorf("line below the display shows %q after the walk, want it "+ + "empty", last) + } +} + +// TestPlainWalkShowsNoPath checks that the walk's display prints no path +// when stderr is not a terminal. +// +//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr +func TestPlainWalkShowsNoPath(t *testing.T) { + stderr := captureStderr(t) + p := startProgress(progressParams{ + label: scanPhase("walk"), unit: scanUnit, total: -1, + }, nil, testRedraw, time.Now) + + p.setPath("/srv/a") + p.increment() + p.finish() + + if strings.Contains(stderr(), "/srv/a") { + t.Errorf("stderr = %q, want no path", stderr()) + } +} diff --git a/internal/sfdupes/scan.go b/internal/sfdupes/scan.go index ba45468..8c11e2f 100644 --- a/internal/sfdupes/scan.go +++ b/internal/sfdupes/scan.go @@ -542,6 +542,7 @@ func (s *scanState) walkPhase(events <-chan walkEvent, roots []string) { s.st.walked++ + prog.setPath(ev.rec.path) prog.increment() old, ok := s.existing[ev.rec.path]