Repository: gokcehan/lf
Pull request #1298 (2023-06-10 23:24:25)

Make CI cross-compile lf for every supported platform; support darwin-arm64
This reworks the `gen/xbuild.sh` script to exit with a failure if any of the targets don't compile. It also adds a CI job to try cross-compiling for every platform on every pull request.

The new check is a little slow and compute-expensive, but I am hoping it's well within the quota.

See #1299 for a demo of how the CI would have failed after #1288 and before #1295.

Example of [a specific CI failure](https://github.com/gokcehan/lf/actions/runs/5237849410/jobs/9456282754) for #1299:

![screenshot](https://github.com/gokcehan/lf/assets/4123047/3d7a6b7c-8642-4d4c-a98a-c8b886c0b184)




-----

Also added support for `darwin-arm64`, as that has [at least one user](https://github.com/gokcehan/lf/issues/1294) and it seems to work fine.


ilyagr: Thank you for looking this over! It's past my bedtime; I'll look at your version and your other comments at some later point.

🌃💤  
joelim-work: BTW I forgot to mention but you may also want to run this through [`shellcheck`](https://github.com/koalaman/shellcheck). I usually use it to see if there's any variables I forgot to quote, or other mistakes that are easy to make when writing shell scripts.
ilyagr: OK, I updated this and borrowed quite a few of your ideas. Thank you very much!

I am also a fan of `shellcheck`. It demanded a bit too much quoting for my taste, but it's probably still a good idea to make scripts shellcheck-clean.
ilyagr: I added one more commit making the script a little more complex but decreasing repetition. Let me know if I should get rid of it.

Patch:
diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index f8ae79c..7124b0f 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -8,7 +8,7 @@ on:
 
 jobs:
 
-  build:
+  tests:
     runs-on: ${{ matrix.os }}
     strategy:
       matrix:
@@ -34,6 +34,21 @@ jobs:
         version: "2022.1.3"
         install-go: false
 
+  cross-compile:
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v3
+      with:
+        fetch-depth: 1
+
+    - name: Set up Go
+      uses: actions/setup-go@v4
+      with:
+        go-version: 1.18
+
+    - name: Cross-compile on all supported platforms
+      run: gen/xbuild.sh
+
   generate:
     runs-on: ubuntu-latest
     steps:
diff --git a/complete.go b/complete.go
index cae5082..3d91186 100644
--- a/complete.go
+++ b/complete.go
@@ -218,6 +218,7 @@ var (
 		"infotimefmtnew",
 		"infotimefmtold",
 		"truncatechar",
+		"truncatepct",
 	}
 )
 
diff --git a/doc.go b/doc.go
index 806b6aa..782f167 100644
--- a/doc.go
+++ b/doc.go
@@ -172,6 +172,7 @@ The following options can be used to customize the behavior of lf:
 	tempmarks        string    (default '')
 	timefmt          string    (default 'Mon Jan _2 15:04:05 2006')
 	truncatechar     string    (default '~')
+	truncatepct      int       (default 100)
 	waitmsg          string    (default 'Press any key to continue')
 	wrapscan         bool      (default true)
 	wrapscroll       bool      (default false)
@@ -941,6 +942,23 @@ Format string of the file modification time shown in the bottom line.
 
 Truncate character shown at the end when the file name does not fit to the pane.
 
+	truncatepct  int       (default 100)
+
+When a filename is too long to be shown completely, the available space is
+partitioned in two pieces. truncatepct defines a fraction (in percent
+between 0 and 100) for the size of the first piece, which will show the
+beginning of the filename. The second piece will show the end of the filename
+and will use the rest of the available space. Both pieces are separated by the
+truncation character (truncatechar).
+A value of 100 will only show the beginning of the filename,
+while a value of 0 will only show the end of the filename, e.g.:
+
+- `set truncatepct 100` -> "very-long-filename-tr~" (default)
+
+- `set truncatepct 50`  -> "very-long-f~-truncated"
+
+- `set truncatepct 0`   -> "~ng-filename-truncated"
+
 	waitmsg        string    (default 'Press any key to continue')
 
 String shown after commands of shell-wait type.
diff --git a/docstring.go b/docstring.go
index 61692de..24f6275 100644
--- a/docstring.go
+++ b/docstring.go
@@ -175,6 +175,7 @@ The following options can be used to customize the behavior of lf:
     tempmarks        string    (default '')
     timefmt          string    (default 'Mon Jan _2 15:04:05 2006')
     truncatechar     string    (default '~')
+    truncatepct      int       (default 100)
     waitmsg          string    (default 'Press any key to continue')
     wrapscan         bool      (default true)
     wrapscroll       bool      (default false)
@@ -1013,6 +1014,22 @@ Format string of the file modification time shown in the bottom line.
 
 Truncate character shown at the end when the file name does not fit to the pane.
 
+    truncatepct  int       (default 100)
+
+When a filename is too long to be shown completely, the available space is
+partitioned in two pieces. truncatepct defines a fraction (in percent between
+0 and 100) for the size of the first piece, which will show the beginning of
+the filename. The second piece will show the end of the filename and will use
+the rest of the available space. Both pieces are separated by the truncation
+character (truncatechar). A value of 100 will only show the beginning of the
+filename, while a value of 0 will only show the end of the filename, e.g.:
+
+- 'set truncatepct 100' -> "very-long-filename-tr~" (default)
+
+- 'set truncatepct 50' -> "very-long-f~-truncated"
+
+- 'set truncatepct 0' -> "~ng-filename-truncated"
+
     waitmsg        string    (default 'Press any key to continue')
 
 String shown after commands of shell-wait type.
diff --git a/eval.go b/eval.go
index 24975c3..2eea96d 100644
--- a/eval.go
+++ b/eval.go
@@ -831,6 +831,17 @@ func (e *setExpr) eval(app *app, args []string) {
 		}
 
 		gOpts.truncatechar = e.val
+	case "truncatepct":
+		n, err := strconv.Atoi(e.val)
+		if err != nil {
+			app.ui.echoerrf("truncatepct: %s", err)
+			return
+		}
+		if n < 0 || n > 100 {
+			app.ui.echoerrf("truncatepct: must be between 0 and 100 (both inclusive), got %d", n)
+			return
+		}
+		gOpts.truncatepct = n
 	case "waitmsg":
 		gOpts.waitmsg = e.val
 	case "wrapscan":
diff --git a/gen/xbuild.sh b/gen/xbuild.sh
index af8c814..0dc423f 100755
--- a/gen/xbuild.sh
+++ b/gen/xbuild.sh
@@ -8,51 +8,85 @@
 # compressed in an archive form (`.zip` for windows and `.tar.gz` for the rest)
 # within a folder named `dist`.
 
-set -o verbose
+[ -z "$version" ] && version=$(git describe --tags)
 
-[ -z $version ] && version=$(git describe --tags)
+test -d dist && {
+    echo "gen/xbuild.sh: WARNING: removing preexisting subdir 'dist'"
+    rm -r dist || exit 5
+}
+mkdir dist || exit 5
 
-mkdir -p dist
+ERRORS=
 
-# https://golang.org/doc/install/source#environment
+build() {
+    # https://golang.org/doc/install/source#environment
+    echo "=== Building for GOOS=$1 and GOARCH=$2."
+    CGO_ENABLED=0 GOOS="$1" GOARCH="$2" go build -o dist/ \
+        -ldflags="-s -w -X main.gVersion=$version"
+    if test "$?" != "0"; then
+        ERRORS=1
+    else
+        package "$1" "$2" || exit 5
+    fi
+}
 
-CGO_ENABLED=0 GOOS=android   GOARCH=arm64    go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-android-arm64.tar.gz   lf --remove-files
-CGO_ENABLED=0 GOOS=darwin    GOARCH=amd64    go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-darwin-amd64.tar.gz    lf --remove-files
-CGO_ENABLED=0 GOOS=dragonfly GOARCH=amd64    go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-dragonfly-amd64.tar.gz lf --remove-files
-CGO_ENABLED=0 GOOS=freebsd   GOARCH=386      go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-freebsd-386.tar.gz     lf --remove-files
-CGO_ENABLED=0 GOOS=freebsd   GOARCH=amd64    go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-freebsd-amd64.tar.gz   lf --remove-files
-CGO_ENABLED=0 GOOS=freebsd   GOARCH=arm      go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-freebsd-arm.tar.gz     lf --remove-files
-CGO_ENABLED=0 GOOS=illumos   GOARCH=amd64    go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-illumos-amd64.tar.gz   lf --remove-files
-CGO_ENABLED=0 GOOS=linux     GOARCH=386      go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-linux-386.tar.gz       lf --remove-files
-CGO_ENABLED=0 GOOS=linux     GOARCH=amd64    go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-linux-amd64.tar.gz     lf --remove-files
-CGO_ENABLED=0 GOOS=linux     GOARCH=arm      go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-linux-arm.tar.gz       lf --remove-files
-CGO_ENABLED=0 GOOS=linux     GOARCH=arm64    go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-linux-arm64.tar.gz     lf --remove-files
-CGO_ENABLED=0 GOOS=linux     GOARCH=ppc64    go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-linux-ppc64.tar.gz     lf --remove-files
-CGO_ENABLED=0 GOOS=linux     GOARCH=ppc64le  go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-linux-ppc64le.tar.gz   lf --remove-files
-CGO_ENABLED=0 GOOS=linux     GOARCH=mips     go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-linux-mips.tar.gz      lf --remove-files
-CGO_ENABLED=0 GOOS=linux     GOARCH=mipsle   go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-linux-mipsle.tar.gz    lf --remove-files
-CGO_ENABLED=0 GOOS=linux     GOARCH=mips64   go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-linux-mips64.tar.gz    lf --remove-files
-CGO_ENABLED=0 GOOS=linux     GOARCH=mips64le go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-linux-mips64le.tar.gz  lf --remove-files
-CGO_ENABLED=0 GOOS=linux     GOARCH=s390x    go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-linux-s390x.tar.gz     lf --remove-files
-CGO_ENABLED=0 GOOS=netbsd    GOARCH=386      go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-netbsd-386.tar.gz      lf --remove-files
-CGO_ENABLED=0 GOOS=netbsd    GOARCH=amd64    go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-netbsd-amd64.tar.gz    lf --remove-files
-CGO_ENABLED=0 GOOS=netbsd    GOARCH=arm      go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-netbsd-arm.tar.gz      lf --remove-files
-CGO_ENABLED=0 GOOS=openbsd   GOARCH=386      go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-openbsd-386.tar.gz     lf --remove-files
-CGO_ENABLED=0 GOOS=openbsd   GOARCH=amd64    go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-openbsd-amd64.tar.gz   lf --remove-files
-CGO_ENABLED=0 GOOS=openbsd   GOARCH=arm      go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-openbsd-arm.tar.gz     lf --remove-files
-CGO_ENABLED=0 GOOS=openbsd   GOARCH=arm64    go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-openbsd-arm64.tar.gz   lf --remove-files
-CGO_ENABLED=0 GOOS=solaris   GOARCH=amd64    go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-solaris-amd64.tar.gz   lf --remove-files
+package() (
+    cd dist || return 1 
+    # Since the function is surrounded by (), the cd only affects a subshell
+    OUTFILE=
+    case "$1" in
+    windows)
+        OUTFILE="lf-$1-$2.zip"
+        zip "$OUTFILE" lf.exe --move || return 1
+        ;;
+    *)
+        OUTFILE="lf-$1-$2.tar.gz"
+        tar czf "$OUTFILE" lf --remove-files || return 1
+        ;;
+    esac
+    echo "dist/$OUTFILE successfully created."
+)
 
-CGO_ENABLED=0 GOOS=windows   GOARCH=386      go build -ldflags="-s -w -X main.gVersion=$version" && zip dist/lf-windows-386.zip            lf.exe --move
-CGO_ENABLED=0 GOOS=windows   GOARCH=amd64    go build -ldflags="-s -w -X main.gVersion=$version" && zip dist/lf-windows-amd64.zip          lf.exe --move
+build android arm64
+build darwin amd64
+build darwin arm64
+build dragonfly amd64
+build freebsd 386
+build freebsd amd64
+build freebsd arm
+build illumos amd64
+build linux 386
+build linux amd64
+build linux arm
+build linux arm64
+build linux ppc64
+build linux ppc64le
+build linux mips
+build linux mipsle
+build linux mips64
+build linux mips64le
+build linux s390x
+build netbsd 386
+build netbsd amd64
+build netbsd arm
+build openbsd 386
+build openbsd amd64
+build openbsd arm
+build openbsd arm64
+build solaris amd64
+build windows 386
+build windows amd64
+# Unsupported
+# build aix ppc64
+# build android 386
+# build android amd64
+# build android arm
+# build js wasm
+# build plan9 386
+# build plan9 amd64
+# build plan9 arm
 
-# not supported
-# CGO_ENABLED=0 GOOS=aix       GOARCH=ppc64    go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-aix-ppc64.tar.gz       lf --remove-files
-# CGO_ENABLED=0 GOOS=android   GOARCH=386      go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-android-386.tar.gz     lf --remove-files
-# CGO_ENABLED=0 GOOS=android   GOARCH=amd64    go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-android-amd64.tar.gz   lf --remove-files
-# CGO_ENABLED=0 GOOS=android   GOARCH=arm      go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-android-arm.tar.gz     lf --remove-files
-# CGO_ENABLED=0 GOOS=darwin    GOARCH=arm64    go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-darwin-arm64.tar.gz    lf --remove-files
-# CGO_ENABLED=0 GOOS=js        GOARCH=wasm     go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-js-wasm.tar.gz         lf --remove-files
-# CGO_ENABLED=0 GOOS=plan9     GOARCH=386      go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-plan9-386.tar.gz       lf --remove-files
-# CGO_ENABLED=0 GOOS=plan9     GOARCH=amd64    go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-plan9-amd64.tar.gz     lf --remove-files
-# CGO_ENABLED=0 GOOS=plan9     GOARCH=arm      go build -ldflags="-s -w -X main.gVersion=$version" && tar czf dist/lf-plan9-arm.tar.gz       lf --remove-files
+if test -n "$ERRORS"; then
+    printf "\ngen/xbuild.sh: some targets failed to compile.\n"
+    exit 1
+fi
diff --git a/lf.1 b/lf.1
index 561edc5..2804db4 100644
--- a/lf.1
+++ b/lf.1
@@ -191,6 +191,7 @@ The following options can be used to customize the behavior of lf:
     tempmarks        string    (default '')
     timefmt          string    (default 'Mon Jan _2 15:04:05 2006')
     truncatechar     string    (default '~')
+    truncatepct      int       (default 100)
     waitmsg          string    (default 'Press any key to continue')
     wrapscan         bool      (default true)
     wrapscroll       bool      (default false)
@@ -1119,6 +1120,18 @@ Format string of the file modification time shown in the bottom line.
 .PP
 Truncate character shown at the end when the file name does not fit to the pane.
 .PP
+.EX
+    truncatepct  int       (default 100)
+.EE
+.PP
+When a filename is too long to be shown completely, the available space is partitioned in two pieces. truncatepct defines a fraction (in percent between 0 and 100) for the size of the first piece, which will show the beginning of the filename. The second piece will show the end of the filename and will use the rest of the available space. Both pieces are separated by the truncation character (truncatechar). A value of 100 will only show the beginning of the filename, while a value of 0 will only show the end of the filename, e.g.:
+.PP
+- `set truncatepct 100` -> "very-long-filename-tr~" (default)
+.PP
+- `set truncatepct 50` -> "very-long-f~-truncated"
+.PP
+- `set truncatepct 0` -> "~ng-filename-truncated"
+.PP
 .EX
     waitmsg        string    (default 'Press any key to continue')
 .EE
diff --git a/misc.go b/misc.go
index b0303ea..8199697 100644
--- a/misc.go
+++ b/misc.go
@@ -53,6 +53,19 @@ func runeSliceWidthRange(rs []rune, beg, end int) []rune {
 	return rs[b:]
 }
 
+// Returns the last runes of `rs` that take up at most `maxWidth` space.
+func runeSliceWidthLastRange(rs []rune, maxWidth int) []rune {
+	lastWidth := 0
+	for i := len(rs) - 1; i >= 0; i-- {
+		w := runewidth.RuneWidth(rs[i])
+		if lastWidth+w > maxWidth {
+			return rs[i+1:]
+		}
+		lastWidth += w
+	}
+	return rs
+}
+
 // This function is used to escape whitespaces and special characters with
 // backlashes in a given string.
 func escape(s string) string {
diff --git a/opts.go b/opts.go
index 21d624c..5b8feaa 100644
--- a/opts.go
+++ b/opts.go
@@ -72,6 +72,7 @@ var gOpts struct {
 	infotimefmtnew   string
 	infotimefmtold   string
 	truncatechar     string
+	truncatepct      int
 	ratios           []int
 	hiddenfiles      []string
 	history          bool
@@ -133,6 +134,7 @@ func init() {
 	gOpts.infotimefmtnew = "Jan _2 15:04"
 	gOpts.infotimefmtold = "Jan _2  2006"
 	gOpts.truncatechar = "~"
+	gOpts.truncatepct = 100
 	gOpts.ratios = []int{1, 2, 3}
 	gOpts.hiddenfiles = []string{".*"}
 	gOpts.history = true
diff --git a/ui.go b/ui.go
index 635a428..4b8cb55 100644
--- a/ui.go
+++ b/ui.go
@@ -468,8 +468,11 @@ func (win *win) printDir(screen tcell.Screen, dir *dir, context *dirContext, dir
 
 		filename := []rune(f.Name())
 		if runeSliceWidth(filename) > maxFilenameWidth {
-			filename = runeSliceWidthRange(filename, 0, maxFilenameWidth-1)
+			truncatePos := (maxFilenameWidth - 1) * gOpts.truncatepct / 100
+			lastPart := runeSliceWidthLastRange(filename, maxFilenameWidth-truncatePos-1)
+			filename = runeSliceWidthRange(filename, 0, truncatePos)
 			filename = append(filename, []rune(gOpts.truncatechar)...)
+			filename = append(filename, lastPart...)
 		}
 		for i := runeSliceWidth(filename); i < maxFilenameWidth; i++ {
 			filename = append(filename, ' ')


Test patch:
diff --git a/misc_test.go b/misc_test.go
index aa6f6df..758e342 100644
--- a/misc_test.go
+++ b/misc_test.go
@@ -83,6 +83,47 @@ func TestRuneSliceWidthRange(t *testing.T) {
 	}
 }
 
+func TestRuneSliceWidthLastRange(t *testing.T) {
+	tests := []struct {
+		rs       []rune
+		maxWidth int
+		exp      []rune
+	}{
+		{[]rune{}, 0, []rune{}},
+		{[]rune{}, 1, []rune{}},
+		{[]rune{'a', 'ı', 'b', ' '}, 0, []rune{}},
+		{[]rune{'a', 'ı', 'b', ' '}, 1, []rune{' '}},
+		{[]rune{'a', 'ı', 'b', ' '}, 2, []rune{'b', ' '}},
+		{[]rune{'a', 'ı', 'b', ' '}, 3, []rune{'ı', 'b', ' '}},
+		{[]rune{'a', 'ı', 'b', ' '}, 4, []rune{'a', 'ı', 'b', ' '}},
+		{[]rune{'a', 'ı', 'b', ' '}, 5, []rune{'a', 'ı', 'b', ' '}},
+		{[]rune{'世', '界', '世', '界'}, 0, []rune{}},
+		{[]rune{'世', '界', '世', '界'}, 1, []rune{}},
+		{[]rune{'世', '界', '世', '界'}, 2, []rune{'界'}},
+		{[]rune{'世', '界', '世', '界'}, 3, []rune{'界'}},
+		{[]rune{'世', '界', '世', '界'}, 4, []rune{'世', '界'}},
+		{[]rune{'世', '界', '世', '界'}, 5, []rune{'世', '界'}},
+		{[]rune{'世', '界', '世', '界'}, 6, []rune{'界', '世', '界'}},
+		{[]rune{'世', '界', '世', '界'}, 7, []rune{'界', '世', '界'}},
+		{[]rune{'世', '界', '世', '界'}, 8, []rune{'世', '界', '世', '界'}},
+		{[]rune{'世', '界', '世', '界'}, 9, []rune{'世', '界', '世', '界'}},
+		{[]rune{'世', 'a', '界', 'ı'}, 0, []rune{}},
+		{[]rune{'世', 'a', '界', 'ı'}, 1, []rune{'ı'}},
+		{[]rune{'世', 'a', '界', 'ı'}, 2, []rune{'ı'}},
+		{[]rune{'世', 'a', '界', 'ı'}, 3, []rune{'界', 'ı'}},
+		{[]rune{'世', 'a', '界', 'ı'}, 4, []rune{'a', '界', 'ı'}},
+		{[]rune{'世', 'a', '界', 'ı'}, 5, []rune{'a', '界', 'ı'}},
+		{[]rune{'世', 'a', '界', 'ı'}, 6, []rune{'世', 'a', '界', 'ı'}},
+		{[]rune{'世', 'a', '界', 'ı'}, 7, []rune{'世', 'a', '界', 'ı'}},
+	}
+
+	for _, test := range tests {
+		if got := runeSliceWidthLastRange(test.rs, test.maxWidth); !reflect.DeepEqual(got, test.exp) {
+			t.Errorf("at input '%v' expected '%v' but got '%v'", test.rs, test.exp, got)
+		}
+	}
+}
+
 func TestEscape(t *testing.T) {
 	tests := []struct {
 		s   string