Repository: gokcehan/lf
Pull request #1381 (2023-08-12 13:53:21)

add 'setlocal' for directory specific options
cc #117 #171

This has been on the back of my mind for quite a while as sorting the downloads directory by time automatically is quite common in other file managers, so I decided to give this a try today. I'm pushing this as a PR to benefit from our usual review mechanism, so discussion is welcomed.

You can try this with the following example:

```
setlocal ~/Downloads sortby time
```

This is still a draft. Some things that are worth mentioning are as follows:

1. There were two approaches in my mind before I started working on this, keep the local option values in `dir` or in a global map. First approach seemed a little complicated as we want to be able to set local options even if the corresponding directories are not loaded yet. On the other hand, the second approach requires checking the global map each time an option is used, though I don't expect it to create a performance bottleneck.

2. Adding a new keyword (i.e. `setlocal`) seemed inevitable. I also thought about reusing the `set` keyword with an optional directory value, but `set` already have an optional field for boolean options, so `setlocal` seemed like the safer choice. It is possible to shorten `setlocal` to `setl` but I think it is better to be explicit in this case.

3. I have only added `sortby` so far for testing. In general, there are many options that are not applicable as a local option or they don't make much sense even if they are applicable. So I thought it would be better to add options selectively whenever they make sense. Currently, I'm thinking of only adding options related to sorting and maybe also things like `info` initially. I expect these options to cover most use-cases that users have in mind.

4. There is currently no way to remove a local option once it is set. If there is a need for it, I'm not sure how it should work yet.

5. There could be some issues related to `sortType`. If a local option in this struct is set, then I think other options in this struct will not be updated when the corresponding global option value is changed. I'm not yet sure if this is a niche case or it is worth considering. Also, I think we have some inconsistencies about the included options in `sortType`. For example, I think `dironly` could have been added to `sortOption` as well. I think the reason `ignorecase` and `ignoredia` is excluded is due to them being used in places other than sorting. I'm not sure about `hiddenfiles` but maybe it could have been added as a separate field to `sortType`. I don't remember much about why we introduced `sortType` but one possibility is to get rid of it altogether to fix the issue with `setlocal`.


Patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index ec2a13b..20205ed 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -28,7 +28,7 @@ jobs:
         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 
   winget:
-    runs-on: windows-latest # Action can only run on Windows
+    runs-on: ubuntu-latest
     needs: build
     steps:
       - uses: vedantmgoyal2009/winget-releaser@v2
