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
|
||||
directories, as well as fast and intuative switching between active
|
||||
workspaces/sessions
|
||||
- **Quick Access history**: recently opened workspaces bubble to the top with a configurable cap.
|
||||
- **Async directory scans**: project discovery runs in the background so the UI never blocks.
|
||||
- **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
|
||||
|
||||
An example configuration calling the plugin
|
||||
## Installation
|
||||
|
||||
```lua
|
||||
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
|
||||
config = wezterm.config_builder()
|
||||
end
|
||||
|
||||
--INFO: The sessionizer lverages the `LEADER` mod
|
||||
config.leader = {
|
||||
key = "a",
|
||||
mods = "CTRL",
|
||||
timeout_milliseconds = 1000
|
||||
}
|
||||
|
||||
config.keys = {}
|
||||
|
||||
-- INFO: The following is the project directories to search
|
||||
local projects = {
|
||||
"~/personal",
|
||||
"~/work"
|
||||
}
|
||||
|
||||
sessionizer.set_projects(projects)
|
||||
sessionizer.configure(config)
|
||||
sessionizer.apply_to_config(config, {
|
||||
key = "F",
|
||||
mods = "LEADER",
|
||||
add_to_launch_menu = true,
|
||||
projects = {
|
||||
{ path = "~/personal", max_depth = 3 },
|
||||
{ path = "~/work", max_depth = 2 },
|
||||
},
|
||||
history_file = "~/.local/share/sessionizer-history.txt",
|
||||
history_max_entries = 8,
|
||||
})
|
||||
|
||||
return config
|
||||
```
|
||||
|
||||
## USEAGE
|
||||
## Configuration Reference
|
||||
|
||||
To use the sessionizer you have to define and pass through a table of project
|
||||
folders, that are the paths to your applicable repositores to leverage for the
|
||||
workspaces.
|
||||
All options are optional; reasonable defaults live in `plugins/sessionizer/src/config.lua`.
|
||||
|
||||
```lua
|
||||
local projects = {
|
||||
"~/personal",
|
||||
"~/work"
|
||||
}
|
||||
```
|
||||
| Option | Description |
|
||||
| --------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `key` / `mods` | Key binding that opens the sessionizer InputSelector. |
|
||||
| `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
|
||||
`LEADER` + `f`
|
||||
### Project roots and wildcards
|
||||
|
||||
To display the active windows/sessions all you have to do is press the key
|
||||
combination of `LEADER` + `s`
|
||||
`projects[*].path` supports glob patterns like `*`, `?`, and character classes (for example `~/src/*` or `~/work/**/repos`).
|
||||
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
|
||||
config.keys = {
|
||||
-- ... other bindings
|
||||
{
|
||||
key = "w",
|
||||
mods = "CTRL|ALT",
|
||||
action = sessionizer.switch_workspace()
|
||||
}
|
||||
}
|
||||
```
|
||||
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.
|
||||
|
||||
Launch-menu entries (when enabled) spawn new wezterm windows directly into the chosen project directory.
|
||||
|
||||
## Development Notes
|
||||
|
||||
- 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 state = require("plugins.sessionizer.state")
|
||||
local ui = require("plugins.sessionizer.ui")
|
||||
local workspaces = require("plugins.sessionizer.workspaces")
|
||||
local state = require("plugins.sessionizer.src.state")
|
||||
local ui = require("plugins.sessionizer.src.ui")
|
||||
local workspaces = require("plugins.sessionizer.src.workspaces")
|
||||
|
||||
local M = {}
|
||||
|
||||
function M.apply_to_config(config, user_config)
|
||||
local opts = user_config or {}
|
||||
|
||||
-- Let State handle config applying and defaults
|
||||
state.apply_to_config(opts)
|
||||
|
||||
-- Normalize projects for internal use
|
||||
state.project_base = {}
|
||||
for _, base in ipairs(opts.projects or {}) do
|
||||
table.insert(state.project_base, {
|
||||
path = base.path or base,
|
||||
max_depth = base.max_depth or state.default_depth,
|
||||
if opts.key and opts.mods then
|
||||
config.keys = config.keys or {}
|
||||
table.insert(config.keys, {
|
||||
key = opts.key,
|
||||
mods = opts.mods,
|
||||
action = ui.make_switcher(),
|
||||
})
|
||||
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
|
||||
config.launch_menu = config.launch_menu or {}
|
||||
for _, dir in ipairs(workspaces.all_dirs()) do
|
||||
table.insert(config.launch_menu, {
|
||||
label = "Workspace: " .. wezterm.basename(dir),
|
||||
cwd = dir,
|
||||
args = { "nvim" },
|
||||
})
|
||||
local dirs = workspaces.cached_dirs()
|
||||
if #dirs ~= 0 then
|
||||
for _, dir in ipairs(dirs) do
|
||||
table.insert(config.launch_menu, {
|
||||
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
|
||||
|
|
|
|||
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 State = require("plugins.sessionizer.state")
|
||||
local State = require("plugins.sessionizer.src.state")
|
||||
|
||||
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.
|
||||
-- @param cmd table: command and arguments
|
||||
-- @param retries number: number of attempts
|
||||
-- @param delay_ms number: delay in milliseconds between attempts
|
||||
-- @return boolean, string|nil: success status and output
|
||||
function M.retry_command(cmd, retries, delay_ms)
|
||||
local last_out = nil
|
||||
for a = 1, retries do
|
||||
local ok, out = wezterm.run_child_process(cmd)
|
||||
last_out = out
|
||||
if ok then
|
||||
return true, out
|
||||
end
|
||||
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)
|
||||
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
|
||||
end
|
||||
|
||||
|
|
@ -60,7 +131,7 @@ function M.depth_flags(req)
|
|||
-- Unlimited depth
|
||||
return {}, {}
|
||||
end
|
||||
local d = req or State.DEFAULT_DEPTH
|
||||
local d = req or State.default_depth
|
||||
return { "--max-depth", tostring(d) }, { "-maxdepth", tostring(d) }
|
||||
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