diff --git a/README.md b/README.md index 14078ef..afcb5fc 100644 --- a/README.md +++ b/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 ` 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. 🚀 diff --git a/command_builder.lua b/command_builder.lua deleted file mode 100644 index 5423142..0000000 --- a/command_builder.lua +++ /dev/null @@ -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 diff --git a/init.lua b/init.lua index ae01098..8ab9f9a 100644 --- a/init.lua +++ b/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 diff --git a/src/command_builder.lua b/src/command_builder.lua new file mode 100644 index 0000000..2b65147 --- /dev/null +++ b/src/command_builder.lua @@ -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 diff --git a/src/config.lua b/src/config.lua new file mode 100644 index 0000000..ec9313f --- /dev/null +++ b/src/config.lua @@ -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 diff --git a/src/history.lua b/src/history.lua new file mode 100644 index 0000000..efddf07 --- /dev/null +++ b/src/history.lua @@ -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 diff --git a/src/notify.lua b/src/notify.lua new file mode 100644 index 0000000..5c1d5ef --- /dev/null +++ b/src/notify.lua @@ -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 diff --git a/src/state.lua b/src/state.lua new file mode 100644 index 0000000..036eee6 --- /dev/null +++ b/src/state.lua @@ -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 \ No newline at end of file diff --git a/src/ui.lua b/src/ui.lua new file mode 100644 index 0000000..768fc54 --- /dev/null +++ b/src/ui.lua @@ -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 diff --git a/utils.lua b/src/utils.lua similarity index 55% rename from utils.lua rename to src/utils.lua index fdbd34a..064c15b 100644 --- a/utils.lua +++ b/src/utils.lua @@ -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 diff --git a/src/workspaces.lua b/src/workspaces.lua new file mode 100644 index 0000000..6f0ae50 --- /dev/null +++ b/src/workspaces.lua @@ -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 diff --git a/state.lua b/state.lua deleted file mode 100644 index 129fe23..0000000 --- a/state.lua +++ /dev/null @@ -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 diff --git a/ui.lua b/ui.lua deleted file mode 100644 index 46a614b..0000000 --- a/ui.lua +++ /dev/null @@ -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 diff --git a/workspaces.lua b/workspaces.lua deleted file mode 100644 index 35a363e..0000000 --- a/workspaces.lua +++ /dev/null @@ -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