diff --git a/README.md b/README.md
index a88d59f..e0b8bbb 100644
--- a/README.md
+++ b/README.md
@@ -9,8 +9,6 @@
 [![Go Report Card](https://goreportcard.com/badge/github.com/gokcehan/lf)](https://goreportcard.com/report/github.com/gokcehan/lf)
 [![Go Reference](https://pkg.go.dev/badge/github.com/gokcehan/lf.svg)](https://pkg.go.dev/github.com/gokcehan/lf)
 
-> This is a work in progress. Use at your own risk.
-
 `lf` (as in "list files") is a terminal file manager written in Go with a heavy inspiration from ranger file manager.
 See [faq](https://github.com/gokcehan/lf/wiki/FAQ) for more information and [tutorial](https://github.com/gokcehan/lf/wiki/Tutorial) for a gentle introduction with screencasts.
 
diff --git a/app.go b/app.go
index fc9bd11..5b7f55f 100644
--- a/app.go
+++ b/app.go
@@ -33,6 +33,7 @@ type app struct {
 	menuCompActive bool
 	menuComps      []string
 	menuCompInd    int
+	selectionOut   []string
 }
 
 func newApp(ui *ui, nav *nav) *app {
@@ -328,19 +329,6 @@ func (app *app) loop() {
 
 			log.Print("bye!")
 
-			if gLastDirPath != "" {
-				f, err := os.Create(gLastDirPath)
-				if err != nil {
-					log.Printf("opening last dir file: %s", err)
-				}
-				defer f.Close()
-
-				_, err = f.WriteString(app.nav.currDir().path)
-				if err != nil {
-					log.Printf("writing last dir file: %s", err)
-				}
-			}
-
 			return
 		case n := <-app.nav.copyBytesChan:
 			app.nav.copyBytes += n
@@ -519,7 +507,7 @@ func (app *app) runShell(s string, args []string, prefix string) {
 	switch prefix {
 	case "$", "!":
 		cmd.Stdin = os.Stdin
-		cmd.Stdout = os.Stdout
+		cmd.Stdout = os.Stderr
 		cmd.Stderr = os.Stderr
 
 		app.runCmdSync(cmd, prefix == "!")
@@ -605,7 +593,7 @@ func (app *app) runPagerOn(stdin io.Reader) {
 	cmd := shellCommand(envPager, nil)
 
 	cmd.Stdin = stdin
-	cmd.Stdout = os.Stdout
+	cmd.Stdout = os.Stderr
 	cmd.Stderr = os.Stderr
 
 	app.runCmdSync(cmd, false)
diff --git a/client.go b/client.go
index 0fab7d5..2beff11 100644
--- a/client.go
+++ b/client.go
@@ -61,6 +61,52 @@ func run() {
 	app.loop()
 
 	app.ui.screen.Fini()
+
+	if gLastDirPath != "" {
+		writeLastDir(gLastDirPath, app.nav.currDir().path)
+	}
+
+	if gSelectionPath != "" && len(app.selectionOut) > 0 {
+		writeSelection(gSelectionPath, app.selectionOut)
+	}
+
+	if gPrintLastDir {
+		fmt.Println(app.nav.currDir().path)
+	}
+
+	if gPrintSelection && len(app.selectionOut) > 0 {
+		for _, file := range app.selectionOut {
+			fmt.Println(file)
+		}
+	}
+}
+
+func writeLastDir(filename string, lastDir string) {
+	f, err := os.Create(filename)
+	if err != nil {
+		log.Printf("opening last dir file: %s", err)
+		return
+	}
+	defer f.Close()
+
+	_, err = f.WriteString(lastDir)
+	if err != nil {
+		log.Printf("writing last dir file: %s", err)
+	}
+}
+
+func writeSelection(filename string, selection []string) {
+	f, err := os.Create(filename)
+	if err != nil {
+		log.Printf("opening selection file: %s", err)
+		return
+	}
+	defer f.Close()
+
+	_, err = f.WriteString(strings.Join(selection, "\n"))
+	if err != nil {
+		log.Printf("writing selection file: %s", err)
+	}
 }
 
 func readExpr() <-chan expr {
diff --git a/complete.go b/complete.go
index 4bd7a21..b43a424 100644
--- a/complete.go
+++ b/complete.go
@@ -11,6 +11,7 @@ import (
 var (
 	gCmdWords = []string{
 		"set",
+		"setlocal",
 		"map",
 		"maps",
 		"cmap",
@@ -179,6 +180,9 @@ var (
 		"reverse!",
 		"ruler",
 		"preserve",
+		"sixel",
+		"nosixel",
+		"sixel!",
 		"smartcase",
 		"nosmartcase",
 		"smartcase!",
@@ -221,6 +225,23 @@ var (
 		"truncatechar",
 		"truncatepct",
 	}
+
+	gLocalOptWords = []string{
+		"dirfirst",
+		"nodirfirst",
+		"dirfirst!",
+		"dironly",
+		"nodironly",
+		"dironly!",
+		"hidden",
+		"nohidden",
+		"hidden!",
+		"info",
+		"reverse",
+		"noreverse",
+		"reverse!",
+		"sortby",
+	}
 )
 
 func matchLongest(s1, s2 []rune) []rune {
@@ -407,6 +428,9 @@ func completeCmd(acc []rune) (matches []string, longestAcc []rune) {
 		}
 	case 3:
 		switch f[0] {
+		case "setlocal":
+			matches, longest = matchWord(f[2], gLocalOptWords)
+			longestAcc = append(acc[:len(acc)-len([]rune(f[len(f)-1]))], longest...)
 		case "map", "cmap":
 			matches, longest = matchCmd(f[2])
 			longestAcc = append(acc[:len(acc)-len([]rune(f[len(f)-1]))], longest...)
@@ -416,7 +440,7 @@ func completeCmd(acc []rune) (matches []string, longestAcc []rune) {
 		}
 	default:
 		switch f[0] {
-		case "set", "map", "cmap", "cmd":
+		case "set", "setlocal", "map", "cmap", "cmd":
 			longestAcc = acc
 		default:
 			matches, longest = matchFile(f[len(f)-1])
diff --git a/doc.go b/doc.go
index 6028d60..4e43332 100644
--- a/doc.go
+++ b/doc.go
@@ -164,10 +164,11 @@ The following options can be used to customize the behavior of lf:
 	shell            string    (default 'sh' for Unix and 'cmd' for Windows)
 	shellflag        string    (default '-c' for Unix and '/c' for Windows)
 	shellopts        []string  (default '')
+	sixel            bool      (default false)
 	smartcase        bool      (default true)
 	smartdia         bool      (default false)
 	sortby           string    (default 'natural')
-	statfmt          string    (default "\033[36m%p\033[0m| %c| %u| %g| %s| %t| -> %l")
+	statfmt          string    (default "\033[36m%p\033[0m| %c| %u| %g| %S| %t| -> %l")
 	tabstop          int       (default 8)
 	tagfmt           string    (default "\033[31m")
 	tempmarks        string    (default '')
@@ -910,6 +911,10 @@ Command line flag used to pass shell commands.
 
 List of shell options to pass to the shell executable.
 
+	sixel          bool      (default false)
+
+Render sixel images in preview.
+
 	smartcase      bool      (default true)
 
 Override 'ignorecase' option when the pattern contains an uppercase character.
@@ -925,10 +930,10 @@ This option has no effect when 'ignoredia' is disabled.
 Sort type for directories.
 Currently supported sort types are 'natural', 'name', 'size', 'time', 'ctime', 'atime', and 'ext'.
 
-	statfmt    string        (default "\033[36m%p\033[0m| %c| %u| %g| %s| %t| -> %l")
+	statfmt    string        (default "\033[36m%p\033[0m| %c| %u| %g| %S| %t| -> %l")
 
 Format string of the file info shown in the bottom left corner.
-Special expansions are provided, '%p' as the file permissions, '%c' as the link count, '%u' as the user, '%g' as the group, '%s' as the file size, '%t' as the last modified time, and '%l' as the link target.
+Special expansions are provided, '%p' as the file permissions, '%c' as the link count, '%u' as the user, '%g' as the group, '%s' as the file size, '%S' as the file size but with a fixed width of four characters (left-padded with spaces), '%t' as the last modified time, and '%l' as the link target.
 The `|` character splits the format string into sections. Any section containing a failed expansion (result is a blank string) is discarded and not shown.
 
 	tabstop        int       (default 8)
@@ -1120,7 +1125,7 @@ Characters from '#' to newline are comments and ignored:
 
 	# comments start with '#'
 
-There are four special commands ('set', 'map', 'cmap', and 'cmd') for configuration.
+There are five special commands ('set', 'setlocal', 'map', 'cmap', and 'cmd') for configuration.
 
 Command 'set' is used to set an option which can be boolean, integer, or string:
 
@@ -1134,6 +1139,21 @@ Command 'set' is used to set an option which can be boolean, integer, or string:
 	set sortby 'time'  # string value with single quotes (whitespaces)
 	set sortby "time"  # string value with double quotes (backslash escapes)
 
+Command 'setlocal' is used to set a local option for a directory which can be boolean or string.
+Currently supported local options are 'dirfirst', 'dironly', 'hidden', 'info', 'reverse', and 'sortby'.
+Adding a trailing path separator (i.e. '/' for Unix and '\' for Windows) sets the option for the given directory along with its subdirectories:
+
+	setlocal /foo/bar hidden         # boolean enable
+	setlocal /foo/bar hidden true    # boolean enable
+	setlocal /foo/bar nohidden       # boolean disable
+	setlocal /foo/bar hidden false   # boolean disable
+	setlocal /foo/bar hidden!        # boolean toggle
+	setlocal /foo/bar sortby time    # string value w/o quotes
+	setlocal /foo/bar sortby 'time'  # string value with single quotes (whitespaces)
+	setlocal /foo/bar sortby "time"  # string value with double quotes (backslash escapes)
+	setlocal /foo/bar  hidden        # for only '/foo/bar' directory
+	setlocal /foo/bar/ hidden        # for '/foo/bar' and its subdirectories (e.g. '/foo/bar/baz')
+
 Command 'map' is used to bind a key to a command which can be builtin command, custom command, or shell command:
 
 	map gh cd ~        # builtin command
diff --git a/docstring.go b/docstring.go
index 773e806..4facbb2 100644
--- a/docstring.go
+++ b/docstring.go
@@ -167,10 +167,11 @@ The following options can be used to customize the behavior of lf:
     shell            string    (default 'sh' for Unix and 'cmd' for Windows)
     shellflag        string    (default '-c' for Unix and '/c' for Windows)
     shellopts        []string  (default '')
+    sixel            bool      (default false)
     smartcase        bool      (default true)
     smartdia         bool      (default false)
     sortby           string    (default 'natural')
-    statfmt          string    (default "\033[36m%p\033[0m| %c| %u| %g| %s| %t| -> %l")
+    statfmt          string    (default "\033[36m%p\033[0m| %c| %u| %g| %S| %t| -> %l")
     tabstop          int       (default 8)
     tagfmt           string    (default "\033[31m")
     tempmarks        string    (default '')
@@ -979,6 +980,10 @@ Command line flag used to pass shell commands.
 
 List of shell options to pass to the shell executable.
 
+    sixel          bool      (default false)
+
+Render sixel images in preview.
+
     smartcase      bool      (default true)
 
 Override 'ignorecase' option when the pattern contains an uppercase character.
@@ -994,14 +999,15 @@ diacritic. This option has no effect when 'ignoredia' is disabled.
 Sort type for directories. Currently supported sort types are 'natural', 'name',
 'size', 'time', 'ctime', 'atime', and 'ext'.
 
-    statfmt    string        (default "\033[36m%p\033[0m| %c| %u| %g| %s| %t| -> %l")
+    statfmt    string        (default "\033[36m%p\033[0m| %c| %u| %g| %S| %t| -> %l")
 
 Format string of the file info shown in the bottom left corner. Special
 expansions are provided, '%p' as the file permissions, '%c' as the link count,
-'%u' as the user, '%g' as the group, '%s' as the file size, '%t' as the last
-modified time, and '%l' as the link target. The '|' character splits the format
-string into sections. Any section containing a failed expansion (result is a
-blank string) is discarded and not shown.
+'%u' as the user, '%g' as the group, '%s' as the file size, '%S' as the file
+size but with a fixed width of four characters (left-padded with spaces), '%t'
+as the last modified time, and '%l' as the link target. The '|' character splits
+the format string into sections. Any section containing a failed expansion
+(result is a blank string) is discarded and not shown.
 
     tabstop        int       (default 8)
 
@@ -1205,8 +1211,8 @@ Characters from '#' to newline are comments and ignored:
 
     # comments start with '#'
 
-There are four special commands ('set', 'map', 'cmap', and 'cmd') for
-configuration.
+There are five special commands ('set', 'setlocal', 'map', 'cmap', and 'cmd')
+for configuration.
 
 Command 'set' is used to set an option which can be boolean, integer, or string:
 
@@ -1220,6 +1226,23 @@ Command 'set' is used to set an option which can be boolean, integer, or string:
     set sortby 'time'  # string value with single quotes (whitespaces)
     set sortby "time"  # string value with double quotes (backslash escapes)
 
+Command 'setlocal' is used to set a local option for a directory which can be
+boolean or string. Currently supported local options are 'dirfirst', 'dironly',
+'hidden', 'info', 'reverse', and 'sortby'. Adding a trailing path separator
+(i.e. '/' for Unix and '\' for Windows) sets the option for the given directory
+along with its subdirectories:
+
+    setlocal /foo/bar hidden         # boolean enable
+    setlocal /foo/bar hidden true    # boolean enable
+    setlocal /foo/bar nohidden       # boolean disable
+    setlocal /foo/bar hidden false   # boolean disable
+    setlocal /foo/bar hidden!        # boolean toggle
+    setlocal /foo/bar sortby time    # string value w/o quotes
+    setlocal /foo/bar sortby 'time'  # string value with single quotes (whitespaces)
+    setlocal /foo/bar sortby "time"  # string value with double quotes (backslash escapes)
+    setlocal /foo/bar  hidden        # for only '/foo/bar' directory
+    setlocal /foo/bar/ hidden        # for '/foo/bar' and its subdirectories (e.g. '/foo/bar/baz')
+
 Command 'map' is used to bind a key to a command which can be builtin command,
 custom command, or shell command:
 
diff --git a/etc/lf.bash b/etc/lf.bash
index 23d3c3a..cde3a3c 100644
--- a/etc/lf.bash
+++ b/etc/lf.bash
@@ -15,6 +15,8 @@ _lf () {
         -last-dir-path
         -log
         -memprofile
+        -print-last-dir
+        -print-selection
         -remote
         -selection-path
         -server
diff --git a/etc/lf.csh b/etc/lf.csh
index 21c0882..d137e6d 100644
--- a/etc/lf.csh
+++ b/etc/lf.csh
@@ -9,7 +9,7 @@
 #     endif
 #
 
-set LF_ARGS = "-command -config -cpuprofile -doc -last-dir-path -log -memprofile -remote -selection-path -server -single -version -help "
+set LF_ARGS = "-command -config -cpuprofile -doc -last-dir-path -log -memprofile -print-last-dir -print-selection -remote -selection-path -server -single -version -help "
 
 complete lf   "C/-*/(${LF_ARGS})/"
 complete lfcd "C/-*/(${LF_ARGS})/"
diff --git a/etc/lf.fish b/etc/lf.fish
index 4087c8f..6a26770 100644
--- a/etc/lf.fish
+++ b/etc/lf.fish
@@ -13,6 +13,8 @@ complete -c lf -o doc -d 'show documentation'
 complete -c lf -o last-dir-path -r -d 'path to the file to write the last dir on exit (to use for cd)'
 complete -c lf -o log -r -d 'path to the log file to write messages'
 complete -c lf -o memprofile -r -d 'path to the file to write the memory profile'
+complete -c lf -o print-last-dir -d 'print the last dir to stdout on exit (to use for cd)'
+complete -c lf -o print-selection -d 'print the selected files to stdout on open (to use as open file dialog)'
 complete -c lf -o remote -x -d 'send remote command to server'
 complete -c lf -o selection-path -r -d 'path to the file to write selected files on open (to use as open file dialog)'
 complete -c lf -o server -d 'start server (automatic)'
diff --git a/etc/lf.nu b/etc/lf.nu
index e4b1d0f..017a3a4 100644
--- a/etc/lf.nu
+++ b/etc/lf.nu
@@ -1,5 +1,5 @@
 # Autocompletion for nushell.
-# 
+#
 # Documentation: https://www.nushell.sh/book/externs.html
 
 # To enable autocompletion you may put this file into a directory:
@@ -20,6 +20,8 @@ export extern "lf" [
   --last-dir-path: string     # path to the file to write the last dir on exit (to use for cd)
   --log: string               # path to the log file to write messages
   --memprofile: string        # path to the file to write the memory profile
+  --print-last-dir            # print the last dir to stdout on exit (to use for cd)
+  --print-selection           # print the selected files to stdout on open (to use as open file dialog)
   --remote: string            # send remote command to server
   --selection-path: string    # path to the file to write selected files on open (to use as open file dialog)
   --server                    # start server (automatic)
diff --git a/etc/lf.ps1 b/etc/lf.ps1
index ed33cb0..7bb49c9 100644
--- a/etc/lf.ps1
+++ b/etc/lf.ps1
@@ -16,6 +16,8 @@ Register-ArgumentCompleter -Native -CommandName 'lf' -ScriptBlock {
         [CompletionResult]::new('-last-dir-path ', '-last-dir-path', [CompletionResultType]::ParameterName, 'path to the file to write the last dir on exit (to use for cd)')
         [CompletionResult]::new('-log ', '-log', [CompletionResultType]::ParameterName, 'path to the log file to write messages')
         [CompletionResult]::new('-memprofile ', '-memprofile', [CompletionResultType]::ParameterName, 'path to the file to write the memory profile')
+        [CompletionResult]::new('-print-last-dir', '-print-last-dir', [CompletionResultType]::ParameterName, 'print the last dir to stdout on exit (to use for cd)')
+        [CompletionResult]::new('-print-selection', '-print-selection', [CompletionResultType]::ParameterName, 'print the selected files to stdout on open (to use as open file dialog)')
         [CompletionResult]::new('-remote ', '-remote', [CompletionResultType]::ParameterName, 'send remote command to server')
         [CompletionResult]::new('-selection-path ', '-selection-path', [CompletionResultType]::ParameterName, 'path to the file to write selected files on open (to use as open file dialog)')
         [CompletionResult]::new('-server', '-server', [CompletionResultType]::ParameterName, 'start server (automatic)')
diff --git a/etc/lf.zsh b/etc/lf.zsh
index 54cec9f..cc2219c 100644
--- a/etc/lf.zsh
+++ b/etc/lf.zsh
@@ -20,6 +20,8 @@ arguments=(
     '-last-dir-path[path to the file to write the last dir on exit (to use for cd)]'
     '-log[path to the log file to write messages]'
     '-memprofile[path to the file to write the memory profile]'
+    '-print-last-dir[print the last dir to stdout on exit (to use for cd)]'
+    '-print-selection[print the selected files to stdout on open (to use as open file dialog)]'
     '-remote[send remote command to server]'
     '-selection-path[path to the file to write selected files on open (to use as open file dialog)]'
     '-server[start server (automatic)]'
diff --git a/etc/lfcd.sh b/etc/lfcd.sh
index 323e406..e35199a 100644
--- a/etc/lfcd.sh
+++ b/etc/lfcd.sh
@@ -15,16 +15,6 @@
 #
 
 lfcd () {
-    tmp="$(mktemp)"
     # `command` is needed in case `lfcd` is aliased to `lf`
-    command lf -last-dir-path="$tmp" "$@"
-    if [ -f "$tmp" ]; then
-        dir="$(cat "$tmp")"
-        rm -f "$tmp"
-        if [ -d "$dir" ]; then
-            if [ "$dir" != "$(pwd)" ]; then
-                cd "$dir"
-            fi
-        fi
-    fi
+    cd "$(command lf -print-last-dir "$@")"
 }
diff --git a/eval.go b/eval.go
index 334550e..101f92f 100644
--- a/eval.go
+++ b/eval.go
@@ -891,6 +891,27 @@ func (e *setExpr) eval(app *app, args []string) {
 			return
 		}
 		gOpts.wrapscroll = !gOpts.wrapscroll
+	case "sixel":
+		if e.val == "" || e.val == "true" {
+			gOpts.sixel = true
+		} else if e.val == "false" {
+			gOpts.sixel = false
+		} else {
+			app.ui.echoerr("sixel: value should be empty, 'true', or 'false'")
+			return
+		}
+	case "nosixel":
+		if e.val != "" {
+			app.ui.echoerrf("nosixel: unexpected value: %s", e.val)
+			return
+		}
+		gOpts.sixel = false
+	case "sixel!":
+		if e.val != "" {
+			app.ui.echoerrf("sixel!: unexpected value: %s", e.val)
+			return
+		}
+		gOpts.sixel = !gOpts.sixel
 	default:
 		// any key with the prefix user_ is accepted as a user defined option
 		if strings.HasPrefix(e.opt, "user_") {
@@ -903,6 +924,185 @@ func (e *setExpr) eval(app *app, args []string) {
 	app.ui.loadFileInfo(app.nav)
 }
 
+func (e *setLocalExpr) eval(app *app, args []string) {
+	path := replaceTilde(e.path)
+	if !filepath.IsAbs(path) {
+		app.ui.echoerr("setlocal: path should be absolute")
+		return
+	}
+
+	switch e.opt {
+	case "dirfirst":
+		if e.val == "" || e.val == "true" {
+			gLocalOpts.dirfirsts[path] = true
+		} else if e.val == "false" {
+			gLocalOpts.dirfirsts[path] = false
+		} else {
+			app.ui.echoerr("dirfirst: value should be empty, 'true', or 'false'")
+			return
+		}
+		app.nav.sort()
+		app.ui.sort()
+	case "nodirfirst":
+		if e.val != "" {
+			app.ui.echoerrf("nodirfirst: unexpected value: %s", e.val)
+			return
+		}
+		gLocalOpts.dirfirsts[path] = false
+		app.nav.sort()
+		app.ui.sort()
+	case "dirfirst!":
+		if e.val != "" {
+			app.ui.echoerrf("dirfirst!: unexpected value: %s", e.val)
+			return
+		}
+		if val, ok := gLocalOpts.dirfirsts[path]; ok {
+			gLocalOpts.dirfirsts[path] = !val
+		} else {
+			val = gOpts.sortType.option&dirfirstSort != 0
+			gLocalOpts.dirfirsts[path] = !val
+		}
+		app.nav.sort()
+		app.ui.sort()
+	case "dironly":
+		if e.val == "" || e.val == "true" {
+			gLocalOpts.dironlys[path] = true
+		} else if e.val == "false" {
+			gLocalOpts.dironlys[path] = false
+		} else {
+			app.ui.echoerr("dironly: value should be empty, 'true', or 'false'")
+			return
+		}
+		app.nav.sort()
+		app.ui.sort()
+	case "nodironly":
+		if e.val != "" {
+			app.ui.echoerrf("nodironly: unexpected value: %s", e.val)
+			return
+		}
+		gLocalOpts.dironlys[path] = false
+		app.nav.sort()
+		app.ui.sort()
+	case "dironly!":
+		if e.val != "" {
+			app.ui.echoerrf("dironly!: unexpected value: %s", e.val)
+			return
+		}
+		if val, ok := gLocalOpts.dironlys[path]; ok {
+			gLocalOpts.dironlys[path] = !val
+		} else {
+			gLocalOpts.dironlys[path] = !gOpts.dironly
+		}
+		app.nav.sort()
+		app.ui.sort()
+	case "hidden":
+		if e.val == "" || e.val == "true" {
+			gLocalOpts.hiddens[path] = true
+		} else if e.val == "false" {
+			gLocalOpts.hiddens[path] = false
+		} else {
+			app.ui.echoerr("hidden: value should be empty, 'true', or 'false'")
+			return
+		}
+		app.nav.sort()
+		app.ui.sort()
+	case "nohidden":
+		if e.val != "" {
+			app.ui.echoerrf("nohidden: unexpected value: %s", e.val)
+			return
+		}
+		gLocalOpts.hiddens[path] = false
+		app.nav.sort()
+		app.ui.sort()
+	case "hidden!":
+		if e.val != "" {
+			app.ui.echoerrf("hidden!: unexpected value: %s", e.val)
+			return
+		}
+		if val, ok := gLocalOpts.hiddens[path]; ok {
+			gLocalOpts.hiddens[path] = !val
+		} else {
+			val = gOpts.sortType.option&hiddenSort != 0
+			gLocalOpts.hiddens[path] = !val
+		}
+		app.nav.sort()
+		app.ui.sort()
+	case "info":
+		if e.val == "" {
+			gLocalOpts.infos[path] = nil
+			return
+		}
+		toks := strings.Split(e.val, ":")
+		for _, s := range toks {
+			switch s {
+			case "size", "time", "atime", "ctime":
+			default:
+				app.ui.echoerr("info: should consist of 'size', 'time', 'atime' or 'ctime' separated with colon")
+				return
+			}
+		}
+		gLocalOpts.infos[path] = toks
+	case "reverse":
+		if e.val == "" || e.val == "true" {
+			gLocalOpts.reverses[path] = true
+		} else if e.val == "false" {
+			gLocalOpts.reverses[path] = false
+		} else {
+			app.ui.echoerr("reverse: value should be empty, 'true', or 'false'")
+			return
+		}
+		app.nav.sort()
+		app.ui.sort()
+	case "noreverse":
+		if e.val != "" {
+			app.ui.echoerrf("noreverse: unexpected value: %s", e.val)
+			return
+		}
+		gLocalOpts.reverses[path] = false
+		app.nav.sort()
+		app.ui.sort()
+	case "reverse!":
+		if e.val != "" {
+			app.ui.echoerrf("reverse!: unexpected value: %s", e.val)
+			return
+		}
+		if val, ok := gLocalOpts.reverses[path]; ok {
+			gLocalOpts.reverses[path] = !val
+		} else {
+			val = gOpts.sortType.option&reverseSort != 0
+			gLocalOpts.reverses[path] = !val
+		}
+		app.nav.sort()
+		app.ui.sort()
+	case "sortby":
+		switch e.val {
+		case "natural":
+			gLocalOpts.sortMethods[path] = naturalSort
+		case "name":
+			gLocalOpts.sortMethods[path] = nameSort
+		case "size":
+			gLocalOpts.sortMethods[path] = sizeSort
+		case "time":
+			gLocalOpts.sortMethods[path] = timeSort
+		case "ctime":
+			gLocalOpts.sortMethods[path] = ctimeSort
+		case "atime":
+			gLocalOpts.sortMethods[path] = atimeSort
+		case "ext":
+			gLocalOpts.sortMethods[path] = extSort
+		default:
+			app.ui.echoerr("sortby: value should either be 'natural', 'name', 'size', 'time', 'atime', 'ctime' or 'ext'")
+			return
+		}
+		app.nav.sort()
+		app.ui.sort()
+	default:
+		app.ui.echoerrf("unknown option: %s", e.opt)
+		return
+	}
+	app.ui.loadFileInfo(app.nav)
+}
+
 func (e *mapExpr) eval(app *app, args []string) {
 	if e.expr == nil {
 		delete(gOpts.keys, e.keys)
@@ -1425,28 +1625,9 @@ func (e *callExpr) eval(app *app, args []string) {
 			return
 		}
 
-		if gSelectionPath != "" {
-			out, err := os.Create(gSelectionPath)
-			if err != nil {
-				log.Printf("opening selection file: %s", err)
-				return
-			}
-			defer out.Close()
-
-			var path string
-			if list, err := app.nav.currFileOrSelections(); err == nil {
-				path = strings.Join(list, "\n")
-			} else {
-				return
-			}
-
-			_, err = out.WriteString(path)
-			if err != nil {
-				log.Printf("writing selection file: %s", err)
-			}
-
+		if gSelectionPath != "" || gPrintSelection {
+			app.selectionOut, _ = app.nav.currFileOrSelections()
 			app.quitChan <- struct{}{}
-
 			return
 		}
 
diff --git a/lf.1 b/lf.1
index 34e740d..4d238fa 100644
--- a/lf.1
+++ b/lf.1
@@ -183,10 +183,11 @@ The following options can be used to customize the behavior of lf:
     shell            string    (default 'sh' for Unix and 'cmd' for Windows)
     shellflag        string    (default '-c' for Unix and '/c' for Windows)
     shellopts        []string  (default '')
+    sixel            bool      (default false)
     smartcase        bool      (default true)
     smartdia         bool      (default false)
     sortby           string    (default 'natural')
-    statfmt          string    (default "\e033[36m%p\e033[0m| %c| %u| %g| %s| %t| -> %l")
+    statfmt          string    (default "\e033[36m%p\e033[0m| %c| %u| %g| %S| %t| -> %l")
     tabstop          int       (default 8)
     tagfmt           string    (default "\e033[31m")
     tempmarks        string    (default '')
@@ -1083,6 +1084,12 @@ Command line flag used to pass shell commands.
 .PP
 List of shell options to pass to the shell executable.
 .PP
+.EX
+    sixel          bool      (default false)
+.EE
+.PP
+Render sixel images in preview.
+.PP
 .EX
     smartcase      bool      (default true)
 .EE
@@ -1102,10 +1109,10 @@ Override 'ignoredia' option when the pattern contains a character with diacritic
 Sort type for directories. Currently supported sort types are 'natural', 'name', 'size', 'time', 'ctime', 'atime', and 'ext'.
 .PP
 .EX
-    statfmt    string        (default "\e033[36m%p\e033[0m| %c| %u| %g| %s| %t| -> %l")
+    statfmt    string        (default "\e033[36m%p\e033[0m| %c| %u| %g| %S| %t| -> %l")
 .EE
 .PP
-Format string of the file info shown in the bottom left corner. Special expansions are provided, '%p' as the file permissions, '%c' as the link count, '%u' as the user, '%g' as the group, '%s' as the file size, '%t' as the last modified time, and '%l' as the link target. The `|` character splits the format string into sections. Any section containing a failed expansion (result is a blank string) is discarded and not shown.
+Format string of the file info shown in the bottom left corner. Special expansions are provided, '%p' as the file permissions, '%c' as the link count, '%u' as the user, '%g' as the group, '%s' as the file size, '%S' as the file size but with a fixed width of four characters (left-padded with spaces), '%t' as the last modified time, and '%l' as the link target. The `|` character splits the format string into sections. Any section containing a failed expansion (result is a blank string) is discarded and not shown.
 .PP
 .EX
     tabstop        int       (default 8)
@@ -1342,7 +1349,7 @@ Characters from '#' to newline are comments and ignored:
     # comments start with '#'
 .EE
 .PP
-There are four special commands ('set', 'map', 'cmap', and 'cmd') for configuration.
+There are five special commands ('set', 'setlocal', 'map', 'cmap', and 'cmd') for configuration.
 .PP
 Command 'set' is used to set an option which can be boolean, integer, or string:
 .PP
@@ -1358,6 +1365,21 @@ Command 'set' is used to set an option which can be boolean, integer, or string:
     set sortby "time"  # string value with double quotes (backslash escapes)
 .EE
 .PP
+Command 'setlocal' is used to set a local option for a directory which can be boolean or string. Currently supported local options are 'dirfirst', 'dironly', 'hidden', 'info', 'reverse', and 'sortby'. Adding a trailing path separator (i.e. '/' for Unix and '\e' for Windows) sets the option for the given directory along with its subdirectories:
+.PP
+.EX
+    setlocal /foo/bar hidden         # boolean enable
+    setlocal /foo/bar hidden true    # boolean enable
+    setlocal /foo/bar nohidden       # boolean disable
+    setlocal /foo/bar hidden false   # boolean disable
+    setlocal /foo/bar hidden!        # boolean toggle
+    setlocal /foo/bar sortby time    # string value w/o quotes
+    setlocal /foo/bar sortby 'time'  # string value with single quotes (whitespaces)
+    setlocal /foo/bar sortby "time"  # string value with double quotes (backslash escapes)
+    setlocal /foo/bar  hidden        # for only '/foo/bar' directory
+    setlocal /foo/bar/ hidden        # for '/foo/bar' and its subdirectories (e.g. '/foo/bar/baz')
+.EE
+.PP
 Command 'map' is used to bind a key to a command which can be builtin command, custom command, or shell command:
 .PP
 .EX
diff --git a/main.go b/main.go
index 22667c9..ad88a82 100644
--- a/main.go
+++ b/main.go
@@ -22,18 +22,20 @@ var (
 type arrayFlag []string
 
 var (
-	gSingleMode    bool
-	gClientID      int
-	gHostname      string
-	gLastDirPath   string
-	gSelectionPath string
-	gSocketProt    string
-	gSocketPath    string
-	gLogPath       string
-	gSelect        string
-	gConfigPath    string
-	gCommands      arrayFlag
-	gVersion       string
+	gSingleMode     bool
+	gPrintLastDir   bool
+	gPrintSelection bool
+	gClientID       int
+	gHostname       string
+	gLastDirPath    string
+	gSelectionPath  string
+	gSocketProt     string
+	gSocketPath     string
+	gLogPath        string
+	gSelect         string
+	gConfigPath     string
+	gCommands       arrayFlag
+	gVersion        string
 )
 
 func (a *arrayFlag) Set(v string) error {
@@ -228,6 +230,16 @@ func main() {
 		false,
 		"start a client without server")
 
+	printLastDir := flag.Bool(
+		"print-last-dir",
+		false,
+		"print the last dir to stdout on exit (to use for cd)")
+
+	printSelection := flag.Bool(
+		"print-selection",
+		false,
+		"print the selected files to stdout on open (to use as open file dialog)")
+
 	remoteCmd := flag.String(
 		"remote",
 		"",
@@ -305,6 +317,8 @@ func main() {
 		serve()
 	default:
 		gSingleMode = *singleMode
+		gPrintLastDir = *printLastDir
+		gPrintSelection = *printSelection
 
 		if !gSingleMode {
 			checkServer()
diff --git a/nav.go b/nav.go
index a407c1e..8889f54 100644
--- a/nav.go
+++ b/nav.go
@@ -2,6 +2,7 @@ package main
 
 import (
 	"bufio"
+	"bytes"
 	"errors"
 	"fmt"
 	"io"
@@ -36,6 +37,7 @@ type file struct {
 	accessTime time.Time
 	changeTime time.Time
 	ext        string
+	err        error
 }
 
 func (file *file) TotalSize() int64 {
@@ -48,6 +50,17 @@ func (file *file) TotalSize() int64 {
 	return file.Size()
 }
 
+type fakeStat struct {
+	name string
+}
+
+func (fs *fakeStat) Name() string       { return fs.name }
+func (fs *fakeStat) Size() int64        { return 0 }
+func (fs *fakeStat) Mode() os.FileMode  { return os.FileMode(0000) }
+func (fs *fakeStat) ModTime() time.Time { return time.Unix(0, 0) }
+func (fs *fakeStat) IsDir() bool        { return false }
+func (fs *fakeStat) Sys() any           { return nil }
+
 func readdir(path string) ([]*file, error) {
 	f, err := os.Open(path)
 	if err != nil {
@@ -67,6 +80,18 @@ func readdir(path string) ([]*file, error) {
 		}
 		if err != nil {
 			log.Printf("getting file information: %s", err)
+			files = append(files, &file{
+				FileInfo:   &fakeStat{name: fname},
+				linkState:  notLink,
+				linkTarget: "",
+				path:       fpath,
+				dirCount:   -1,
+				dirSize:    -1,
+				accessTime: time.Unix(0, 0),
+				changeTime: time.Unix(0, 0),
+				ext:        filepath.Ext(fpath),
+				err:        err,
+			})
 			continue
 		}
 
@@ -129,6 +154,7 @@ func readdir(path string) ([]*file, error) {
 			accessTime: at,
 			changeTime: ct,
 			ext:        ext,
+			err:        nil,
 		})
 	}
 
@@ -184,8 +210,8 @@ func normalize(s1, s2 string, ignorecase, ignoredia bool) (string, string) {
 }
 
 func (dir *dir) sort() {
-	dir.sortType = gOpts.sortType
-	dir.dironly = gOpts.dironly
+	dir.sortType = getSortType(dir.path)
+	dir.dironly = getDirOnly(dir.path)
 	dir.hiddenfiles = gOpts.hiddenfiles
 	dir.ignorecase = gOpts.ignorecase
 	dir.ignoredia = gOpts.ignoredia
@@ -446,7 +472,7 @@ func (nav *nav) loadDirInternal(path string) *dir {
 		loading:     true,
 		loadTime:    time.Now(),
 		path:        path,
-		sortType:    gOpts.sortType,
+		sortType:    getSortType(path),
 		hiddenfiles: gOpts.hiddenfiles,
 		ignorecase:  gOpts.ignorecase,
 		ignoredia:   gOpts.ignoredia,
@@ -509,8 +535,8 @@ func (nav *nav) checkDir(dir *dir) {
 			}
 			nav.dirChan <- nd
 		}()
-	case dir.sortType != gOpts.sortType ||
-		dir.dironly != gOpts.dironly ||
+	case dir.sortType != getSortType(dir.path) ||
+		dir.dironly != getDirOnly(dir.path) ||
 		!reflect.DeepEqual(dir.hiddenfiles, gOpts.hiddenfiles) ||
 		dir.ignorecase != gOpts.ignorecase ||
 		dir.ignoredia != gOpts.ignoredia:
@@ -847,6 +873,22 @@ func (nav *nav) preview(path string, win *win) {
 		reader = f
 	}
 
+	prefix := make([]byte, 2)
+	if gOpts.sixel {
+		n, err := reader.Read(prefix)
+		reader = io.MultiReader(bytes.NewReader(prefix[:n]), reader)
+
+		if err == nil && string(prefix) == gSixelBegin {
+			b, err := io.ReadAll(reader)
+			if err != nil {
+				log.Printf("loading sixel: %s", err)
+			}
+			str := string(b)
+			reg.sixel = &str
+			return
+		}
+	}
+
 	buf := bufio.NewScanner(reader)
 
 	for i := 0; i < win.h && buf.Scan(); i++ {
diff --git a/opts.go b/opts.go
index 3283cc6..d04d305 100644
--- a/opts.go
+++ b/opts.go
@@ -1,6 +1,9 @@
 package main
 
-import "time"
+import (
+	"path/filepath"
+	"time"
+)
 
 type sortMethod byte
 
@@ -49,6 +52,7 @@ var gOpts struct {
 	mouse            bool
 	number           bool
 	preview          bool
+	sixel            bool
 	relativenumber   bool
 	smartcase        bool
 	smartdia         bool
@@ -91,6 +95,91 @@ var gOpts struct {
 	tagfmt           string
 }
 
+var gLocalOpts struct {
+	sortMethods map[string]sortMethod
+	dirfirsts   map[string]bool
+	dironlys    map[string]bool
+	hiddens     map[string]bool
+	reverses    map[string]bool
+	infos       map[string][]string
+}
+
+func localOptPaths(path string) []string {
+	var list []string
+	list = append(list, path)
+	for curr := path; !isRoot(curr); curr = filepath.Dir(curr) {
+		list = append(list, curr+string(filepath.Separator))
+	}
+	return list
+}
+
+func getSortMethod(path string) sortMethod {
+	for _, key := range localOptPaths(path) {
+		if val, ok := gLocalOpts.sortMethods[key]; ok {
+			return val
+		}
+	}
+	return gOpts.sortType.method
+}
+
+func getSortType(path string) sortType {
+	method := getSortMethod(path)
+	option := gOpts.sortType.option
+	for _, key := range localOptPaths(path) {
+		if val, ok := gLocalOpts.dirfirsts[key]; ok {
+			if val {
+				option |= dirfirstSort
+			} else {
+				option &= ^dirfirstSort
+			}
+			break
+		}
+	}
+	for _, key := range localOptPaths(path) {
+		if val, ok := gLocalOpts.hiddens[key]; ok {
+			if val {
+				option |= hiddenSort
+			} else {
+				option &= ^hiddenSort
+			}
+			break
+		}
+	}
+	for _, key := range localOptPaths(path) {
+		if val, ok := gLocalOpts.reverses[key]; ok {
+			if val {
+				option |= reverseSort
+			} else {
+				option &= ^reverseSort
+			}
+			break
+		}
+	}
+	val := sortType{
+		method: method,
+		option: option,
+	}
+	return val
+}
+
+func getDirOnly(path string) bool {
+	for _, key := range localOptPaths(path) {
+		if val, ok := gLocalOpts.dironlys[key]; ok {
+			return val
+		}
+	}
+	return gOpts.dironly
+}
+
+func getInfo(path string) []string {
+	for _, key := range localOptPaths(path) {
+		if val, ok := gLocalOpts.infos[key]; ok {
+			return val
+		}
+	}
+	return gOpts.info
+}
+
 func init() {
 	gOpts.anchorfind = true
 	gOpts.autoquit = false
@@ -113,6 +202,7 @@ func init() {
 	gOpts.mouse = false
 	gOpts.number = false
 	gOpts.preview = true
+	gOpts.sixel = false
 	gOpts.relativenumber = false
 	gOpts.smartcase = true
 	gOpts.smartdia = false
@@ -132,7 +222,7 @@ func init() {
 	gOpts.selmode = "all"
 	gOpts.shell = gDefaultShell
 	gOpts.shellflag = gDefaultShellFlag
-	gOpts.statfmt = "\033[36m%p\033[0m| %c| %u| %g| %s| %t| -> %l"
+	gOpts.statfmt = "\033[36m%p\033[0m| %c| %u| %g| %S| %t| -> %l"
 	gOpts.timefmt = time.ANSIC
 	gOpts.infotimefmtnew = "Jan _2 15:04"
 	gOpts.infotimefmtold = "Jan _2  2006"
@@ -266,5 +356,12 @@ func init() {
 	gOpts.cmds = make(map[string]expr)
 	gOpts.user = make(map[string]string)
 
+	gLocalOpts.sortMethods = make(map[string]sortMethod)
+	gLocalOpts.dirfirsts = make(map[string]bool)
+	gLocalOpts.dironlys = make(map[string]bool)
+	gLocalOpts.hiddens = make(map[string]bool)
+	gLocalOpts.reverses = make(map[string]bool)
+	gLocalOpts.infos = make(map[string][]string)
+
 	setDefaults()
 }
diff --git a/parse.go b/parse.go
index e6abeb0..f6ba4e7 100644
--- a/parse.go
+++ b/parse.go
@@ -2,34 +2,37 @@ package main
 
 // Grammar of the language used in the evaluator
 //
-// Expr     = SetExpr
-//          | MapExpr
-//          | CMapExpr
-//          | CmdExpr
-//          | CallExpr
-//          | ExecExpr
-//          | ListExpr
+// Expr         = SetExpr
+//              | SetLocalExpr
+//              | MapExpr
+//              | CMapExpr
+//              | CmdExpr
+//              | CallExpr
+//              | ExecExpr
+//              | ListExpr
 //
-// SetExpr  = 'set' <opt> <val> ';'
+// SetExpr      = 'set' <opt> <val> ';'
 //
-// MapExpr  = 'map' <keys> Expr
+// SetLocalExpr = 'setlocal' <dir> <opt> <val> ';'
 //
-// CMapExpr = 'cmap' <key> Expr
+// MapExpr      = 'map' <keys> Expr
 //
-// CmdExpr  = 'cmd' <name> Expr
+// CMapExpr     = 'cmap' <key> Expr
 //
-// CallExpr = <name> <args> ';'
+// CmdExpr      = 'cmd' <name> Expr
 //
-// ExecExpr = Prefix      <value>      '\n'
-//          | Prefix '{{' <value> '}}' ';'
+// CallExpr     = <name> <args> ';'
 //
-// Prefix   = '$' | '%' | '!' | '&'
+// ExecExpr     = Prefix      <value>      '\n'
+//              | Prefix '{{' <value> '}}' ';'
 //
-// ListExpr = ':'      Expr ListRest      '\n'
-//          | ':' '{{' Expr ListRest '}}' ';'
+// Prefix       = '$' | '%' | '!' | '&'
 //
-// ListRest = Nil
-//          | Expr ListExpr
+// ListExpr     = ':'      Expr ListRest      '\n'
+//              | ':' '{{' Expr ListRest '}}' ';'
+//
+// ListRest     = Nil
+//              | Expr ListExpr
 
 import (
 	"bytes"
@@ -50,6 +53,14 @@ type setExpr struct {
 
 func (e *setExpr) String() string { return fmt.Sprintf("set %s %s", e.opt, e.val) }
 
+type setLocalExpr struct {
+	path string
+	opt  string
+	val  string
+}
+
+func (e *setLocalExpr) String() string { return fmt.Sprintf("setlocal %s %s %s", e.path, e.opt, e.val) }
+
 type mapExpr struct {
 	keys string
 	expr expr
@@ -176,6 +187,30 @@ func (p *parser) parseExpr() expr {
 			s.scan()
 
 			result = &setExpr{opt, val}
+		case "setlocal":
+			var val string
+
+			s.scan()
+			if s.typ != tokenIdent {
+				p.err = fmt.Errorf("expected directory: %s", s.tok)
+			}
+			dir := s.tok
+
+			s.scan()
+			if s.typ != tokenIdent {
+				p.err = fmt.Errorf("expected identifier: %s", s.tok)
+			}
+			opt := s.tok
+
+			s.scan()
+			if s.typ != tokenSemicolon {
+				val = s.tok
+				s.scan()
+			}
+
+			s.scan()
+
+			result = &setLocalExpr{dir, opt, val}
 		case "map":
 			var expr expr
 
diff --git a/sixel.go b/sixel.go
new file mode 100644
index 0000000..ee590ce
--- /dev/null
+++ b/sixel.go
@@ -0,0 +1,61 @@
+package main
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/gdamore/tcell/v2"
+)
+
+const (
+	gSixelBegin  = "\033P"
+	gSixelFiller = '\u2800'
+)
+
+type sixelScreen struct {
+	xprev, yprev int
+	sixel        *string
+	altFill      bool
+	lastFile     string // TODO maybe use hash of sixels instead to flip altFill
+}
+
+func (sxs *sixelScreen) fillerStyle(filePath string) tcell.Style {
+	if sxs.lastFile != filePath {
+		sxs.altFill = !sxs.altFill
+	}
+
+	if sxs.altFill {
+		return tcell.StyleDefault.Bold(true)
+	}
+	return tcell.StyleDefault
+}
+
+func (sxs *sixelScreen) showSixels() {
+	if sxs.sixel == nil {
+		return
+	}
+
+	// XXX: workaround for bug where quitting lf might leave the terminal in bold
+	fmt.Print("\033[0m")
+
+	fmt.Print("\0337")                              // Save cursor position
+	fmt.Printf("\033[%d;%dH", sxs.yprev, sxs.xprev) // Move cursor to position
+	fmt.Print(*sxs.sixel)                           //
+	fmt.Print("\0338")                              // Restore cursor position
+}
+
+func (sxs *sixelScreen) printSixel(win *win, screen tcell.Screen, reg *reg) {
+	if reg.sixel == nil {
+		return
+	}
+
+	// HACK: fillers are used to control when tcell redraws the region where a sixel image is drawn.
+	// alternating between bold and regular is to clear the image before drawing a new one.
+	st := sxs.fillerStyle(reg.path)
+	for y := 0; y < win.h; y++ {
+		st = win.print(screen, 0, y, st, strings.Repeat(string(gSixelFiller), win.w))
+	}
+
+	sxs.xprev, sxs.yprev = win.x+1, win.y+1
+	sxs.sixel = reg.sixel
+}
diff --git a/ui.go b/ui.go
index 82595f5..6861f7e 100644
--- a/ui.go
+++ b/ui.go
@@ -252,7 +252,7 @@ func (win *win) printRight(screen tcell.Screen, y int, st tcell.Style, s string)
 	win.print(screen, win.w-printLength(s), y, st, s)
 }
 
-func (win *win) printReg(screen tcell.Screen, reg *reg, previewLoading bool) {
+func (win *win) printReg(screen tcell.Screen, reg *reg, previewLoading bool, sxs *sixelScreen) {
 	if reg == nil {
 		return
 	}
@@ -274,6 +274,8 @@ func (win *win) printReg(screen tcell.Screen, reg *reg, previewLoading bool) {
 
 		st = win.print(screen, 2, i, st, l)
 	}
+
+	sxs.printSixel(win, screen, reg)
 }
 
 var gThisYear = time.Now().Year()
@@ -288,7 +290,7 @@ func infotimefmt(t time.Time) string {
 func fileInfo(f *file, d *dir) string {
 	var info string
 
-	for _, s := range gOpts.info {
+	for _, s := range getInfo(d.path) {
 		switch s {
 		case "size":
 			if !(f.IsDir() && gOpts.dircounts) {
@@ -558,6 +560,7 @@ func getWins(screen tcell.Screen) []*win {
 
 type ui struct {
 	screen      tcell.Screen
+	sxScreen    sixelScreen
 	polling     bool
 	wins        []*win
 	promptWin   *win
@@ -600,6 +603,7 @@ func newUI(screen tcell.Screen) *ui {
 		styles:      parseStyles(),
 		icons:       parseIcons(),
 		currentFile: "",
+		sxScreen:    sixelScreen{},
 	}
 
 	go ui.pollEvents()
@@ -679,6 +683,7 @@ type reg struct {
 	loadTime time.Time
 	path     string
 	lines    []string
+	sixel    *string
 }
 
 func (ui *ui) loadFile(app *app, volatile bool) {
@@ -721,6 +726,11 @@ func (ui *ui) loadFileInfo(nav *nav) {
 		return
 	}
 
+	if curr.err != nil {
+		ui.echoerrf("stat: %s", curr.err)
+		return
+	}
+
 	statfmt := strings.ReplaceAll(gOpts.statfmt, "|", "\x1f")
 	replace := func(s string, val string) {
 		if val == "" {
@@ -733,6 +743,7 @@ func (ui *ui) loadFileInfo(nav *nav) {
 	replace("%u", userName(curr))
 	replace("%g", groupName(curr))
 	replace("%s", humanize(curr.Size()))
+	replace("%S", fmt.Sprintf("%4s", humanize(curr.Size())))
 	replace("%t", curr.ModTime().Format(gOpts.timefmt))
 	replace("%l", curr.linkTarget)
 
@@ -955,6 +966,7 @@ func (ui *ui) draw(nav *nav) {
 			ui.screen.SetContent(i, j, ' ', nil, st)
 		}
 	}
+	ui.sxScreen.sixel = nil
 
 	ui.drawPromptLine(nav)
 
@@ -1003,7 +1015,7 @@ func (ui *ui) draw(nav *nav) {
 					&dirStyle{colors: ui.styles, icons: ui.icons, role: Preview},
 					nav.previewLoading)
 			} else if curr.Mode().IsRegular() {
-				preview.printReg(ui.screen, ui.regPrev, nav.previewLoading)
+				preview.printReg(ui.screen, ui.regPrev, nav.previewLoading, &ui.sxScreen)
 			}
 		}
 	}
@@ -1033,6 +1045,11 @@ func (ui *ui) draw(nav *nav) {
 	}
 
 	ui.screen.Show()
+	if ui.menuBuf == nil && ui.cmdPrefix == "" && ui.sxScreen.sixel != nil {
+		ui.sxScreen.lastFile = ui.regPrev.path
+		ui.sxScreen.showSixels()
+	}
+
 }
 
 func findBinds(keys map[string]expr, prefix string) (binds map[string]expr, ok bool) {
@@ -1430,8 +1447,8 @@ func (ui *ui) exportSizes() {
 }
 
 func anyKey() {
-	fmt.Print(gOpts.waitmsg)
-	defer fmt.Print("\n")
+	fmt.Fprint(os.Stderr, gOpts.waitmsg)
+	defer fmt.Fprint(os.Stderr, "\n")
 	oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
 	if err != nil {
 		panic(err)


Test patch:
diff --git a/eval_test.go b/eval_test.go
index 48cb445..2d13118 100644
--- a/eval_test.go
+++ b/eval_test.go
@@ -191,6 +191,84 @@ var gEvalTests = []struct {
 		[]expr{&setExpr{"ratios", "1:2:3"}, &setExpr{"hidden", ""}},
 	},
 
+	{
+		"setlocal /foo/bar hidden # trailing comments are allowed",
+		[]string{"setlocal", "/foo/bar", "hidden", "\n"},
+		[]expr{&setLocalExpr{"/foo/bar", "hidden", ""}},
+	},
+
+	{
+		"setlocal /foo/bar hidden; setlocal /foo/bar reverse",
+		[]string{"setlocal", "/foo/bar", "hidden", ";", "setlocal", "/foo/bar", "reverse", "\n"},
+		[]expr{&setLocalExpr{"/foo/bar", "hidden", ""}, &setLocalExpr{"/foo/bar", "reverse", ""}},
+	},
+
+	{
+		"setlocal /foo/bar hidden\nsetlocal /foo/bar reverse",
+		[]string{"setlocal", "/foo/bar", "hidden", "\n", "setlocal", "/foo/bar", "reverse", "\n"},
+		[]expr{&setLocalExpr{"/foo/bar", "hidden", ""}, &setLocalExpr{"/foo/bar", "reverse", ""}},
+	},
+
+	{
+		`setlocal /foo/bar info ""`,
+		[]string{"setlocal", "/foo/bar", "info", "", "\n"},
+		[]expr{&setLocalExpr{"/foo/bar", "info", ""}},
+	},
+
+	{
+		`setlocal /foo/bar info "size"`,
+		[]string{"setlocal", "/foo/bar", "info", "size", "\n"},
+		[]expr{&setLocalExpr{"/foo/bar", "info", "size"}},
+	},
+
+	{
+		"setlocal /foo/bar info size:time",
+		[]string{"setlocal", "/foo/bar", "info", "size:time", "\n"},
+		[]expr{&setLocalExpr{"/foo/bar", "info", "size:time"}},
+	},
+
+	{
+		"setlocal /foo/bar info size:time;",
+		[]string{"setlocal", "/foo/bar", "info", "size:time", ";"},
+		[]expr{&setLocalExpr{"/foo/bar", "info", "size:time"}},
+	},
+
+	{
+		":setlocal /foo/bar info size:time",
+		[]string{":", "setlocal", "/foo/bar", "info", "size:time", "\n", "\n"},
+		[]expr{&listExpr{[]expr{&setLocalExpr{"/foo/bar", "info", "size:time"}}, 1}},
+	},
+
+	{
+		":setlocal /foo/bar info size:time\nsetlocal /foo/bar hidden",
+		[]string{":", "setlocal", "/foo/bar", "info", "size:time", "\n", "\n", "setlocal", "/foo/bar", "hidden", "\n"},
+		[]expr{&listExpr{[]expr{&setLocalExpr{"/foo/bar", "info", "size:time"}}, 1}, &setLocalExpr{"/foo/bar", "hidden", ""}},
+	},
+
+	{
+		":setlocal /foo/bar info size:time;",
+		[]string{":", "setlocal", "/foo/bar", "info", "size:time", ";", "\n"},
+		[]expr{&listExpr{[]expr{&setLocalExpr{"/foo/bar", "info", "size:time"}}, 1}},
+	},
+
+	{
+		":setlocal /foo/bar info size:time;\nsetlocal /foo/bar hidden",
+		[]string{":", "setlocal", "/foo/bar", "info", "size:time", ";", "\n", "setlocal", "/foo/bar", "hidden", "\n"},
+		[]expr{&listExpr{[]expr{&setLocalExpr{"/foo/bar", "info", "size:time"}}, 1}, &setLocalExpr{"/foo/bar", "hidden", ""}},
+	},
+
+	{
+		"setlocal /foo/bar info size:time\n setlocal /foo/bar hidden",
+		[]string{"setlocal", "/foo/bar", "info", "size:time", "\n", "setlocal", "/foo/bar", "hidden", "\n"},
+		[]expr{&setLocalExpr{"/foo/bar", "info", "size:time"}, &setLocalExpr{"/foo/bar", "hidden", ""}},
+	},
+
+	{
+		"setlocal /foo/bar info size:time \nsetlocal /foo/bar hidden",
+		[]string{"setlocal", "/foo/bar", "info", "size:time", "\n", "setlocal", "/foo/bar", "hidden", "\n"},
+		[]expr{&setLocalExpr{"/foo/bar", "info", "size:time"}, &setLocalExpr{"/foo/bar", "hidden", ""}},
+	},
+
 	{
 		"map gh cd ~",
 		[]string{"map", "gh", "cd", "~", "\n"},