diff --git a/yazi/.config/yazi/plugins/whoosh.yazi/.luarc.json b/yazi/.config/yazi/plugins/whoosh.yazi/.luarc.json
new file mode 100644
index 0000000..ceb4ebc
--- /dev/null
+++ b/yazi/.config/yazi/plugins/whoosh.yazi/.luarc.json
@@ -0,0 +1,4 @@
+{
+ "$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json",
+ "diagnostics.globals": ["ya", "ui", "cx", "ps"]
+}
diff --git a/yazi/.config/yazi/plugins/whoosh.yazi/LICENSE b/yazi/.config/yazi/plugins/whoosh.yazi/LICENSE
new file mode 100644
index 0000000..634b10e
--- /dev/null
+++ b/yazi/.config/yazi/plugins/whoosh.yazi/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 Hunter Hwang
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/yazi/.config/yazi/plugins/whoosh.yazi/README.md b/yazi/.config/yazi/plugins/whoosh.yazi/README.md
new file mode 100644
index 0000000..0077cd9
--- /dev/null
+++ b/yazi/.config/yazi/plugins/whoosh.yazi/README.md
@@ -0,0 +1,455 @@
+
🌀 whoosh.yazi
+
+ A lightning-fast, keyboard-first bookmark manager for Yazi
+ Save, search, and jump to your favorite paths in a blink
+
+
+---
+
+> [!TIP]
+> **Russian version:** [README-RU.md](README-RU.md)
+
+> [!NOTE]
+> [Yazi](https://github.com/sxyazi/yazi) plugin for bookmark management, supporting the following features:
+>
+> - **Persistent bookmarks** - No bookmarks are lost after you close yazi
+> - **Temporary bookmarks** - Session-only bookmarks that don't persist between restarts
+> - **Quick navigation** - Jump, delete, and rename bookmarks by keymap
+> - **Fuzzy search** - Support fuzzy search through [fzf](https://github.com/junegunn/fzf)
+> - **Multiple bookmark deletion** - Select multiple bookmarks with TAB in fzf
+> - **Configuration bookmarks** - Pre-configure bookmarks using Lua language
+> - **Smart path truncation** - Configurable path shortening for better readability
+> - **Directory history** - Navigate back to previous directory with Backspace
+> - **Tab history navigation** - Browse and jump to recently visited directories with Tab key
+> - **Quick bookmark creation** - Create temporary bookmarks directly from navigation menu
+> - **Configurable menu shortcuts** - Override the default Tab/Backspace/Enter/Space bindings from `init.lua`
+
+
+

+
+
+## Installation
+
+> [!IMPORTANT]
+> Requires Yazi v25.5.28+
+
+```sh
+# Manual installation
+
+# Linux/macOS
+git clone https://gitlab.com/WhoSowSee/whoosh.yazi.git ~/.config/yazi/plugins/whoosh.yazi
+
+# Windows
+git clone https://gitlab.com/WhoSowSee/whoosh.yazi.git $env:APPDATA\yazi\config\plugins\whoosh.yazi
+```
+
+## Usage
+
+Add this to your `init.lua`
+
+```lua
+-- You can configure your bookmarks using simplified syntax
+local bookmarks = {
+ { tag = "Desktop", path = "~/Desktop", key = "d" },
+ { tag = "Documents", path = "~/Documents", key = "D" },
+ { tag = "Downloads", path = "~/Downloads", key = "o" },
+}
+
+-- You can also configure bookmarks with key arrays
+local bookmarks = {
+ { tag = "Desktop", path = "~/Desktop", key = { "d", "D" } },
+ { tag = "Documents", path = "~/Documents", key = { "d", "d" } },
+ { tag = "Downloads", path = "~/Downloads", key = "o" },
+}
+
+-- Windows-specific bookmarks
+if ya.target_family() == "windows" then
+ local home_path = os.getenv("USERPROFILE")
+ table.insert(bookmarks, {
+ tag = "Scoop Local",
+ path = os.getenv("SCOOP") or (home_path .. "\\scoop"),
+ key = "p"
+ })
+ table.insert(bookmarks, {
+ tag = "Scoop Global",
+ path = os.getenv("SCOOP_GLOBAL") or "C:\\ProgramData\\scoop",
+ key = "P"
+ })
+end
+
+require("whoosh"):setup {
+ -- Configuration bookmarks (cannot be deleted through plugin)
+ bookmarks = bookmarks,
+
+ -- Notification settings
+ jump_notify = false,
+
+ -- Key generation for auto-assigning bookmark keys
+ keys = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
+
+ -- Configure the built-in menu action hotkeys
+ -- false - hide menu item
+ special_keys = {
+ create_temp = "", -- Create a temporary bookmark from the menu
+ fuzzy_search = "", -- Launch fuzzy search (fzf)
+ history = "", -- Open directory history
+ previous_dir = "", -- Jump back to the previous directory
+ },
+
+ -- File path for storing user bookmarks
+ bookmarks_path = (ya.target_family() == "windows" and os.getenv("APPDATA") .. "\\yazi\\config\\plugins\\whoosh.yazi\\bookmarks") or
+ (os.getenv("HOME") .. "/.config/yazi/plugins/whoosh.yazi/bookmarks"),
+
+ -- Replace home directory with "~"
+ home_alias_enabled = true, -- Toggle home aliasing in displays
+
+ -- Path truncation in navigation menu
+ path_truncate_enabled = false, -- Enable/disable path truncation
+ path_max_depth = 3, -- Maximum path depth before truncation
+
+ -- Path truncation in fuzzy search (fzf)
+ fzf_path_truncate_enabled = false, -- Enable/disable path truncation in fzf
+ fzf_path_max_depth = 5, -- Maximum path depth before truncation in fzf
+
+ -- Long folder name truncation
+ path_truncate_long_names_enabled = false, -- Enable in navigation menu
+ fzf_path_truncate_long_names_enabled = false, -- Enable in fzf
+ path_max_folder_name_length = 20, -- Max length in navigation menu
+ fzf_path_max_folder_name_length = 20, -- Max length in fzf
+
+ -- History directory settings
+ history_size = 10, -- Number of directories in history (default 10)
+ history_fzf_path_truncate_enabled = false, -- Enable/disable path truncation by depth for history
+ history_fzf_path_max_depth = 5, -- Maximum path depth before truncation for history (default 5)
+ history_fzf_path_truncate_long_names_enabled = false, -- Enable/disable long folder name truncation for history
+ history_fzf_path_max_folder_name_length = 30, -- Maximum length for folder names in history (default 30)
+}
+```
+
+Add this to your `keymap.toml`:
+
+```toml
+[[mgr.prepend_keymap]]
+on = "["
+run = "plugin whoosh jump_by_key"
+desc = "Jump bookmark by key"
+
+# Direct fuzzy search access
+[[mgr.prepend_keymap]]
+on = "}"
+run = "plugin whoosh jump_by_fzf"
+desc = "Direct fuzzy search for bookmarks"
+
+# Basic bookmark operations
+[[mgr.prepend_keymap]]
+on = [ "]", "a" ]
+run = "plugin whoosh save"
+desc = "Add bookmark (hovered file/directory)"
+
+[[mgr.prepend_keymap]]
+on = [ "]", "A" ]
+run = "plugin whoosh save_cwd"
+desc = "Add bookmark (current directory)"
+
+# Temporary bookmarks
+[[mgr.prepend_keymap]]
+on = [ "]", "t" ]
+run = "plugin whoosh save_temp"
+desc = "Add temporary bookmark (hovered file/directory)"
+
+[[mgr.prepend_keymap]]
+on = [ "]", "T" ]
+run = "plugin whoosh save_cwd_temp"
+desc = "Add temporary bookmark (current directory)"
+
+# Jump to bookmarks
+[[mgr.prepend_keymap]]
+on = ""
+run = "plugin whoosh jump_key_k"
+desc = "Jump directly to bookmark with key k"
+
+[[mgr.prepend_keymap]]
+on = [ "]", "f" ]
+run = "plugin whoosh jump_by_fzf"
+desc = "Jump bookmark by fzf"
+
+# Delete bookmarks
+[[mgr.prepend_keymap]]
+on = [ "]", "d" ]
+run = "plugin whoosh delete_by_key"
+desc = "Delete bookmark by key"
+
+[[mgr.prepend_keymap]]
+on = [ "]", "D" ]
+run = "plugin whoosh delete_by_fzf"
+desc = "Delete bookmarks by fzf (use TAB to select multiple)"
+
+[[mgr.prepend_keymap]]
+on = [ "]", "C" ]
+run = "plugin whoosh delete_all"
+desc = "Delete all user bookmarks"
+
+# Rename bookmarks
+[[mgr.prepend_keymap]]
+on = [ "]", "r" ]
+run = "plugin whoosh rename_by_key"
+desc = "Rename bookmark by key"
+
+[[mgr.prepend_keymap]]
+on = [ "]", "R" ]
+run = "plugin whoosh rename_by_fzf"
+desc = "Rename bookmark by fzf"
+```
+
+## Features
+
+### Temporary Bookmarks
+
+Session-only bookmarks that don't persist between Yazi restarts:
+
+- Create using `save_temp` or `save_cwd_temp` commands
+- Identified with [TEMP] prefix in navigation menu and fzf
+- Automatically cleared when Yazi restarts
+- Can be deleted individually or all at once with `delete_all_temp`
+
+### Directory History
+
+
+

+
+
+The plugin supports a smart directory history system:
+
+- **Independent history per tab** - Each tab maintains its own history
+- **Automatic tracking** - History updates when navigating between directories
+- **Current directory filtering** - Current directory is excluded from history display
+- **Configurable size** - Number of stored directories is configurable (default 10)
+- **Separate truncation settings** - Independent path display settings for history
+
+**System behavior:**
+
+- History is empty on first yazi startup
+- Previous directories are added to history only when navigating to a new directory
+- New items are added to the beginning of the list (sorted from newest to oldest)
+- When limit is exceeded, oldest items are removed
+- Duplicates are automatically removed and moved to the top
+
+### Navigation Menu Features
+
+When using `jump_by_key`, you get access to a smart navigation menu with:
+
+- **Create temporary bookmark** - Press `` to quickly bookmark current directory
+- **Fuzzy search** - Press `` to open fzf search
+- **Directory history** - Press `` to browse history via fzf (only if history exists)
+- **Previous directory** - Press `` to return to the previous directory (if available)
+- **All bookmarks** - Both permanent and temporary bookmarks with clear visual distinction
+
+### Directory History Navigation
+
+The plugin provides two ways to navigate history:
+
+1. **Through navigation menu** - When using `jump_by_key`, press `` to access history
+2. **Direct access** - Trigger the configured history special key (default ``) for direct fzf access to history
+
+#### Neovim `` keymap (yazi.nvim)
+
+When this plugin runs inside [mikavilpas/yazi.nvim](https://github.com/mikavilpas/yazi.nvim), the default `` mapping (`cycle_open_buffers`) is handled by Neovim before Yazi sees it. If pressing `` returns you to the buffer where Yazi was opened, disable or remap that key in the yazi.nvim configuration so the directory history picker can receive it:
+
+```lua
+ opts = {
+ keymaps = {
+ cycle_open_buffers = false,
+ },
+ -- OR
+ keymaps = {
+ cycle_open_buffers = "",
+ },
+ },
+```
+
+Full config file example:
+
+```lua
+return {
+ "mikavilpas/yazi.nvim",
+ version = "*",
+ event = "VeryLazy",
+ dependencies = { { "nvim-lua/plenary.nvim", lazy = true } },
+ keys = {
+ { "-", mode = { "n", "v" }, "Yazi", desc = "Open Yazi" },
+ { "cw", "Yazi cwd", desc = "Open Yazi at CWD" },
+ },
+ opts = {
+ open_for_directories = false,
+ keymaps = {
+ cycle_open_buffers = false,
+ },
+ },
+
+ init = function() vim.g.loaded_netrwPlugin = 1 end,
+}
+```
+
+If you prefer to keep Neovim's `` binding but still want access to the history picker, remap whoosh's shortcut via `special_keys` in the `init.lua` file:
+
+```lua
+require("whoosh"):setup {
+ special_keys = {
+ history = "",
+ },
+}
+```
+
+### Bookmark Types
+
+The plugin supports three types of bookmarks:
+
+1. **Configuration bookmarks** - Defined in `init.lua`, cannot be deleted through the plugin
+2. **User bookmarks** - Created during usage, saved to file, can be deleted
+3. **Temporary bookmarks** - Session-only, stored in memory, cleared on restart
+
+When paths conflict, user bookmarks override configuration bookmarks in the display
+
+## Configuration Options
+
+The plugin supports the following configuration options in the `setup()` function:
+
+| Option | Type | Default | Description |
+| -------------------------------------- | ------- | ----------------------- | ------------------------------------------------------------------ |
+| `bookmarks` | table | `{}` | Pre-configured bookmarks (cannot be deleted through plugin) |
+| `jump_notify` | boolean | `false` | Show notification when jumping to a bookmark |
+| `keys` | string | `"0123456789abcdef..."` | Characters used for auto-generating bookmark keys |
+| `special_keys` | table | `see description` | Override the built-in menu shortcuts (Enter/Space/Tab/Backspace); set to `false` to hide an item |
+| `path` | string | OS-dependent | File path where user bookmarks are stored |
+| `home_alias_enabled` | boolean | `true` | Replace paths under the user's home directory with `~` |
+| `path_truncate_enabled` | boolean | `false` | Enable/disable path truncation in navigation menu |
+| `path_max_depth` | number | `3` | Maximum path depth before truncation with "…" in navigation menu |
+| `fzf_path_truncate_enabled` | boolean | `false` | Enable/disable path truncation in fuzzy search (fzf) |
+| `fzf_path_max_depth` | number | `5` | Maximum path depth before truncation with "…" in fzf |
+| `path_truncate_long_names_enabled` | boolean | `false` | Enable/disable long folder name truncation in navigation menu |
+| `fzf_path_truncate_long_names_enabled` | boolean | `false` | Enable/disable long folder name truncation in fzf |
+| `path_max_folder_name_length` | number | `20` | Maximum folder name length before truncation in navigation menu |
+| `fzf_path_max_folder_name_length` | number | `20` | Maximum folder name length before truncation in fzf |
+| `history_size` | number | `10` | Number of directories to keep in Tab history |
+| `history_fzf_path_truncate_enabled` | boolean | `false` | Enable/disable path truncation by depth for Tab history display |
+| `history_fzf_path_max_depth` | number | `5` | Maximum path depth before truncation for Tab history |
+| `history_fzf_path_truncate_long_names_enabled` | boolean | `false` | Enable/disable long folder name truncation for Tab history |
+| `history_fzf_path_max_folder_name_length` | number | `30` | Maximum folder name length before truncation for Tab history |
+
+**Note:** Configuration bookmarks defined in the `bookmarks` option cannot be deleted through the plugin interface. They serve as permanent, protected bookmarks that are always available
+
+### Bookmark Configuration
+
+The plugin supports a simplified bookmark syntax in the configuration:
+
+```lua
+-- Simplified syntax (recommended)
+local bookmarks = {
+ { tag = "Desktop", path = "~/Desktop", key = "d" },
+ { tag = "Projects", path = "~/Projects", key = "p" },
+}
+```
+
+**Features of simplified syntax:**
+
+- **Tilde expansion** - `~` is automatically expanded to home directory
+- **Path normalization** - Separators `/` are automatically converted for your OS
+- **Automatic trailing separator** - Directories get proper trailing separators
+
+### Path Truncation
+
+The path truncation feature can be controlled by two options:
+
+- `path_truncate_enabled` (boolean, default: `false`) - Enables or disables path truncation entirely. If not specified in config, defaults to `false`
+- `path_max_depth` (number, default: `3`) - Controls how long paths are displayed in the navigation menu
+
+When `path_truncate_enabled` is explicitly set to `true` and a path has more directory levels than `path_max_depth`, the beginning parts are replaced with "…" to keep the display concise.
+
+**By default (when `path_truncate_enabled` is not specified or set to `false`):**
+
+- All paths are displayed in full without truncation
+- `C:\Users\Documents\Projects\MyProject` → `C:\Users\Documents\Projects\MyProject` (full path)
+
+**With `path_truncate_enabled = true` and `path_max_depth = 3`:**
+
+- `C:\Users\Documents` → `C:\Users\Documents` (no change, 3 parts)
+- `C:\Users\Documents\Projects\MyProject` → `C:\…\Projects\MyProject` (truncated, 5 parts)
+- `~/.config/yazi/plugins/whoosh.yazi` → `~\…\plugins\whoosh.yazi` (truncated, 5 parts)
+
+#### Folder Name Length Truncation
+
+Long folder names can be truncated to improve readability in both navigation menu and fuzzy search:
+
+**Configuration Options:**
+
+- `path_truncate_long_names_enabled` (boolean, default: `false`) - Enable/disable for navigation menu
+- `fzf_path_truncate_long_names_enabled` (boolean, default: `false`) - Enable/disable for fuzzy search (fzf)
+- `path_max_folder_name_length` (number, default: `20`) - Maximum length for folder names in navigation menu
+- `fzf_path_max_folder_name_length` (number, default: `20`) - Maximum length for folder names in fuzzy search
+
+**How it works:**
+
+- Individual folder names longer than the specified limit are truncated to 40% of the limit + "..."
+- This truncation is applied to each folder name separately and works independently of depth-based path truncation
+- Both truncation methods can be used together for optimal display
+- Windows drive letters (e.g., `C:\`) are handled specially and never truncated
+
+**Examples with `path_max_folder_name_length = 20`:**
+
+- `VeryLongFolderNameThatExceedsLimit` → `VeryLongF…` (9 chars + "…")
+- `C:\VeryLongFolderNameThatExceedsLimit\Documents` → `C:\VeryLongF…\Documents`
+- `ShortName` → `ShortName` (no change, under limit)
+- `/home/VeryLongFolderNameThatExceedsLimit/projects` → `/home/VeryLongF…/projects`
+
+**Combined with depth truncation:**
+
+When both folder name truncation and depth-based truncation are enabled, folder names are shortened first, then depth truncation is applied:
+
+- Original: `C:\Users\VeryLongFolderNameThatExceedsLimit\Documents\Projects\MyProject`
+- After folder name truncation: `C:\Users\VeryLongF…\Documents\Projects\MyProject`
+- After depth truncation (max_depth=3): `C:\…\Projects\MyProject`
+
+This feature significantly improves readability in deeply nested directory structures while preserving the most relevant path information.
+
+## Available Commands
+
+| Command | Description |
+| ------------------ | ------------------------------------------------------------- |
+| `save` | Add bookmark for hovered file/directory |
+| `save_cwd` | Add bookmark for current working directory |
+| `save_temp` | Add temporary bookmark for hovered file/directory |
+| `save_cwd_temp` | Add temporary bookmark for current working directory |
+| `jump_by_key` | Open navigation menu to jump to bookmark by key |
+| `jump_key_` | Jump instantly to bookmark matching the provided key sequence |
+| `jump_by_fzf` | Open fuzzy search to jump to bookmark |
+| `delete_by_key` | Delete bookmark by selecting with key |
+| `delete_by_fzf` | Delete multiple bookmarks using fzf (TAB to select) |
+| `delete_all` | Delete all user-created bookmarks (excludes config bookmarks) |
+| `delete_all_temp` | Delete all temporary bookmarks |
+| `rename_by_key` | Rename bookmark by selecting with key |
+| `rename_by_fzf` | Rename bookmark using fuzzy search |
+
+### Direct Key Shortcuts
+
+You can jump without opening the menu by calling the plugin with an inline key sequence:
+
+- `plugin whoosh jump_key_` - inline sequence such as `jump_key_k`, `jump_key_`, or `jump_key_bb`.
+
+Sequences must be provided inline; whitespace-separated forms are not supported. The format matches the bookmark editing prompt, so you can mix plain characters, comma-separated tokens, and special keys like `` or ``.
+
+### Navigation Menu Controls
+
+When using `jump_by_key`, the following special controls are available:
+
+| Default key | Action |
+| ------------ | ----------------------------------------------- |
+| `` | Create temporary bookmark for current directory |
+| `` | Open fuzzy search |
+| `` | Open directory history (only if history exists) |
+| `` | Return to previous directory (if available) |
+| `[a-zA-Z0-9]` | Jump to bookmark with corresponding key |
+
+## Inspiration
+
+- [yamb](https://github.com/h-hg/yamb.yazi)
+- [bunny](https://github.com/stelcodes/bunny.yazi)
diff --git a/yazi/.config/yazi/plugins/whoosh.yazi/bookmarks b/yazi/.config/yazi/plugins/whoosh.yazi/bookmarks
new file mode 100644
index 0000000..4a3996e
--- /dev/null
+++ b/yazi/.config/yazi/plugins/whoosh.yazi/bookmarks
@@ -0,0 +1,16 @@
+zshrc /home/liph/dotfiles/zshrc z
+yazi /home/liph/dotfiles/yazi/.config/yazi y
+tank /mnt/tank t,t
+podman /mnt/flash1/podman t,p
+ohmyposh /home/liph/dotfiles/ohmyposh/.config/ohmyposh d,o
+nvim /home/liph/dotfiles/nvim/.config/nvim/ n,n,n
+neovim_plug /home/liph/dotfiles/nvim/.config/nvim/lua/plugins/ n,p
+mnt /mnt m
+kitty /home/liph/dotfiles/kitty/.config/kitty k
+hyprland /home/liph/dotfiles/hyprland/.config/hypr h,l
+home /home/liph f
+Downloads /home/liph/Downloads D,D
+dotfiles /home/liph/dotfiles d,d
+Documents /home/liph/Documents D,d
+.config /home/liph/.config c
+aerc /home/liph/dotfiles/aerc/.config/aerc d,a
diff --git a/yazi/.config/yazi/plugins/whoosh.yazi/image/history.png b/yazi/.config/yazi/plugins/whoosh.yazi/image/history.png
new file mode 100644
index 0000000..3a00481
Binary files /dev/null and b/yazi/.config/yazi/plugins/whoosh.yazi/image/history.png differ
diff --git a/yazi/.config/yazi/plugins/whoosh.yazi/image/plugin.png b/yazi/.config/yazi/plugins/whoosh.yazi/image/plugin.png
new file mode 100644
index 0000000..4bba237
Binary files /dev/null and b/yazi/.config/yazi/plugins/whoosh.yazi/image/plugin.png differ
diff --git a/yazi/.config/yazi/plugins/whoosh.yazi/main.lua b/yazi/.config/yazi/plugins/whoosh.yazi/main.lua
new file mode 100644
index 0000000..855ee3d
--- /dev/null
+++ b/yazi/.config/yazi/plugins/whoosh.yazi/main.lua
@@ -0,0 +1,1742 @@
+-- @since 25.5.28 this is a test
+local path_sep = package.config:sub(1, 1)
+
+local DEFAULT_SPECIAL_KEYS = {
+ create_temp = "",
+ fuzzy_search = "",
+ history = "",
+ previous_dir = "",
+}
+
+local function get_fzf_delimiter()
+ if ya.target_family() == "windows" then
+ return "--delimiter=\\t"
+ else
+ return "--delimiter='\t'"
+ end
+end
+
+local get_hovered_path = ya.sync(function(state)
+ local h = cx.active.current.hovered
+ if h then
+ local path = tostring(h.url)
+ if h.cha.is_dir then
+ if ya.target_family() == "windows" and path:match("^[A-Za-z]:$") then
+ return path .. "\\"
+ end
+ return path
+ end
+ return path
+ else
+ return ""
+ end
+end)
+
+local is_hovered_directory = ya.sync(function(state)
+ local h = cx.active.current.hovered
+ if h then
+ return h.cha.is_dir
+ end
+ return false
+end)
+
+local get_current_dir_path = ya.sync(function()
+ local path = tostring(cx.active.current.cwd)
+ if ya.target_family() == "windows" and path:match("^[A-Za-z]:$") then
+ return path .. "\\"
+ end
+ return path
+end)
+
+local get_state_attr = ya.sync(function(state, attr)
+ return state[attr]
+end)
+
+local set_state_attr = ya.sync(function(state, attr, value)
+ state[attr] = value
+end)
+
+local set_bookmarks = ya.sync(function(state, path, value)
+ state.bookmarks[path] = value
+end)
+
+local set_temp_bookmarks = ya.sync(function(state, path, value)
+ state.temp_bookmarks[path] = value
+end)
+
+local get_temp_bookmarks = ya.sync(function(state)
+ return state.temp_bookmarks
+end)
+
+local get_current_tab_idx = ya.sync(function(state)
+ return cx.tabs.idx
+end)
+
+local get_directory_history = ya.sync(function(state)
+ return state.directory_history
+end)
+
+local add_to_history = ya.sync(function(state, tab_idx, path)
+ if not state.directory_history[tab_idx] then
+ state.directory_history[tab_idx] = {}
+ end
+
+ local history = state.directory_history[tab_idx]
+ local history_size = state.history_size or 10
+
+ for i = #history, 1, -1 do
+ if history[i] == path then
+ table.remove(history, i)
+ end
+ end
+
+ table.insert(history, 1, path)
+
+ while #history > history_size do
+ table.remove(history, #history)
+ end
+end)
+
+local get_tab_history = ya.sync(function(state, tab_idx)
+ return state.directory_history[tab_idx] or {}
+end)
+
+local function ensure_directory(path)
+ local dir_path = path:match("(.+)[\\/][^\\/]*$")
+ if not dir_path then
+ return
+ end
+ if ya.target_family() == "windows" then
+ os.execute('mkdir "' .. dir_path:gsub("/", "\\") .. '" 2>nul')
+ else
+ os.execute('mkdir -p "' .. dir_path .. '"')
+ end
+end
+
+local function normalize_path(path)
+ local normalized_path = tostring(path):gsub("[\\/]+", path_sep)
+
+ if ya.target_family() == "windows" then
+ if normalized_path:match("^[A-Za-z]:[\\/]*$") then
+ normalized_path = normalized_path:gsub("^([A-Za-z]:)[\\/]*", "%1\\")
+ else
+ normalized_path = normalized_path:gsub("^([A-Za-z]:)[\\/]+", "%1\\")
+ normalized_path = normalized_path:gsub("[\\/]+$", "")
+ end
+ else
+ if normalized_path ~= "/" then
+ normalized_path = normalized_path:gsub("[\\/]+$", "")
+ end
+ end
+
+ return normalized_path
+end
+
+local function apply_home_alias(path)
+ if not path or path == "" then
+ return path
+ end
+
+ local home_alias_enabled = get_state_attr("home_alias_enabled")
+ if home_alias_enabled == false then
+ return path
+ end
+
+ if path:sub(1, 1) == "~" then
+ return path
+ end
+
+ local home = os.getenv("HOME")
+ if ya.target_family() == "windows" and (not home or home == "") then
+ home = os.getenv("USERPROFILE")
+ end
+ if not home or home == "" then
+ return path
+ end
+
+ local normalized_home = normalize_path(home)
+ if not normalized_home or normalized_home == "" then
+ return path
+ end
+
+ local sep = path_sep
+
+ if ya.target_family() == "windows" then
+ local path_lower = path:lower()
+ local home_lower = normalized_home:lower()
+ if path_lower == home_lower then
+ return "~"
+ end
+ local prefix_lower = (normalized_home .. sep):lower()
+ if path_lower:sub(1, #prefix_lower) == prefix_lower then
+ return "~" .. path:sub(#normalized_home + 1)
+ end
+ else
+ if path == normalized_home then
+ return "~"
+ end
+ local prefix = normalized_home .. sep
+ if path:sub(1, #prefix) == prefix then
+ return "~" .. path:sub(#normalized_home + 1)
+ end
+ end
+
+ return path
+end
+
+local function normalize_special_key(value, default)
+ if value == nil then
+ return default
+ end
+ if value == false then
+ return nil
+ end
+ if type(value) == "string" then
+ local trimmed = value:gsub("^%s*(.-)%s*$", "%1")
+ if trimmed == "" then
+ return nil
+ end
+ return trimmed
+ end
+ if type(value) == "table" then
+ local seq = {}
+ for _, item in ipairs(value) do
+ if type(item) == "string" then
+ local trimmed = item:gsub("^%s*(.-)%s*$", "%1")
+ if trimmed ~= "" then
+ table.insert(seq, trimmed)
+ end
+ end
+ end
+ if #seq == 0 then
+ return nil
+ end
+ return seq
+ end
+ return default
+end
+
+local function truncate_long_folder_names(path, max_folder_length)
+ if not max_folder_length or max_folder_length <= 0 then
+ return path
+ end
+
+ local separator = ya.target_family() == "windows" and "\\" or "/"
+ local parts = {}
+
+ for part in path:gmatch("[^" .. separator .. "]+") do
+ if #part > max_folder_length then
+ local keep_length = math.max(3, math.floor(max_folder_length * 0.4))
+ local truncated = part:sub(1, keep_length) .. "..."
+ table.insert(parts, truncated)
+ else
+ table.insert(parts, part)
+ end
+ end
+
+ local result = table.concat(parts, separator)
+
+ if path:sub(1, 1) == separator then
+ result = separator .. result
+ end
+
+ return result
+end
+
+local function truncate_path(path, max_parts)
+ max_parts = max_parts or 3
+ local normalized_path = normalize_path(path)
+ normalized_path = apply_home_alias(normalized_path)
+
+ local parts = {}
+ local separator = ya.target_family() == "windows" and "\\" or "/"
+
+ if ya.target_family() == "windows" then
+ local drive, rest = normalized_path:match("^([A-Za-z]:\\)(.*)$")
+ if drive then
+ table.insert(parts, drive)
+ if rest and rest ~= "" then
+ for part in rest:gmatch("[^\\]+") do
+ table.insert(parts, part)
+ end
+ end
+ else
+ for part in normalized_path:gmatch("[^\\]+") do
+ table.insert(parts, part)
+ end
+ end
+ else
+ if normalized_path:sub(1, 1) == "/" then
+ table.insert(parts, "/")
+ local rest = normalized_path:sub(2)
+ if rest ~= "" then
+ for part in rest:gmatch("[^/]+") do
+ table.insert(parts, part)
+ end
+ end
+ elseif normalized_path:sub(1, 1) == "~" then
+ table.insert(parts, "~")
+ local rest = normalized_path:sub(2)
+ if rest:sub(1, 1) == "/" then
+ rest = rest:sub(2)
+ end
+ if rest ~= "" then
+ for part in rest:gmatch("[^/]+") do
+ table.insert(parts, part)
+ end
+ end
+ else
+ for part in normalized_path:gmatch("[^/]+") do
+ table.insert(parts, part)
+ end
+ end
+ end
+
+ if #parts > max_parts then
+ local result_parts = {}
+ local first_part = parts[1]
+
+ if ya.target_family() == "windows" and first_part:match("^[A-Za-z]:\\$") then
+ first_part = first_part:sub(1, -2)
+ end
+
+ if ya.target_family() ~= "windows" and first_part == "/" then
+ table.insert(result_parts, "")
+ else
+ table.insert(result_parts, first_part)
+ end
+
+ table.insert(result_parts, "…")
+ for i = #parts - max_parts + 2, #parts do
+ table.insert(result_parts, parts[i])
+ end
+
+ local out = table.concat(result_parts, separator)
+ if ya.target_family() ~= "windows" then
+ out = out:gsub("^//+", "/")
+ end
+ return out
+ else
+ return normalized_path
+ end
+end
+
+local function path_to_desc(path)
+ local path_truncate_enabled = get_state_attr("path_truncate_enabled")
+ local result_path = apply_home_alias(normalize_path(path))
+
+ local path_truncate_long_names_enabled = get_state_attr("path_truncate_long_names_enabled")
+ if path_truncate_long_names_enabled == true then
+ local max_folder_length = get_state_attr("path_max_folder_name_length") or 20
+ result_path = truncate_long_folder_names(result_path, max_folder_length)
+ end
+
+ if path_truncate_enabled == true then
+ local max_depth = get_state_attr("path_max_depth") or 3
+ result_path = truncate_path(result_path, max_depth)
+ end
+
+ return result_path
+end
+
+local function get_display_width(str)
+ return ui.Line(str):width()
+end
+
+local function truncate_long_folder_names(path, max_folder_length)
+ if not max_folder_length or max_folder_length <= 0 then
+ return path
+ end
+
+ local separator = ya.target_family() == "windows" and "\\" or "/"
+ local parts = {}
+ local is_windows = ya.target_family() == "windows"
+
+ if is_windows then
+ local drive, rest = path:match("^([A-Za-z]:\\)(.*)$")
+ if drive then
+ table.insert(parts, drive:sub(1, -2))
+ if rest and rest ~= "" then
+ for part in rest:gmatch("[^\\]+") do
+ if #part > max_folder_length then
+ local keep_length = math.max(3, math.floor(max_folder_length * 0.4))
+ local truncated = part:sub(1, keep_length) .. "..."
+ table.insert(parts, truncated)
+ else
+ table.insert(parts, part)
+ end
+ end
+ end
+ return table.concat(parts, separator)
+ end
+ end
+
+ for part in path:gmatch("[^" .. (separator == "\\" and "\\\\" or separator) .. "]+") do
+ if #part > max_folder_length then
+ local keep_length = math.max(3, math.floor(max_folder_length * 0.4))
+ local truncated = part:sub(1, keep_length) .. "..."
+ table.insert(parts, truncated)
+ else
+ table.insert(parts, part)
+ end
+ end
+
+ local result = table.concat(parts, separator)
+
+ if path:sub(1, 1) == separator then
+ result = separator .. result
+ end
+
+ return result
+end
+
+local function path_to_desc_for_fzf(path)
+ local fzf_path_truncate_enabled = get_state_attr("fzf_path_truncate_enabled")
+ local result_path = apply_home_alias(normalize_path(path))
+
+ local fzf_path_truncate_long_names_enabled = get_state_attr("fzf_path_truncate_long_names_enabled")
+ if fzf_path_truncate_long_names_enabled == true then
+ local max_folder_length = get_state_attr("fzf_path_max_folder_name_length") or 20
+ result_path = truncate_long_folder_names(result_path, max_folder_length)
+ end
+
+ if fzf_path_truncate_enabled == true then
+ local max_depth = get_state_attr("fzf_path_max_depth") or 5
+ result_path = truncate_path(result_path, max_depth)
+ end
+
+ return result_path
+end
+
+local function path_to_desc_for_history(path)
+ local history_fzf_path_truncate_enabled = get_state_attr("history_fzf_path_truncate_enabled")
+ local result_path = apply_home_alias(normalize_path(path))
+
+ local history_fzf_path_truncate_long_names_enabled = get_state_attr("history_fzf_path_truncate_long_names_enabled")
+ if history_fzf_path_truncate_long_names_enabled == true then
+ local max_folder_length = get_state_attr("history_fzf_path_max_folder_name_length") or 30
+ result_path = truncate_long_folder_names(result_path, max_folder_length)
+ end
+
+ if history_fzf_path_truncate_enabled == true then
+ local max_depth = get_state_attr("history_fzf_path_max_depth") or 5
+ result_path = truncate_path(result_path, max_depth)
+ end
+
+ return result_path
+end
+
+local function format_bookmark_for_menu(tag, key)
+ return tag
+end
+
+local function format_bookmark_for_fzf(tag, path, key, max_tag_width, max_path_width)
+ local tag_width = math.max(max_tag_width, 15)
+ local path_width = math.max(max_path_width or 30, 30)
+
+ local formatted_tag = tag
+ local tag_display_width = get_display_width(tag)
+ if tag_display_width > tag_width then
+ formatted_tag = tag:sub(1, tag_width - 3) .. "..."
+ else
+ formatted_tag = tag .. string.rep(" ", tag_width - tag_display_width)
+ end
+
+ local display_path = path_to_desc_for_fzf(path)
+ local formatted_path = display_path
+ local path_display_width = get_display_width(display_path)
+ if path_display_width > path_width then
+ formatted_path = display_path:sub(1, path_width - 3) .. "..."
+ else
+ formatted_path = display_path .. string.rep(" ", path_width - path_display_width)
+ end
+
+ local key_display = ""
+ if key then
+ if type(key) == "table" then
+ key_display = table.concat(key, ",")
+ elseif type(key) == "string" and #key > 0 then
+ key_display = key
+ else
+ key_display = tostring(key)
+ end
+ end
+
+ return formatted_tag .. " " .. formatted_path .. " " .. key_display
+end
+
+local function sort_bookmarks(bookmarks, key1, key2, reverse)
+ reverse = reverse or false
+ table.sort(bookmarks, function(x, y)
+ if not x or not y then
+ return false
+ end
+ local x_key1, y_key1 = x[key1], y[key1]
+ local x_key2, y_key2 = x[key2], y[key2]
+ if x_key1 == nil and y_key1 == nil then
+ if x_key2 == nil and y_key2 == nil then
+ return false
+ elseif x_key2 == nil then
+ return false
+ elseif y_key2 == nil then
+ return true
+ else
+ return tostring(x_key2) < tostring(y_key2)
+ end
+ elseif x_key1 == nil then
+ return false
+ elseif y_key1 == nil then
+ return true
+ else
+ return tostring(x_key1) < tostring(y_key1)
+ end
+ end)
+ if reverse then
+ local n = #bookmarks
+ for i = 1, math.floor(n / 2) do
+ bookmarks[i], bookmarks[n - i + 1] = bookmarks[n - i + 1], bookmarks[i]
+ end
+ end
+ return bookmarks
+end
+
+local action_save, action_jump, action_delete, which_find, fzf_find, fzf_find_for_rename, fzf_history
+
+local function get_all_bookmarks()
+ local all_b = {}
+ local config_b = get_state_attr("config_bookmarks")
+ local user_b = get_state_attr("bookmarks")
+
+ for path, item in pairs(config_b) do
+ all_b[path] = item
+ end
+ for path, item in pairs(user_b) do
+ all_b[path] = item
+ end
+ return all_b
+end
+
+local function serialize_key_for_file(key)
+ if type(key) == "table" then
+ return table.concat(key, ",")
+ elseif type(key) == "string" then
+ return key
+ else
+ return tostring(key)
+ end
+end
+
+local function deserialize_key_from_file(key_str)
+ if not key_str or key_str == "" then
+ return ""
+ end
+
+ key_str = key_str:gsub("^%s*(.-)%s*$", "%1")
+ if key_str == "" then
+ return ""
+ end
+
+ if key_str:find(",") then
+ local seq = {}
+ for token in key_str:gmatch("[^,%s]+") do
+ token = token:gsub("^%s*(.-)%s*$", "%1")
+ if token ~= "" then
+ if token:match("^<.->$") then
+ table.insert(seq, token)
+ else
+ for _, cp in utf8.codes(token) do
+ table.insert(seq, utf8.char(cp))
+ end
+ end
+ end
+ end
+ return seq
+ end
+
+ if key_str:match("^<.->$") then
+ return key_str
+ end
+
+ if utf8.len(key_str) > 1 then
+ local seq = {}
+ for _, cp in utf8.codes(key_str) do
+ table.insert(seq, utf8.char(cp))
+ end
+ return seq
+ else
+ return key_str
+ end
+end
+
+local save_to_file = function(mb_path, bookmarks)
+ ensure_directory(mb_path)
+ local file = io.open(mb_path, "w")
+ if file == nil then
+ ya.notify({
+ title = "Bookmarks Error",
+ content = "Cannot create bookmark file: " .. mb_path,
+ timeout = 2,
+ level = "error",
+ })
+ return
+ end
+ local array = {}
+ for _, item in pairs(bookmarks) do
+ table.insert(array, item)
+ end
+ sort_bookmarks(array, "tag", "key", true)
+ for _, item in ipairs(array) do
+ local serialized_key = serialize_key_for_file(item.key)
+ file:write(string.format("%s\t%s\t%s\n", item.tag, item.path, serialized_key))
+ end
+ file:close()
+end
+
+fzf_find = function()
+ local mb_path = get_state_attr("path")
+ local temp_bookmarks = get_temp_bookmarks()
+
+ local permit = ui.hide()
+ local temp_file_path = nil
+ local cmd
+
+ local all_perm_bookmarks = get_all_bookmarks()
+
+ temp_file_path = os.tmpname()
+ local temp_file = io.open(temp_file_path, "w")
+ if temp_file then
+ local all_fzf_items = {}
+ local max_tag_width = 0
+ local max_path_width = 0
+
+ if temp_bookmarks and next(temp_bookmarks) then
+ local temp_array = {}
+ for _, item in pairs(temp_bookmarks) do
+ if item and item.tag and item.path and item.key then
+ table.insert(temp_array, item)
+ end
+ end
+ sort_bookmarks(temp_array, "tag", "key", true)
+ for _, item in ipairs(temp_array) do
+ local tag_with_prefix = "[TEMP] " .. item.tag
+ local display_path = path_to_desc_for_fzf(item.path)
+ table.insert(all_fzf_items, { tag = tag_with_prefix, path = item.path, key = item.key or "" })
+ max_tag_width = math.max(max_tag_width, get_display_width(tag_with_prefix))
+ max_path_width = math.max(max_path_width, get_display_width(display_path))
+ end
+ end
+
+ if all_perm_bookmarks and next(all_perm_bookmarks) then
+ local perm_array = {}
+ for _, item in pairs(all_perm_bookmarks) do
+ table.insert(perm_array, item)
+ end
+ sort_bookmarks(perm_array, "tag", "key", true)
+ for _, item in ipairs(perm_array) do
+ local display_path = path_to_desc_for_fzf(item.path)
+ table.insert(all_fzf_items, { tag = item.tag, path = item.path, key = item.key or "" })
+ max_tag_width = math.max(max_tag_width, get_display_width(item.tag))
+ max_path_width = math.max(max_path_width, get_display_width(display_path))
+ end
+ end
+
+ if #all_fzf_items > 0 then
+ for _, item in ipairs(all_fzf_items) do
+ local formatted_line = format_bookmark_for_fzf(item.tag, item.path, item.key, max_tag_width, max_path_width)
+ temp_file:write(formatted_line .. "\t" .. item.path .. "\n")
+ end
+ temp_file:close()
+ cmd = string.format('fzf %s --with-nth=1 --prompt="Search > " < "%s"', get_fzf_delimiter(), temp_file_path)
+ else
+ temp_file:close()
+ cmd = 'echo No bookmarks found | fzf --prompt="Search > "'
+ end
+ else
+ cmd = 'echo No bookmarks found | fzf --prompt="Search > "'
+ end
+
+ local handle = io.popen(cmd, "r")
+ local result = ""
+ if handle then
+ result = string.gsub(handle:read("*all") or "", "^%s*(.-)%s*$", "%1")
+ handle:close()
+ end
+
+ if temp_file_path then
+ os.remove(temp_file_path)
+ end
+ permit:drop()
+
+ if result and result ~= "" and result ~= "No bookmarks found" then
+ local tab_pos = result:find("\t")
+ if tab_pos then
+ return result:sub(tab_pos + 1)
+ end
+ end
+ return nil
+end
+
+fzf_find_for_rename = function()
+ local mb_path = get_state_attr("path")
+ local temp_bookmarks = get_temp_bookmarks()
+
+ local permit = ui.hide()
+ local temp_file_path = nil
+ local cmd
+
+ local all_perm_bookmarks = get_all_bookmarks()
+
+ temp_file_path = os.tmpname()
+ local temp_file = io.open(temp_file_path, "w")
+ if temp_file then
+ local all_fzf_items = {}
+ local max_tag_width = 0
+ local max_path_width = 0
+
+ if temp_bookmarks and next(temp_bookmarks) then
+ local temp_array = {}
+ for _, item in pairs(temp_bookmarks) do
+ if item and item.tag and item.path and item.key then
+ table.insert(temp_array, item)
+ end
+ end
+ sort_bookmarks(temp_array, "tag", "key", true)
+ for _, item in ipairs(temp_array) do
+ local tag_with_prefix = "[TEMP] " .. item.tag
+ local display_path = path_to_desc_for_fzf(item.path)
+ table.insert(all_fzf_items, { tag = tag_with_prefix, path = item.path, key = item.key or "" })
+ max_tag_width = math.max(max_tag_width, get_display_width(tag_with_prefix))
+ max_path_width = math.max(max_path_width, get_display_width(display_path))
+ end
+ end
+
+ if all_perm_bookmarks and next(all_perm_bookmarks) then
+ local perm_array = {}
+ for _, item in pairs(all_perm_bookmarks) do
+ table.insert(perm_array, item)
+ end
+ sort_bookmarks(perm_array, "tag", "key", true)
+ for _, item in ipairs(perm_array) do
+ local display_path = path_to_desc_for_fzf(item.path)
+ table.insert(all_fzf_items, { tag = item.tag, path = item.path, key = item.key or "" })
+ max_tag_width = math.max(max_tag_width, get_display_width(item.tag))
+ max_path_width = math.max(max_path_width, get_display_width(display_path))
+ end
+ end
+
+ if #all_fzf_items > 0 then
+ for _, item in ipairs(all_fzf_items) do
+ local formatted_line = format_bookmark_for_fzf(item.tag, item.path, item.key, max_tag_width, max_path_width)
+ temp_file:write(formatted_line .. "\t" .. item.path .. "\n")
+ end
+ temp_file:close()
+ cmd = string.format('fzf %s --with-nth=1 --prompt="Rename > " < "%s"', get_fzf_delimiter(), temp_file_path)
+ else
+ temp_file:close()
+ cmd = 'echo No bookmarks found | fzf --prompt="Rename > "'
+ end
+ else
+ cmd = 'echo No bookmarks found | fzf --prompt="Rename > "'
+ end
+
+ local handle = io.popen(cmd, "r")
+ local result = ""
+ if handle then
+ result = string.gsub(handle:read("*all") or "", "^%s*(.-)%s*$", "%1")
+ handle:close()
+ end
+
+ if temp_file_path then
+ os.remove(temp_file_path)
+ end
+ permit:drop()
+
+ if result and result ~= "" and result ~= "No bookmarks found" then
+ local tab_pos = result:find("\t")
+ if tab_pos then
+ return result:sub(tab_pos + 1)
+ end
+ end
+ return nil
+end
+
+fzf_find_multi = function()
+ local temp_bookmarks = get_temp_bookmarks()
+ local user_bookmarks = get_state_attr("bookmarks")
+
+ local permit = ui.hide()
+ local temp_file_path = nil
+ local cmd
+
+ temp_file_path = os.tmpname()
+ local temp_file = io.open(temp_file_path, "w")
+ if temp_file then
+ local all_fzf_items = {}
+ local max_tag_width = 0
+ local max_path_width = 0
+
+ if temp_bookmarks and next(temp_bookmarks) then
+ local temp_array = {}
+ for _, item in pairs(temp_bookmarks) do
+ if item and item.tag and item.path and item.key then
+ table.insert(temp_array, item)
+ end
+ end
+ sort_bookmarks(temp_array, "tag", "key", true)
+ for _, item in ipairs(temp_array) do
+ local tag_with_prefix = "[TEMP] " .. item.tag
+ local display_path = path_to_desc_for_fzf(item.path)
+ table.insert(all_fzf_items, { tag = tag_with_prefix, path = item.path, key = item.key or "" })
+ max_tag_width = math.max(max_tag_width, get_display_width(tag_with_prefix))
+ max_path_width = math.max(max_path_width, get_display_width(display_path))
+ end
+ end
+
+ if user_bookmarks and next(user_bookmarks) then
+ local user_array = {}
+ for _, item in pairs(user_bookmarks) do
+ table.insert(user_array, item)
+ end
+ sort_bookmarks(user_array, "tag", "key", true)
+ for _, item in ipairs(user_array) do
+ local display_path = path_to_desc_for_fzf(item.path)
+ table.insert(all_fzf_items, { tag = item.tag, path = item.path, key = item.key or "" })
+ max_tag_width = math.max(max_tag_width, get_display_width(item.tag))
+ max_path_width = math.max(max_path_width, get_display_width(display_path))
+ end
+ end
+
+ if #all_fzf_items > 0 then
+ for _, item in ipairs(all_fzf_items) do
+ local formatted_line = format_bookmark_for_fzf(item.tag, item.path, item.key, max_tag_width, max_path_width)
+ temp_file:write(formatted_line .. "\t" .. item.path .. "\n")
+ end
+ temp_file:close()
+ cmd =
+ string.format('fzf --multi %s --with-nth=1 --prompt="Delete > " < "%s"', get_fzf_delimiter(), temp_file_path)
+ else
+ temp_file:close()
+ cmd = 'echo No deletable bookmarks found | fzf --prompt="Delete > "'
+ end
+ else
+ cmd = 'echo No deletable bookmarks found | fzf --prompt="Delete > "'
+ end
+
+ local handle = io.popen(cmd, "r")
+ local result = ""
+ if handle then
+ result = handle:read("*all") or ""
+ handle:close()
+ end
+
+ if temp_file_path then
+ os.remove(temp_file_path)
+ end
+ permit:drop()
+
+ if result and result ~= "" and result ~= "No deletable bookmarks found" then
+ local paths = {}
+ for line in result:gmatch("[^\r\n]+") do
+ line = string.gsub(line, "^%s*(.-)%s*$", "%1")
+ if line ~= "" then
+ local tab_pos = line:find("\t")
+ if tab_pos then
+ table.insert(paths, line:sub(tab_pos + 1))
+ end
+ end
+ end
+ return paths
+ end
+ return {}
+end
+
+fzf_history = function()
+ local current_tab = get_current_tab_idx()
+ local history = get_tab_history(current_tab)
+ local current_path = normalize_path(get_current_dir_path())
+
+ local filtered_history = {}
+ if history then
+ for _, path in ipairs(history) do
+ if path ~= current_path then
+ table.insert(filtered_history, path)
+ end
+ end
+ end
+
+ if not filtered_history or #filtered_history == 0 then
+ return nil
+ end
+
+ local permit = ui.hide()
+ local temp_file_path = os.tmpname()
+ local temp_file = io.open(temp_file_path, "w")
+
+ if temp_file then
+ for i, path in ipairs(filtered_history) do
+ local display_path = path_to_desc_for_history(path)
+ local formatted_line = string.format("%2d. %s", i, display_path)
+ temp_file:write(formatted_line .. "\t" .. path .. "\n")
+ end
+ temp_file:close()
+
+ local cmd = string.format('fzf %s --with-nth=1 --prompt="History > " < "%s"', get_fzf_delimiter(), temp_file_path)
+ local handle = io.popen(cmd, "r")
+ local result = ""
+ if handle then
+ result = string.gsub(handle:read("*all") or "", "^%s*(.-)%s*$", "%1")
+ handle:close()
+ end
+
+ os.remove(temp_file_path)
+ permit:drop()
+
+ if result and result ~= "" then
+ local tab_pos = result:find("\t")
+ if tab_pos then
+ return result:sub(tab_pos + 1)
+ end
+ end
+ else
+ permit:drop()
+ end
+
+ return nil
+end
+
+local create_special_menu_items = function()
+ local special_items = {}
+ local special_keys = get_state_attr("special_keys") or DEFAULT_SPECIAL_KEYS
+ local create_temp_key = special_keys.create_temp
+ if create_temp_key then
+ table.insert(special_items, { desc = "Create temporary bookmark", on = create_temp_key, path = "__CREATE_TEMP__" })
+ end
+
+ local fuzzy_search_key = special_keys.fuzzy_search
+ if fuzzy_search_key then
+ table.insert(special_items, { desc = "Fuzzy search", on = fuzzy_search_key, path = "__FUZZY_SEARCH__" })
+ end
+
+ local current_tab = get_current_tab_idx()
+ local history = get_tab_history(current_tab)
+ local current_path = normalize_path(get_current_dir_path())
+
+ local filtered_history = {}
+ if history then
+ for _, path in ipairs(history) do
+ if path ~= current_path then
+ table.insert(filtered_history, path)
+ end
+ end
+ end
+
+ local history_key = special_keys.history
+ if history_key and filtered_history and #filtered_history > 0 then
+ table.insert(special_items, { desc = "Directory history", on = history_key, path = "__HISTORY__" })
+ end
+
+ local previous_dir_key = special_keys.previous_dir
+ if previous_dir_key and filtered_history and filtered_history[1] then
+ local previous_dir = filtered_history[1]
+ local display_path = path_to_desc(previous_dir)
+ table.insert(special_items, { desc = "<- " .. display_path, on = previous_dir_key, path = previous_dir })
+ end
+
+ return special_items
+end
+
+which_find = function()
+ local bookmarks = get_all_bookmarks()
+ local temp_bookmarks = get_temp_bookmarks()
+
+ local cands_static = create_special_menu_items()
+ local cands_bookmarks = {}
+
+ local all_bookmark_items = {}
+ local max_tag_width = 0
+ local max_path_width = 0
+
+ if temp_bookmarks then
+ for path, item in pairs(temp_bookmarks) do
+ if item and item.tag and #item.tag ~= 0 then
+ local tag_with_prefix = "[TEMP] " .. item.tag
+ local display_path = path_to_desc(item.path or path)
+ table.insert(
+ all_bookmark_items,
+ { tag = tag_with_prefix, path = item.path or path, key = item.key or "", is_temp = true }
+ )
+ max_tag_width = math.max(max_tag_width, get_display_width(tag_with_prefix))
+ max_path_width = math.max(max_path_width, get_display_width(display_path))
+ end
+ end
+ end
+
+ for path, item in pairs(bookmarks) do
+ if item and item.tag and #item.tag ~= 0 then
+ local display_path = path_to_desc(item.path or path)
+ table.insert(
+ all_bookmark_items,
+ { tag = item.tag, path = item.path or path, key = item.key or "", is_temp = false }
+ )
+ max_tag_width = math.max(max_tag_width, get_display_width(item.tag))
+ max_path_width = math.max(max_path_width, get_display_width(display_path))
+ end
+ end
+
+ for _, item in ipairs(all_bookmark_items) do
+ if
+ item.key
+ and item.key ~= ""
+ and (type(item.key) == "string" or (type(item.key) == "table" and #item.key > 0))
+ then
+ local formatted_desc = format_bookmark_for_menu(item.tag, item.key)
+ table.insert(cands_bookmarks, { desc = formatted_desc, on = item.key, path = item.path })
+ end
+ end
+
+ sort_bookmarks(cands_bookmarks, "on", "desc", false)
+
+ local cands = {}
+ for _, item in ipairs(cands_static) do
+ table.insert(cands, item)
+ end
+ for _, item in ipairs(cands_bookmarks) do
+ table.insert(cands, item)
+ end
+
+ if #cands == #cands_static and #cands_bookmarks == 0 then
+ ya.notify({ title = "Bookmarks", content = "No bookmarks found", timeout = 1, level = "info" })
+ end
+ local idx = ya.which({ cands = cands })
+ if idx == nil then
+ return nil
+ end
+ return cands[idx].path
+end
+
+which_find_deletable = function()
+ local user_bookmarks = get_state_attr("bookmarks")
+ local temp_bookmarks = get_temp_bookmarks()
+
+ local cands_bookmarks = {}
+
+ local all_bookmark_items = {}
+
+ if temp_bookmarks then
+ for path, item in pairs(temp_bookmarks) do
+ if item and item.tag and #item.tag ~= 0 then
+ local tag_with_prefix = "[TEMP] " .. item.tag
+ table.insert(
+ all_bookmark_items,
+ { tag = tag_with_prefix, path = item.path or path, key = item.key or "", is_temp = true }
+ )
+ end
+ end
+ end
+
+ if user_bookmarks then
+ for path, item in pairs(user_bookmarks) do
+ if item and item.tag and #item.tag ~= 0 then
+ table.insert(
+ all_bookmark_items,
+ { tag = item.tag, path = item.path or path, key = item.key or "", is_temp = false }
+ )
+ end
+ end
+ end
+
+ for _, item in ipairs(all_bookmark_items) do
+ if
+ item.key
+ and item.key ~= ""
+ and (type(item.key) == "string" or (type(item.key) == "table" and #item.key > 0))
+ then
+ local formatted_desc = format_bookmark_for_menu(item.tag, item.key)
+ table.insert(cands_bookmarks, { desc = formatted_desc, on = item.key, path = item.path })
+ end
+ end
+
+ sort_bookmarks(cands_bookmarks, "on", "desc", false)
+
+ if #cands_bookmarks == 0 then
+ ya.notify({ title = "Bookmarks", content = "No deletable bookmarks found", timeout = 1, level = "info" })
+ return nil
+ end
+
+ local idx = ya.which({ cands = cands_bookmarks })
+ if idx == nil then
+ return nil
+ end
+ return cands_bookmarks[idx].path
+end
+
+action_jump = function(path)
+ if path == nil then
+ return
+ end
+
+ local jump_notify = get_state_attr("jump_notify")
+ local all_bookmarks = get_all_bookmarks()
+ local temp_bookmarks = get_temp_bookmarks()
+
+ if path == "__CREATE_TEMP__" then
+ action_save(get_current_dir_path(), true)
+ return
+ elseif path == "__FUZZY_SEARCH__" then
+ local selected_path = fzf_find()
+ if selected_path then
+ action_jump(selected_path)
+ end
+ return
+ elseif path == "__HISTORY__" then
+ local selected_path = fzf_history()
+ if selected_path then
+ action_jump(selected_path)
+ end
+ return
+ end
+
+ local bookmark = temp_bookmarks[path] or all_bookmarks[path]
+ if not bookmark then
+ ya.emit("cd", { path })
+ if jump_notify then
+ ya.notify({ title = "Bookmarks", content = 'Jump to "' .. path_to_desc(path) .. '"', timeout = 1, level = "info" })
+ end
+ return
+ end
+
+ local tag = bookmark.tag
+ local is_temp = temp_bookmarks[path] ~= nil
+
+ ya.emit("cd", { path })
+
+ if jump_notify then
+ local prefix = is_temp and "[TEMP] " or ""
+ ya.notify({ title = "Bookmarks", content = 'Jump to "' .. prefix .. tag .. '"', timeout = 1, level = "info" })
+ end
+end
+
+local function parse_keys_input(input)
+ if not input or input == "" then
+ return {}
+ end
+ local seq = {}
+ for token in input:gmatch("[^,%s]+") do
+ token = token:gsub("^%s*(.-)%s*$", "%1")
+ if token ~= "" then
+ if token:match("^<.->$") then
+ table.insert(seq, token)
+ else
+ for _, cp in utf8.codes(token) do
+ table.insert(seq, utf8.char(cp))
+ end
+ end
+ end
+ end
+ return seq
+end
+
+local function format_keys_for_display(keys)
+ if type(keys) == "table" then
+ return table.concat(keys, ",")
+ elseif type(keys) == "string" then
+ return keys
+ else
+ return ""
+ end
+end
+
+local function _seq_from_key(k)
+ if type(k) == "table" then
+ local out = {}
+ for _, t in ipairs(k) do
+ if t:match("^<.->$") then
+ table.insert(out, t)
+ else
+ for _, cp in utf8.codes(t) do
+ table.insert(out, utf8.char(cp))
+ end
+ end
+ end
+ return out
+ elseif type(k) == "string" then
+ return parse_keys_input(k)
+ else
+ return {}
+ end
+end
+
+local function _seq_equal(a, b)
+ if #a ~= #b then
+ return false
+ end
+ for i = 1, #a do
+ if a[i] ~= b[i] then
+ return false
+ end
+ end
+ return true
+end
+
+local function _seq_is_prefix(short, long)
+ if #short >= #long then
+ return false
+ end
+ for i = 1, #short do
+ if short[i] ~= long[i] then
+ return false
+ end
+ end
+ return true
+end
+
+local function _seq_to_string(seq)
+ return table.concat(seq, ",")
+end
+
+local function find_path_by_key_sequence(seq)
+ if not seq or #seq == 0 then
+ return nil
+ end
+
+ local function matches(candidate)
+ if candidate == nil or candidate == "" then
+ return false
+ end
+ local candidate_seq = _seq_from_key(candidate)
+ if #candidate_seq == 0 then
+ return false
+ end
+ return _seq_equal(seq, candidate_seq)
+ end
+
+ for _, item in ipairs(create_special_menu_items() or {}) do
+ if matches(item.on) then
+ return item.path
+ end
+ end
+
+ local temp = get_temp_bookmarks()
+ for path, item in pairs(temp or {}) do
+ if matches(item.key) then
+ return path
+ end
+ end
+
+ local bookmarks = get_all_bookmarks()
+ for path, item in pairs(bookmarks or {}) do
+ if matches(item.key) then
+ return path
+ end
+ end
+
+ return nil
+end
+
+local function jump_by_key_spec(spec)
+ local cleaned = (spec or ""):gsub("^%s*(.-)%s*$", "%1")
+ if cleaned == "" then
+ ya.notify({ title = "Bookmarks", content = "Missing key sequence", timeout = 1, level = "warn" })
+ return false
+ end
+
+ local seq = parse_keys_input(cleaned)
+ if #seq == 0 then
+ ya.notify({ title = "Bookmarks", content = "Missing key sequence", timeout = 1, level = "warn" })
+ return false
+ end
+
+ local path = find_path_by_key_sequence(seq)
+ if not path then
+ ya.notify({
+ title = "Bookmarks",
+ content = "Bookmark not found for key: " .. _seq_to_string(seq),
+ timeout = 1,
+ level = "info",
+ })
+ return false
+ end
+
+ action_jump(path)
+ return true
+end
+
+local generate_key = function()
+ local keys = get_state_attr("keys")
+ local key2rank = get_state_attr("key2rank")
+ local bookmarks = get_all_bookmarks()
+ local temp_bookmarks = get_temp_bookmarks()
+
+ local mb = {}
+ for _, item in pairs(bookmarks) do
+ if item and item.key then
+ if type(item.key) == "string" and #item.key == 1 then
+ table.insert(mb, item.key)
+ elseif type(item.key) == "table" then
+ for _, k in ipairs(item.key) do
+ if type(k) == "string" and #k == 1 then
+ table.insert(mb, k)
+ end
+ end
+ end
+ end
+ end
+ if temp_bookmarks then
+ for _, item in pairs(temp_bookmarks) do
+ if item and item.key then
+ if type(item.key) == "string" and #item.key == 1 then
+ table.insert(mb, item.key)
+ elseif type(item.key) == "table" then
+ for _, k in ipairs(item.key) do
+ if type(k) == "string" and #k == 1 then
+ table.insert(mb, k)
+ end
+ end
+ end
+ end
+ end
+ end
+ if #mb == 0 then
+ return keys[1]
+ end
+
+ table.sort(mb, function(a, b)
+ return (key2rank[a] or 999) < (key2rank[b] or 999)
+ end)
+ local idx = 1
+ for _, key in ipairs(keys) do
+ if idx > #mb or (key2rank[key] or 999) < (key2rank[mb[idx]] or 999) then
+ return key
+ end
+ idx = idx + 1
+ end
+ return nil
+end
+
+action_save = function(path, is_temp)
+ if path == nil or #path == 0 then
+ return
+ end
+
+ local mb_path = get_state_attr("path")
+ local all_bookmarks = get_all_bookmarks()
+ local temp_bookmarks = get_temp_bookmarks()
+ local path_obj
+ if is_temp and temp_bookmarks and temp_bookmarks[path] then
+ path_obj = temp_bookmarks[path]
+ else
+ path_obj = all_bookmarks[path] or (temp_bookmarks and temp_bookmarks[path])
+ end
+ local tag = path_obj and path_obj.tag or path:match(".*[\\/]([^\\/]+)[\\/]?$")
+
+ while true do
+ local title = is_temp and "Tag ⟨alias name⟩ [TEMPORARY]" or "Tag ⟨alias name⟩"
+ local value, event = ya.input({ title = title, value = tag, pos = { "top-center", y = 3, w = 40 } })
+ if event ~= 1 then
+ return
+ end
+ tag = value or ""
+ if #tag == 0 then
+ ya.notify({ title = "Bookmarks", content = "Empty tag", timeout = 1, level = "info" })
+ else
+ local tag_obj = nil
+ for _, item in pairs(all_bookmarks) do
+ if item.tag == tag then
+ tag_obj = item
+ break
+ end
+ end
+ if not tag_obj and temp_bookmarks then
+ for _, item in pairs(temp_bookmarks) do
+ if item.tag == tag then
+ tag_obj = item
+ break
+ end
+ end
+ end
+ if tag_obj == nil or tag_obj.path == path then
+ break
+ end
+ ya.notify({ title = "Bookmarks", content = "Duplicated tag", timeout = 1, level = "info" })
+ end
+ end
+
+ local key = path_obj and path_obj.key or generate_key()
+ local key_display = format_keys_for_display(key)
+
+ while true do
+ local value, event = ya.input({
+ title = "Keys ⟨space, comma or empty separator⟩",
+ value = key_display,
+ pos = { "top-center", y = 3, w = 50 },
+ })
+ if event ~= 1 then
+ return
+ end
+
+ local input_str = value or ""
+ if input_str == "" then
+ key = ""
+ break
+ end
+
+ local parsed_keys = parse_keys_input(input_str)
+ if #parsed_keys == 0 then
+ key = ""
+ break
+ elseif #parsed_keys == 1 then
+ key = parsed_keys[1]
+ else
+ key = parsed_keys
+ end
+
+ local new_seq = _seq_from_key(key)
+ local conflict, conflict_seq
+
+ local function check(items)
+ for _, item in pairs(items or {}) do
+ if item and item.key and item.path ~= path then
+ local exist = _seq_from_key(item.key)
+ if #exist > 0 then
+ if _seq_equal(new_seq, exist) then
+ conflict, conflict_seq = "duplicate", exist
+ return true
+ end
+ if _seq_is_prefix(new_seq, exist) or _seq_is_prefix(exist, new_seq) then
+ conflict, conflict_seq = "prefix", exist
+ return true
+ end
+ end
+ end
+ end
+ return false
+ end
+
+ if check(all_bookmarks) or check(temp_bookmarks) then
+ local msg = (conflict == "duplicate") and ("Duplicated key sequence: " .. _seq_to_string(new_seq))
+ or ("Ambiguous with existing sequence: " .. _seq_to_string(conflict_seq))
+ ya.notify({ title = "Bookmarks", content = msg, timeout = 2, level = "info" })
+ key_display = input_str
+ else
+ break
+ end
+ end
+
+ if is_temp then
+ set_temp_bookmarks(path, { tag = tag, path = path, key = key })
+ ya.notify({ title = "Bookmarks", content = '[TEMP] "' .. tag .. '" saved', timeout = 1, level = "info" })
+ else
+ set_bookmarks(path, { tag = tag, path = path, key = key })
+ local user_bookmarks = get_state_attr("bookmarks")
+ save_to_file(mb_path, user_bookmarks)
+ ya.notify({ title = "Bookmarks", content = '"' .. tag .. '" saved', timeout = 1, level = "info" })
+ end
+end
+
+action_delete = function(path)
+ if path == nil then
+ return
+ end
+
+ local mb_path = get_state_attr("path")
+ local user_bookmarks = get_state_attr("bookmarks")
+ local temp_bookmarks = get_temp_bookmarks()
+ local bookmark = temp_bookmarks[path] or user_bookmarks[path]
+
+ if not bookmark then
+ ya.notify({
+ title = "Bookmarks",
+ content = "Cannot delete: Not a user or temp bookmark",
+ timeout = 2,
+ level = "warn",
+ })
+ return
+ end
+ local tag = bookmark.tag
+ local is_temp = temp_bookmarks[path] ~= nil
+
+ if is_temp then
+ set_temp_bookmarks(path, nil)
+ ya.notify({ title = "Bookmarks", content = '[TEMP] "' .. tag .. '" deleted', timeout = 1, level = "info" })
+ else
+ set_bookmarks(path, nil)
+ local updated_user_bookmarks = get_state_attr("bookmarks")
+ save_to_file(mb_path, updated_user_bookmarks)
+ ya.notify({ title = "Bookmarks", content = '"' .. tag .. '" deleted', timeout = 1, level = "info" })
+ end
+end
+
+action_delete_multi = function(paths)
+ if not paths or #paths == 0 then
+ return
+ end
+
+ local mb_path = get_state_attr("path")
+ local user_bookmarks = get_state_attr("bookmarks")
+ local temp_bookmarks = get_temp_bookmarks()
+
+ local deleted_count = 0
+ local deleted_temp_count = 0
+ local deleted_names = {}
+ local not_found_count = 0
+
+ for _, path in ipairs(paths) do
+ local bookmark = temp_bookmarks[path] or user_bookmarks[path]
+ if bookmark then
+ local tag = bookmark.tag
+ local is_temp = temp_bookmarks[path] ~= nil
+
+ if is_temp then
+ set_temp_bookmarks(path, nil)
+ deleted_temp_count = deleted_temp_count + 1
+ table.insert(deleted_names, "[TEMP] " .. tag)
+ else
+ set_bookmarks(path, nil)
+ deleted_count = deleted_count + 1
+ table.insert(deleted_names, tag)
+ end
+ else
+ not_found_count = not_found_count + 1
+ end
+ end
+
+ if deleted_count > 0 then
+ local updated_user_bookmarks = get_state_attr("bookmarks")
+ save_to_file(mb_path, updated_user_bookmarks)
+ end
+
+ local total_deleted = deleted_count + deleted_temp_count
+ local message_parts = {}
+
+ if total_deleted > 0 then
+ table.insert(message_parts, string.format("Deleted %d bookmark(s)", total_deleted))
+ if deleted_count > 0 and deleted_temp_count > 0 then
+ table.insert(message_parts, string.format("(%d permanent, %d temporary)", deleted_count, deleted_temp_count))
+ elseif deleted_temp_count > 0 then
+ table.insert(message_parts, "(temporary)")
+ end
+ end
+
+ if not_found_count > 0 then
+ table.insert(message_parts, string.format("%d not found", not_found_count))
+ end
+
+ local final_message = table.concat(message_parts, ", ")
+ if total_deleted > 0 then
+ ya.notify({ title = "Bookmarks", content = final_message, timeout = 2, level = "info" })
+ else
+ ya.notify({ title = "Bookmarks", content = "No bookmarks were deleted", timeout = 1, level = "warn" })
+ end
+end
+
+local action_delete_all = function(temp_only)
+ local mb_path = get_state_attr("path")
+ local title = temp_only and "Delete all temporary bookmarks? ⟨y/n⟩" or "Delete all user bookmarks? ⟨y/n⟩"
+ local value, event = ya.input({ title = title, pos = { "top-center", y = 3, w = 45 } })
+ if event ~= 1 or string.lower(value or "") ~= "y" then
+ ya.notify({ title = "Bookmarks", content = "Cancel delete", timeout = 1, level = "info" })
+ return
+ end
+
+ if temp_only then
+ set_state_attr("temp_bookmarks", {})
+ ya.notify({ title = "Bookmarks", content = "All temporary bookmarks deleted", timeout = 1, level = "info" })
+ else
+ set_state_attr("bookmarks", {})
+ save_to_file(mb_path, {})
+ ya.notify({ title = "Bookmarks", content = "All user-created bookmarks deleted", timeout = 1, level = "info" })
+ end
+end
+
+return {
+ setup = function(state, options)
+ local default_path = (ya.target_family() == "windows" and os.getenv("APPDATA") .. "\\yazi\\config\\bookmarks")
+ or (os.getenv("HOME") .. "/.config/yazi/bookmarks")
+ local bookmarks_path = options.bookmarks_path or options.path
+ if type(bookmarks_path) == "string" and bookmarks_path ~= "" then
+ state.path = bookmarks_path
+ else
+ state.path = default_path
+ end
+ state.jump_notify = options.jump_notify == nil and false or options.jump_notify
+ state.home_alias_enabled = options.home_alias_enabled == nil and true or options.home_alias_enabled
+ state.path_truncate_enabled = options.path_truncate_enabled == nil and false or options.path_truncate_enabled
+ state.path_max_depth = options.path_max_depth or 3
+ state.fzf_path_truncate_enabled = options.fzf_path_truncate_enabled == nil and false
+ or options.fzf_path_truncate_enabled
+ state.fzf_path_max_depth = options.fzf_path_max_depth or 5
+ state.path_truncate_long_names_enabled = options.path_truncate_long_names_enabled == nil and false
+ or options.path_truncate_long_names_enabled
+ state.fzf_path_truncate_long_names_enabled = options.fzf_path_truncate_long_names_enabled == nil and false
+ or options.fzf_path_truncate_long_names_enabled
+ state.path_max_folder_name_length = options.path_max_folder_name_length or 20
+ state.fzf_path_max_folder_name_length = options.fzf_path_max_folder_name_length or 20
+
+ state.history_size = options.history_size or 10
+ state.history_fzf_path_truncate_enabled = options.history_fzf_path_truncate_enabled == nil and false
+ or options.history_fzf_path_truncate_enabled
+ state.history_fzf_path_max_depth = options.history_fzf_path_max_depth or 5
+ state.history_fzf_path_truncate_long_names_enabled = options.history_fzf_path_truncate_long_names_enabled == nil
+ and false
+ or options.history_fzf_path_truncate_long_names_enabled
+ state.history_fzf_path_max_folder_name_length = options.history_fzf_path_max_folder_name_length or 30
+
+ local special_keys_options = options.special_keys or {}
+ local special_keys = {}
+ for name, default_key in pairs(DEFAULT_SPECIAL_KEYS) do
+ local normalized = normalize_special_key(special_keys_options[name], default_key)
+ if normalized ~= nil then
+ special_keys[name] = normalized
+ end
+ end
+ state.special_keys = special_keys
+
+ ensure_directory(state.path)
+ local keys = options.keys or "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ state.keys, state.key2rank = {}, {}
+ for i = 1, #keys do
+ local char = keys:sub(i, i)
+ table.insert(state.keys, char)
+ state.key2rank[char] = i
+ end
+
+ local function convert_simple_bookmarks(simple_bookmarks)
+ local converted = {}
+ local path_sep = package.config:sub(1, 1)
+ local home_path = ya.target_family() == "windows" and os.getenv("USERPROFILE") or os.getenv("HOME")
+
+ for _, bookmark in ipairs(simple_bookmarks or {}) do
+ local path = bookmark.path
+ if path:sub(1, 1) == "~" then
+ path = home_path .. path:sub(2)
+ end
+
+ if ya.target_family() == "windows" then
+ path = path:gsub("/", "\\")
+ else
+ path = path:gsub("\\", "/")
+ end
+
+ if path:sub(-1) ~= path_sep then
+ path = path .. path_sep
+ end
+
+ converted[path] = {
+ tag = bookmark.tag,
+ path = path,
+ key = bookmark.key,
+ }
+ end
+
+ return converted
+ end
+
+ state.config_bookmarks = {}
+
+ local bookmarks_to_process = options.bookmarks or {}
+ if #bookmarks_to_process > 0 and bookmarks_to_process[1].tag then
+ state.config_bookmarks = convert_simple_bookmarks(bookmarks_to_process)
+ else
+ for _, item in pairs(bookmarks_to_process) do
+ state.config_bookmarks[item.path] = { tag = item.tag, path = item.path, key = item.key }
+ end
+ end
+
+ local user_bookmarks = {}
+ local file = io.open(state.path, "r")
+ if file ~= nil then
+ for line in file:lines() do
+ local tag, path, key_str = string.match(line, "(.-)\t(.-)\t(.*)")
+ if tag and path then
+ local key = deserialize_key_from_file(key_str or "")
+ user_bookmarks[path] = { tag = tag, path = path, key = key }
+ end
+ end
+ file:close()
+ end
+ state.bookmarks = user_bookmarks
+ save_to_file(state.path, state.bookmarks)
+
+ state.temp_bookmarks = {}
+ state.directory_history = {}
+ state.last_paths = {}
+ state.initialized_tabs = {}
+
+ ps.sub("cd", function(body)
+ local tab = body.tab or cx.tabs.idx
+ local new_path = normalize_path(tostring(cx.active.current.cwd))
+
+ if not state.initialized_tabs[tab] then
+ state.last_paths[tab] = new_path
+ state.initialized_tabs[tab] = true
+ return
+ end
+
+ local previous_path = state.last_paths[tab]
+
+ if previous_path and previous_path ~= new_path then
+ add_to_history(tab, previous_path)
+ end
+
+ state.last_paths[tab] = new_path
+ end)
+ end,
+
+ entry = function(self, jobs)
+ local args = jobs.args or {}
+ local action = args[1]
+
+ if type(action) == "string" and action:sub(1, 9):lower() == "jump_key_" then
+ jump_by_key_spec(action:sub(10))
+ return
+ end
+
+ if not action then
+ return
+ end
+
+ if action == "save" then
+ if is_hovered_directory() then
+ action_save(get_hovered_path(), false)
+ else
+ ya.notify({ title = "Bookmarks", content = "Selected item is not a directory", timeout = 2, level = "warn" })
+ end
+ elseif action == "save_cwd" then
+ action_save(get_current_dir_path(), false)
+ elseif action == "save_temp" then
+ if is_hovered_directory() then
+ action_save(get_hovered_path(), true)
+ else
+ ya.notify({ title = "Bookmarks", content = "Selected item is not a directory", timeout = 2, level = "warn" })
+ end
+ elseif action == "save_cwd_temp" then
+ action_save(get_current_dir_path(), true)
+ elseif action == "delete_by_key" then
+ action_delete(which_find_deletable())
+ elseif action == "delete_by_fzf" then
+ action_delete_multi(fzf_find_multi())
+ elseif action == "delete_multi_by_fzf" then
+ action_delete_multi(fzf_find_multi())
+ elseif action == "delete_all" then
+ action_delete_all(false)
+ elseif action == "delete_all_temp" then
+ action_delete_all(true)
+ elseif action == "jump_by_key" then
+ action_jump(which_find())
+ elseif action == "jump_by_fzf" then
+ action_jump(fzf_find())
+ elseif action == "rename_by_key" then
+ local path = which_find()
+ if path then
+ local temp_b = get_temp_bookmarks()
+ action_save(path, temp_b[path] ~= nil)
+ end
+ elseif action == "rename_by_fzf" then
+ local path = fzf_find_for_rename()
+ if path then
+ local temp_b = get_temp_bookmarks()
+ action_save(path, temp_b[path] ~= nil)
+ end
+ end
+ end,
+}
diff --git a/yazi/.config/yazi/plugins/whoosh.yazi/test.file b/yazi/.config/yazi/plugins/whoosh.yazi/test.file
new file mode 100644
index 0000000..e69de29