From 37893d2d5af8da96567bd70857521ff86e20d798 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 11 Nov 2026 09:37:21 +0100 Subject: [PATCH] Show the walk path on the line below its display (closes #160) (#166) --- README.md | 8 ++ go.mod | 2 +- internal/sfdupes/progress.go | 116 +++++++++++++++++++-- internal/sfdupes/progress_test.go | 162 ++++++++++++++++++++++++++++++ internal/sfdupes/scan.go | 1 + 5 files changed, 279 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b815066..d326640 100644 --- a/README.md +++ b/README.md @@ -1241,6 +1241,14 @@ 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 path is escaped as in the reports (see "Report + output format"). Each control character left after that is written as `\x` and + its code in two hexadecimal digits, such as `\x1b` for the escape character. A + control character is one that a terminal acts on instead of showing. 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/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..4a8e006 100644 --- a/internal/sfdupes/progress.go +++ b/internal/sfdupes/progress.go @@ -9,8 +9,10 @@ import ( "strings" "sync" "time" + "unicode" "unicode/utf8" + "github.com/rivo/uniseg" "github.com/schollz/progressbar/v3" "golang.org/x/term" ) @@ -116,7 +118,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 +144,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 +243,82 @@ 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 is escaped as warnings are, and its + // other control characters are written out, so that the terminal + // shows them instead of acting on them. It stays clear of the + // last column, so that no terminal wraps it. + shown := escapeControlChars(escapePath(p.path)) + _ = p.bar.AddDetail(cutToColumns(shown, width-1)) + } +} + +// escapeControlChars returns s with each control character written as +// \x and its code in two hex digits, as \x1b for the escape character. +// The control characters are U+0000 to U+001F, U+007F and U+0080 to +// U+009F, as unicode.IsControl reports them. A byte from 0x80 to 0x9F +// that is not part of a valid UTF-8 character is written the same way, +// as \x9b, since a terminal not in UTF-8 mode acts on it. Every other +// byte is kept as it is. +func escapeControlChars(s string) string { + var b strings.Builder + + for rest := s; rest != ""; { + char, size := utf8.DecodeRuneInString(rest) + if char == utf8.RuneError && size == 1 { + // Not part of a valid UTF-8 character. A terminal not in + // UTF-8 mode reads the byte as the character with its code, + // so that 0x9b is a control character there and 0xe9 is not. + char = rune(rest[0]) + } + + if unicode.IsControl(char) { + fmt.Fprintf(&b, `\x%02x`, char) + } else { + b.WriteString(rest[:size]) + } + + rest = rest[size:] + } + + return b.String() +} + +// 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 +343,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 +396,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 +422,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 +485,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 +505,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 c2a6401..50ec4db 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,164 @@ 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) + } + } +} + +func TestEscapeControlChars(t *testing.T) { + t.Parallel() + + cases := map[string]string{ + "/srv/music": "/srv/music", + "/a\x1b[2Jb": `/a\x1b[2Jb`, + "/a\x00\a\x7fb": `/a\x00\x07\x7fb`, + // U+009B takes two bytes in UTF-8. + "/a\u009b2Jb": `/a\x9b2Jb`, + // Byte 0x9b alone is not valid UTF-8. A terminal not in UTF-8 + // mode acts on it, as on every byte from 0x80 to 0x9f. + "/a\x9b2Jb": `/a\x9b2Jb`, + "/a\x80\x9f\xa0b": `/a\x80\x9f` + "\xa0b", + // In ě, which is c4 9b in UTF-8, byte 0x9b is part of a valid + // character. + "/aě2Jb": "/aě2Jb", + "/café/AB": "/café/AB", + "/latin1-caf\xe9": "/latin1-caf\xe9", + } + for in, want := range cases { + if got := escapeControlChars(in); got != want { + t.Errorf("escapeControlChars(%q) = %q, want %q", in, got, 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) + } +} + +// TestTerminalWalkEscapesControlChars checks that a walked path holding +// escape characters, which would clear the screen and set the window +// title, puts none on the line below the walk's display. The line shows +// each control character written out, as \x1b. +// +//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr +func TestTerminalWalkEscapesControlChars(t *testing.T) { + stderr := captureStderr(t) + p := startProgress(progressParams{ + label: scanPhase("walk"), unit: scanUnit, total: -1, + }, func() int { return 80 }, testRedraw, time.Now) + + p.setPath("/srv/\x1b[2J\x1b]0;title\a/beach.jpg") + p.increment() + awaitOutput(t, stderr, "beach.jpg", 1) + p.finish() + + lines := pathLines(stderr()) + for _, line := range lines { + if strings.Contains(line, "\x1b") { + t.Errorf("line below the display = %q, want no escape "+ + "character", line) + } + } + + const shown = `/srv/\x1b[2J\x1b]0;title\x07/beach.jpg` + if !slices.Contains(lines, shown) { + t.Errorf("lines below the display = %q, want %q among them", + lines, shown) + } +} + +// 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 d716056..13d55a1 100644 --- a/internal/sfdupes/scan.go +++ b/internal/sfdupes/scan.go @@ -562,6 +562,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]