refactor(sessionizer): move lua modules under src

This commit is contained in:
Jeremie Fraeys 2026-02-03 11:42:52 -05:00
parent 60d1b38018
commit 1c93cd8c46
No known key found for this signature in database
14 changed files with 991 additions and 318 deletions

117
README.md
View file

@ -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. 🚀

View file

@ -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

View file

@ -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
View 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
View 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
View 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
View 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
View 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
View 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

View file

@ -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
View 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

View file

@ -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
View file

@ -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

View file

@ -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