Show the walk's path on the line below its display (closes #160)
check / check (push) Canceled after 0s

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
This commit is contained in:
clawbot committed 2026-11-11 06:56:20 +00:00
1 parent db56fd0e4c
commit 684c3680c9
6 files changed
+269 -10

No files matched your search

+8
View File
@@ -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
+6
View File
@@ -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
+1 -1
View File
@@ -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
+98 -9
View File
@@ -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 {
+155
View File
@@ -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())
}
}
+1
View File
@@ -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]