Compare commits

...

7 Commits

Author SHA1 Message Date
Dmitriy Kovalenko a5451c09b7 chore: Update docs for - feat: Make zig optional for building
docs / docs (push) Has been cancelled
2026-02-28 21:32:29 +00:00
Dmitriy Kovalenko 184f245be1 feat: Make zig optional for building 2026-02-28 13:32:10 -08:00
bansheerubber 29f81a141f feat: add cursorlineopt config option for styling livegrep file preview (#262)
* feat: add cursorlineopt config option for styling livegrep file preview

* fix: better validation for junk `cursorlineopt` values
2026-02-27 21:00:30 -08:00
Dmitriy Kovalenko d2e3993398 feat: cross-compile for android (! for termux users) (#264)
* feat: cross-compile for android (! for termux users)

* chore: Update docs for - feat: cross-compile for android (! for termux users)
2026-02-27 20:57:35 -08:00
bansheerubber a411100fa4 chore(docs): replace flex = nil with flex = false in README.md (#263) 2026-02-27 17:57:26 -08:00
Italo Silva 0a993d7a30 feat!: Make layout flex on small screens & responsive (#255)
* feat: Add `flex` option for preview

* chore: Update docs for - feat: Add `flex` option for preview

* feat: Recalculate layout on resize

---------

Co-authored-by: Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>
2026-02-25 18:36:20 -08:00
Dmitriy Kovalenko 7c2d46f1e2 chore: Upgrade every used neovim deprecated lua apis (#261)
* chore: Do not use any neovim deprecated apis

* chore: Update docs for - chore: Do not use any neovim deprecated apis
2026-02-25 17:44:13 -08:00
31 changed files with 795 additions and 535 deletions
+3 -8
View File
@@ -10,12 +10,7 @@ rustflags = ["-C", "target-feature=-crt-static"]
[target.aarch64-unknown-linux-musl]
rustflags = ["-C", "target-feature=-crt-static"]
# Android/Termux: no hardcoded linker so native Termux builds use the system cc.
# For CI cross-compilation the linker is set via CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER env var.
[target.aarch64-linux-android]
rustflags = [
"-C",
"linker=aarch64-linux-android-clang",
"-C",
"link-args=-rdynamic",
"-C",
"default-linker-libraries",
]
rustflags = ["-C", "link-args=-rdynamic"]
+2 -2
View File
@@ -44,7 +44,7 @@ jobs:
- name: Build Rust binary (Windows)
if: matrix.target
run: cargo build --release --target ${{ matrix.target }} -p fff-nvim
run: cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
- name: Copy binary to target/release (Windows)
if: matrix.target
@@ -71,7 +71,7 @@ jobs:
- name: Build Rust binary
if: ${{ !matrix.target }}
run: cargo build --release -p fff-nvim
run: cargo build --release -p fff-nvim --features zlob
- name: Install Neovim
uses: rhysd/action-setup-vim@v1
+55 -10
View File
@@ -2,7 +2,7 @@ name: Prebuild
on:
push:
branches: [main, feat/interchangable-ffi]
branches: [main, feat/interchangable-ffi, feat/termux]
pull_request:
jobs:
@@ -36,6 +36,12 @@ jobs:
artifact_name: target/aarch64-unknown-linux-musl/release/libfff_nvim.so
ext: so
## Android (Termux)
- os: ubuntu-latest
target: aarch64-linux-android
artifact_name: target/aarch64-linux-android/release/libfff_nvim.so
ext: so
## macOS builds
- os: macos-latest
target: x86_64-apple-darwin
@@ -73,15 +79,29 @@ jobs:
run: cargo install cargo-zigbuild
- name: Build for Linux
if: contains(matrix.os, 'ubuntu')
if: contains(matrix.os, 'ubuntu') && !contains(matrix.target, 'android')
run: |
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-nvim
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-nvim --features zlob
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
- name: Build for Android (Termux)
if: contains(matrix.target, 'android')
run: |
NDK_BIN="$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin"
# NDK clang for C deps (libgit2, lmdb, blake3) that need Bionic sysroot headers
export CC_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang"
export CXX_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang++"
export AR_aarch64_linux_android="$NDK_BIN/llvm-ar"
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$NDK_BIN/aarch64-linux-android24-clang"
cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
- name: Build for macOS
if: contains(matrix.os, 'macos')
run: |
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-nvim
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
- name: Ad-hoc sign macOS binary
@@ -92,7 +112,7 @@ jobs:
if: contains(matrix.os, 'windows')
shell: bash
run: |
cargo build --release --target ${{ matrix.target }} -p fff-nvim
cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
- name: Upload artifacts
@@ -137,6 +157,13 @@ jobs:
lib_filename: libfff_c.so
ext: so
## Android (Termux)
- os: ubuntu-latest
target: aarch64-linux-android
artifact_name: target/aarch64-linux-android/release/libfff_c.so
lib_filename: libfff_c.so
ext: so
## macOS builds
- os: macos-latest
target: x86_64-apple-darwin
@@ -183,15 +210,28 @@ jobs:
run: cargo install cargo-zigbuild
- name: Build for Linux
if: contains(matrix.os, 'ubuntu')
if: contains(matrix.os, 'ubuntu') && !contains(matrix.target, 'android')
run: |
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-c
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-c --features zlob
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
- name: Build for Android (Termux)
if: contains(matrix.target, 'android')
run: |
NDK_BIN="$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin"
export CC_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang"
export CXX_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang++"
export AR_aarch64_linux_android="$NDK_BIN/llvm-ar"
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$NDK_BIN/aarch64-linux-android24-clang"
cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
- name: Build for macOS
if: contains(matrix.os, 'macos')
run: |
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-c
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
- name: Ad-hoc sign macOS binary
@@ -202,10 +242,11 @@ jobs:
if: contains(matrix.os, 'windows')
shell: bash
run: |
cargo build --release --target ${{ matrix.target }} -p fff-c
cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
- name: Prepare npm package
if: "!contains(matrix.target, 'android')"
shell: bash
run: |
# Copy the built binary into the platform npm package directory
@@ -218,6 +259,7 @@ jobs:
path: c-lib-${{ matrix.target }}.*
- name: Upload npm package artifact
if: "!contains(matrix.target, 'android')"
uses: actions/upload-artifact@v4
with:
name: npm-${{ matrix.npm_package }}
@@ -227,6 +269,8 @@ jobs:
name: Release
needs: [build-nvim, build-c]
runs-on: ubuntu-latest
# do not create releases on the forks (no permissions)
if: github.repository == 'dmtrKovalenko/fff.nvim'
permissions:
contents: write
steps:
@@ -403,7 +447,8 @@ jobs:
name: Comment on PR
needs: [build-nvim, build-c]
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
# comments doesn't work on forks
if: github.event_name == 'pull_request' && github.repository == 'dmtrKovalenko/fff.nvim'
permissions:
pull-requests: write
steps:
+6 -2
View File
@@ -37,7 +37,9 @@ jobs:
components: rustfmt, clippy
- name: Run tests
run: cargo test --verbose --workspace --exclude fff-nvim
run: |
cargo test --verbose -p fff-core -p fff-query-parser -p fff-c --features zlob
cargo test --verbose -p grep-searcher
fmt:
name: cargo fmt
@@ -71,4 +73,6 @@ jobs:
components: clippy
- name: Run clippy
run: cargo clippy -- -D warnings
run: |
cargo clippy -p fff-core -p fff-query-parser -p fff-nvim -p fff-c --features zlob -- -D warnings
cargo clippy -p grep-searcher -- -D warnings
+2 -2
View File
@@ -23,8 +23,8 @@
"param-type-mismatch": "Warning",
"assign-type-mismatch": "Warning",
"cast-type-mismatch": "Warning",
"deprecated": "Hint",
"undefined-doc-param": "Hint"
"deprecated": "Warning",
"undefined-doc-param": "Warning"
},
"neededFileStatus": {
"undefined-global": "Any",
+2 -2
View File
@@ -21,8 +21,8 @@
"param-type-mismatch": "Warning",
"assign-type-mismatch": "Warning",
"cast-type-mismatch": "Warning",
"deprecated": "Hint",
"undefined-doc-param": "Hint"
"deprecated": "Warning",
"undefined-doc-param": "Warning"
},
"neededFileStatus": {
"undefined-global": "Any",
Generated
+3 -70
View File
@@ -482,6 +482,7 @@ dependencies = [
"fff-query-parser",
"git2",
"glidesort",
"globset",
"grep-matcher",
"grep-searcher",
"heed",
@@ -492,7 +493,6 @@ dependencies = [
"notify",
"notify-debouncer-full 0.7.0",
"once_cell",
"openssl",
"parking_lot",
"pathdiff",
"rand",
@@ -531,7 +531,6 @@ dependencies = [
"notify",
"notify-debouncer-full 0.6.0",
"once_cell",
"openssl",
"pathdiff",
"rand",
"rayon",
@@ -543,7 +542,6 @@ dependencies = [
"tracing",
"tracing-appender",
"tracing-subscriber",
"zlob",
]
[[package]]
@@ -564,21 +562,6 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "form_urlencoded"
version = "1.2.1"
@@ -630,7 +613,6 @@ dependencies = [
"libc",
"libgit2-sys",
"log",
"openssl-sys",
"url",
]
@@ -986,7 +968,6 @@ dependencies = [
"cc",
"libc",
"libz-sys",
"openssl-sys",
"pkg-config",
]
@@ -1301,54 +1282,6 @@ version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "openssl"
version = "0.10.73"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
dependencies = [
"bitflags 2.9.1",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "openssl-src"
version = "300.5.1+3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "735230c832b28c000e3bc117119e6466a663ec73506bc0a9907ea4187508e42a"
dependencies = [
"cc",
]
[[package]]
name = "openssl-sys"
version = "0.9.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571"
dependencies = [
"cc",
"libc",
"openssl-src",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -2576,9 +2509,9 @@ dependencies = [
[[package]]
name = "zlob"
version = "1.2.9"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "674f4e74544c0a00887c0dff7862fe13742de8c5dbcf6db9eac0643362554d44"
checksum = "07934fb13f0f4e14281bab6b0e984cfc03891d2cf5b0a8bd0ab14fd240e21106"
dependencies = [
"bindgen",
"bitflags 2.9.1",
+2 -2
View File
@@ -20,16 +20,16 @@ dunce = "1.0"
# git2 - base config without TLS (each crate adds platform-specific TLS)
git2 = { version = "0.20.2", default-features = false, features = [
"vendored-libgit2",
"vendored-openssl",
] }
glidesort = "0.1"
globset = "0.4"
grep-matcher = "0.1.8"
grep-searcher = { path = "crates/fff-searcher" }
heed = "0.22.0"
ignore = "0.4.22"
memmap2 = "0.9"
mimalloc = "0.1.47"
zlob = "1.2.9"
zlob = "1.3.0"
mlua = { version = "0.11.1", features = ["module", "luajit"] }
neo_frizbee = "0.8.1"
+2 -2
View File
@@ -3,7 +3,7 @@ PLENARY_DIR ?= ../plenary.nvim
.PHONY: build test test-rust test-lua test-bun test-setup prepare-bun
build:
cargo build --release
cargo build --release --features zlob
test-setup:
@if [ ! -d "$(PLENARY_DIR)" ]; then \
@@ -12,7 +12,7 @@ test-setup:
fi
test-rust:
cargo test --workspace
cargo test --workspace --features zlob
test-lua: test-setup build
nvim --headless -u tests/minimal_init.lua \
+8 -5
View File
@@ -141,6 +141,10 @@ require('fff').setup({
prompt_position = 'bottom', -- or 'top'
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
preview_size = 0.5,
flex = { -- set to false to disable flex layout
size = 130, -- column threshold: if screen width >= size, use preview_position; otherwise use wrap
wrap = 'top', -- position to use when screen is narrower than size
},
show_scrollbar = true, -- Show scrollbar for pagination
-- How to shorten long directory paths in the file list:
-- 'middle_number' (default): uses dots for 1-3 hidden (a/./b, a/../b, a/.../b)
@@ -156,6 +160,7 @@ require('fff').setup({
binary_file_threshold = 1024, -- amount of bytes to scan for binary content (set 0 to disable)
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
line_numbers = false,
cursorlineopt = 'both', -- the cursorlineopt used for lines in grep file previews, see :h cursorlineopt
wrap_lines = false,
filetypes = {
svg = { wrap_lines = true },
@@ -176,7 +181,7 @@ require('fff').setup({
preview_scroll_down = '<C-d>',
toggle_debug = '<F2>',
-- grep mode: cycle between plain text, regex, and fuzzy search
toggle_grep_regex = '<S-Tab>',
cycle_grep_modes = '<S-Tab>',
-- goes to the previous query in history
cycle_previous_query = '<C-Up>',
-- multi-select keymaps for quickfix
@@ -185,16 +190,14 @@ require('fff').setup({
-- this are specific for the normal mode (you can exit it using any other keybind like jj)
focus_list = '<leader>l',
focus_preview = '<leader>p',
toggle_grep_regex = '<S-Tab>',
},
hl = {
border = 'FloatBorder',
normal = 'Normal',
cursor = 'CursorLine',
cursor = 'CursorLine', -- Falls back to 'Visual' if CursorLine is not defined
matched = 'IncSearch',
title = 'Title',
prompt = 'Question',
cursor = fallback_hl({ 'CursorLine', 'Visual' }),
frecency = 'Number',
debug = 'Comment',
combo_header = 'Number',
@@ -271,7 +274,7 @@ require('fff').setup({
time_budget_ms = 150, -- Max search time in ms per call (prevents UI freeze, 0 = no limit)
modes = { 'plain', 'regex', 'fuzzy' }, -- Available grep modes and their cycling order
},
}})
})
```
### Key Features
+4
View File
@@ -8,6 +8,10 @@ license = "MIT"
[lib]
crate-type = ["cdylib"]
[features]
default = []
zlob = ["fff-core/zlob"]
[dependencies]
mimalloc.workspace = true
tracing.workspace = true
+6 -7
View File
@@ -13,6 +13,9 @@ crate-type = ["rlib", "staticlib", "cdylib"]
default = []
# Enable C FFI exports
ffi = []
# Use zlob (Zig-compiled C globbing library) for glob matching.
# Requires Zig to be installed. When disabled, falls back to globset (pure Rust).
zlob = ["dep:zlob", "fff-query-parser/zlob"]
[dependencies]
# Workspace dependencies
@@ -23,7 +26,7 @@ thiserror = { workspace = true }
tracing = { workspace = true }
# Local crates
fff-query-parser = { path = "../fff-query-parser" }
fff-query-parser = { path = "../fff-query-parser", default-features = false }
# External dependencies
bindet = { workspace = true }
@@ -32,6 +35,7 @@ chrono = { workspace = true }
dirs = { workspace = true }
git2 = { workspace = true }
glidesort = { workspace = true }
globset = { workspace = true }
grep-matcher = { workspace = true }
grep-searcher = { workspace = true }
memchr = "2"
@@ -49,12 +53,7 @@ serde = { version = "1.0", features = ["derive"] }
smartstring = { version = "1.0.1", features = ["serde"] }
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
zlob = { workspace = true }
# Platform-specific: Use vendored OpenSSL on non-Windows (Linux, macOS)
[target.'cfg(not(windows))'.dependencies]
openssl = { version = "0.10", features = ["vendored"] }
zlob = { workspace = true, optional = true }
# Platform-specific: dunce for Windows to avoid \\?\ extended path prefix
[target.'cfg(windows)'.dependencies]
dunce = { workspace = true }
+42 -12
View File
@@ -1,16 +1,46 @@
fn main() {
// When the `zlob` feature is enabled (Zig-compiled C library):
// On Windows MSVC, explicitly link the C runtime libraries.
// This is needed because Zig-compiled static libraries (zlob) don't emit
// /DEFAULTLIB directives for the MSVC CRT. Without this, symbols like
// strcmp, memcpy, memchr etc. from vendored C libraries (libgit2, lmdb)
// are unresolved when linking the cdylib.
//
// We link both msvcrt (classic CRT) and ucrt (Universal CRT where memchr,
// strcmp etc. live on newer MSVC/ARM64 targets).
let target = std::env::var("TARGET").unwrap_or_default();
if target.contains("windows") && target.contains("msvc") {
println!("cargo:rustc-link-lib=msvcrt");
println!("cargo:rustc-link-lib=ucrt");
println!("cargo:rustc-link-lib=vcruntime");
// Zig-compiled static libraries don't emit /DEFAULTLIB directives for the
// MSVC CRT, so symbols like strcmp, memcpy etc. would be unresolved.
if std::env::var("CARGO_FEATURE_ZLOB").is_ok() {
let target = std::env::var("TARGET").unwrap_or_default();
if target.contains("windows") && target.contains("msvc") {
println!("cargo:rustc-link-lib=msvcrt");
println!("cargo:rustc-link-lib=ucrt");
println!("cargo:rustc-link-lib=vcruntime");
}
} else if std::env::var("CI").is_ok() {
// CI must always build with zlob for production-quality binaries.
if !zig_available() {
panic!(
"CI detected but Zig is not installed. \
Please install Zig and build with `--features zlob`."
);
}
panic!(
"CI detected but `zlob` feature is not enabled. \
Build with `--features zlob`."
);
} else {
// Hint: if Zig is available but the zlob feature wasn't enabled,
// let the developer know they can get faster glob matching.
if zig_available() {
println!(
"cargo:warning=Zig detected but `zlob` feature is not enabled. \
Build with `--features zlob` for faster glob matching."
);
}
}
}
/// Probe the system for a working Zig installation.
fn zig_available() -> bool {
std::process::Command::new("zig")
.arg("version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
+62 -27
View File
@@ -9,7 +9,6 @@
use ahash::AHashSet;
use fff_query_parser::{Constraint, GitStatusFilter};
use smallvec::SmallVec;
use zlob::{ZlobFlags, zlob_match_paths};
use crate::git::is_modified_status;
@@ -235,32 +234,8 @@ fn collect_glob_indices<'a>(
) {
match constraint {
Constraint::Glob(pattern) => {
if let Ok(Some(matches)) = zlob_match_paths(pattern, paths, ZlobFlags::RECOMMENDED) {
let matched_set: AHashSet<usize> =
matches.iter().map(|s| s.as_ptr() as usize).collect();
let indices: AHashSet<usize> = if paths.len() >= PAR_THRESHOLD {
use rayon::prelude::*;
paths
.par_iter()
.enumerate()
.filter(|(_, p)| matched_set.contains(&(p.as_ptr() as usize)))
.map(|(i, _)| i)
.collect::<Vec<_>>()
.into_iter()
.collect()
} else {
paths
.iter()
.enumerate()
.filter(|(_, p)| matched_set.contains(&(p.as_ptr() as usize)))
.map(|(i, _)| i)
.collect()
};
results.push((is_negated, indices));
} else {
results.push((is_negated, AHashSet::new()));
}
let indices = match_glob_pattern(pattern, paths);
results.push((is_negated, indices));
}
Constraint::Not(inner) => {
collect_glob_indices(inner, paths, results, !is_negated);
@@ -269,6 +244,66 @@ fn collect_glob_indices<'a>(
}
}
/// Match a glob pattern against a list of paths, returning the set of matching indices.
///
/// When the `zlob` feature is enabled, delegates to `zlob::zlob_match_paths` (Zig-compiled
/// C library, fastest). Otherwise falls back to `globset::Glob` (pure Rust).
#[cfg(feature = "zlob")]
fn match_glob_pattern(pattern: &str, paths: &[&str]) -> AHashSet<usize> {
let Ok(Some(matches)) = zlob::zlob_match_paths(pattern, paths, zlob::ZlobFlags::RECOMMENDED)
else {
return AHashSet::new();
};
let matched_set: AHashSet<usize> = matches.iter().map(|s| s.as_ptr() as usize).collect();
if paths.len() >= PAR_THRESHOLD {
use rayon::prelude::*;
paths
.par_iter()
.enumerate()
.filter(|(_, p)| matched_set.contains(&(p.as_ptr() as usize)))
.map(|(i, _)| i)
.collect::<Vec<_>>()
.into_iter()
.collect()
} else {
paths
.iter()
.enumerate()
.filter(|(_, p)| matched_set.contains(&(p.as_ptr() as usize)))
.map(|(i, _)| i)
.collect()
}
}
#[cfg(not(feature = "zlob"))]
fn match_glob_pattern(pattern: &str, paths: &[&str]) -> AHashSet<usize> {
let Ok(glob) = globset::Glob::new(pattern) else {
return AHashSet::new();
};
let matcher = glob.compile_matcher();
if paths.len() >= PAR_THRESHOLD {
use rayon::prelude::*;
paths
.par_iter()
.enumerate()
.filter(|(_, p)| matcher.is_match(p))
.map(|(i, _)| i)
.collect::<Vec<_>>()
.into_iter()
.collect()
} else {
paths
.iter()
.enumerate()
.filter(|(_, p)| matcher.is_match(p))
.map(|(i, _)| i)
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
+4 -5
View File
@@ -7,6 +7,10 @@ edition = "2024"
path = "src/lib.rs"
crate-type = ["cdylib", "rlib"]
[features]
default = []
zlob = ["fff-core/zlob"]
[[bin]]
name = "test_watcher"
path = "src/bin/test_watcher.rs"
@@ -63,7 +67,6 @@ serde = { version = "1.0", features = ["derive"] }
smartstring = { version = "1.0.1", features = ["serde"] }
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
zlob = { workspace = true }
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
@@ -78,7 +81,3 @@ harness = false
name = "query_tracker_bench"
harness = false
# Platform-specific: Use vendored OpenSSL on non-Windows (Linux, macOS)
# On Windows, git2 uses the native SChannel TLS backend
[target.'cfg(not(windows))'.dependencies]
openssl = { version = "0.10", features = ["vendored"] }
+5 -1
View File
@@ -6,9 +6,13 @@ edition = "2024"
[lib]
path = "src/lib.rs"
[features]
default = []
zlob = ["dep:zlob"]
[dependencies]
smallvec = { workspace = true }
zlob = { version = "1.2.8" }
zlob = { workspace = true, optional = true }
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
+3 -3
View File
@@ -1,5 +1,5 @@
use crate::constraints::Constraint;
use zlob::{ZlobFlags, has_wildcards};
use crate::glob_detect::has_wildcards;
/// Parser configuration trait - allows different picker types to customize parsing
pub trait ParserConfig {
@@ -40,7 +40,7 @@ pub trait ParserConfig {
/// Override this in configs where some wildcard characters are common
/// in search text (e.g. grep mode where `?` and `[` appear in code).
fn is_glob_pattern(&self, token: &str) -> bool {
has_wildcards(token, ZlobFlags::RECOMMENDED)
has_wildcards(token)
}
/// Custom constraint parsers for picker-specific needs
@@ -87,7 +87,7 @@ impl ParserConfig for GrepConfig {
/// - Contains `{…}` → brace expansion (e.g. `{src,lib}`)
fn is_glob_pattern(&self, token: &str) -> bool {
// Must contain at least one glob wildcard character
if !has_wildcards(token, ZlobFlags::RECOMMENDED) {
if !has_wildcards(token) {
return false;
}
@@ -0,0 +1,18 @@
//! Glob wildcard detection — delegates to zlob when available, pure-Rust fallback otherwise.
//!
//! All call sites use a single function: `has_wildcards(text) -> bool`.
//! When the `zlob` feature is enabled this calls `zlob::has_wildcards` with
//! `ZlobFlags::RECOMMENDED`; without it we check for the same set of wildcard
//! characters (`*`, `?`, `[`, `{`) in pure Rust.
#[cfg(feature = "zlob")]
#[inline]
pub fn has_wildcards(s: &str) -> bool {
zlob::has_wildcards(s, zlob::ZlobFlags::RECOMMENDED)
}
#[cfg(not(feature = "zlob"))]
#[inline]
pub fn has_wildcards(s: &str) -> bool {
s.bytes().any(|b| matches!(b, b'*' | b'?' | b'[' | b'{'))
}
+1
View File
@@ -41,6 +41,7 @@
mod config;
mod constraints;
pub mod glob_detect;
pub mod location;
mod parser;
+3 -3
View File
@@ -1,8 +1,8 @@
use crate::ConstraintVec;
use crate::config::ParserConfig;
use crate::constraints::{Constraint, GitStatusFilter, TextPartsBuffer};
use crate::glob_detect::has_wildcards;
use crate::location::{Location, parse_location};
use zlob::{ZlobFlags, has_wildcards};
#[derive(Debug, Clone, PartialEq)]
#[allow(clippy::large_enum_variant)]
@@ -184,7 +184,7 @@ fn parse_token<'a, C: ParserConfig>(token: &'a str, config: &C) -> Option<Constr
// Only return Extension if the rest doesn't have wildcards
// e.g., *.rs is Extension, but *.test.* should be Glob
let ext_part = &token[2..];
if !has_wildcards(ext_part, ZlobFlags::RECOMMENDED) {
if !has_wildcards(ext_part) {
return Some(constraint);
}
}
@@ -283,7 +283,7 @@ fn parse_token_without_negation<'a, C: ParserConfig>(
// Try extension first (*.rs) - simple patterns without additional wildcards
if let Some(constraint) = parse_extension(token) {
let ext_part = &token[2..];
if !has_wildcards(ext_part, ZlobFlags::RECOMMENDED) {
if !has_wildcards(ext_part) {
return Some(constraint);
}
}
+9 -6
View File
@@ -1,4 +1,4 @@
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 February 25
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 February 28
==============================================================================
Table of Contents *fff.nvim-table-of-contents*
@@ -149,6 +149,10 @@ all available options:
prompt_position = 'bottom', -- or 'top'
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
preview_size = 0.5,
flex = { -- set to false to disable flex layout
size = 130, -- column threshold: if screen width >= size, use preview_position; otherwise use wrap
wrap = 'top', -- position to use when screen is narrower than size
},
show_scrollbar = true, -- Show scrollbar for pagination
-- How to shorten long directory paths in the file list:
-- 'middle_number' (default): uses dots for 1-3 hidden (a/./b, a/../b, a/.../b)
@@ -164,6 +168,7 @@ all available options:
binary_file_threshold = 1024, -- amount of bytes to scan for binary content (set 0 to disable)
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
line_numbers = false,
cursorlineopt = 'both', -- the cursorlineopt used for lines in grep file previews, see :h cursorlineopt
wrap_lines = false,
filetypes = {
svg = { wrap_lines = true },
@@ -184,7 +189,7 @@ all available options:
preview_scroll_down = '<C-d>',
toggle_debug = '<F2>',
-- grep mode: cycle between plain text, regex, and fuzzy search
toggle_grep_regex = '<S-Tab>',
cycle_grep_modes = '<S-Tab>',
-- goes to the previous query in history
cycle_previous_query = '<C-Up>',
-- multi-select keymaps for quickfix
@@ -193,16 +198,14 @@ all available options:
-- this are specific for the normal mode (you can exit it using any other keybind like jj)
focus_list = '<leader>l',
focus_preview = '<leader>p',
toggle_grep_regex = '<S-Tab>',
},
hl = {
border = 'FloatBorder',
normal = 'Normal',
cursor = 'CursorLine',
cursor = 'CursorLine', -- Falls back to 'Visual' if CursorLine is not defined
matched = 'IncSearch',
title = 'Title',
prompt = 'Question',
cursor = fallback_hl({ 'CursorLine', 'Visual' }),
frecency = 'Number',
debug = 'Comment',
combo_header = 'Number',
@@ -279,7 +282,7 @@ all available options:
time_budget_ms = 150, -- Max search time in ms per call (prevents UI freeze, 0 = no limit)
modes = { 'plain', 'regex', 'fuzzy' }, -- Available grep modes and their cycling order
},
}})
})
<
+8 -9
View File
@@ -57,14 +57,13 @@ end
local function apply_header_highlights(buf, ns_id, line_idx, text_len, border_hl)
local config = require('fff.conf').get()
vim.api.nvim_buf_add_highlight(buf, ns_id, border_hl, line_idx - 1, 0, -1)
vim.api.nvim_buf_add_highlight(
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, { end_row = line_idx, end_col = 0, hl_group = border_hl })
vim.api.nvim_buf_set_extmark(
buf,
ns_id,
config.hl.combo_header,
line_idx - 1,
LEFT_HEADER_PADDING,
LEFT_HEADER_PADDING + text_len
{ end_col = LEFT_HEADER_PADDING + text_len, hl_group = config.hl.combo_header }
)
end
@@ -72,18 +71,18 @@ local function get_or_create_overlay_buf(state_key)
if not overlay_state[state_key] or not vim.api.nvim_buf_is_valid(overlay_state[state_key]) then
---@diagnostic disable-next-line: assign-type-mismatch
overlay_state[state_key] = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_option(overlay_state[state_key], 'bufhidden', 'wipe')
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = overlay_state[state_key] })
end
return overlay_state[state_key]
end
local function update_overlay_content(buf, content, border_hl)
-- Batch all buffer operations together for performance
vim.api.nvim_buf_set_option(buf, 'modifiable', true)
vim.api.nvim_set_option_value('modifiable', true, { buf = buf })
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { content })
vim.api.nvim_buf_clear_namespace(buf, overlay_state.ns_id, 0, -1)
vim.api.nvim_buf_add_highlight(buf, overlay_state.ns_id, border_hl, 0, 0, -1)
vim.api.nvim_buf_set_option(buf, 'modifiable', false)
vim.api.nvim_buf_set_extmark(buf, overlay_state.ns_id, 0, 0, { end_row = 1, end_col = 0, hl_group = border_hl })
vim.api.nvim_set_option_value('modifiable', false, { buf = buf })
end
local function position_overlay_window(state_key, buf, width, row, col)
@@ -106,7 +105,7 @@ local function position_overlay_window(state_key, buf, width, row, col)
overlay_state[state_key] = vim.api.nvim_open_win(buf, false, win_config)
end
vim.api.nvim_win_set_option(overlay_state[state_key], 'winhl', 'Normal:Normal')
vim.api.nvim_set_option_value('winhighlight', 'Normal:Normal', { win = overlay_state[state_key] })
end
local function update_overlays(list_win, combo_header_line, border_hl)
+84 -5
View File
@@ -1,8 +1,82 @@
local M = {}
--@class fff.conf.State
--- @class FffLayoutConfig
--- @field height number
--- @field width number
--- @field prompt_position string
--- @field preview_position string
--- @field preview_size number
--- @field show_scrollbar boolean
--- @field path_shorten_strategy string
--- @class FffPreviewConfig
--- @field enabled boolean
--- @field max_size number
--- @field chunk_size number
--- @field binary_file_threshold number
--- @field imagemagick_info_format_str string
--- @field line_numbers boolean
--- @field cursorlineopt string
--- @field wrap_lines boolean
--- @field filetypes table<string, table>
--- @class FffKeymapsConfig
--- @field close string
--- @field select string
--- @field select_split string
--- @field select_vsplit string
--- @field select_tab string
--- @field move_up string|string[]
--- @field move_down string|string[]
--- @field preview_scroll_up string
--- @field preview_scroll_down string
--- @field toggle_debug string
--- @field cycle_grep_modes string
--- @field cycle_previous_query string
--- @field toggle_select string
--- @field send_to_quickfix string
--- @field focus_list string
--- @field focus_preview string
--- @class FffFrecencyConfig
--- @field enabled boolean
--- @field db_path string
--- @class FffHistoryConfig
--- @field enabled boolean
--- @field db_path string
--- @field min_combo_count number
--- @field combo_boost_score_multiplier number
--- @class FffGrepConfig
--- @field max_file_size number
--- @field max_matches_per_file number
--- @field smart_case boolean
--- @field time_budget_ms number
--- @field modes string[]
--- @class FffConfig
--- @field base_path string
--- @field prompt string
--- @field title string
--- @field max_results number
--- @field max_threads number
--- @field lazy_sync boolean
--- @field layout FffLayoutConfig
--- @field preview FffPreviewConfig
--- @field keymaps FffKeymapsConfig
--- @field hl table<string, string>
--- @field frecency FffFrecencyConfig
--- @field history FffHistoryConfig
--- @field git table
--- @field debug table
--- @field logging table
--- @field file_picker table
--- @field grep FffGrepConfig
---@class fff.conf.State
local state = {
---@type table | nil
---@type FffConfig|nil
config = nil,
}
@@ -127,6 +201,10 @@ local function init()
prompt_position = 'bottom', -- or 'top'
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
preview_size = 0.5,
flex = { -- set to nil to disable flex layout
size = 130, -- column threshold: if screen width >= size, use preview_position; otherwise use wrap
wrap = 'top', -- position to use when screen is narrower than size
},
show_scrollbar = true, -- Show scrollbar for pagination
-- How to shorten long directory paths in the file list:
-- 'middle_number' (default): uses dots for 1-3 hidden (a/./b, a/../b, a/.../b)
@@ -142,6 +220,7 @@ local function init()
binary_file_threshold = 1024, -- amount of bytes to scan for binary content (set 0 to disable)
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
line_numbers = false,
cursorlineopt = 'both',
wrap_lines = false,
filetypes = {
svg = { wrap_lines = true },
@@ -162,7 +241,7 @@ local function init()
preview_scroll_down = '<C-d>',
toggle_debug = '<F2>',
-- grep mode: cycle between plain text, regex, and fuzzy search
toggle_grep_regex = '<S-Tab>',
cycle_grep_modes = '<S-Tab>',
-- goes to the previous query in history
cycle_previous_query = '<C-Up>',
-- multi-select keymaps for quickfix
@@ -264,10 +343,10 @@ local function init()
end
--- Setup the file picker with the given configuration
--- @param config table Configuration options
--- @param config FffConfig Configuration options
function M.setup(config) vim.g.fff = config end
--- @return table the fff configuration
--- @return FffConfig the fff configuration
function M.get()
if not state.config then init() end
return state.config
+15 -12
View File
@@ -25,11 +25,11 @@ local function reserve_image_buffer_space(bufnr, metadata_lines_count)
table.insert(buffer_lines, '')
end
local was_modifiable = vim.api.nvim_buf_get_option(bufnr, 'modifiable')
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
local was_modifiable = vim.api.nvim_get_option_value('modifiable', { buf = bufnr })
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, buffer_lines)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', was_modifiable)
vim.api.nvim_set_option_value('modifiable', was_modifiable, { buf = bufnr })
return metadata_lines_count or 2
end
@@ -44,9 +44,9 @@ local function update_metadata_lines(bufnr, info_lines, reserved_lines_count)
metadata_lines[i] = info_lines[i] or ''
end
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_buf_set_lines(bufnr, 0, reserved_lines_count, false, metadata_lines)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
end
local function identify_image_lines_async(file_path, bufnr, callback)
@@ -146,7 +146,10 @@ end
--- @param bufnr number Buffer number to display in
--- @return boolean
function M.display_image(file_path, bufnr)
vim.api.nvim_buf_set_option(bufnr, 'number', false)
local wins = vim.fn.win_findbuf(bufnr)
for _, win in ipairs(wins) do
vim.api.nvim_set_option_value('number', false, { win = win })
end
local reserved_metadata_lines = reserve_image_buffer_space(bufnr, 2)
local image_content_starts_at_line = reserved_metadata_lines + 1
@@ -163,9 +166,9 @@ function M.display_image(file_path, bufnr)
'',
'snacks.nvim plugin is not installed or not available.',
}
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_buf_set_lines(bufnr, image_content_starts_at_line, -1, false, error_lines)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
return false
end
@@ -176,9 +179,9 @@ function M.display_image(file_path, bufnr)
'Terminal does not support image preview.',
'Please use a terminal that supports images, such as Kitty, Wezterm or Alacritty.',
}
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_buf_set_lines(bufnr, image_content_starts_at_line, -1, false, error_lines)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
return false
end
@@ -189,9 +192,9 @@ function M.display_image(file_path, bufnr)
'File format is not supported for image preview.',
'File: ' .. vim.fn.fnamemodify(file_path, ':t'),
}
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_buf_set_lines(bufnr, image_content_starts_at_line, -1, false, error_lines)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
return false
end
+30 -27
View File
@@ -8,19 +8,19 @@ local M = {}
local function set_buffer_lines(bufnr, lines)
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
end
local function append_buffer_lines(bufnr, lines)
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
if not lines or #lines == 0 then return end
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
local current_lines = vim.api.nvim_buf_line_count(bufnr)
vim.api.nvim_buf_set_lines(bufnr, current_lines, current_lines, false, lines)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
end
local function find_existing_buffer(file_path)
@@ -272,8 +272,8 @@ local function link_buffer_content(source_bufnr, target_bufnr)
local lines = vim.api.nvim_buf_get_lines(source_bufnr, 0, -1, false)
set_buffer_lines(target_bufnr, lines)
local source_ft = vim.api.nvim_buf_get_option(source_bufnr, 'filetype')
if source_ft ~= '' then vim.api.nvim_buf_set_option(target_bufnr, 'filetype', source_ft) end
local source_ft = vim.api.nvim_get_option_value('filetype', { buf = source_bufnr })
if source_ft ~= '' then vim.api.nvim_set_option_value('filetype', source_ft, { buf = target_bufnr }) end
M.state.has_more_content = false
M.state.total_file_lines = #lines
@@ -315,7 +315,6 @@ end
--- Check if file is too big for initial preview (inspired by snacks.nvim)
--- @param file_path string Path to the file
--- @param bufnr number|nil Buffer number to check (unused with dynamic loading)
--- @return boolean True if file is too big for initial preview
function M.is_big_file(file_path)
-- Only check file size for early detection - no line limits with dynamic loading
@@ -485,10 +484,12 @@ function M.preview_file(file_path, bufnr)
if success then
local file_config = M.get_file_config(file_path)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
vim.api.nvim_buf_set_option(bufnr, 'readonly', true)
vim.api.nvim_buf_set_option(bufnr, 'buftype', 'nofile')
vim.api.nvim_buf_set_option(bufnr, 'wrap', file_config.wrap_lines or M.config.wrap_lines)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
if M.state.winid and vim.api.nvim_win_is_valid(M.state.winid) then
vim.api.nvim_set_option_value('wrap', file_config.wrap_lines or M.config.wrap_lines, { win = M.state.winid })
end
M.state.scroll_offset = 0
@@ -528,11 +529,13 @@ function M.preview_file(file_path, bufnr)
set_buffer_lines(bufnr, content)
local file_config = M.get_file_config(file_path)
vim.api.nvim_buf_set_option(bufnr, 'filetype', info.filetype)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
vim.api.nvim_buf_set_option(bufnr, 'readonly', true)
vim.api.nvim_buf_set_option(bufnr, 'buftype', 'nofile')
vim.api.nvim_buf_set_option(bufnr, 'wrap', file_config.wrap_lines or M.config.wrap_lines)
vim.api.nvim_set_option_value('filetype', info.filetype, { buf = bufnr })
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
if M.state.winid and vim.api.nvim_win_is_valid(M.state.winid) then
vim.api.nvim_set_option_value('wrap', file_config.wrap_lines or M.config.wrap_lines, { win = M.state.winid })
end
M.state.content_height = #content
M.state.scroll_offset = 0
@@ -560,9 +563,9 @@ function M.preview_binary_file(file_path, bufnr)
local lines = {}
set_buffer_lines(bufnr, lines)
vim.api.nvim_buf_set_option(bufnr, 'filetype', 'text')
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
vim.api.nvim_buf_set_option(bufnr, 'readonly', true)
vim.api.nvim_set_option_value('filetype', 'text', { buf = bufnr })
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
if vim.fn.executable('file') == 1 then
local cmd = { 'file', '-b', file_path }
@@ -726,10 +729,10 @@ function M.update_file_info_buffer(file, bufnr, file_index)
end
set_buffer_lines(bufnr, file_info_lines)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
vim.api.nvim_buf_set_option(bufnr, 'readonly', true)
vim.api.nvim_buf_set_option(bufnr, 'buftype', 'nofile')
vim.api.nvim_buf_set_option(bufnr, 'wrap', false)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
vim.api.nvim_set_option_value('wrap', false, { buf = bufnr })
return true
end
@@ -769,10 +772,10 @@ function M.clear_buffer(bufnr)
pcall(vim.treesitter.stop, bufnr)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
vim.api.nvim_buf_set_option(bufnr, 'filetype', '')
vim.api.nvim_buf_set_option(bufnr, 'syntax', '')
vim.api.nvim_buf_set_option(bufnr, 'buftype', 'nofile')
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_set_option_value('filetype', '', { buf = bufnr })
vim.api.nvim_set_option_value('syntax', '', { buf = bufnr })
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
set_buffer_lines(bufnr, {})
end
+25 -9
View File
@@ -106,7 +106,13 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
-- 2. Icon
if icon and icon_hl_group and vim.fn.strdisplaywidth(icon) > 0 then
local icon_hl = is_current_file and 'Comment' or icon_hl_group
vim.api.nvim_buf_add_highlight(buf, ns_id, icon_hl, line_idx - 1, 0, vim.fn.strdisplaywidth(icon))
vim.api.nvim_buf_set_extmark(
buf,
ns_id,
line_idx - 1,
0,
{ end_col = vim.fn.strdisplaywidth(icon), hl_group = icon_hl }
)
end
-- 3. Git text color (filename)
@@ -114,7 +120,13 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
local git_text_hl = item.git_status and git_utils.get_text_highlight(item.git_status) or nil
if git_text_hl and git_text_hl ~= '' and not is_current_file then
local filename_start = #icon + 1
vim.api.nvim_buf_add_highlight(buf, ns_id, git_text_hl, line_idx - 1, filename_start, filename_start + #filename)
vim.api.nvim_buf_set_extmark(
buf,
ns_id,
line_idx - 1,
filename_start,
{ end_col = filename_start + #filename, hl_group = git_text_hl }
)
end
end
@@ -122,7 +134,13 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
if ctx.debug_enabled then
local start_pos, end_pos = line_content:find('[⭐️🔥✨•]%d+')
if start_pos and end_pos then
vim.api.nvim_buf_add_highlight(buf, ns_id, ctx.config.hl.frecency, line_idx - 1, start_pos - 1, end_pos)
vim.api.nvim_buf_set_extmark(
buf,
ns_id,
line_idx - 1,
start_pos - 1,
{ end_col = end_pos, hl_group = ctx.config.hl.frecency }
)
end
end
@@ -132,13 +150,12 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
if icon then
prefix_len = prefix_len + #icon + 1 -- if icon add icon bytes + space
end
vim.api.nvim_buf_add_highlight(
vim.api.nvim_buf_set_extmark(
buf,
ns_id,
ctx.config.hl.directory_path,
line_idx - 1,
prefix_len,
prefix_len + #dir_path
{ end_col = prefix_len + #dir_path, hl_group = ctx.config.hl.directory_path }
)
end
@@ -210,13 +227,12 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
if ctx.query and ctx.query ~= '' then
local match_start, match_end = string.find(line_content, ctx.query, 1)
if match_start and match_end then
vim.api.nvim_buf_add_highlight(
vim.api.nvim_buf_set_extmark(
buf,
ns_id,
ctx.config.hl.matched or 'IncSearch',
line_idx - 1,
match_start - 1,
match_end
{ end_col = match_end, hl_group = ctx.config.hl.matched or 'IncSearch' }
)
end
end
+11 -4
View File
@@ -15,7 +15,7 @@
local M = {}
--- @class ListRenderContext
--- @field config table User configuration
--- @field config FffConfig User configuration
--- @field items table[] Array of data items to render
--- @field cursor number Current cursor position (1-based index into items)
--- @field win_height number Window height in lines
@@ -162,9 +162,9 @@ local function update_buffer_and_cursor(lines, item_to_lines, ctx, list_buf, lis
if cursor_item then cursor_line = cursor_item.last end
end
vim.api.nvim_buf_set_option(list_buf, 'modifiable', true)
vim.api.nvim_set_option_value('modifiable', true, { buf = list_buf })
vim.api.nvim_buf_set_lines(list_buf, 0, -1, false, lines)
vim.api.nvim_buf_set_option(list_buf, 'modifiable', false)
vim.api.nvim_set_option_value('modifiable', false, { buf = list_buf })
vim.api.nvim_buf_clear_namespace(list_buf, ns_id, 0, -1)
@@ -225,7 +225,14 @@ function M.render(ctx, list_buf, list_win, ns_id)
for i = 0, #lines - 1 do
local line = lines[i + 1]
if line and line:match('^%s+No results found') then
pcall(vim.api.nvim_buf_add_highlight, list_buf, ns_id, suggestion_hl, i, 0, -1)
pcall(
vim.api.nvim_buf_set_extmark,
list_buf,
ns_id,
i,
0,
{ end_row = i + 1, end_col = 0, hl_group = suggestion_hl }
)
end
end
end
+5 -12
View File
@@ -18,14 +18,7 @@ function M.find_files(opts)
end
--- Live grep: search file contents in the current directory
--- @param opts? table Optional configuration overrides
--- @param opts.cwd? string Custom working directory
--- @param opts.title? string Window title (default: "Live Grep")
--- @param opts.prompt? string Input prompt text (default: "grep> ")
--- @param opts.layout? table Layout overrides
--- @param opts.grep? table Grep-specific overrides {max_file_size, smart_case, max_matches_per_file, modes}
--- @param opts.grep.modes? table Available search modes and their cycling order (default: {'plain', 'regex', 'fuzzy'})
--- @param opts.query? string Initial search query to pre-fill
--- @param opts? {cwd?: string, title?: string, prompt?: string, layout?: table, grep?: {max_file_size?: number, smart_case?: boolean, max_matches_per_file?: number, modes?: string[]}, query?: string} Optional configuration overrides
function M.live_grep(opts)
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
if not picker_ok then
@@ -38,13 +31,13 @@ function M.live_grep(opts)
local grep_config = vim.tbl_deep_extend('force', config.grep or {}, (opts and opts.grep) or {})
local picker_opts = vim.tbl_deep_extend('force', opts or {}, {
title = (opts and opts.title) or 'Live Grep',
local picker_opts = vim.tbl_deep_extend('force', {
title = 'Live Grep',
mode = 'grep',
renderer = grep_renderer,
grep_config = grep_config,
query = opts and opts.query or nil,
})
query = '',
}, opts or {})
picker_ui.open(picker_opts)
end
+356 -275
View File
@@ -67,7 +67,7 @@ local function get_preview_position()
local terminal_width = vim.o.columns
local terminal_height = vim.o.lines
return utils.resolve_config_value(
local position = utils.resolve_config_value(
config.layout.preview_position,
terminal_width,
terminal_height,
@@ -75,30 +75,206 @@ local function get_preview_position()
'right',
'layout.preview_position'
)
local flex = config.layout.flex
if flex then
local size = flex.size or 130
local wrap = flex.wrap or 'top'
if terminal_width < size then return wrap end
end
return position
end
return 'right'
end
--- Function-based config options:
--- config.layout.width: number|function(terminal_width, terminal_height): number
--- config.layout.height: number|function(terminal_width, terminal_height): number
--- config.layout.preview_size: number|function(terminal_width, terminal_height): number
--- config.layout.preview_position: string|function(terminal_width, terminal_height): string
--- config.layout.prompt_position: string|function(terminal_width, terminal_height): string
local function compute_layout(config)
local debug_enabled_in_preview = M.enabled_preview() and config.debug and config.debug.show_file_info or false
--- @class LayoutConfig
--- @field total_width number
--- @field total_height number
--- @field start_col number
--- @field start_row number
--- @field preview_position string|function Preview position ('left'|'right'|'top'|'bottom') or function(terminal_width, terminal_height): string
--- @field prompt_position string
--- @field debug_enabled boolean
--- @field preview_width number
--- @field preview_height number
--- @field separator_width number
--- @field file_info_height number
local terminal_width = vim.o.columns
local terminal_height = vim.o.lines
local width_ratio = utils.resolve_config_value(
config.layout.width,
terminal_width,
terminal_height,
utils.is_valid_ratio,
0.8,
'layout.width'
)
local height_ratio = utils.resolve_config_value(
config.layout.height,
terminal_width,
terminal_height,
utils.is_valid_ratio,
0.8,
'layout.height'
)
local width = math.floor(terminal_width * width_ratio)
local height = math.floor(terminal_height * height_ratio)
local col_ratio_default = 0.5 - (width_ratio / 2)
local col_ratio = col_ratio_default
if config.layout.col ~= nil then
col_ratio = utils.resolve_config_value(
config.layout.col,
terminal_width,
terminal_height,
utils.is_valid_ratio,
col_ratio_default,
'layout.col'
)
end
local row_ratio_default = 0.5 - (height_ratio / 2)
local row_ratio = row_ratio_default
if config.layout.row ~= nil then
row_ratio = utils.resolve_config_value(
config.layout.row,
terminal_width,
terminal_height,
utils.is_valid_ratio,
row_ratio_default,
'layout.row'
)
end
local col = math.floor(terminal_width * col_ratio)
local row = math.floor(terminal_height * row_ratio)
local prompt_position = get_prompt_position()
local preview_position = get_preview_position()
local preview_size_ratio = utils.resolve_config_value(
config.layout.preview_size,
terminal_width,
terminal_height,
utils.is_valid_ratio,
0.4,
'layout.preview_size'
)
local layout_config = {
total_width = width,
total_height = height,
start_col = col,
start_row = row,
preview_position = preview_position,
prompt_position = prompt_position,
debug_enabled = debug_enabled_in_preview,
preview_width = M.enabled_preview() and math.floor(width * preview_size_ratio) or 0,
preview_height = M.enabled_preview() and math.floor(height * preview_size_ratio) or 0,
separator_width = 3,
file_info_height = debug_enabled_in_preview and 10 or 0,
}
local layout = M.calculate_layout_dimensions(layout_config)
return layout, debug_enabled_in_preview
end
--- Build window config tables for list, input, preview, and file_info windows.
--- @param layout table The computed layout from calculate_layout_dimensions
--- @param config table The picker config
--- @return table window_configs Table with list, input, preview, file_info keys
local function build_window_configs(layout, config)
local border_chars, t_junctions = get_border_chars()
local prompt_position = get_prompt_position()
local title = ' ' .. (config.title or 'FFFiles') .. ' '
-- List border: when prompt at bottom, list has top+sides (no bottom); when top, T-junctions at top
local list_border = prompt_position == 'bottom'
and { border_chars[1], border_chars[2], border_chars[3], border_chars[4], '', '', '', border_chars[8] }
or {
t_junctions[1],
border_chars[2],
t_junctions[2],
border_chars[4],
border_chars[5],
border_chars[6],
border_chars[7],
border_chars[8],
}
local list_cfg = {
relative = 'editor',
width = layout.list_width,
height = layout.list_height,
col = layout.list_col,
row = layout.list_row,
border = list_border,
style = 'minimal',
}
if prompt_position == 'bottom' then
list_cfg.title = title
list_cfg.title_pos = 'left'
end
-- Input border: inverse of list border
local input_border = prompt_position == 'bottom'
and {
t_junctions[1],
border_chars[2],
t_junctions[2],
border_chars[4],
border_chars[5],
border_chars[6],
border_chars[7],
border_chars[8],
}
or { border_chars[1], border_chars[2], border_chars[3], border_chars[4], '', '', '', border_chars[8] }
local input_cfg = {
relative = 'editor',
width = layout.input_width,
height = 1,
col = layout.input_col,
row = layout.input_row,
border = input_border,
style = 'minimal',
}
if prompt_position == 'top' then
input_cfg.title = title
input_cfg.title_pos = 'left'
end
local preview_cfg = nil
if layout.preview then
preview_cfg = {
relative = 'editor',
width = layout.preview.width,
height = layout.preview.height,
col = layout.preview.col,
row = layout.preview.row,
style = 'minimal',
border = border_chars,
title = ' Preview ',
title_pos = 'left',
}
end
local file_info_cfg = nil
if layout.file_info then
file_info_cfg = {
relative = 'editor',
width = layout.file_info.width,
height = layout.file_info.height,
col = layout.file_info.col,
row = layout.file_info.row,
style = 'minimal',
border = border_chars,
title = ' File Info ',
title_pos = 'left',
}
end
return {
list = list_cfg,
input = input_cfg,
preview = preview_cfg,
file_info = file_info_cfg,
}
end
--- Calculate layout dimensions and positions for all windows
--- @param cfg table
@@ -307,7 +483,7 @@ M.state = {
grep_next_file_offset = 0,
},
config = nil,
config = nil, -- @type FFFConfig|nil
-- Custom renderer (optional, defaults to file_renderer if not provided)
renderer = nil,
@@ -350,216 +526,48 @@ M.state = {
function M.create_ui()
local config = M.state.config
if not config then return false end
if not M.state.ns_id then
M.state.ns_id = vim.api.nvim_create_namespace('fff_picker_status')
combo_renderer.init(M.state.ns_id)
end
local debug_enabled_in_preview = M.enabled_preview() and config and config.debug and config.debug.show_file_info
local terminal_width = vim.o.columns
local terminal_height = vim.o.lines
-- Calculate width and height (support function or number)
---@diagnostic disable: need-check-nil
local width_ratio = utils.resolve_config_value(
config.layout.width,
terminal_width,
terminal_height,
utils.is_valid_ratio,
0.8,
'layout.width'
)
local height_ratio = utils.resolve_config_value(
config.layout.height,
terminal_width,
terminal_height,
utils.is_valid_ratio,
0.8,
'layout.height'
)
local width = math.floor(terminal_width * width_ratio)
local height = math.floor(terminal_height * height_ratio)
-- Calculate col and row (support function or number)
local col_ratio_default = 0.5 - (width_ratio / 2) -- default center
local col_ratio
if config.layout.col ~= nil then
col_ratio = utils.resolve_config_value(
config.layout.col,
terminal_width,
terminal_height,
utils.is_valid_ratio,
col_ratio_default,
'layout.col'
)
else
col_ratio = col_ratio_default
end
local row_ratio_default = 0.5 - (height_ratio / 2) -- default center
local row_ratio
if config.layout.row ~= nil then
row_ratio = utils.resolve_config_value(
config.layout.row,
terminal_width,
terminal_height,
utils.is_valid_ratio,
row_ratio_default,
'layout.row'
)
else
row_ratio = row_ratio_default
end
local col = math.floor(terminal_width * col_ratio)
local row = math.floor(terminal_height * row_ratio)
local prompt_position = get_prompt_position()
local preview_position = get_preview_position()
local preview_size_ratio = utils.resolve_config_value(
config.layout.preview_size,
terminal_width,
terminal_height,
utils.is_valid_ratio,
0.4,
'layout.preview_size'
)
---@diagnostic enable: need-check-nil
local layout_config = {
total_width = width,
total_height = height,
start_col = col,
start_row = row,
preview_position = preview_position,
prompt_position = prompt_position,
debug_enabled = debug_enabled_in_preview,
preview_width = M.enabled_preview() and math.floor(width * preview_size_ratio) or 0,
preview_height = M.enabled_preview() and math.floor(height * preview_size_ratio) or 0,
separator_width = 3,
file_info_height = debug_enabled_in_preview and 10 or 0,
}
local layout = M.calculate_layout_dimensions(layout_config)
local layout, debug_enabled_in_preview = compute_layout(config)
M.state.layout = layout
M.state.input_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_option(M.state.input_buf, 'bufhidden', 'wipe')
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = M.state.input_buf })
M.state.list_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_option(M.state.list_buf, 'bufhidden', 'wipe')
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = M.state.list_buf })
if M.enabled_preview() then
M.state.preview_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_option(M.state.preview_buf, 'bufhidden', 'wipe')
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = M.state.preview_buf })
end
if debug_enabled_in_preview then
M.state.file_info_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_option(M.state.file_info_buf, 'bufhidden', 'wipe')
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = M.state.file_info_buf })
else
M.state.file_info_buf = nil
end
local border_chars, t_junctions = get_border_chars()
local list_window_config = {
relative = 'editor',
width = layout.list_width,
height = layout.list_height,
col = layout.list_col,
row = layout.list_row,
-- To make the input feel connected with the picker, we customize the
-- respective corner border characters based on prompt_position
-- When prompt at bottom: list has top border + sides, no bottom (connects to input below)
-- When prompt at top: list has sides + bottom with T-junctions at top (connects to input above)
border = prompt_position == 'bottom'
and { border_chars[1], border_chars[2], border_chars[3], border_chars[4], '', '', '', border_chars[8] }
or {
t_junctions[1],
border_chars[2],
t_junctions[2],
border_chars[4],
border_chars[5],
border_chars[6],
border_chars[7],
border_chars[8],
},
style = 'minimal',
}
local win_configs = build_window_configs(layout, config)
local title = ' ' .. (M.state.config.title or 'FFFiles') .. ' '
-- Only add title if prompt is at bottom - when prompt is top, title should be on input
if prompt_position == 'bottom' then
list_window_config.title = title
list_window_config.title_pos = 'left'
end
M.state.list_win = vim.api.nvim_open_win(M.state.list_buf, false, list_window_config)
-- Create file info window if debug enabled
if debug_enabled_in_preview and layout.file_info then
M.state.file_info_win = vim.api.nvim_open_win(M.state.file_info_buf, false, {
relative = 'editor',
width = layout.file_info.width,
height = layout.file_info.height,
col = layout.file_info.col,
row = layout.file_info.row,
style = 'minimal',
border = border_chars,
title = ' File Info ',
title_pos = 'left',
})
M.state.list_win = vim.api.nvim_open_win(M.state.list_buf, false, win_configs.list)
if debug_enabled_in_preview and win_configs.file_info then
M.state.file_info_win = vim.api.nvim_open_win(M.state.file_info_buf, false, win_configs.file_info)
else
M.state.file_info_win = nil
end
-- Create preview window
if M.enabled_preview() and layout.preview then
M.state.preview_win = vim.api.nvim_open_win(M.state.preview_buf, false, {
relative = 'editor',
width = layout.preview.width,
height = layout.preview.height,
col = layout.preview.col,
row = layout.preview.row,
style = 'minimal',
border = border_chars,
title = ' Preview ',
title_pos = 'left',
})
if M.enabled_preview() and win_configs.preview then
M.state.preview_win = vim.api.nvim_open_win(M.state.preview_buf, false, win_configs.preview)
end
local input_window_config = {
relative = 'editor',
width = layout.input_width,
height = 1,
col = layout.input_col,
row = layout.input_row,
-- To make the input feel connected with the picker, we customize the
-- respective corner border characters based on prompt_position
-- if prompt at bottom: input has T-junctions at top (connects to list above), full bottom border
-- if prompt at top: input has top border + sides, no bottom (connects to list below)
border = prompt_position == 'bottom' and {
t_junctions[1],
border_chars[2],
t_junctions[2],
border_chars[4],
border_chars[5],
border_chars[6],
border_chars[7],
border_chars[8],
} or { border_chars[1], border_chars[2], border_chars[3], border_chars[4], '', '', '', border_chars[8] },
style = 'minimal',
}
if prompt_position == 'top' then
input_window_config.title = title
input_window_config.title_pos = 'left'
end
M.state.input_win = vim.api.nvim_open_win(M.state.input_buf, false, input_window_config)
M.state.input_win = vim.api.nvim_open_win(M.state.input_buf, false, win_configs.input)
M.setup_buffers()
M.setup_windows()
@@ -581,8 +589,8 @@ function M.setup_buffers()
vim.api.nvim_buf_set_name(M.state.list_buf, 'fffiles list')
if M.enabled_preview() then vim.api.nvim_buf_set_name(M.state.preview_buf, 'fffile preview') end
vim.api.nvim_buf_set_option(M.state.input_buf, 'buftype', 'prompt')
vim.api.nvim_buf_set_option(M.state.input_buf, 'filetype', 'fff_input')
vim.api.nvim_set_option_value('buftype', 'prompt', { buf = M.state.input_buf })
vim.api.nvim_set_option_value('filetype', 'fff_input', { buf = M.state.input_buf })
vim.fn.prompt_setprompt(M.state.input_buf, M.state.config.prompt)
@@ -590,23 +598,23 @@ function M.setup_buffers()
-- syntax highlighting. This makes sure that it's always off.
vim.api.nvim_create_autocmd('Syntax', {
buffer = M.state.input_buf,
callback = function() vim.api.nvim_buf_set_option(M.state.input_buf, 'syntax', '') end,
callback = function() vim.api.nvim_set_option_value('syntax', '', { buf = M.state.input_buf }) end,
})
vim.api.nvim_buf_set_option(M.state.list_buf, 'buftype', 'nofile')
vim.api.nvim_buf_set_option(M.state.list_buf, 'filetype', 'fff_list')
vim.api.nvim_buf_set_option(M.state.list_buf, 'modifiable', false)
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = M.state.list_buf })
vim.api.nvim_set_option_value('filetype', 'fff_list', { buf = M.state.list_buf })
vim.api.nvim_set_option_value('modifiable', false, { buf = M.state.list_buf })
if M.state.file_info_buf then
vim.api.nvim_buf_set_option(M.state.file_info_buf, 'buftype', 'nofile')
vim.api.nvim_buf_set_option(M.state.file_info_buf, 'filetype', 'fff_file_info')
vim.api.nvim_buf_set_option(M.state.file_info_buf, 'modifiable', false)
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = M.state.file_info_buf })
vim.api.nvim_set_option_value('filetype', 'fff_file_info', { buf = M.state.file_info_buf })
vim.api.nvim_set_option_value('modifiable', false, { buf = M.state.file_info_buf })
end
if M.enabled_preview() then
vim.api.nvim_buf_set_option(M.state.preview_buf, 'buftype', 'nofile')
vim.api.nvim_buf_set_option(M.state.preview_buf, 'filetype', 'fff_preview')
vim.api.nvim_buf_set_option(M.state.preview_buf, 'modifiable', false)
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = M.state.preview_buf })
vim.api.nvim_set_option_value('filetype', 'fff_preview', { buf = M.state.preview_buf })
vim.api.nvim_set_option_value('modifiable', false, { buf = M.state.preview_buf })
end
end
@@ -614,34 +622,64 @@ function M.setup_windows()
local hl = M.state.config.hl
local win_hl = string.format('Normal:%s,FloatBorder:%s,FloatTitle:%s', hl.normal, hl.border, hl.title)
vim.api.nvim_win_set_option(M.state.input_win, 'wrap', false)
vim.api.nvim_win_set_option(M.state.input_win, 'cursorline', false)
vim.api.nvim_win_set_option(M.state.input_win, 'number', false)
vim.api.nvim_win_set_option(M.state.input_win, 'relativenumber', false)
vim.api.nvim_win_set_option(M.state.input_win, 'signcolumn', 'no')
vim.api.nvim_win_set_option(M.state.input_win, 'foldcolumn', '0')
vim.api.nvim_win_set_option(M.state.input_win, 'winhighlight', win_hl)
vim.api.nvim_set_option_value('wrap', false, { win = M.state.input_win })
vim.api.nvim_set_option_value('cursorline', false, { win = M.state.input_win })
vim.api.nvim_set_option_value('number', false, { win = M.state.input_win })
vim.api.nvim_set_option_value('relativenumber', false, { win = M.state.input_win })
vim.api.nvim_set_option_value('signcolumn', 'no', { win = M.state.input_win })
vim.api.nvim_set_option_value('foldcolumn', '0', { win = M.state.input_win })
vim.api.nvim_set_option_value('winhighlight', win_hl, { win = M.state.input_win })
vim.api.nvim_win_set_option(M.state.list_win, 'wrap', false)
vim.api.nvim_win_set_option(M.state.list_win, 'cursorline', false)
vim.api.nvim_win_set_option(M.state.list_win, 'number', false)
vim.api.nvim_win_set_option(M.state.list_win, 'relativenumber', false)
vim.api.nvim_win_set_option(M.state.list_win, 'signcolumn', 'yes:1') -- Enable signcolumn for git status borders
vim.api.nvim_win_set_option(M.state.list_win, 'foldcolumn', '0')
vim.api.nvim_win_set_option(M.state.list_win, 'winhighlight', win_hl)
vim.api.nvim_set_option_value('wrap', false, { win = M.state.list_win })
vim.api.nvim_set_option_value('cursorline', false, { win = M.state.list_win })
vim.api.nvim_set_option_value('number', false, { win = M.state.list_win })
vim.api.nvim_set_option_value('relativenumber', false, { win = M.state.list_win })
vim.api.nvim_set_option_value('signcolumn', 'yes:1', { win = M.state.list_win }) -- Enable signcolumn for git status borders
vim.api.nvim_set_option_value('foldcolumn', '0', { win = M.state.list_win })
vim.api.nvim_set_option_value('winhighlight', win_hl, { win = M.state.list_win })
if M.enabled_preview() then
vim.api.nvim_win_set_option(M.state.preview_win, 'wrap', false)
vim.api.nvim_win_set_option(M.state.preview_win, 'cursorline', M.state.mode == 'grep')
vim.api.nvim_win_set_option(
M.state.preview_win,
'number',
M.state.mode == 'grep' or (preview_config and preview_config.line_numbers or false)
vim.api.nvim_set_option_value('wrap', false, { win = M.state.preview_win })
vim.api.nvim_set_option_value('cursorline', M.state.mode == 'grep', { win = M.state.preview_win })
local cursorlineopt = utils.resolve_config_value(
preview_config.cursorlineopt,
vim.o.columns,
vim.o.lines,
function(value)
if type(value) ~= 'string' or #value == 0 then return false end
local has_line = false
local has_screenline = false
for opt in value:gmatch('[^,]+') do
if not utils.is_one_of(opt:gsub('%s+', ''), { 'line', 'screenline', 'number', 'both' }) then return false end
if opt == 'line' or opt == 'both' then has_line = true end
if opt == 'screenline' then has_screenline = true end
end
if has_line and has_screenline then return false end
return true
end,
'both',
'preview.cursorlineopt'
)
vim.api.nvim_win_set_option(M.state.preview_win, 'relativenumber', false)
vim.api.nvim_win_set_option(M.state.preview_win, 'signcolumn', 'no')
vim.api.nvim_win_set_option(M.state.preview_win, 'foldcolumn', '0')
vim.api.nvim_win_set_option(M.state.preview_win, 'winhighlight', win_hl)
vim.api.nvim_set_option_value(
'cursorlineopt',
M.state.mode == 'grep' and cursorlineopt or vim.o.cursorlineopt,
{ win = M.state.preview_win }
)
vim.api.nvim_set_option_value(
'number',
M.state.mode == 'grep' or (preview_config and preview_config.line_numbers or false),
{ win = M.state.preview_win }
)
vim.api.nvim_set_option_value('relativenumber', false, { win = M.state.preview_win })
vim.api.nvim_set_option_value('signcolumn', 'no', { win = M.state.preview_win })
vim.api.nvim_set_option_value('foldcolumn', '0', { win = M.state.preview_win })
vim.api.nvim_set_option_value('winhighlight', win_hl, { win = M.state.preview_win })
end
local picker_group = vim.api.nvim_create_augroup('fff_picker_focus', { clear = true })
@@ -685,6 +723,18 @@ function M.setup_windows()
end,
desc = 'Close picker when focus leaves picker windows',
})
vim.api.nvim_create_autocmd('VimResized', {
group = picker_group,
callback = function()
if not M.state.active then return end
vim.schedule(function()
if not M.state.active then return end
M.relayout()
end)
end,
desc = 'Re-layout picker on terminal resize',
})
end
local function set_keymap(mode, keys, handler, opts)
@@ -779,7 +829,7 @@ function M.setup_keymaps()
set_keymap({ 'i', 'n' }, keymaps.toggle_debug, M.toggle_debug, input_opts)
set_keymap({ 'i', 'n' }, keymaps.toggle_select, M.toggle_select, input_opts)
set_keymap({ 'i', 'n' }, keymaps.send_to_quickfix, M.send_to_quickfix, input_opts)
set_keymap({ 'i', 'n' }, keymaps.toggle_grep_regex, M.toggle_grep_regex, input_opts)
set_keymap({ 'i', 'n' }, keymaps.cycle_grep_modes, M.cycle_grep_modes, input_opts)
-- List buffer
set_keymap('n', keymaps.close, M.close, list_opts)
@@ -878,7 +928,7 @@ end
--- Cycle through grep search modes based on configured modes list.
--- Only works when the picker is in grep mode. Triggers a re-search
--- with the current query using the new mode.
function M.toggle_grep_regex()
function M.cycle_grep_modes()
if not M.state.active or M.state.mode ~= 'grep' then return end
local config = conf.get()
@@ -929,7 +979,7 @@ function M.on_input_change()
query = query:gsub('\r', ''):match('^%s*(.-)%s*$') or ''
vim.api.nvim_buf_set_option(M.state.input_buf, 'modifiable', true)
vim.api.nvim_set_option_value('modifiable', true, { buf = M.state.input_buf })
vim.api.nvim_buf_set_lines(M.state.input_buf, 0, -1, false, { M.state.config.prompt .. query })
-- Move cursor to end
@@ -1125,7 +1175,7 @@ function M.load_page_at_index(new_page_index, adjust_cursor_fn)
if not ok then
vim.notify('Error in paginated search: ' .. tostring(results), vim.log.levels.ERROR)
vim.api.nvim_err_writeln('FFF ERROR: Paginated search failed: ' .. tostring(results))
vim.notify('FFF ERROR: Paginated search failed: ' .. tostring(results))
return false
end
@@ -1343,16 +1393,23 @@ local function render_grep_empty_state(ctx)
end
end
vim.api.nvim_buf_set_option(M.state.list_buf, 'modifiable', true)
vim.api.nvim_set_option_value('modifiable', true, { buf = M.state.list_buf })
vim.api.nvim_buf_set_lines(M.state.list_buf, 0, -1, false, content)
vim.api.nvim_buf_set_option(M.state.list_buf, 'modifiable', false)
vim.api.nvim_set_option_value('modifiable', false, { buf = M.state.list_buf })
vim.api.nvim_buf_clear_namespace(M.state.list_buf, M.state.ns_id, 0, -1)
-- For bottom prompt, ensure empty state is anchored at the bottom
if prompt_position == 'bottom' then scroll_to_bottom() end
for _, h in ipairs(hl_cmds) do
pcall(vim.api.nvim_buf_add_highlight, M.state.list_buf, M.state.ns_id, h.hl, h.row, h.col_start, h.col_end)
pcall(
vim.api.nvim_buf_set_extmark,
M.state.list_buf,
M.state.ns_id,
h.row,
h.col_start,
{ end_col = h.col_end, hl_group = h.hl }
)
end
for i = 0, #content - 1 do
@@ -1360,18 +1417,24 @@ local function render_grep_empty_state(ctx)
if
line and (line:match('^%s+Start typing') or line:match('^%s+Tips') or line:match('^%s+"') or line:match('^%s+!'))
then
pcall(vim.api.nvim_buf_add_highlight, M.state.list_buf, M.state.ns_id, 'Comment', i, 0, -1)
pcall(
vim.api.nvim_buf_set_extmark,
M.state.list_buf,
M.state.ns_id,
i,
0,
{ end_row = i + 1, end_col = 0, hl_group = 'Comment' }
)
end
-- Dim border characters
if line and (line:match('^%s+[╭╰│]') or line:match('[╮╯│]%s*$')) then
pcall(
vim.api.nvim_buf_add_highlight,
vim.api.nvim_buf_set_extmark,
M.state.list_buf,
M.state.ns_id,
config.hl.border or 'FloatBorder',
i,
0,
-1
{ end_row = i + 1, end_col = 0, hl_group = config.hl.border or 'FloatBorder' }
)
end
end
@@ -1403,7 +1466,7 @@ local function build_render_context()
local text_width = win_width - text_offset
-- Combo detection (only in file picker mode with real results, not grep or suggestions)
local combo_boost_score_multiplier = config.history and config.history.combo_boost_multiplier or 100
local combo_boost_score_multiplier = config.history and config.history.combo_boost_score_multiplier or 100
local has_combo, combo_header_line, combo_header_text_len, combo_item_index
if M.state.mode == 'grep' or M.state.suggestion_source then
has_combo = false
@@ -1669,7 +1732,7 @@ function M.clear_preview()
})
if M.state.file_info_buf then
vim.api.nvim_buf_set_option(M.state.file_info_buf, 'modifiable', true)
vim.api.nvim_set_option_value('modifiable', true, { buf = M.state.file_info_buf })
vim.api.nvim_buf_set_lines(M.state.file_info_buf, 0, -1, false, {
'File Info Panel',
'',
@@ -1682,12 +1745,12 @@ function M.clear_preview()
'',
'Navigate: ↑↓ or Ctrl+p/n',
})
vim.api.nvim_buf_set_option(M.state.file_info_buf, 'modifiable', false)
vim.api.nvim_set_option_value('modifiable', false, { buf = M.state.file_info_buf })
end
vim.api.nvim_buf_set_option(M.state.preview_buf, 'modifiable', true)
vim.api.nvim_set_option_value('modifiable', true, { buf = M.state.preview_buf })
vim.api.nvim_buf_set_lines(M.state.preview_buf, 0, -1, false, { 'No preview available' })
vim.api.nvim_buf_set_option(M.state.preview_buf, 'modifiable', false)
vim.api.nvim_set_option_value('modifiable', false, { buf = M.state.preview_buf })
end
--- Update status information on the right side of input using virtual text
@@ -1716,8 +1779,7 @@ function M.update_status(progress)
return
end
-- Grep mode: show keybind + label, e.g. "<S-Tab> fuzzy"
local keybind = config.keymaps.toggle_grep_regex
local keybind = config.keymaps.cycle_grep_modes
-- Normalize: if it's a table of keys, use the first one for display
if type(keybind) == 'table' then keybind = keybind[1] or '<S-Tab>' end
@@ -2005,9 +2067,9 @@ local function find_suitable_window()
if vim.api.nvim_win_is_valid(win) then
local buf = vim.api.nvim_win_get_buf(win)
if vim.api.nvim_buf_is_valid(buf) then
local buftype = vim.api.nvim_buf_get_option(buf, 'buftype')
local modifiable = vim.api.nvim_buf_get_option(buf, 'modifiable')
local filetype = vim.api.nvim_buf_get_option(buf, 'filetype')
local buftype = vim.api.nvim_get_option_value('buftype', { buf = buf })
local modifiable = vim.api.nvim_get_option_value('modifiable', { buf = buf })
local filetype = vim.api.nvim_get_option_value('filetype', { buf = buf })
local is_picker_window = (
win == M.state.input_win
@@ -2210,8 +2272,8 @@ function M.select(action)
if action == 'edit' then
local current_buf = vim.api.nvim_get_current_buf()
local current_buftype = vim.api.nvim_buf_get_option(current_buf, 'buftype')
local current_buf_modifiable = vim.api.nvim_buf_get_option(current_buf, 'modifiable')
local current_buftype = vim.api.nvim_get_option_value('buftype', { buf = current_buf })
local current_buf_modifiable = vim.api.nvim_get_option_value('modifiable', { buf = current_buf })
-- If current active buffer is not a normal buffer we find a suitable window with a tab otherwise opening a new split
if current_buftype ~= '' or not current_buf_modifiable then
@@ -2247,6 +2309,39 @@ function M.select(action)
end)
end
function M.relayout()
if not M.state.active then return end
local config = M.state.config
if not config then return end
local layout, _ = compute_layout(config)
M.state.layout = layout
local win_configs = build_window_configs(layout, config)
if M.state.list_win and vim.api.nvim_win_is_valid(M.state.list_win) then
vim.api.nvim_win_set_config(M.state.list_win, win_configs.list)
end
if M.state.input_win and vim.api.nvim_win_is_valid(M.state.input_win) then
vim.api.nvim_win_set_config(M.state.input_win, win_configs.input)
end
if M.state.preview_win and vim.api.nvim_win_is_valid(M.state.preview_win) and win_configs.preview then
vim.api.nvim_win_set_config(M.state.preview_win, win_configs.preview)
end
if M.state.file_info_win and vim.api.nvim_win_is_valid(M.state.file_info_win) and win_configs.file_info then
vim.api.nvim_win_set_config(M.state.file_info_win, win_configs.file_info)
end
-- now rerenderw ith the new computed windows
M.render_list()
M.update_preview()
M.update_status()
end
function M.close()
if not M.state.active then return end
@@ -2467,21 +2562,7 @@ function M.open_with_callback(query, callback, opts)
end
--- Open the file picker UI
--- @param opts? table Optional configuration to override defaults
--- @param opts.cwd? string Custom working directory (default: vim.fn.getcwd())
--- @param opts.title? string Window title (default: "FFFiles")
--- @param opts.prompt? string Input prompt text (default: "🪿 ")
--- @param opts.max_results? number Maximum number of results to display (default: 100)
--- @param opts.max_threads? number Maximum number of threads for file scanning (default: 4)
--- @param opts.layout? table Layout configuration
--- @param opts.layout.width? number|function Window width as ratio (0.0-1.0) or function(terminal_width, terminal_height): number (default: 0.8)
--- @param opts.layout.height? number|function Window height as ratio (0.0-1.0) or function(terminal_width, terminal_height): number (default: 0.8)
--- @param opts.layout.prompt_position? string|function Prompt position: 'top'|'bottom' or function(terminal_width, terminal_height): string (default: 'bottom')
--- @param opts.layout.preview_position? string|function Preview position: 'left'|'right'|'top'|'bottom' or function(terminal_width, terminal_height): string (default: 'right')
--- @param opts.layout.preview_size? number|function Preview size as ratio (0.0-1.0) or function(terminal_width, terminal_height): number (default: 0.5)
--- @param opts.renderer? table Custom renderer implementing {render_line, apply_highlights} interface (default: file_renderer)
--- @param opts.mode? string Picker mode: nil (default file picker) or 'grep' (live grep)
--- @param opts.grep_config? table Grep-specific config overrides {max_file_size, smart_case, max_matches_per_file}
--- @param opts? {cwd?: string, title?: string, prompt?: string, max_results?: number, max_threads?: number, layout?: {width?: number|function, height?: number|function, prompt_position?: string|function, preview_position?: string|function, preview_size?: number|function}, renderer?: table, mode?: string, grep_config?: table, query?: string} Optional configuration to override defaults
function M.open(opts)
if M.state.active then return end
+4 -4
View File
@@ -38,7 +38,7 @@ function M.render(layout, config, list_win, pagination, prompt_position)
-- rendering in a separate buffer to overflow the border
if not scrollbar_exists then
scrollbar_state.buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_option(scrollbar_state.buf, 'bufhidden', 'wipe')
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = scrollbar_state.buf })
scrollbar_state.win = vim.api.nvim_open_win(scrollbar_state.buf, false, {
relative = 'editor',
@@ -52,7 +52,7 @@ function M.render(layout, config, list_win, pagination, prompt_position)
})
local scrollbar_hl = string.format('Normal:%s', config.hl.border)
vim.api.nvim_win_set_option(scrollbar_state.win, 'winhighlight', scrollbar_hl)
vim.api.nvim_set_option_value('winhighlight', scrollbar_hl, { win = scrollbar_state.win })
scrollbar_state.ever_shown = true
end
@@ -83,9 +83,9 @@ function M.render(layout, config, list_win, pagination, prompt_position)
end
end
pcall(vim.api.nvim_buf_set_option, scrollbar_state.buf, 'modifiable', true)
pcall(vim.api.nvim_set_option_value, 'modifiable', true, { buf = scrollbar_state.buf })
pcall(vim.api.nvim_buf_set_lines, scrollbar_state.buf, 0, -1, false, lines)
pcall(vim.api.nvim_buf_set_option, scrollbar_state.buf, 'modifiable', false)
pcall(vim.api.nvim_set_option_value, 'modifiable', false, { buf = scrollbar_state.buf })
pcall(vim.api.nvim_buf_clear_namespace, scrollbar_state.buf, ns_id, 0, -1)
if thumb_size > 0 then
+15 -9
View File
@@ -11,18 +11,24 @@ function M.get_triple()
if os_name == 'darwin' then
os_name = 'apple-darwin'
elseif os_name == 'linux' then
-- Detect if we're on musl or glibc
local handle = io.popen('ldd --version 2>&1')
if handle then
local output = handle:read('*a')
handle:close()
if output and output:match('musl') then
os_name = 'unknown-linux-musl'
-- Detect Android/Termux before checking musl/glibc.
-- Termux uses Bionic libc (not glibc or musl) and has no ldd.
if os.getenv('TERMUX_VERSION') or os.getenv('ANDROID_ROOT') then
os_name = 'linux-android'
else
-- Detect if we're on musl or glibc
local handle = io.popen('ldd --version 2>&1')
if handle then
local output = handle:read('*a')
handle:close()
if output and output:match('musl') then
os_name = 'unknown-linux-musl'
else
os_name = 'unknown-linux-gnu'
end
else
os_name = 'unknown-linux-gnu'
end
else
os_name = 'unknown-linux-gnu'
end
elseif os_name:match('windows') or os_name:match('mingw') or os_name:match('msys') then
os_name = 'pc-windows-msvc'