refactor(sessionizer): move lua modules under src
This commit is contained in:
parent
60d1b38018
commit
1c93cd8c46
14 changed files with 991 additions and 318 deletions
117
README.md
117
README.md
|
|
@ -1,78 +1,79 @@
|
||||||
# Sessionizer for Wezterm
|
# Sessionizer for WezTerm
|
||||||
|
|
||||||
---
|
Lightweight workspace/session switcher inspired by [ThePrimeagen's tmux-sessionizer](https://github.com/ThePrimeagen/.dotfiles/blob/master/bin/.local/scripts/tmux-sessionizer). It scans your project directories, remembers recent workspaces, and lets you jump between them with fuzzy search inside WezTerm.
|
||||||
|
|
||||||
A tmux like sessionizer for Wezterm that was inspired by [ThePrimeagen's tmux-sessionizer](https://github.com/ThePrimeagen/.dotfiles/blob/master/bin/.local/scripts/tmux-sessionizer)
|
## Features
|
||||||
|
|
||||||
The sessionizer allows for opening of windows/sessions based on the passed in
|
- **Quick Access history**: recently opened workspaces bubble to the top with a configurable cap.
|
||||||
directories, as well as fast and intuative switching between active
|
- **Async directory scans**: project discovery runs in the background so the UI never blocks.
|
||||||
workspaces/sessions
|
- **Glob project roots**: project `path` entries support shell-style globs (for example `~/src/*`) and expand to all matching directories.
|
||||||
|
- **Safe defaults**: configuration is validated, labels are sanitized, and the history file lives wherever you choose.
|
||||||
|
- **Notifications**: human-friendly toasts (or log fallbacks) when scans fail or no projects are found.
|
||||||
|
|
||||||
## Setup
|
## Installation
|
||||||
|
|
||||||
An example configuration calling the plugin
|
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
local wezterm = require "wezterm"
|
local wezterm = require "wezterm"
|
||||||
local sessionizer = wezterm.plugin.require("https://github.com/ElCapitanSponge/sessionizer.wezterm")
|
local sessionizer = wezterm.plugin.require(
|
||||||
|
"https://github.com/jfaeys/sessionizer.wezterm"
|
||||||
|
)
|
||||||
|
|
||||||
local config = {}
|
local config = wezterm.config_builder and wezterm.config_builder() or {}
|
||||||
|
|
||||||
if wezterm.config_builder then
|
sessionizer.apply_to_config(config, {
|
||||||
config = wezterm.config_builder()
|
key = "F",
|
||||||
end
|
mods = "LEADER",
|
||||||
|
add_to_launch_menu = true,
|
||||||
--INFO: The sessionizer lverages the `LEADER` mod
|
projects = {
|
||||||
config.leader = {
|
{ path = "~/personal", max_depth = 3 },
|
||||||
key = "a",
|
{ path = "~/work", max_depth = 2 },
|
||||||
mods = "CTRL",
|
},
|
||||||
timeout_milliseconds = 1000
|
history_file = "~/.local/share/sessionizer-history.txt",
|
||||||
}
|
history_max_entries = 8,
|
||||||
|
})
|
||||||
config.keys = {}
|
|
||||||
|
|
||||||
-- INFO: The following is the project directories to search
|
|
||||||
local projects = {
|
|
||||||
"~/personal",
|
|
||||||
"~/work"
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionizer.set_projects(projects)
|
|
||||||
sessionizer.configure(config)
|
|
||||||
|
|
||||||
return config
|
return config
|
||||||
```
|
```
|
||||||
|
|
||||||
## USEAGE
|
## Configuration Reference
|
||||||
|
|
||||||
To use the sessionizer you have to define and pass through a table of project
|
All options are optional; reasonable defaults live in `plugins/sessionizer/src/config.lua`.
|
||||||
folders, that are the paths to your applicable repositores to leverage for the
|
|
||||||
workspaces.
|
|
||||||
|
|
||||||
```lua
|
| Option | Description |
|
||||||
local projects = {
|
| --------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||||
"~/personal",
|
| `key` / `mods` | Key binding that opens the sessionizer InputSelector. |
|
||||||
"~/work"
|
| `add_to_launch_menu` | When `true`, cached projects are added to the wezterm launch menu. |
|
||||||
}
|
| `projects` | Array of strings or `{ path, max_depth }` tables describing directories to scan. Relative paths expand relative to `$HOME`. |
|
||||||
```
|
| `exclude_dirs` | Directory names to skip while scanning (see `plugins/sessionizer/src/state.lua`). |
|
||||||
|
| `default_depth` | Fallback depth when a project entry omits `max_depth`. |
|
||||||
|
| `history_file` | Location of the plain-text history log. Can live outside `$HOME` if desired. |
|
||||||
|
| `history_max_entries` | Number of Quick Access entries surfaced and persisted. |
|
||||||
|
| `warmup` | When `true` (default), a background scan is started after config load to make first open faster. Set to `false` for fastest startup. |
|
||||||
|
| `warmup_delay_ms` | Delay (milliseconds) before starting warmup scan. Useful to keep WezTerm startup snappy while still preloading results. |
|
||||||
|
|
||||||
To display the sessionizer all you have to do is press the key combination of
|
### Project roots and wildcards
|
||||||
`LEADER` + `f`
|
|
||||||
|
|
||||||
To display the active windows/sessions all you have to do is press the key
|
`projects[*].path` supports glob patterns like `*`, `?`, and character classes (for example `~/src/*` or `~/work/**/repos`).
|
||||||
combination of `LEADER` + `s`
|
If a glob matches multiple paths, sessionizer scans each match.
|
||||||
|
|
||||||
## Change keybinding
|
`max_depth` accepts a number, `-1` (unlimited), or `"*"` (treated as a very large depth).
|
||||||
|
|
||||||
To change the keybinding from the default (`LEADER` + `f`):
|
## Usage
|
||||||
|
|
||||||
```lua
|
Press the configured key combo (e.g. `LEADER + F`) to open the sessionizer. Fuzzy search through the list of discovered projects. Recently opened projects show a `Quick Access:` prefix for fast navigation.
|
||||||
config.keys = {
|
|
||||||
-- ... other bindings
|
Launch-menu entries (when enabled) spawn new wezterm windows directly into the chosen project directory.
|
||||||
{
|
|
||||||
key = "w",
|
## Development Notes
|
||||||
mods = "CTRL|ALT",
|
|
||||||
action = sessionizer.switch_workspace()
|
- Directory scanning relies on `fd` when available, otherwise falls back to `find`/PowerShell.
|
||||||
}
|
- `fd` availability is detected via `wezterm.run_child_process` and cached (avoids spawning a shell on each invocation).
|
||||||
}
|
- Caches are deep-copied to prevent mutation across calls.
|
||||||
```
|
- Workspace/project caches are invalidated automatically when the config changes (based on a fingerprint of `projects`, `exclude_dirs`, and `default_depth`).
|
||||||
|
- A built-in profiler can log scan timings when `profiler_enabled = true` (see `plugins/sessionizer/src/state.lua`). It logs per-base `scan_base <path>` and overall `workspace_scan` durations.
|
||||||
|
- When profiling is enabled, extra diagnostic metrics are logged:
|
||||||
|
- Per-base: `cache_hit`, backend (`fd`/`find`/PowerShell), glob-expanded path count, raw output line count, directory count, and skipped control-character paths.
|
||||||
|
- Overall: number of bases scanned, total directories returned (before dedupe), and unique directory count.
|
||||||
|
- All user-facing errors route through `plugins/sessionizer/src/notify.lua`, so the plugin stays compatible with different wezterm builds.
|
||||||
|
|
||||||
|
Contributions and suggestions are welcome—keep the footprint small and the workflow fast. 🚀
|
||||||
|
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
local wezterm = require("wezterm")
|
|
||||||
local State = require("plugins.sessionizer.state")
|
|
||||||
local utils = require("plugins.sessionizer.utils")
|
|
||||||
|
|
||||||
local M = {}
|
|
||||||
|
|
||||||
-- Cache tool availability and scan results
|
|
||||||
local _has_fd = nil
|
|
||||||
local _scan_cache = {}
|
|
||||||
local _meta_lookup = {}
|
|
||||||
|
|
||||||
-- Check for `fd` binary without spawning full shell
|
|
||||||
local function has_fd()
|
|
||||||
if _has_fd == nil then
|
|
||||||
_has_fd = os.execute("command -v fd > /dev/null 2>&1") == true
|
|
||||||
end
|
|
||||||
return _has_fd
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Build the command to scan directories
|
|
||||||
function M.build_cmd(base)
|
|
||||||
local path, depth = base.path, base.max_depth
|
|
||||||
local fd_d, find_d = utils.depth_flags(depth)
|
|
||||||
local excl = utils.get_exclude_flags() or {}
|
|
||||||
|
|
||||||
if State.is_windows then
|
|
||||||
return { "Get-ChildItem", "-Path", path, "-Directory", table.unpack(excl) }
|
|
||||||
end
|
|
||||||
|
|
||||||
if has_fd() then
|
|
||||||
return {
|
|
||||||
"fd",
|
|
||||||
"--min-depth",
|
|
||||||
"1",
|
|
||||||
table.unpack(fd_d),
|
|
||||||
"-t",
|
|
||||||
"d",
|
|
||||||
path,
|
|
||||||
table.unpack(excl),
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Build find command with exclude pruning
|
|
||||||
local cmd = { "find", path, "-mindepth", "1", table.unpack(find_d) }
|
|
||||||
local prune_flags = utils.build_prune_flags(path, State.exclude_dirs)
|
|
||||||
for _, flag in ipairs(prune_flags) do
|
|
||||||
table.insert(cmd, flag)
|
|
||||||
end
|
|
||||||
table.insert(cmd, "-type")
|
|
||||||
table.insert(cmd, "d")
|
|
||||||
table.insert(cmd, "-print")
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.scan_base(base)
|
|
||||||
local key = base.path .. ":" .. (base.max_depth or "default")
|
|
||||||
if _scan_cache[key] then
|
|
||||||
return _scan_cache[key]
|
|
||||||
end
|
|
||||||
|
|
||||||
local ok, out = utils.retry_command(M.build_cmd(base), 3, 200)
|
|
||||||
if not ok then
|
|
||||||
wezterm.log_error("Failed to scan: " .. base.path)
|
|
||||||
return {}
|
|
||||||
end
|
|
||||||
|
|
||||||
local choices = {}
|
|
||||||
|
|
||||||
for _, line in ipairs(wezterm.split_by_newlines(out)) do
|
|
||||||
local id = line
|
|
||||||
local label = line:gsub(wezterm.home_dir, "~")
|
|
||||||
|
|
||||||
table.insert(choices, {
|
|
||||||
id = id,
|
|
||||||
label = label,
|
|
||||||
})
|
|
||||||
|
|
||||||
-- store metadata separately
|
|
||||||
_meta_lookup[id] = {
|
|
||||||
workspace = utils.basename(line),
|
|
||||||
title = utils.basename(line),
|
|
||||||
path = line,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
_scan_cache[key] = choices
|
|
||||||
return choices
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Export metadata so other modules (like ui.lua) can use it
|
|
||||||
M.meta_lookup = _meta_lookup
|
|
||||||
|
|
||||||
return M
|
|
||||||
55
init.lua
55
init.lua
|
|
@ -1,41 +1,46 @@
|
||||||
local wezterm = require("wezterm")
|
local wezterm = require("wezterm")
|
||||||
local state = require("plugins.sessionizer.state")
|
local state = require("plugins.sessionizer.src.state")
|
||||||
local ui = require("plugins.sessionizer.ui")
|
local ui = require("plugins.sessionizer.src.ui")
|
||||||
local workspaces = require("plugins.sessionizer.workspaces")
|
local workspaces = require("plugins.sessionizer.src.workspaces")
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
function M.apply_to_config(config, user_config)
|
function M.apply_to_config(config, user_config)
|
||||||
local opts = user_config or {}
|
local opts = user_config or {}
|
||||||
|
|
||||||
-- Let State handle config applying and defaults
|
|
||||||
state.apply_to_config(opts)
|
state.apply_to_config(opts)
|
||||||
|
|
||||||
-- Normalize projects for internal use
|
if opts.key and opts.mods then
|
||||||
state.project_base = {}
|
config.keys = config.keys or {}
|
||||||
for _, base in ipairs(opts.projects or {}) do
|
table.insert(config.keys, {
|
||||||
table.insert(state.project_base, {
|
key = opts.key,
|
||||||
path = base.path or base,
|
mods = opts.mods,
|
||||||
max_depth = base.max_depth or state.default_depth,
|
action = ui.make_switcher(),
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Setup keybinding
|
|
||||||
table.insert(config.keys, {
|
|
||||||
key = opts.key,
|
|
||||||
mods = opts.mods,
|
|
||||||
action = ui.make_switcher(),
|
|
||||||
})
|
|
||||||
|
|
||||||
-- Add launch menu if requested
|
|
||||||
if opts.add_to_launch_menu then
|
if opts.add_to_launch_menu then
|
||||||
config.launch_menu = config.launch_menu or {}
|
config.launch_menu = config.launch_menu or {}
|
||||||
for _, dir in ipairs(workspaces.all_dirs()) do
|
local dirs = workspaces.cached_dirs()
|
||||||
table.insert(config.launch_menu, {
|
if #dirs ~= 0 then
|
||||||
label = "Workspace: " .. wezterm.basename(dir),
|
for _, dir in ipairs(dirs) do
|
||||||
cwd = dir,
|
table.insert(config.launch_menu, {
|
||||||
args = { "nvim" },
|
label = "Workspace: " .. wezterm.basename(dir.id or dir),
|
||||||
})
|
cwd = dir.id or dir,
|
||||||
|
args = { "nvim" },
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Optional warmup to reduce latency on first invocation
|
||||||
|
if opts.warmup ~= false then
|
||||||
|
local delay_ms = tonumber(opts.warmup_delay_ms or 0) or 0
|
||||||
|
if delay_ms > 0 then
|
||||||
|
wezterm.time.call_after(delay_ms / 1000, function()
|
||||||
|
workspaces.refresh_async()
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
workspaces.refresh_async()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
131
src/command_builder.lua
Normal file
131
src/command_builder.lua
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
local wezterm = require("wezterm")
|
||||||
|
local State = require("plugins.sessionizer.src.state")
|
||||||
|
local utils = require("plugins.sessionizer.src.utils")
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
local CONTROL_PATTERN = "[%z\r\n]"
|
||||||
|
local _has_fd = nil
|
||||||
|
|
||||||
|
-- Check for `fd` binary without spawning full shell
|
||||||
|
local function has_fd()
|
||||||
|
if _has_fd ~= nil then
|
||||||
|
return _has_fd
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok = false
|
||||||
|
local success, _ = pcall(function()
|
||||||
|
ok = wezterm.run_child_process({ "fd", "--version" })
|
||||||
|
end)
|
||||||
|
|
||||||
|
_has_fd = (success and ok) and true or false
|
||||||
|
return _has_fd
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.build_cmd(base)
|
||||||
|
local path, depth = base.path, base.max_depth
|
||||||
|
local effective_depth = utils.normalize_depth(depth)
|
||||||
|
local fd_d, find_d = utils.depth_flags(effective_depth)
|
||||||
|
local excl = utils.get_exclude_flags() or {}
|
||||||
|
|
||||||
|
-- Resolve glob patterns in path
|
||||||
|
local paths = utils.resolve_glob_all(path)
|
||||||
|
|
||||||
|
if State.is_windows then
|
||||||
|
local cmd = { "Get-ChildItem", "-Directory" }
|
||||||
|
for _, p in ipairs(paths) do
|
||||||
|
table.insert(cmd, "-Path")
|
||||||
|
table.insert(cmd, p)
|
||||||
|
end
|
||||||
|
for _, flag in ipairs(excl) do
|
||||||
|
if flag ~= "*" then
|
||||||
|
table.insert(cmd, flag)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return cmd
|
||||||
|
end
|
||||||
|
|
||||||
|
-- fd implementation
|
||||||
|
if has_fd() then
|
||||||
|
local cmd = {
|
||||||
|
"fd",
|
||||||
|
"--min-depth",
|
||||||
|
"1",
|
||||||
|
}
|
||||||
|
local fd_depth_flags = fd_d
|
||||||
|
if
|
||||||
|
fd_depth_flags
|
||||||
|
and fd_depth_flags[1] == "--max-depth"
|
||||||
|
and (fd_depth_flags[2] == nil or fd_depth_flags[2] == "")
|
||||||
|
then
|
||||||
|
fd_depth_flags = {}
|
||||||
|
end
|
||||||
|
utils.append_all(cmd, fd_depth_flags)
|
||||||
|
table.insert(cmd, "-t")
|
||||||
|
table.insert(cmd, "d")
|
||||||
|
table.insert(cmd, ".")
|
||||||
|
-- Add all resolved paths
|
||||||
|
utils.append_all(cmd, paths)
|
||||||
|
for _, flag in ipairs(excl) do
|
||||||
|
if flag ~= "*" then
|
||||||
|
table.insert(cmd, flag)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return cmd
|
||||||
|
end
|
||||||
|
|
||||||
|
-- find implementation - handle multiple paths
|
||||||
|
if #paths == 1 then
|
||||||
|
local cmd = {
|
||||||
|
"find",
|
||||||
|
paths[1],
|
||||||
|
"-mindepth",
|
||||||
|
"1",
|
||||||
|
table.unpack(find_d),
|
||||||
|
}
|
||||||
|
|
||||||
|
local prune_flags = utils.build_prune_flags(paths[1], State.exclude_dirs)
|
||||||
|
for _, flag in ipairs(prune_flags) do
|
||||||
|
if flag ~= "*" and flag ~= "" then
|
||||||
|
table.insert(cmd, flag)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
table.insert(cmd, "-type")
|
||||||
|
table.insert(cmd, "d")
|
||||||
|
table.insert(cmd, "-print")
|
||||||
|
return cmd
|
||||||
|
else
|
||||||
|
-- Multiple paths: use parentheses grouping
|
||||||
|
local cmd = { "find" }
|
||||||
|
for _, p in ipairs(paths) do
|
||||||
|
table.insert(cmd, p)
|
||||||
|
end
|
||||||
|
table.insert(cmd, "-mindepth")
|
||||||
|
table.insert(cmd, "1")
|
||||||
|
for _, d in ipairs(find_d) do
|
||||||
|
table.insert(cmd, d)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Combine prune flags for all paths
|
||||||
|
local all_prune_flags = {}
|
||||||
|
for _, p in ipairs(paths) do
|
||||||
|
local prune_flags = utils.build_prune_flags(p, State.exclude_dirs)
|
||||||
|
for _, flag in ipairs(prune_flags) do
|
||||||
|
if flag ~= "*" and flag ~= "" then
|
||||||
|
table.insert(all_prune_flags, flag)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, flag in ipairs(all_prune_flags) do
|
||||||
|
table.insert(cmd, flag)
|
||||||
|
end
|
||||||
|
|
||||||
|
table.insert(cmd, "-type")
|
||||||
|
table.insert(cmd, "d")
|
||||||
|
table.insert(cmd, "-print")
|
||||||
|
return cmd
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
13
src/config.lua
Normal file
13
src/config.lua
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
local wezterm = require("wezterm")
|
||||||
|
|
||||||
|
local HOME = wezterm.home_dir
|
||||||
|
|
||||||
|
local Config = {
|
||||||
|
defaults = {
|
||||||
|
history_file = HOME .. "/.wezterm-sessionizer-history.txt",
|
||||||
|
history_max_entries = 10,
|
||||||
|
default_depth = 3,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return Config
|
||||||
106
src/history.lua
Normal file
106
src/history.lua
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
local wezterm = require("wezterm")
|
||||||
|
local State = require("plugins.sessionizer.src.state")
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
-- Cache to hold the loaded history list (array of strings)
|
||||||
|
local history_list = {}
|
||||||
|
local history_loaded = false
|
||||||
|
|
||||||
|
--- Reads the history file and populates the history_list cache.
|
||||||
|
function M.load(force)
|
||||||
|
if history_loaded and not force then
|
||||||
|
return history_list
|
||||||
|
end
|
||||||
|
|
||||||
|
local history_path = State.history_file
|
||||||
|
local f, err = io.open(history_path, "r")
|
||||||
|
|
||||||
|
-- Clear cache and return if file not found
|
||||||
|
history_list = {}
|
||||||
|
|
||||||
|
if f then
|
||||||
|
local content = f:read("*a")
|
||||||
|
f:close()
|
||||||
|
|
||||||
|
-- Split content into lines and filter out empty strings
|
||||||
|
for _, line in ipairs(wezterm.split_by_newlines(content)) do
|
||||||
|
if line ~= "" then
|
||||||
|
table.insert(history_list, line)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
history_loaded = true
|
||||||
|
return history_list
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Writes the current history_list back to the file, enforcing the size limit N.
|
||||||
|
function M.save()
|
||||||
|
local history_path = State.history_file
|
||||||
|
local max_n = State.history_max_entries
|
||||||
|
|
||||||
|
-- Limit the list to the top N entries before writing
|
||||||
|
local write_list = {}
|
||||||
|
for i = 1, math.min(#history_list, max_n) do
|
||||||
|
table.insert(write_list, history_list[i])
|
||||||
|
end
|
||||||
|
|
||||||
|
local content = table.concat(write_list, "\n") .. "\n" -- Ensure a trailing newline
|
||||||
|
|
||||||
|
local f, err = io.open(history_path, "w")
|
||||||
|
if f then
|
||||||
|
f:write(content)
|
||||||
|
f:close()
|
||||||
|
else
|
||||||
|
wezterm.log_error("Failed to write sessionizer history to " .. history_path .. ": " .. tostring(err))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Updates the history by moving the accessed ID to the front of the list.
|
||||||
|
-- @param id string: The ID (full path) of the project just used.
|
||||||
|
-- @return table: The updated history list.
|
||||||
|
function M.update_access(id)
|
||||||
|
if not history_loaded then
|
||||||
|
M.load()
|
||||||
|
end
|
||||||
|
|
||||||
|
if history_list[1] == id then
|
||||||
|
return history_list
|
||||||
|
end
|
||||||
|
|
||||||
|
local found_index = nil
|
||||||
|
for i, path in ipairs(history_list) do
|
||||||
|
if path == id then
|
||||||
|
found_index = i
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if found_index then
|
||||||
|
-- Move-to-Front: remove from current position
|
||||||
|
table.remove(history_list, found_index)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Insert the new ID at the beginning
|
||||||
|
table.insert(history_list, 1, id)
|
||||||
|
|
||||||
|
history_loaded = true
|
||||||
|
|
||||||
|
M.save() -- Save immediately after update
|
||||||
|
|
||||||
|
return history_list
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Forces the cache to be forgotten so the next load hits disk.
|
||||||
|
function M.reload()
|
||||||
|
history_loaded = false
|
||||||
|
return M.load(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Retrieves the current list of history paths.
|
||||||
|
function M.get_list()
|
||||||
|
return history_list
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
44
src/notify.lua
Normal file
44
src/notify.lua
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
local wezterm = require("wezterm")
|
||||||
|
|
||||||
|
local DEFAULT_TITLE = "Sessionizer"
|
||||||
|
local DEFAULT_TIMEOUT = 3000
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
local function toast_via_window(win, message, timeout)
|
||||||
|
if win and win.toast_notification then
|
||||||
|
win:toast_notification(DEFAULT_TITLE, message, nil, timeout or DEFAULT_TIMEOUT)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function toast_via_api(message, timeout)
|
||||||
|
if wezterm.toast_notification then
|
||||||
|
wezterm.toast_notification({
|
||||||
|
title = DEFAULT_TITLE,
|
||||||
|
message = message,
|
||||||
|
timeout_milliseconds = timeout or DEFAULT_TIMEOUT,
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.show(message, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
local win = opts.win
|
||||||
|
local timeout = opts.timeout
|
||||||
|
|
||||||
|
if toast_via_window(win, message, timeout) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if toast_via_api(message, timeout) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
wezterm.log_info(string.format("[%s] %s", DEFAULT_TITLE, message))
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
180
src/state.lua
Normal file
180
src/state.lua
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
local wezterm = require("wezterm")
|
||||||
|
local Config = require("plugins.sessionizer.src.config")
|
||||||
|
|
||||||
|
local HOME = wezterm.home_dir
|
||||||
|
local IS_WINDOWS = wezterm.target_triple:find("windows") ~= nil
|
||||||
|
local CONTROL_PATTERN = "[%z\r\n]"
|
||||||
|
|
||||||
|
local State = {
|
||||||
|
project_base = {},
|
||||||
|
exclude_dirs = {},
|
||||||
|
default_depth = Config.defaults.default_depth,
|
||||||
|
config_fingerprint = nil,
|
||||||
|
cached_directories = nil,
|
||||||
|
cached_checksum = nil,
|
||||||
|
_exclude_flags = nil,
|
||||||
|
history_file = nil,
|
||||||
|
history_max_entries = Config.defaults.history_max_entries,
|
||||||
|
profiler_enabled = false,
|
||||||
|
is_windows = IS_WINDOWS,
|
||||||
|
last_scan_error = nil,
|
||||||
|
scan_in_progress = false,
|
||||||
|
scan_pending = false,
|
||||||
|
}
|
||||||
|
|
||||||
|
local function fingerprint_config(project_base, exclude_dirs, default_depth)
|
||||||
|
local h = 0
|
||||||
|
local function add(s)
|
||||||
|
for i = 1, #s do
|
||||||
|
h = (h * 31 + s:byte(i)) & 0xFFFFFFFF
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
add(tostring(default_depth or ""))
|
||||||
|
add("\0")
|
||||||
|
for _, base in ipairs(project_base or {}) do
|
||||||
|
add(tostring(base.path or ""))
|
||||||
|
add("\0")
|
||||||
|
add(tostring(base.max_depth or ""))
|
||||||
|
add("\0")
|
||||||
|
end
|
||||||
|
add("\0")
|
||||||
|
for _, d in ipairs(exclude_dirs or {}) do
|
||||||
|
add(tostring(d))
|
||||||
|
add("\0")
|
||||||
|
end
|
||||||
|
return ("%08x"):format(h)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function normalize_path(path)
|
||||||
|
if path:sub(1, 1) == "~" then
|
||||||
|
path = HOME .. path:sub(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
local is_absolute
|
||||||
|
if IS_WINDOWS then
|
||||||
|
is_absolute = path:match("^%a:[/\\]") or path:sub(1, 2) == "\\\\"
|
||||||
|
else
|
||||||
|
is_absolute = path:sub(1, 1) == "/"
|
||||||
|
end
|
||||||
|
|
||||||
|
if not is_absolute then
|
||||||
|
path = HOME .. "/" .. path
|
||||||
|
end
|
||||||
|
|
||||||
|
return path
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_history_path(path)
|
||||||
|
local default_path = Config.defaults.history_file
|
||||||
|
|
||||||
|
if type(path) ~= "string" or path == "" then
|
||||||
|
return default_path
|
||||||
|
end
|
||||||
|
|
||||||
|
local final = normalize_path(path)
|
||||||
|
|
||||||
|
if final:find(CONTROL_PATTERN) then
|
||||||
|
wezterm.log_error("[sessionizer] history_file contains control characters; using default path")
|
||||||
|
return default_path
|
||||||
|
end
|
||||||
|
|
||||||
|
if final:sub(1, #HOME) ~= HOME then
|
||||||
|
wezterm.log_info("[sessionizer] history_file is outside the home directory; ensure permissions are correct")
|
||||||
|
end
|
||||||
|
|
||||||
|
return final
|
||||||
|
end
|
||||||
|
|
||||||
|
State.history_file = resolve_history_path(Config.defaults.history_file)
|
||||||
|
|
||||||
|
local function sanitize_depth(value, default)
|
||||||
|
local depth = tonumber(value) or default
|
||||||
|
if depth == -1 then
|
||||||
|
return -1
|
||||||
|
end
|
||||||
|
depth = math.floor(depth)
|
||||||
|
return math.max(depth, 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function sanitize_projects(projects, default_depth)
|
||||||
|
local sanitized = {}
|
||||||
|
for _, base in ipairs(projects or {}) do
|
||||||
|
local path = base
|
||||||
|
local depth = default_depth
|
||||||
|
if type(base) == "table" then
|
||||||
|
path = base.path or base[1]
|
||||||
|
depth = sanitize_depth(base.max_depth or base.depth or default_depth, default_depth)
|
||||||
|
elseif type(base) == "string" then
|
||||||
|
depth = default_depth
|
||||||
|
else
|
||||||
|
wezterm.log_error("[sessionizer] Ignoring project entry with unsupported type")
|
||||||
|
goto continue
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(path) ~= "string" or path == "" then
|
||||||
|
wezterm.log_error("[sessionizer] Ignoring project entry missing a path")
|
||||||
|
goto continue
|
||||||
|
end
|
||||||
|
|
||||||
|
if path:find(CONTROL_PATTERN) then
|
||||||
|
wezterm.log_error("[sessionizer] Ignoring project path containing control characters")
|
||||||
|
goto continue
|
||||||
|
end
|
||||||
|
|
||||||
|
table.insert(sanitized, {
|
||||||
|
path = normalize_path(path),
|
||||||
|
max_depth = depth,
|
||||||
|
})
|
||||||
|
::continue::
|
||||||
|
end
|
||||||
|
return sanitized
|
||||||
|
end
|
||||||
|
|
||||||
|
local function sanitize_excludes(excludes)
|
||||||
|
local list = {}
|
||||||
|
for _, dir in ipairs(excludes or {}) do
|
||||||
|
if type(dir) ~= "string" or dir == "" then
|
||||||
|
wezterm.log_error("[sessionizer] Ignoring exclude_dirs entry with invalid value")
|
||||||
|
elseif dir:find(CONTROL_PATTERN) then
|
||||||
|
wezterm.log_error("[sessionizer] Ignoring exclude_dirs value containing control characters")
|
||||||
|
else
|
||||||
|
table.insert(list, dir)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return list
|
||||||
|
end
|
||||||
|
|
||||||
|
State.config_fingerprint = fingerprint_config(State.project_base, State.exclude_dirs, State.default_depth)
|
||||||
|
|
||||||
|
function State.apply_to_config(config)
|
||||||
|
local prior_fingerprint = State.config_fingerprint
|
||||||
|
local default_depth =
|
||||||
|
sanitize_depth(config.default_depth or Config.defaults.default_depth, Config.defaults.default_depth)
|
||||||
|
State.default_depth = default_depth
|
||||||
|
State.project_base = sanitize_projects(config.projects or {}, default_depth)
|
||||||
|
State.exclude_dirs = sanitize_excludes(config.exclude_dirs or {})
|
||||||
|
State.history_max_entries = sanitize_depth(
|
||||||
|
config.history_max_entries or Config.defaults.history_max_entries,
|
||||||
|
Config.defaults.history_max_entries
|
||||||
|
)
|
||||||
|
State.history_file = resolve_history_path(config.history_file or Config.defaults.history_file)
|
||||||
|
State.last_scan_error = nil
|
||||||
|
State.cached_directories = nil
|
||||||
|
State.cached_checksum = nil
|
||||||
|
State._exclude_flags = nil
|
||||||
|
State.scan_in_progress = false
|
||||||
|
State.scan_pending = false
|
||||||
|
State.config_fingerprint = fingerprint_config(State.project_base, State.exclude_dirs, State.default_depth)
|
||||||
|
State.config_changed = (prior_fingerprint ~= nil and prior_fingerprint ~= State.config_fingerprint) or false
|
||||||
|
end
|
||||||
|
|
||||||
|
function State.clear_cache()
|
||||||
|
State.cached_directories = nil
|
||||||
|
State.cached_checksum = nil
|
||||||
|
State._exclude_flags = nil
|
||||||
|
State.scan_in_progress = false
|
||||||
|
State.scan_pending = false
|
||||||
|
end
|
||||||
|
|
||||||
|
return State
|
||||||
139
src/ui.lua
Normal file
139
src/ui.lua
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
local wezterm = require("wezterm")
|
||||||
|
local act = wezterm.action
|
||||||
|
local workspace = require("plugins.sessionizer.src.workspaces")
|
||||||
|
local history = require("plugins.sessionizer.src.history")
|
||||||
|
local State = require("plugins.sessionizer.src.state")
|
||||||
|
local notify = require("plugins.sessionizer.src.notify")
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
-- Callback function that handles switching to a workspace
|
||||||
|
-- ... (switch_logic remains the same) ...
|
||||||
|
local function switch_logic(win, pane, id, label)
|
||||||
|
if not id or id == "" then
|
||||||
|
if label then
|
||||||
|
wezterm.log_warn("No workspace ID provided for switch: " .. tostring(label))
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Log access and save history
|
||||||
|
history.update_access(id)
|
||||||
|
|
||||||
|
local metadata = workspace.get_metadata(id) or {}
|
||||||
|
|
||||||
|
-- Determine workspace name: use metadata.workspace or fallback to basename of id
|
||||||
|
local workspace_name = metadata.workspace or wezterm.basename(id)
|
||||||
|
local title_label = metadata.title or ("Workspace: " .. label)
|
||||||
|
|
||||||
|
win:perform_action(
|
||||||
|
act.SwitchToWorkspace({
|
||||||
|
name = workspace_name, -- Use workspace name from metadata or fallback
|
||||||
|
spawn = {
|
||||||
|
label = title_label, -- Title shown on tab or workspace label
|
||||||
|
cwd = id, -- Start cwd for workspace
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
pane
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Creates a wezterm InputSelector action for choosing and switching workspace.
|
||||||
|
-- Returns a function suitable for keybinding or callback.
|
||||||
|
function M.make_switcher()
|
||||||
|
return wezterm.action_callback(function(win, pane)
|
||||||
|
-- ADDED: Load history data before scanning for directories
|
||||||
|
history.load()
|
||||||
|
|
||||||
|
local function present(choices)
|
||||||
|
-- ADDED: Sorting logic using plain text history list
|
||||||
|
local history_list = history.get_list()
|
||||||
|
local max_n = State.history_max_entries
|
||||||
|
local quick_access_label = "Quick Access: "
|
||||||
|
|
||||||
|
-- Create a map of the top N history items for O(1) lookup
|
||||||
|
local history_rank = {}
|
||||||
|
for i, path in ipairs(history_list) do
|
||||||
|
-- Only track the top N items
|
||||||
|
if i <= max_n then
|
||||||
|
history_rank[path] = i
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Custom sort function:
|
||||||
|
-- 1. Put historical items first.
|
||||||
|
-- 2. Sort historical items by their rank (1 = most recent).
|
||||||
|
-- 3. Sort non-historical items alphabetically.
|
||||||
|
table.sort(choices, function(a, b)
|
||||||
|
local a_rank = history_rank[a.id]
|
||||||
|
local b_rank = history_rank[b.id]
|
||||||
|
|
||||||
|
local a_is_historical = a_rank ~= nil
|
||||||
|
local b_is_historical = b_rank ~= nil
|
||||||
|
|
||||||
|
if a_is_historical ~= b_is_historical then
|
||||||
|
return a_is_historical -- true (a has history) > false (b does not)
|
||||||
|
elseif a_is_historical and b_is_historical then
|
||||||
|
return a_rank < b_rank -- Rank 1 < Rank 2 (Most recent first)
|
||||||
|
else
|
||||||
|
return a.label < b.label -- Alphabetical fallback
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- Prepend the Quick Access label to the top N items
|
||||||
|
for i, choice in ipairs(choices) do
|
||||||
|
if history_rank[choice.id] and i <= max_n then
|
||||||
|
-- Guard against re-prefixing across invocations
|
||||||
|
if choice.label:sub(1, #quick_access_label) ~= quick_access_label then
|
||||||
|
choice.label = quick_access_label .. choice.label
|
||||||
|
end
|
||||||
|
else
|
||||||
|
-- Stop after the quick access section is passed
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- END ADDED LOGIC
|
||||||
|
|
||||||
|
win:perform_action(
|
||||||
|
act.InputSelector({
|
||||||
|
title = "Sessionizer",
|
||||||
|
fuzzy = true,
|
||||||
|
fuzzy_description = "Fuzzy search projects: ",
|
||||||
|
choices = choices,
|
||||||
|
action = wezterm.action_callback(switch_logic),
|
||||||
|
}),
|
||||||
|
pane
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
local choices = workspace.all_dirs()
|
||||||
|
if #choices == 0 then
|
||||||
|
if not State.scan_in_progress then
|
||||||
|
workspace.refresh_async()
|
||||||
|
end
|
||||||
|
notify.show("Scanning projects...", { win = win, timeout = 1500 })
|
||||||
|
|
||||||
|
local attempts = 0
|
||||||
|
local function retry_open()
|
||||||
|
attempts = attempts + 1
|
||||||
|
local updated = workspace.cached_dirs()
|
||||||
|
if #updated ~= 0 then
|
||||||
|
present(updated)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if attempts >= 20 then
|
||||||
|
notify.show("No projects found", { win = win, timeout = 2000 })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
wezterm.time.call_after(0.1, retry_open)
|
||||||
|
end
|
||||||
|
|
||||||
|
wezterm.time.call_after(0.1, retry_open)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
present(choices)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
|
|
@ -1,24 +1,95 @@
|
||||||
local wezterm = require("wezterm")
|
local wezterm = require("wezterm")
|
||||||
local State = require("plugins.sessionizer.state")
|
local State = require("plugins.sessionizer.src.state")
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
|
function M.resolve_glob_all(path)
|
||||||
|
if not path or path == "" then
|
||||||
|
return { path }
|
||||||
|
end
|
||||||
|
|
||||||
|
if tostring(path):find("[%*%?%[%]]") then
|
||||||
|
local matches = wezterm.glob(path)
|
||||||
|
return matches and #matches > 0 and matches or { path }
|
||||||
|
end
|
||||||
|
|
||||||
|
return { path }
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.normalize_depth(depth)
|
||||||
|
if depth == "*" or depth == nil then
|
||||||
|
return 999
|
||||||
|
end
|
||||||
|
return depth
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.append_all(dst, src)
|
||||||
|
for _, v in ipairs(src or {}) do
|
||||||
|
table.insert(dst, v)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function shell_quote(s)
|
||||||
|
if s == nil then
|
||||||
|
return "''"
|
||||||
|
end
|
||||||
|
s = tostring(s)
|
||||||
|
-- POSIX-safe single-quote escaping: ' -> '\''
|
||||||
|
return "'" .. s:gsub("'", "'\\''") .. "'"
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.profile(label, fn)
|
||||||
|
if not State.profiler_enabled then
|
||||||
|
return fn()
|
||||||
|
end
|
||||||
|
|
||||||
|
local t0 = os.clock()
|
||||||
|
local results = { fn() }
|
||||||
|
local dt_ms = (os.clock() - t0) * 1000
|
||||||
|
local unpack_fn = table.unpack or unpack
|
||||||
|
wezterm.log_info(("[sessionizer] %s: %.2fms"):format(label, dt_ms))
|
||||||
|
return unpack_fn(results)
|
||||||
|
end
|
||||||
|
|
||||||
--- Retry a shell command up to `retries` times with delay between attempts.
|
--- Retry a shell command up to `retries` times with delay between attempts.
|
||||||
-- @param cmd table: command and arguments
|
-- @param cmd table: command and arguments
|
||||||
-- @param retries number: number of attempts
|
-- @param retries number: number of attempts
|
||||||
-- @param delay_ms number: delay in milliseconds between attempts
|
-- @param delay_ms number: delay in milliseconds between attempts
|
||||||
-- @return boolean, string|nil: success status and output
|
-- @return boolean, string|nil: success status and output
|
||||||
function M.retry_command(cmd, retries, delay_ms)
|
function M.retry_command(cmd, retries, delay_ms)
|
||||||
|
local last_out = nil
|
||||||
for a = 1, retries do
|
for a = 1, retries do
|
||||||
local ok, out = wezterm.run_child_process(cmd)
|
local ok, out = wezterm.run_child_process(cmd)
|
||||||
|
last_out = out
|
||||||
if ok then
|
if ok then
|
||||||
return true, out
|
return true, out
|
||||||
end
|
end
|
||||||
if a < retries then
|
if a < retries then
|
||||||
wezterm.log_error(("Retrying: %s (Attempt %d/%d)"):format(table.concat(cmd, " "), a, retries))
|
if State.profiler_enabled then
|
||||||
|
local out_msg = (type(out) == "string" and out ~= "") and ("\n" .. out) or ""
|
||||||
|
wezterm.log_error(("Retrying: %s (Attempt %d/%d)%s"):format(table.concat(cmd, " "), a, retries, out_msg))
|
||||||
|
end
|
||||||
wezterm.sleep_ms(delay_ms)
|
wezterm.sleep_ms(delay_ms)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
if State.profiler_enabled then
|
||||||
|
if type(last_out) == "string" and last_out ~= "" then
|
||||||
|
wezterm.log_error(("Command failed output:\n%s"):format(last_out))
|
||||||
|
elseif not State.is_windows then
|
||||||
|
-- wezterm.run_child_process doesn't always surface stderr; run a diagnostic command capturing 2>&1
|
||||||
|
local parts = {}
|
||||||
|
for _, a in ipairs(cmd or {}) do
|
||||||
|
table.insert(parts, shell_quote(a))
|
||||||
|
end
|
||||||
|
local diag = table.concat(parts, " ") .. " 2>&1"
|
||||||
|
local ok2, out2 = wezterm.run_child_process({ "sh", "-lc", diag })
|
||||||
|
if ok2 and type(out2) == "string" and out2 ~= "" then
|
||||||
|
wezterm.log_error(("Command failed stderr/stdout (diagnostic):\n%s"):format(out2))
|
||||||
|
elseif type(out2) == "string" and out2 ~= "" then
|
||||||
|
wezterm.log_error(("Command diagnostic output:\n%s"):format(out2))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -60,7 +131,7 @@ function M.depth_flags(req)
|
||||||
-- Unlimited depth
|
-- Unlimited depth
|
||||||
return {}, {}
|
return {}, {}
|
||||||
end
|
end
|
||||||
local d = req or State.DEFAULT_DEPTH
|
local d = req or State.default_depth
|
||||||
return { "--max-depth", tostring(d) }, { "-maxdepth", tostring(d) }
|
return { "--max-depth", tostring(d) }, { "-maxdepth", tostring(d) }
|
||||||
end
|
end
|
||||||
|
|
||||||
215
src/workspaces.lua
Normal file
215
src/workspaces.lua
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
local wezterm = require("wezterm")
|
||||||
|
local state = require("plugins.sessionizer.src.state")
|
||||||
|
local command_builder = require("plugins.sessionizer.src.command_builder")
|
||||||
|
local utils = require("plugins.sessionizer.src.utils")
|
||||||
|
local notify = require("plugins.sessionizer.src.notify")
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
local _scan_cache = {}
|
||||||
|
local _meta_lookup = {}
|
||||||
|
local CONTROL_PATTERN = "[%z\r\n]"
|
||||||
|
|
||||||
|
local function deep_copy(list)
|
||||||
|
local copy = {}
|
||||||
|
for i, v in ipairs(list or {}) do
|
||||||
|
local project_copy = {}
|
||||||
|
for k, val in pairs(v) do
|
||||||
|
project_copy[k] = val
|
||||||
|
end
|
||||||
|
copy[i] = project_copy
|
||||||
|
end
|
||||||
|
return copy
|
||||||
|
end
|
||||||
|
|
||||||
|
local function clear_scan_cache()
|
||||||
|
for k in pairs(_scan_cache) do
|
||||||
|
_scan_cache[k] = nil
|
||||||
|
end
|
||||||
|
for k in pairs(_meta_lookup) do
|
||||||
|
_meta_lookup[k] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function cache_list(list)
|
||||||
|
state.cached_directories = list
|
||||||
|
state.cached_checksum = utils.checksum(list)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function scan_base(base)
|
||||||
|
local key = tostring(base.path) .. ":" .. tostring(base.max_depth or "default")
|
||||||
|
if _scan_cache[key] then
|
||||||
|
if state.profiler_enabled then
|
||||||
|
wezterm.log_info(("[sessionizer] scan_base %s: cache_hit=true"):format(tostring(base.path)))
|
||||||
|
end
|
||||||
|
return _scan_cache[key]
|
||||||
|
end
|
||||||
|
|
||||||
|
return utils.profile("scan_base " .. tostring(base.path), function()
|
||||||
|
local paths = utils.resolve_glob_all(base.path)
|
||||||
|
local cmd = command_builder.build_cmd(base)
|
||||||
|
local backend = cmd[1] or ""
|
||||||
|
|
||||||
|
local ok, out = utils.retry_command(cmd, 3, 200)
|
||||||
|
if not ok then
|
||||||
|
local message = "Failed to scan: " .. tostring(base.path)
|
||||||
|
wezterm.log_error(message)
|
||||||
|
state.last_scan_error = message
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local choices = {}
|
||||||
|
local out_lines = 0
|
||||||
|
local skipped_control = 0
|
||||||
|
|
||||||
|
for _, line in ipairs(wezterm.split_by_newlines(out)) do
|
||||||
|
out_lines = out_lines + 1
|
||||||
|
if line ~= "" then
|
||||||
|
if line:find(CONTROL_PATTERN) then
|
||||||
|
skipped_control = skipped_control + 1
|
||||||
|
wezterm.log_error("[sessionizer] Skipping path with control characters: " .. line)
|
||||||
|
else
|
||||||
|
local id = line
|
||||||
|
local label = line:gsub(wezterm.home_dir, "~")
|
||||||
|
label = label:gsub(CONTROL_PATTERN, "")
|
||||||
|
|
||||||
|
table.insert(choices, {
|
||||||
|
id = id,
|
||||||
|
label = label,
|
||||||
|
})
|
||||||
|
|
||||||
|
_meta_lookup[id] = {
|
||||||
|
workspace = utils.basename(line),
|
||||||
|
title = utils.basename(line),
|
||||||
|
path = line,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
_scan_cache[key] = choices
|
||||||
|
state.last_scan_error = nil
|
||||||
|
if state.profiler_enabled then
|
||||||
|
wezterm.log_info((
|
||||||
|
"[sessionizer] scan_base %s: cache_hit=false backend=%s glob_paths=%d lines=%d dirs=%d skipped_control=%d"
|
||||||
|
):format(
|
||||||
|
tostring(base.path),
|
||||||
|
tostring(backend),
|
||||||
|
#paths,
|
||||||
|
out_lines,
|
||||||
|
#choices,
|
||||||
|
skipped_control
|
||||||
|
))
|
||||||
|
end
|
||||||
|
return choices
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function perform_scan()
|
||||||
|
return utils.profile("workspace_scan", function()
|
||||||
|
local list = {}
|
||||||
|
local seen = {}
|
||||||
|
local last_error = nil
|
||||||
|
local bases = #state.project_base
|
||||||
|
local total_dirs = 0
|
||||||
|
|
||||||
|
for _, base in ipairs(state.project_base) do
|
||||||
|
local ok, scanned = pcall(scan_base, base)
|
||||||
|
if ok and scanned then
|
||||||
|
total_dirs = total_dirs + #scanned
|
||||||
|
for _, folder in ipairs(scanned) do
|
||||||
|
if folder.id and not seen[folder.id] then
|
||||||
|
table.insert(list, folder)
|
||||||
|
seen[folder.id] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
last_error = "Failed to scan project base: " .. tostring(base.path)
|
||||||
|
wezterm.log_error(last_error)
|
||||||
|
notify.show(last_error)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
cache_list(list)
|
||||||
|
state.last_scan_error = last_error
|
||||||
|
|
||||||
|
if state.profiler_enabled then
|
||||||
|
wezterm.log_info((
|
||||||
|
"[sessionizer] workspace_scan: bases=%d total_dirs=%d unique_dirs=%d"
|
||||||
|
):format(bases, total_dirs, #list))
|
||||||
|
end
|
||||||
|
|
||||||
|
return list
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function finish_scan(ok, result)
|
||||||
|
if not ok then
|
||||||
|
local message = "[sessionizer] Workspace scan failed: " .. tostring(result)
|
||||||
|
wezterm.log_error(message)
|
||||||
|
state.last_scan_error = message
|
||||||
|
notify.show(message)
|
||||||
|
end
|
||||||
|
|
||||||
|
state.scan_in_progress = false
|
||||||
|
if state.scan_pending then
|
||||||
|
state.scan_pending = false
|
||||||
|
M.refresh_async()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Kick off a background refresh of project directories without blocking the UI.
|
||||||
|
function M.refresh_async()
|
||||||
|
if state.scan_in_progress then
|
||||||
|
state.scan_pending = true
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if state.config_changed then
|
||||||
|
clear_scan_cache()
|
||||||
|
state.config_changed = false
|
||||||
|
end
|
||||||
|
|
||||||
|
if #state.project_base == 0 then
|
||||||
|
cache_list({})
|
||||||
|
state.last_scan_error = "No project bases configured"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
state.scan_in_progress = true
|
||||||
|
wezterm.time.call_after(0, function()
|
||||||
|
local ok, result = pcall(perform_scan)
|
||||||
|
finish_scan(ok, result)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get a list of cached project directories. Triggers a refresh if cache is empty/stale.
|
||||||
|
function M.all_dirs()
|
||||||
|
if state.cached_directories and utils.checksum(state.cached_directories) == state.cached_checksum then
|
||||||
|
return deep_copy(state.cached_directories)
|
||||||
|
end
|
||||||
|
|
||||||
|
if not state.scan_in_progress then
|
||||||
|
M.refresh_async()
|
||||||
|
end
|
||||||
|
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get cached project directories without triggering a refresh.
|
||||||
|
function M.cached_dirs()
|
||||||
|
if state.cached_directories and utils.checksum(state.cached_directories) == state.cached_checksum then
|
||||||
|
return deep_copy(state.cached_directories)
|
||||||
|
end
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.get_metadata(id)
|
||||||
|
return _meta_lookup[id]
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.clear_scan_cache()
|
||||||
|
clear_scan_cache()
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
25
state.lua
25
state.lua
|
|
@ -1,25 +0,0 @@
|
||||||
local wezterm = require("wezterm")
|
|
||||||
|
|
||||||
local State = {
|
|
||||||
project_base = {},
|
|
||||||
exclude_dirs = {},
|
|
||||||
default_depth = 3,
|
|
||||||
cached_directories = nil,
|
|
||||||
cached_checksum = nil,
|
|
||||||
_exclude_flags = nil,
|
|
||||||
is_windows = wezterm.target_triple:find("windows") ~= nil,
|
|
||||||
}
|
|
||||||
|
|
||||||
function State.apply_to_config(config)
|
|
||||||
State.project_base = config.projects or {}
|
|
||||||
State.exclude_dirs = config.exclude_dirs or {}
|
|
||||||
State.default_depth = config.default_depth or 3
|
|
||||||
end
|
|
||||||
|
|
||||||
function State.clear_cache()
|
|
||||||
State.cached_directories = nil
|
|
||||||
State.cached_checksum = nil
|
|
||||||
State._exclude_flags = nil
|
|
||||||
end
|
|
||||||
|
|
||||||
return State
|
|
||||||
65
ui.lua
65
ui.lua
|
|
@ -1,65 +0,0 @@
|
||||||
local wezterm = require("wezterm")
|
|
||||||
local act = wezterm.action
|
|
||||||
local command_builder = require("plugins.sessionizer.command_builder")
|
|
||||||
local workspace = require("plugins.sessionizer.workspaces")
|
|
||||||
|
|
||||||
local M = {}
|
|
||||||
|
|
||||||
-- Callback function that handles switching to a workspace
|
|
||||||
-- @param win: wezterm window object
|
|
||||||
-- @param pane: wezterm pane object
|
|
||||||
-- @param id: workspace id (usually the full path)
|
|
||||||
-- @param label: display label (usually basename or shortened path)
|
|
||||||
local function switch_logic(win, pane, id, label)
|
|
||||||
if not id or id == "" then
|
|
||||||
wezterm.log_warn("No workspace ID provided for switch")
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
local metadata = command_builder.meta_lookup[id] or {}
|
|
||||||
|
|
||||||
-- Determine workspace name: use metadata.workspace or fallback to basename of id
|
|
||||||
local workspace_name = metadata.workspace or wezterm.basename(id)
|
|
||||||
local title_label = metadata.title or ("Workspace: " .. label)
|
|
||||||
|
|
||||||
win:perform_action(
|
|
||||||
act.SwitchToWorkspace({
|
|
||||||
name = workspace_name, -- Use workspace name from metadata or fallback
|
|
||||||
spawn = {
|
|
||||||
label = title_label, -- Title shown on tab or workspace label
|
|
||||||
cwd = id, -- Start cwd for workspace
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
pane
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Creates a wezterm InputSelector action for choosing and switching workspace.
|
|
||||||
-- Returns a function suitable for keybinding or callback.
|
|
||||||
function M.make_switcher()
|
|
||||||
return wezterm.action_callback(function(win, pane)
|
|
||||||
local choices = workspace.all_dirs()
|
|
||||||
|
|
||||||
if #choices == 0 then
|
|
||||||
wezterm.toast_notification({
|
|
||||||
title = "Sessionizer",
|
|
||||||
message = "No projects found",
|
|
||||||
timeout_milliseconds = 2000,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
win:perform_action(
|
|
||||||
act.InputSelector({
|
|
||||||
title = "Sessionizer",
|
|
||||||
fuzzy = true,
|
|
||||||
fuzzy_description = "Fuzzy search projects: ",
|
|
||||||
choices = choices,
|
|
||||||
action = wezterm.action_callback(switch_logic),
|
|
||||||
}),
|
|
||||||
pane
|
|
||||||
)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
local wezterm = require("wezterm")
|
|
||||||
local state = require("plugins.sessionizer.state")
|
|
||||||
local command_builder = require("plugins.sessionizer.command_builder")
|
|
||||||
local utils = require("plugins.sessionizer.utils")
|
|
||||||
|
|
||||||
local M = {}
|
|
||||||
|
|
||||||
--- Get a list of all project directories across configured bases.
|
|
||||||
-- Uses cached results if checksum matches to avoid rescanning.
|
|
||||||
-- @return table: list of workspace entries with fields like `id` and `label`
|
|
||||||
function M.all_dirs()
|
|
||||||
-- Check if cached list is valid
|
|
||||||
if state.cached_directories then
|
|
||||||
if utils.checksum(state.cached_directories) == state.cached_checksum then
|
|
||||||
-- Return a copy to avoid accidental external mutation
|
|
||||||
local copy = {}
|
|
||||||
for i, v in ipairs(state.cached_directories) do
|
|
||||||
copy[i] = v
|
|
||||||
end
|
|
||||||
return copy
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local list = {}
|
|
||||||
local seen = {} -- for deduplication by id
|
|
||||||
|
|
||||||
for _, base in ipairs(state.project_base) do
|
|
||||||
local ok, scanned = pcall(command_builder.scan_base, base)
|
|
||||||
if ok and scanned then
|
|
||||||
for _, folder in ipairs(scanned) do
|
|
||||||
if not seen[folder.id] then
|
|
||||||
table.insert(list, folder)
|
|
||||||
seen[folder.id] = true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
else
|
|
||||||
wezterm.log_error("Failed to scan project base: " .. tostring(base.path))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Update cache and checksum
|
|
||||||
state.cached_directories = list
|
|
||||||
state.cached_checksum = utils.checksum(list)
|
|
||||||
|
|
||||||
return list
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
|
||||||
Loading…
Reference in a new issue