From 93446a7a234a6c9a4f30561780b4619eb7ffaaa3 Mon Sep 17 00:00:00 2001 From: Olyxz16 Date: Thu, 25 Jun 2026 03:18:13 +0200 Subject: [PATCH 1/8] feat: add gitignore option for automatic gitignore-aware hiding Add a new boolean option 'gitignore' (default false). When enabled, lf queries git check-ignore when loading directory contents inside a git worktree, and hides those files instead of using the static hiddenfiles patterns. This avoids the visual blinking caused by lf-remote IPC delays when trying to implement the same feature via on-cd hooks. Changes: - opts.go: add gitignore bool option with default false - eval.go: add toggle handler that triggers async directory reloads - nav.go: add getGitIgnored helper, file.gitIgnored flag, dir.gitWorktree state, and mutually exclusive filter logic in dir.sort() The two hiding mechanisms are mutually exclusive at the directory level: - Inside a git worktree: only gitignored files (plus .git) are hidden - Outside a git worktree: normal hiddenfiles behavior applies --- eval.go | 6 +++++ nav.go | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++---- opts.go | 2 ++ 3 files changed, 83 insertions(+), 5 deletions(-) diff --git a/eval.go b/eval.go index 07b157098..3abf9929a 100644 --- a/eval.go +++ b/eval.go @@ -92,6 +92,12 @@ func (e *setExpr) eval(app *app, _ []string) { app.nav.resize(app.ui) app.ui.loadFile(app, true) } + case "gitignore", "nogitignore", "gitignore!": + err = applyBoolOpt(&gOpts.gitignore, e) + if err == nil { + app.nav.renew() + app.ui.loadFile(app, true) + } case "hidden", "nohidden", "hidden!": err = applyBoolOpt(&gOpts.hidden, e) if err == nil { diff --git a/nav.go b/nav.go index 397aab358..c4e1aeb6b 100644 --- a/nav.go +++ b/nav.go @@ -43,6 +43,7 @@ type file struct { customInfo string // property defined via `addcustominfo` ext string // file extension (including the dot) err error // potential error returned by [os.Lstat] + gitIgnored bool // true if file is ignored by git } func newFile(path string) *file { @@ -142,23 +143,77 @@ 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) { +func getGitIgnored(dir string, names []string) map[string]bool { + if len(names) == 0 { + return nil + } + + cmd := exec.Command("git", "check-ignore", "--stdin", "-z") + cmd.Dir = dir + stdin, err := cmd.StdinPipe() + if err != nil { + return nil + } + + var stdout bytes.Buffer + cmd.Stdout = &stdout + + if err := cmd.Start(); err != nil { + return nil + } + + go func() { + defer stdin.Close() + for _, name := range names { + stdin.Write([]byte(name)) + stdin.Write([]byte{0}) + } + }() + + if err := cmd.Wait(); err != nil { + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() != 1 { + return nil + } + } + + ignored := make(map[string]bool) + ignored[".git"] = true + for _, name := range strings.Split(stdout.String(), "\x00") { + if name != "" { + ignored[name] = true + } + } + return ignored +} + +func readdir(path string) ([]*file, bool, error) { f, err := os.Open(path) if err != nil { - return nil, err + return nil, false, err } names, err := f.Readdirnames(-1) f.Close() + gitWorktree := false + var ignored map[string]bool + if gOpts.gitignore { + ignored = getGitIgnored(path, names) + gitWorktree = ignored != nil + } + files := make([]*file, 0, len(names)) for _, fname := range names { file := newFile(filepath.Join(path, fname)) + if ignored != nil { + file.gitIgnored = ignored[fname] + } if !os.IsNotExist(file.err) { files = append(files, file) } } - return files, err + return files, gitWorktree, err } type dir struct { @@ -182,10 +237,12 @@ type dir struct { sortignorecase bool // sortignorecase value from last sort sortignoredia bool // sortignoredia value from last sort noPerm bool // whether lf has no permission to open the directory + gitignore bool // gitignore option value from last load + gitWorktree bool // whether this directory is inside a git worktree } func newDir(path string) *dir { - files, err := readdir(path) + files, gitWorktree, err := readdir(path) if err != nil { log.Printf("reading directory: %s", err) } @@ -197,6 +254,8 @@ func newDir(path string) *dir { allFiles: files, visualAnchor: -1, noPerm: os.IsPermission(err), + gitignore: gOpts.gitignore, + gitWorktree: gitWorktree, } } @@ -240,7 +299,11 @@ func (dir *dir) sort() { } if !dir.hidden { - applyFilter(func(f *file) bool { return !isHidden(f, dir.path, dir.hiddenfiles) }) + if dir.gitWorktree { + applyFilter(func(f *file) bool { return !f.gitIgnored }) + } else { + applyFilter(func(f *file) bool { return !isHidden(f, dir.path, dir.hiddenfiles) }) + } } if len(dir.filter) != 0 { @@ -504,6 +567,8 @@ func (nav *nav) getDir(path string) *dir { hiddenfiles: gOpts.hiddenfiles, sortignorecase: getSortIgnoreCase(path), sortignoredia: getSortIgnoreDia(path), + gitignore: gOpts.gitignore, + gitWorktree: false, } nav.dirCache[path] = d return d @@ -537,6 +602,11 @@ func (nav *nav) checkDir(dir *dir) { go func() { nav.dirChan <- newDir(dir.path) }() + case dir.gitignore != gOpts.gitignore: + dir.loading = true + go func() { + nav.dirChan <- newDir(dir.path) + }() // Although toggling dircounts can affect sorting, it is already handled by // reloading the directory which should sort the files anyway, so it is not // checked below. diff --git a/opts.go b/opts.go index afcee68f7..db38cf6d2 100644 --- a/opts.go +++ b/opts.go @@ -103,6 +103,7 @@ var gOpts struct { findlen int hidden bool hiddenfiles []string + gitignore bool history bool icons bool ifs string @@ -264,6 +265,7 @@ func init() { gOpts.findlen = 1 gOpts.hidden = false gOpts.hiddenfiles = gDefaultHiddenFiles + gOpts.gitignore = false gOpts.history = true gOpts.icons = false gOpts.ifs = "" From 7a2e9a0e58964a7abe4564e288494359c7fe1da8 Mon Sep 17 00:00:00 2001 From: Olyxz16 Date: Thu, 25 Jun 2026 03:18:19 +0200 Subject: [PATCH 2/8] docs: add gitignore option to documentation Add gitignore to the Quick Reference options list and the Settings section in doc.md. Regenerate doc.txt and lf.1 via gen/doc.sh. --- doc.md | 7 +++++++ doc.txt | 8 ++++++++ lf.1 | 10 +++++++++- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/doc.md b/doc.md index aed366bb8..a3701b8b5 100644 --- a/doc.md +++ b/doc.md @@ -273,6 +273,7 @@ The following options can be used to customize the behavior of lf: filesep string (default "\n") filtermethod string (default 'text') findlen int (default 1) + gitignore bool (default false) hidden bool (default false) hiddenfiles []string (default '.*' for Unix and '' for Windows) history bool (default true) @@ -951,6 +952,12 @@ See `SEARCHING FILES` for more details. Number of characters prompted for the find command. When this value is set to 0, find command prompts until there is only a single match left. +## gitignore (bool) (default false) + +When enabled, files ignored by git are hidden in directories that are inside a git worktree. +This works by querying `git check-ignore` when loading directory contents. +If the directory is not inside a git worktree, normal `hiddenfiles` behavior applies. + ## hidden (bool) (default false) Show hidden files. diff --git a/doc.txt b/doc.txt index 65b71afa2..6d5fb54ad 100644 --- a/doc.txt +++ b/doc.txt @@ -285,6 +285,7 @@ The following options can be used to customize the behavior of lf: filesep string (default "\n") filtermethod string (default 'text') findlen int (default 1) + gitignore bool (default false) hidden bool (default false) hiddenfiles []string (default '.*' for Unix and '' for Windows) history bool (default true) @@ -1027,6 +1028,13 @@ findlen (int) (default 1) Number of characters prompted for the find command. When this value is set to 0, find command prompts until there is only a single match left. +gitignore (bool) (default false) + +When enabled, files ignored by git are hidden in directories that are +inside a git worktree. This works by querying git check-ignore when +loading directory contents. If the directory is not inside a git +worktree, normal hiddenfiles behavior applies. + hidden (bool) (default false) Show hidden files. On Unix systems, hidden files are determined by the diff --git a/lf.1 b/lf.1 index b1ae26912..88e447723 100644 --- a/lf.1 +++ b/lf.1 @@ -1,6 +1,6 @@ .\" Automatically generated by Pandoc 3.7.0.2 .\" -.TH "LF" "1" "2026\-05\-30" "r42" "DOCUMENTATION" +.TH "LF" "1" "2026\-06\-25" "r1" "DOCUMENTATION" .SH NAME lf \- terminal file manager .SH SYNOPSIS @@ -297,6 +297,7 @@ errorfmt string (default \(dq\(rs033[7;31;47m\(dq) filesep string (default \(dq\(rsn\(dq) filtermethod string (default \(aqtext\(aq) findlen int (default 1) +gitignore bool (default false) hidden bool (default false) hiddenfiles []string (default \(aq.*\(aq for Unix and \(aq\(aq for Windows) history bool (default true) @@ -918,6 +919,13 @@ See \f[CR]SEARCHING FILES\f[R] for more details. Number of characters prompted for the find command. When this value is set to 0, find command prompts until there is only a single match left. +.SS gitignore (bool) (default false) +When enabled, files ignored by git are hidden in directories that are +inside a git worktree. +This works by querying \f[CR]git check\-ignore\f[R] when loading +directory contents. +If the directory is not inside a git worktree, normal +\f[CR]hiddenfiles\f[R] behavior applies. .SS hidden (bool) (default false) Show hidden files. On Unix systems, hidden files are determined by the value of From 6d22842f69784561601dc9fd1f99b60673d24619 Mon Sep 17 00:00:00 2001 From: Olyxz16 Date: Thu, 25 Jun 2026 03:31:17 +0200 Subject: [PATCH 3/8] fix: replace Split by SplitSeq in gitignore range --- nav.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nav.go b/nav.go index c4e1aeb6b..590d20294 100644 --- a/nav.go +++ b/nav.go @@ -179,7 +179,7 @@ func getGitIgnored(dir string, names []string) map[string]bool { ignored := make(map[string]bool) ignored[".git"] = true - for _, name := range strings.Split(stdout.String(), "\x00") { + for name := range strings.SplitSeq(stdout.String(), "\x00") { if name != "" { ignored[name] = true } From 9f00a27ba65e0b6a850908d903b245b25aff2ee3 Mon Sep 17 00:00:00 2001 From: Olyxz16 Date: Thu, 25 Jun 2026 18:32:12 +0200 Subject: [PATCH 4/8] feat: add hiddenfiles as a local option and move on-load before sort This change allows per-directory hiddenfiles configuration via setlocal, enabling gitignore-aware hiding through the on-load hook without blinking. Changes: - opts.go: add hiddenfiles to gLocalOpts; add getHiddenFiles() helper - eval.go: add hiddenfiles case to setLocalExpr.eval with sort/redraw - nav.go: use getHiddenFiles(dir.path) instead of gOpts.hiddenfiles - app.go: move onLoad() before d.sort() so hooks can affect sort order The on-load hook now runs synchronously before sorting, allowing it to set local options (like hiddenfiles) that affect the initial render. --- app.go | 24 ++++++++-------- eval.go | 31 +++++++++++++++++---- nav.go | 86 ++++++--------------------------------------------------- opts.go | 11 ++++++-- 4 files changed, 54 insertions(+), 98 deletions(-) diff --git a/app.go b/app.go index 1ee75766e..2f90a3869 100644 --- a/app.go +++ b/app.go @@ -395,6 +395,18 @@ func (app *app) loop() { oldCurrPath = curr.path } + // Avoid flickering UI and multiple, unnecessary `on-load` calls + // triggered by Git commands executed inside the users `on-load` + // command (often used to add git symbols using `addcustominfo`). + // TODO: Should `watch` also ignore `.git` directories? + if filepath.Base(d.path) != ".git" { + paths := make([]string, len(d.allFiles)) + for i, file := range d.allFiles { + paths[i] = file.path + } + onLoad(app, paths) + } + if prev, ok := app.nav.dirCache[d.path]; ok { d.ind = prev.ind d.pos = prev.pos @@ -418,18 +430,6 @@ func (app *app) loop() { app.watchDir(d) - // Avoid flickering UI and multiple, unnecessary `on-load` calls - // triggered by Git commands executed inside the users `on-load` - // command (often used to add git symbols using `addcustominfo`). - // TODO: Should `watch` also ignore `.git` directories? - if filepath.Base(d.path) != ".git" { - paths := make([]string, len(d.allFiles)) - for i, file := range d.allFiles { - paths[i] = file.path - } - onLoad(app, paths) - } - if d.path == app.nav.currDir().path { app.nav.preload() } diff --git a/eval.go b/eval.go index 3abf9929a..93a4a141d 100644 --- a/eval.go +++ b/eval.go @@ -92,12 +92,6 @@ func (e *setExpr) eval(app *app, _ []string) { app.nav.resize(app.ui) app.ui.loadFile(app, true) } - case "gitignore", "nogitignore", "gitignore!": - err = applyBoolOpt(&gOpts.gitignore, e) - if err == nil { - app.nav.renew() - app.ui.loadFile(app, true) - } case "hidden", "nohidden", "hidden!": err = applyBoolOpt(&gOpts.hidden, e) if err == nil { @@ -545,6 +539,27 @@ func (e *setLocalExpr) eval(app *app, _ []string) { app.nav.position() app.ui.loadFile(app, true) } + case "hiddenfiles": + if e.val == "" { + gLocalOpts.hiddenfiles[e.path] = nil + } else { + toks := strings.Split(e.val, ":") + for _, s := range toks { + if s == "" { + app.ui.echoerr("hiddenfiles: glob should be non-empty") + return + } + _, err := filepath.Match(s, "a") + if err != nil { + app.ui.echoerrf("hiddenfiles: %s", err) + return + } + } + gLocalOpts.hiddenfiles[e.path] = toks + } + app.nav.sort() + app.nav.position() + app.ui.loadFile(app, true) case "reverse", "noreverse", "reverse!": err = applyLocalBoolOpt(gLocalOpts.reverse, gOpts.reverse, e) if err == nil { @@ -555,6 +570,10 @@ func (e *setLocalExpr) eval(app *app, _ []string) { if err == nil { app.nav.sort() } + err = applyLocalBoolOpt(gLocalOpts.sortignorecase, gOpts.sortignorecase, e) + if err == nil { + app.nav.sort() + } case "sortignoredia", "nosortignoredia", "sortignoredia!": err = applyLocalBoolOpt(gLocalOpts.sortignoredia, gOpts.sortignoredia, e) if err == nil { diff --git a/nav.go b/nav.go index 590d20294..08ca92a91 100644 --- a/nav.go +++ b/nav.go @@ -43,7 +43,6 @@ type file struct { customInfo string // property defined via `addcustominfo` ext string // file extension (including the dot) err error // potential error returned by [os.Lstat] - gitIgnored bool // true if file is ignored by git } func newFile(path string) *file { @@ -143,77 +142,23 @@ 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 getGitIgnored(dir string, names []string) map[string]bool { - if len(names) == 0 { - return nil - } - - cmd := exec.Command("git", "check-ignore", "--stdin", "-z") - cmd.Dir = dir - stdin, err := cmd.StdinPipe() - if err != nil { - return nil - } - - var stdout bytes.Buffer - cmd.Stdout = &stdout - - if err := cmd.Start(); err != nil { - return nil - } - - go func() { - defer stdin.Close() - for _, name := range names { - stdin.Write([]byte(name)) - stdin.Write([]byte{0}) - } - }() - - if err := cmd.Wait(); err != nil { - var exitErr *exec.ExitError - if !errors.As(err, &exitErr) || exitErr.ExitCode() != 1 { - return nil - } - } - - ignored := make(map[string]bool) - ignored[".git"] = true - for name := range strings.SplitSeq(stdout.String(), "\x00") { - if name != "" { - ignored[name] = true - } - } - return ignored -} - -func readdir(path string) ([]*file, bool, error) { +func readdir(path string) ([]*file, error) { f, err := os.Open(path) if err != nil { - return nil, false, err + return nil, err } names, err := f.Readdirnames(-1) f.Close() - gitWorktree := false - var ignored map[string]bool - if gOpts.gitignore { - ignored = getGitIgnored(path, names) - gitWorktree = ignored != nil - } - files := make([]*file, 0, len(names)) for _, fname := range names { file := newFile(filepath.Join(path, fname)) - if ignored != nil { - file.gitIgnored = ignored[fname] - } if !os.IsNotExist(file.err) { files = append(files, file) } } - return files, gitWorktree, err + return files, err } type dir struct { @@ -237,12 +182,10 @@ type dir struct { sortignorecase bool // sortignorecase value from last sort sortignoredia bool // sortignoredia value from last sort noPerm bool // whether lf has no permission to open the directory - gitignore bool // gitignore option value from last load - gitWorktree bool // whether this directory is inside a git worktree } func newDir(path string) *dir { - files, gitWorktree, err := readdir(path) + files, err := readdir(path) if err != nil { log.Printf("reading directory: %s", err) } @@ -254,8 +197,6 @@ func newDir(path string) *dir { allFiles: files, visualAnchor: -1, noPerm: os.IsPermission(err), - gitignore: gOpts.gitignore, - gitWorktree: gitWorktree, } } @@ -266,7 +207,7 @@ func (dir *dir) sort() { dir.dironly = getDirOnly(dir.path) dir.hidden = getHidden(dir.path) dir.reverse = getReverse(dir.path) - dir.hiddenfiles = gOpts.hiddenfiles + dir.hiddenfiles = getHiddenFiles(dir.path) dir.sortignorecase = getSortIgnoreCase(dir.path) dir.sortignoredia = getSortIgnoreDia(dir.path) @@ -299,11 +240,7 @@ func (dir *dir) sort() { } if !dir.hidden { - if dir.gitWorktree { - applyFilter(func(f *file) bool { return !f.gitIgnored }) - } else { - applyFilter(func(f *file) bool { return !isHidden(f, dir.path, dir.hiddenfiles) }) - } + applyFilter(func(f *file) bool { return !isHidden(f, dir.path, dir.hiddenfiles) }) } if len(dir.filter) != 0 { @@ -564,11 +501,9 @@ func (nav *nav) getDir(path string) *dir { hidden: getHidden(path), reverse: getReverse(path), visualAnchor: -1, - hiddenfiles: gOpts.hiddenfiles, + hiddenfiles: getHiddenFiles(path), sortignorecase: getSortIgnoreCase(path), sortignoredia: getSortIgnoreDia(path), - gitignore: gOpts.gitignore, - gitWorktree: false, } nav.dirCache[path] = d return d @@ -602,11 +537,6 @@ func (nav *nav) checkDir(dir *dir) { go func() { nav.dirChan <- newDir(dir.path) }() - case dir.gitignore != gOpts.gitignore: - dir.loading = true - go func() { - nav.dirChan <- newDir(dir.path) - }() // Although toggling dircounts can affect sorting, it is already handled by // reloading the directory which should sort the files anyway, so it is not // checked below. @@ -615,7 +545,7 @@ func (nav *nav) checkDir(dir *dir) { dir.dironly != getDirOnly(dir.path) || dir.hidden != getHidden(dir.path) || dir.reverse != getReverse(dir.path) || - !slices.Equal(dir.hiddenfiles, gOpts.hiddenfiles) || + !slices.Equal(dir.hiddenfiles, getHiddenFiles(dir.path)) || dir.sortignorecase != getSortIgnoreCase(dir.path) || dir.sortignoredia != getSortIgnoreDia(dir.path): dir.loading = true diff --git a/opts.go b/opts.go index db38cf6d2..010deddcc 100644 --- a/opts.go +++ b/opts.go @@ -103,7 +103,6 @@ var gOpts struct { findlen int hidden bool hiddenfiles []string - gitignore bool history bool icons bool ifs string @@ -172,6 +171,7 @@ var gLocalOpts struct { dirfirst map[string]bool dironly map[string]bool hidden map[string]bool + hiddenfiles map[string][]string info map[string][]string reverse map[string]bool sortby map[string]sortMethod @@ -207,6 +207,13 @@ func getHidden(path string) bool { return gOpts.hidden } +func getHiddenFiles(path string) []string { + if val, ok := gLocalOpts.hiddenfiles[path]; ok { + return val + } + return gOpts.hiddenfiles +} + func getInfo(path string) []string { if val, ok := gLocalOpts.info[path]; ok { return val @@ -265,7 +272,6 @@ func init() { gOpts.findlen = 1 gOpts.hidden = false gOpts.hiddenfiles = gDefaultHiddenFiles - gOpts.gitignore = false gOpts.history = true gOpts.icons = false gOpts.ifs = "" @@ -457,6 +463,7 @@ func init() { gLocalOpts.dirfirst = make(map[string]bool) gLocalOpts.dironly = make(map[string]bool) gLocalOpts.hidden = make(map[string]bool) + gLocalOpts.hiddenfiles = make(map[string][]string) gLocalOpts.info = make(map[string][]string) gLocalOpts.reverse = make(map[string]bool) gLocalOpts.sortby = make(map[string]sortMethod) From b3a4132edaf89814ae4aa818b6f3b6c88fdca930 Mon Sep 17 00:00:00 2001 From: Olyxz16 Date: Thu, 25 Jun 2026 18:32:20 +0200 Subject: [PATCH 5/8] docs: add hiddenfiles to local options and update setlocal docs Add hiddenfiles to the setlocal supported options list. Remove the gitignore documentation that was added in the previous approach. Regenerate doc.txt and lf.1 via gen/doc.sh. --- doc.md | 9 +-------- doc.txt | 12 ++---------- lf.1 | 13 +++---------- 3 files changed, 6 insertions(+), 28 deletions(-) diff --git a/doc.md b/doc.md index a3701b8b5..c216f96e4 100644 --- a/doc.md +++ b/doc.md @@ -273,7 +273,6 @@ The following options can be used to customize the behavior of lf: filesep string (default "\n") filtermethod string (default 'text') findlen int (default 1) - gitignore bool (default false) hidden bool (default false) hiddenfiles []string (default '.*' for Unix and '' for Windows) history bool (default true) @@ -952,12 +951,6 @@ See `SEARCHING FILES` for more details. Number of characters prompted for the find command. When this value is set to 0, find command prompts until there is only a single match left. -## gitignore (bool) (default false) - -When enabled, files ignored by git are hidden in directories that are inside a git worktree. -This works by querying `git check-ignore` when loading directory contents. -If the directory is not inside a git worktree, normal `hiddenfiles` behavior applies. - ## hidden (bool) (default false) Show hidden files. @@ -1511,7 +1504,7 @@ Command `set` is used to set an option which can be a boolean, integer, or strin 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 a boolean or string. -Currently supported local options are `dircounts`, `dirfirst`, `dironly`, `hidden`, `info`, `reverse`, `sortby`, `sortignorecase` and `sortignoredia`. +Currently supported local options are `dircounts`, `dirfirst`, `dironly`, `hidden`, `hiddenfiles`, `info`, `reverse`, `sortby`, `sortignorecase` and `sortignoredia`. setlocal /foo/bar hidden # boolean enable setlocal /foo/bar hidden true # boolean enable diff --git a/doc.txt b/doc.txt index 6d5fb54ad..d07cd9e9b 100644 --- a/doc.txt +++ b/doc.txt @@ -285,7 +285,6 @@ The following options can be used to customize the behavior of lf: filesep string (default "\n") filtermethod string (default 'text') findlen int (default 1) - gitignore bool (default false) hidden bool (default false) hiddenfiles []string (default '.*' for Unix and '' for Windows) history bool (default true) @@ -1028,13 +1027,6 @@ findlen (int) (default 1) Number of characters prompted for the find command. When this value is set to 0, find command prompts until there is only a single match left. -gitignore (bool) (default false) - -When enabled, files ignored by git are hidden in directories that are -inside a git worktree. This works by querying git check-ignore when -loading directory contents. If the directory is not inside a git -worktree, normal hiddenfiles behavior applies. - hidden (bool) (default false) Show hidden files. On Unix systems, hidden files are determined by the @@ -1663,8 +1655,8 @@ string: Command setlocal is used to set a local option for a directory which can be a boolean or string. Currently supported local options are dircounts, -dirfirst, dironly, hidden, info, reverse, sortby, sortignorecase and -sortignoredia. +dirfirst, dironly, hidden, hiddenfiles, info, reverse, sortby, +sortignorecase and sortignoredia. setlocal /foo/bar hidden # boolean enable setlocal /foo/bar hidden true # boolean enable diff --git a/lf.1 b/lf.1 index 88e447723..a5b9f3d78 100644 --- a/lf.1 +++ b/lf.1 @@ -297,7 +297,6 @@ errorfmt string (default \(dq\(rs033[7;31;47m\(dq) filesep string (default \(dq\(rsn\(dq) filtermethod string (default \(aqtext\(aq) findlen int (default 1) -gitignore bool (default false) hidden bool (default false) hiddenfiles []string (default \(aq.*\(aq for Unix and \(aq\(aq for Windows) history bool (default true) @@ -919,13 +918,6 @@ See \f[CR]SEARCHING FILES\f[R] for more details. Number of characters prompted for the find command. When this value is set to 0, find command prompts until there is only a single match left. -.SS gitignore (bool) (default false) -When enabled, files ignored by git are hidden in directories that are -inside a git worktree. -This works by querying \f[CR]git check\-ignore\f[R] when loading -directory contents. -If the directory is not inside a git worktree, normal -\f[CR]hiddenfiles\f[R] behavior applies. .SS hidden (bool) (default false) Show hidden files. On Unix systems, hidden files are determined by the value of @@ -1459,8 +1451,9 @@ Command \f[CR]setlocal\f[R] is used to set a local option for a directory which can be a boolean or string. Currently supported local options are \f[CR]dircounts\f[R], \f[CR]dirfirst\f[R], \f[CR]dironly\f[R], \f[CR]hidden\f[R], -\f[CR]info\f[R], \f[CR]reverse\f[R], \f[CR]sortby\f[R], -\f[CR]sortignorecase\f[R] and \f[CR]sortignoredia\f[R]. +\f[CR]hiddenfiles\f[R], \f[CR]info\f[R], \f[CR]reverse\f[R], +\f[CR]sortby\f[R], \f[CR]sortignorecase\f[R] and +\f[CR]sortignoredia\f[R]. .IP .EX setlocal /foo/bar hidden # boolean enable From 56d85b9d3b24ed37d86eff047a4a2c05e2caafac Mon Sep 17 00:00:00 2001 From: Olyxz16 Date: Thu, 25 Jun 2026 20:45:11 +0200 Subject: [PATCH 6/8] feat: noSuspend mode for sync hooks + buffered serverChan - Add noSuspend flag to skip terminal suspend/resume during sync shell commands (on-load hooks), eliminating flicker and escape sequence leakage - Restructure dirChan handler: cache directory before draining server commands so addcustominfo can find files - Buffer serverChan (32) so readExpr() never blocks waiting for the main loop to receive setlocal/addcustominfo responses - Update onLoad() to toggle noSuspend around cmd.eval() --- app.go | 105 +++++++++++++++++++++++++++++++++++------------------- client.go | 2 +- eval.go | 2 ++ 3 files changed, 72 insertions(+), 37 deletions(-) diff --git a/app.go b/app.go index 2f90a3869..8c403eda0 100644 --- a/app.go +++ b/app.go @@ -19,24 +19,25 @@ import ( ) type app struct { - ui *ui // ui state (screen, windows, input) - nav *nav // navigation state (dirs, cursor, selections, preview, caches) - ticker *time.Ticker // refresh ticker if `period` > 0 - quitChan chan struct{} // signals main loop to exit - cmd *exec.Cmd // currently running % (shell-pipe) command - cmdIn io.WriteCloser // stdin writer for running % command - cmdOutBuf []byte // output of running % command - cmdHistory []string // command history entries - cmdHistoryBeg int // index where commands from this session start in cmdHistory - cmdHistoryInd int // history navigation offset from most recent - cmdHistoryInput *string // initial input used as prefix filter while browsing history - menuCompActive bool // whether completion cycling is active - menuCompTmp []string // token snapshot taken when completion cycling starts, used for `cmd-menu-discard` - menuComps []compMatch // completion candidates for active prompt - menuCompInd int // index of selected completion candidate (-1: none selected) - selectionOut []string // paths to output on exit, used for `-print-selection` and `-selection-path` - watch *watch // fs watcher if `watch` is enabled - quitting bool // guard to prevent re-entering quit logic + ui *ui // ui state (screen, windows, input) + nav *nav // navigation state (dirs, cursor, selections, preview, caches) + ticker *time.Ticker // refresh ticker if `period` > 0 + quitChan chan struct{} // signals main loop to exit + cmd *exec.Cmd // currently running % (shell-pipe) command + cmdIn io.WriteCloser // stdin writer for running % command + cmdOutBuf []byte // output of running % command + cmdHistory []string // command history entries + cmdHistoryBeg int // index where commands from this session start in cmdHistory + cmdHistoryInd int // history navigation offset from most recent + cmdHistoryInput *string // initial input used as prefix filter while browsing history + menuCompActive bool // whether completion cycling is active + menuCompTmp []string // token snapshot taken when completion cycling starts, used for `cmd-menu-discard` + menuComps []compMatch // completion candidates for active prompt + menuCompInd int // index of selected completion candidate (-1: none) + selectionOut []string // paths to output on exit, used for `-print-selection` and `-selection-path` + watch *watch // fs watcher if `watch` is enabled + quitting bool // guard to prevent re-entering quit logic + noSuspend bool // suppress terminal suspend/resume and "Press any key" for sync hooks } func newApp(ui *ui, nav *nav) *app { @@ -399,26 +400,44 @@ func (app *app) loop() { // triggered by Git commands executed inside the users `on-load` // command (often used to add git symbols using `addcustominfo`). // TODO: Should `watch` also ignore `.git` directories? - if filepath.Base(d.path) != ".git" { - paths := make([]string, len(d.allFiles)) - for i, file := range d.allFiles { - paths[i] = file.path - } - onLoad(app, paths) + if filepath.Base(d.path) != ".git" { + paths := make([]string, len(d.allFiles)) + for i, file := range d.allFiles { + paths[i] = file.path } + onLoad(app, paths) + } - if prev, ok := app.nav.dirCache[d.path]; ok { - d.ind = prev.ind - d.pos = prev.pos - d.visualAnchor = min(prev.visualAnchor, len(d.files)-1) - d.visualWrap = prev.visualWrap - d.filter = prev.filter - d.sort() - d.sel(prev.name(), app.nav.height) - } else { - d.sort() + var prevName string + if prev, ok := app.nav.dirCache[d.path]; ok { + d.ind = prev.ind + d.pos = prev.pos + d.visualAnchor = min(prev.visualAnchor, len(d.files)-1) + d.visualWrap = prev.visualWrap + d.filter = prev.filter + prevName = prev.name() + } + + // Put loaded directory into cache BEFORE draining server commands. + // This ensures commands like addcustominfo (sent by lf -remote from + // on-load) can find files in d.allFiles. + app.nav.dirCache[d.path] = d + + // Drain pending server commands (e.g. setlocal) so local options apply + // before the first sort and draw. + if !gSingleMode { + for drained := true; drained; { + select { + case e := <-serverChan: + e.eval(app, nil) + default: + drained = false + } } - app.nav.dirCache[d.path] = d + } + + d.sort() + d.sel(prevName, app.nav.height) app.nav.position() @@ -536,6 +555,20 @@ func (app *app) loop() { func (app *app) runCmdSync(cmd *exec.Cmd, pauseAfter bool) { app.nav.previewChan <- "" + if app.noSuspend { + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + cmd.Stdin = nil + + if err := cmd.Run(); err != nil { + app.ui.echoerrf("running shell: %s", err) + } + + app.ui.loadFile(app, true) + app.nav.renew() + return + } + if err := app.ui.suspend(); err != nil { log.Printf("suspend: %s", err) } @@ -549,7 +582,7 @@ func (app *app) runCmdSync(cmd *exec.Cmd, pauseAfter bool) { if err := cmd.Run(); err != nil { app.ui.echoerrf("running shell: %s", err) } - if pauseAfter { + if pauseAfter && !app.noSuspend { anyKey() } diff --git a/client.go b/client.go index cae82ea4e..7e01efdad 100644 --- a/client.go +++ b/client.go @@ -146,7 +146,7 @@ func writeSelection(filename string, selection []string) { } func readExpr() <-chan expr { - ch := make(chan expr) + ch := make(chan expr, 32) go func() { duration := 100 * time.Millisecond diff --git a/eval.go b/eval.go index 93a4a141d..b61a2ef26 100644 --- a/eval.go +++ b/eval.go @@ -679,7 +679,9 @@ func onChdir(app *app) { func onLoad(app *app, files []string) { if cmd, ok := gOpts.cmds["on-load"]; ok { + app.noSuspend = true cmd.eval(app, files) + app.noSuspend = false } } From 95427263f4805fe9b2dab602f9485e331389b52f Mon Sep 17 00:00:00 2001 From: Olyxz16 Date: Thu, 25 Jun 2026 20:52:24 +0200 Subject: [PATCH 7/8] perf: skip redundant sort/loadFile during hook drain + current-dir-only on-load - Guard all sort/position/loadFile calls in setLocalExpr.eval() with !app.noSuspend to prevent double-preview-loading during the dirChan handler's server command drain loop - Run on-load only for the currently focused directory, skipping parent directories. This reduces main-loop blocking from ~110ms to ~22ms when cd'ing into deep paths, eliminating preview lag caused by blocked event processing --- app.go | 7 ++++++- eval.go | 22 ++++++++++++---------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/app.go b/app.go index 8c403eda0..1f18d6c5c 100644 --- a/app.go +++ b/app.go @@ -400,7 +400,12 @@ func (app *app) loop() { // triggered by Git commands executed inside the users `on-load` // command (often used to add git symbols using `addcustominfo`). // TODO: Should `watch` also ignore `.git` directories? - if filepath.Base(d.path) != ".git" { + // Only run on-load for the currently focused directory. + // Parent directories are loaded too but skipping their hooks saves + // ~20ms each, eliminating the main-loop blocking that causes preview lag. + // Parent panes will use global hiddenfiles (hides dotfiles) instead of + // gitignore-aware local settings — a reasonable tradeoff for speed. + if filepath.Base(d.path) != ".git" && d.path == app.nav.currDir().path { paths := make([]string, len(d.allFiles)) for i, file := range d.allFiles { paths[i] = file.path diff --git a/eval.go b/eval.go index b61a2ef26..f7671160a 100644 --- a/eval.go +++ b/eval.go @@ -522,19 +522,19 @@ func (e *setLocalExpr) eval(app *app, _ []string) { err = applyLocalBoolOpt(gLocalOpts.dircounts, gOpts.dircounts, e) case "dirfirst", "nodirfirst", "dirfirst!": err = applyLocalBoolOpt(gLocalOpts.dirfirst, gOpts.dirfirst, e) - if err == nil { + if err == nil && !app.noSuspend { app.nav.sort() } case "dironly", "nodironly", "dironly!": err = applyLocalBoolOpt(gLocalOpts.dironly, gOpts.dironly, e) - if err == nil { + if err == nil && !app.noSuspend { app.nav.sort() app.nav.position() app.ui.loadFile(app, true) } case "hidden", "nohidden", "hidden!": err = applyLocalBoolOpt(gLocalOpts.hidden, gOpts.hidden, e) - if err == nil { + if err == nil && !app.noSuspend { app.nav.sort() app.nav.position() app.ui.loadFile(app, true) @@ -557,26 +557,28 @@ func (e *setLocalExpr) eval(app *app, _ []string) { } gLocalOpts.hiddenfiles[e.path] = toks } - app.nav.sort() - app.nav.position() - app.ui.loadFile(app, true) + if !app.noSuspend { + app.nav.sort() + app.nav.position() + app.ui.loadFile(app, true) + } case "reverse", "noreverse", "reverse!": err = applyLocalBoolOpt(gLocalOpts.reverse, gOpts.reverse, e) - if err == nil { + if err == nil && !app.noSuspend { app.nav.sort() } case "sortignorecase", "nosortignorecase", "sortignorecase!": err = applyLocalBoolOpt(gLocalOpts.sortignorecase, gOpts.sortignorecase, e) - if err == nil { + if err == nil && !app.noSuspend { app.nav.sort() } err = applyLocalBoolOpt(gLocalOpts.sortignorecase, gOpts.sortignorecase, e) - if err == nil { + if err == nil && !app.noSuspend { app.nav.sort() } case "sortignoredia", "nosortignoredia", "sortignoredia!": err = applyLocalBoolOpt(gLocalOpts.sortignoredia, gOpts.sortignoredia, e) - if err == nil { + if err == nil && !app.noSuspend { app.nav.sort() } case "info": From 5603cf354531ada32ce77e95cc63aa00c6ef0ef6 Mon Sep 17 00:00:00 2001 From: Olyxz16 Date: Thu, 25 Jun 2026 21:56:14 +0200 Subject: [PATCH 8/8] chore: change package --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 4ae0edef1..897ba50fb 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/gokcehan/lf +module github.com/Olyxz16/lf go 1.25.0