From 684c3680c99bf041ded7b52ef097237679ea10ae Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 10 Nov 2026 23:49:26 +0000 Subject: [PATCH] Show the walk's path on the line below its display (closes #160) On a terminal, each draw of the walk shows the path of the last file it found on the line below, cut short before the last column with wide characters counted as two columns. The progressbar library's detail row draws it. Control characters in the path are written out, as \t or \x1b, so that the terminal does not act on them. The line is erased before a warning and when the walk ends, so the next phase is drawn on it. Nothing changes when stderr is not a terminal. github.com/rivo/uniseg, already in the module graph through the progressbar library, becomes a direct dependency for the column count. Model: opus-5-5 --- README.md | 8 ++ TODO.md | 6 ++ go.mod | 2 +- internal/sfdupes/progress.go | 107 +++++++++++++++++++-- internal/sfdupes/progress_test.go | 155 ++++++++++++++++++++++++++++++ internal/sfdupes/scan.go | 1 + 6 files changed, 269 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 2db44c6..474e5a6 100644 --- a/README.md +++ b/README.md @@ -1191,6 +1191,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/TODO.md b/TODO.md index 9b7d422..6f5f25a 100644 --- a/TODO.md +++ b/TODO.md @@ -29,6 +29,12 @@ 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. Its control characters are written out, as + `\t` or `\x1b`, so that the terminal does not act on them. The line is erased + when the walk ends, and the next phase is drawn on it (2026-11-11, + https://git.eeqj.de/sneak/sfdupes/issues/160) + - The help of `sfdupes` and of `scan` names the database, its default path and `SFDUPES_DATABASE`. The help of `report` and `trees` lists the columns, the order of the rows and the escapes. The help of `info` lists its lines. The 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..c1d48ec 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,73 @@ 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. 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 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 +334,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 +387,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 +413,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 +476,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 +496,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..139af00 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,157 @@ 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`, + "/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 fdb0953..2a6c3c1 100644 --- a/internal/sfdupes/scan.go +++ b/internal/sfdupes/scan.go @@ -563,6 +563,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]