Skip to content

Windows compatibility improvements#2418

Closed
vivainio wants to merge 18 commits intogokcehan:masterfrom
vivainio:master
Closed

Windows compatibility improvements#2418
vivainio wants to merge 18 commits intogokcehan:masterfrom
vivainio:master

Conversation

@vivainio
Copy link
Copy Markdown

@vivainio vivainio commented Mar 5, 2026

Summary

This PR improves lf usability on Windows, particularly when using a POSIX shell (sh/bash via Git Bash or similar) instead of cmd. Together these changes make it practical to share a single lf config file between Windows and Linux, without needing separate platform-specific config files.

Changes

Shell flag auto-correction (os_windows.go)
When shell is set to a non-cmd shell (e.g. sh, bash, pwsh), the default shellflag /c is invalid. lf now automatically uses -c for the non-cmd code path in shellCommand.

Dot file hiding (os_windows.go)
On Windows, toggling hidden files (zh) now also hides files starting with . (e.g. .gitignore, .env), matching Linux behavior. Previously only the Windows FILE_ATTRIBUTE_HIDDEN attribute was checked.

Shebang-aware script execution (os_windows.go, os.go, nav.go)
When previewer or cleaner is set to a shell script (e.g. a #!/bin/sh script), lf on Windows now detects the shebang and automatically runs the script through the configured shell instead of trying to execute it directly (which fails since Windows cannot run shebang scripts natively). This only applies when the shell is a known POSIX shell (sh, bash, zsh, dash) — cmd and pwsh are excluded. Path backslashes are converted to forward slashes for sh compatibility.

Relative path and env var expansion (misc.go, app.go, eval.go)
set previewer, set cleaner, set rulerfile, and source now support:

  • Environment variable expansion ($VAR)
  • Tilde expansion (~)
  • Relative paths resolved against the config file's directory

This allows configs like set previewer preview (resolves to ~/.config/lf/preview or %APPDATA%\lf\preview) without needing absolute paths.

Motivation

A common pain point for users who work on both Windows and Linux is maintaining two separate lf config files. With these changes, a single lfrc using set shell sh and a shared previewer script works on both platforms without any platform-specific wrappers or path gymnastics.

Test plan

  • On Windows with set shell sh: verify shellflag is -c and shell commands work
  • On Windows: verify dotfiles (.gitignore etc.) are hidden when hidden is off
  • On Windows with a shebang previewer script: verify preview works without a .cmd wrapper
  • Verify set previewer preview resolves correctly on both Linux and Windows
  • Verify existing cmd-based configs are unaffected

vivainio added 17 commits March 5, 2026 20:25
Mirrors Linux behavior where files starting with '.' are hidden
by default via gDefaultHiddenFiles = []string{".*"}.
When the shell is set to sh, bash, pwsh, or any other non-cmd shell
on Windows, the default shellflag /c is invalid. Unix-style shells and
PowerShell both require -c (short for -Command in PowerShell, standard
flag in POSIX shells).

If the user has explicitly set shellflag to something other than /c,
that value is respected as-is. Only the cmd.exe default /c is replaced.
On Windows, shell scripts (e.g. sh/bash scripts with a shebang) cannot
be executed directly by the OS. When the previewer has no native Windows
extension (.exe, .cmd, .bat), lf now runs it via the configured shell
(e.g. `sh preview path w h x y mode`) instead of exec-ing it directly.

Native executables are unaffected.
…dows

sh (Git Bash/MSYS2) cannot resolve Windows-style backslash paths when
used as a script filename argument. Convert backslashes to forward
slashes before passing the previewer path to sh.
Previously any file without a native extension (.exe, .cmd, .bat) was
run through sh. Now we peek at the first two bytes to check for a #!
shebang, so native binaries without extensions are executed directly.
Rename previewCommand to scriptCommand and apply it to both the
previewer and cleaner calls in nav.go. Both are user-configured
scripts that may have a shebang and need the same treatment.
Replace the broad "not cmd" check with an explicit allowlist of
POSIX-compatible shells (sh, bash, zsh, dash). This prevents pwsh
and other non-POSIX shells from being used to run shebang scripts.
@vivainio vivainio changed the title WIP pr for windows compatibility changes Windows compatibility improvements Mar 5, 2026
Copy link
Copy Markdown
Collaborator

@CatsDeservePets CatsDeservePets left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[See my other comments]

Comment thread os_windows.go
Comment on lines +139 to +150
if ext == ".exe" || ext == ".cmd" || ext == ".bat" {
return exec.Command(path, args...)
}
// Run scripts with a shebang through POSIX-compatible shells only.
// Convert backslashes to forward slashes so sh can resolve the path.
shellBase := strings.ToLower(strings.TrimSuffix(filepath.Base(gOpts.shell), ".exe"))
posixShells := map[string]bool{"sh": true, "bash": true, "zsh": true, "dash": true}
if posixShells[shellBase] && hasShebang(path) {
path = filepath.ToSlash(path)
return exec.Command(gOpts.shell, append([]string{path}, args...)...)
}
return exec.Command(path, args...)
Copy link
Copy Markdown
Collaborator

@CatsDeservePets CatsDeservePets Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded checks like this will never satisfy every usage-case.

This one in particular is really dangerous and incorrect. You are just testing whether the file has a shebang but execute it in shell anyway. What if your previewer is a python script? What if your shell is set to sh but the previewer is using bash syntax?

Comment thread os_windows.go
Comment on lines +231 to +233
if strings.HasPrefix(f.Name(), ".") {
return true
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like this for 2 reasons:

  • Coming from Unix land myself (and much preferring it over Windows), I fully get why this behaviour might be desired (especially when using things like GitBash or WSL). However, this is not the general mindset of Windows user. Hence, the -Hidden and -Force parameters of Dir/Get-ChildItem don't care for dots in filenames at all, only the hidden attribute.
  • If we were to change this behaviour, this would not be the right place at all. If you take a look at Unix variant of os.go, isHidden does not have this logic either. Instead, we have this this initialisation at the very top of the file:
gDefaultHiddenFiles = []string{".*"}

I am not sure if you are aware of the hiddenfiles setting.

Comment thread os_windows.go
Comment on lines +169 to +173
shellflag := gOpts.shellflag
if shellflag == "/c" {
shellflag = "-c"
}
args = append([]string{shellflag, s}, args...)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@joelim-work convinced me in #2132 (review), why implicit shenanigans like this are not a good idea. In there, I wanted to automatically update key bindings to PowerShell syntax.

Comment thread app.go
Comment on lines +110 to +114
// Set config directory for relative path resolution
savedConfigDir := gConfigDir
gConfigDir = filepath.Dir(path)
defer func() { gConfigDir = savedConfigDir }()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general, I am not opposed to relative path resolution for ruler, previewer, and cleaner. However, this is not the right approach. Temporarily modifying gConfigDir might lead to a race condition. Also, being able to successfully source files after showing an error message would just lead to confusion.
The path itself should be corrected before even passing it to readFile.

Comment thread misc.go
Comment on lines +42 to +53
// expandPath expands env vars, tilde, and resolves relative paths against config directory
func expandPath(s string) string {
s = os.ExpandEnv(s)
s = replaceTilde(s)

if !filepath.IsAbs(s) && gConfigDir != "" {
s = filepath.Join(gConfigDir, s)
}

return s
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can lead to confusing behaviour as os.ExpandEnv will remove references it cannot find:

a := []string{"foobar", "foo$bar", "$foobar"}
for _, v := range a {
	fmt.Printf("%q\n", os.ExpandEnv(v))
}

Output:

"foobar"
"foo"
""

Also, support for env vars is something that has been discussed for quite some time now, but is more complicated as it first seems:

Comment thread eval.go
Comment on lines 1546 to +1551
case "source":
if len(e.args) != 1 {
app.ui.echoerr("source: requires an argument")
return
}
app.readFile(replaceTilde(e.args[0]))
app.readFile(expandPath(e.args[0]))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the source command should ever use the config dir as a fallback. Invoking it is something that is done explicitly by the user. A user expects it to resolve paths relative to the CWD.

@CatsDeservePets
Copy link
Copy Markdown
Collaborator

Hello @vivainio!
Are you still working on this? Any thoughts on my comments? Or should this be closed?

@vivainio
Copy link
Copy Markdown
Author

Hello @vivainio! Are you still working on this? Any thoughts on my comments? Or should this be closed?

Hey, it's fine to close it. I switch much less between windows and wsl right now, I'll revisit the issue when I have to use native Windows side a bit more

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants