commit c4379654bc179c72c57693b12d16a95d21c1651d Author: Jeremie Fraeys Date: Thu Jul 17 12:14:24 2025 -0400 refactored to use apply_to_config diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..dd84ea7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md new file mode 100644 index 0000000..48d5f81 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/custom.md @@ -0,0 +1,10 @@ +--- +name: Custom issue template +about: Describe this issue template's purpose here. +title: '' +labels: '' +assignees: '' + +--- + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..45e48bf --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +andrew@brunker.net.au. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..36eb81d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Andrew Brunker + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..14078ef --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# Sessionizer for 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) + +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 + +## Setup + +An example configuration calling the plugin + +```lua +local wezterm = require "wezterm" +local sessionizer = wezterm.plugin.require("https://github.com/ElCapitanSponge/sessionizer.wezterm") + +local config = {} + +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) + +return config +``` + +## USEAGE + +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. + +```lua +local projects = { + "~/personal", + "~/work" +} +``` + +To display the sessionizer all you have to do is press the key combination of +`LEADER` + `f` + +To display the active windows/sessions all you have to do is press the key +combination of `LEADER` + `s` + +## Change keybinding + +To change the keybinding from the default (`LEADER` + `f`): + +```lua +config.keys = { + -- ... other bindings + { + key = "w", + mods = "CTRL|ALT", + action = sessionizer.switch_workspace() + } +} +``` diff --git a/command_builder.lua b/command_builder.lua new file mode 100644 index 0000000..dae6031 --- /dev/null +++ b/command_builder.lua @@ -0,0 +1,82 @@ +local wezterm = require("wezterm") +local State = require("state") +local utils = require("utils") + +local M = {} + +-- Cache tool availability and scan results +local _has_fd = nil +local _scan_cache = {} + +-- 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() + + 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 + +-- Scan the base directory and return a structured list of paths +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 res = {} + for _, line in ipairs(wezterm.split_by_newlines(out)) do + table.insert(res, { + id = line, + label = line:gsub(wezterm.home_dir, "~"), + workspace = utils.basename(line), -- used for workspace naming + title = utils.basename(line), -- optional: used for tab titles + }) + end + + _scan_cache[key] = res + return res +end + +return M diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..f6c5ba0 --- /dev/null +++ b/init.lua @@ -0,0 +1,43 @@ +local wezterm = require("wezterm") +local state = require("sessionizer.state") +local ui = require("sessionizer.ui") +local workspaces = require("sessionizer.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, + }) + 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" }, + }) + end + end +end + +return M diff --git a/schema_builder.lua b/schema_builder.lua new file mode 100644 index 0000000..763a58f --- /dev/null +++ b/schema_builder.lua @@ -0,0 +1,79 @@ +local plugin = require("wezterm.plugin") + +return plugin.with_schema({ + name = "sessionizer", + description = "Project-based sessionizer plugin for WezTerm", + parameters = { + projects = { + description = "List of project base directories and their max depth.", + type = "array", + default = {}, + example = { + { path = "~/projects", max_depth = 3 }, + { path = "~/work", max_depth = 2 }, + }, + items = { + oneOf = { + { type = "string" }, + { + type = "object", + properties = { + path = { + description = "Path to the base directory.", + type = "string", + }, + max_depth = { + description = "Maximum recursive search depth.", + type = "integer", + default = 3, + }, + }, + required = { "path" }, + }, + }, + }, + }, + + exclude_dirs = { + description = "Directory names to exclude from scanning.", + type = "array", + items = { type = "string" }, + default = { + ".git", + "node_modules", + ".vscode", + ".svn", + ".hg", + ".idea", + ".DS_Store", + "__pycache__", + "target", + "build", + }, + }, + + default_depth = { + description = "Default maximum depth for directory scanning.", + type = "integer", + default = 3, + }, + + key = { + description = "Key to trigger the session switcher (default: 'f')", + type = "string", + default = "f", + }, + + mods = { + description = "Modifier keys to trigger the switcher (default: 'LEADER')", + type = "string", + default = "LEADER", + }, + + add_to_launch_menu = { + description = "Whether to append scanned workspaces to the launch menu.", + type = "boolean", + default = false, + }, + }, +}) diff --git a/state.lua b/state.lua new file mode 100644 index 0000000..129fe23 --- /dev/null +++ b/state.lua @@ -0,0 +1,25 @@ +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 new file mode 100644 index 0000000..963dc94 --- /dev/null +++ b/ui.lua @@ -0,0 +1,63 @@ +local wezterm = require("wezterm") +local act = wezterm.action +local workspace = require("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 + + -- Expand ~ to home directory reliably + local cwd = wezterm.expand_path(id) + + win:perform_action( + act.SwitchToWorkspace({ + name = id, -- use the full path or unique ID as workspace name + spawn = { + label = "Workspace: " .. label, + cwd = cwd, + }, + }), + 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 + -- Notify user visually that no workspaces are available + wezterm.toast_notification({ + title = "Sessionizer", + message = "No workspaces found", + timeout_milliseconds = 3000, + }) + return + end + + win:perform_action( + act.InputSelector({ + title = "WezTerm Sessionizer", + fuzzy = true, -- Enable fuzzy search + -- Case-insensitive search (default is true, but explicit is nice) + fuzzy_match_algorithm = "fzy", + choices = choices, + action = wezterm.action_callback(switch_logic), + }), + pane + ) + end) +end + +return M diff --git a/utils.lua b/utils.lua new file mode 100644 index 0000000..cc3bbe0 --- /dev/null +++ b/utils.lua @@ -0,0 +1,93 @@ +local wezterm = require("wezterm") +local State = require("state") + +local M = {} + +--- 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) + for a = 1, retries do + local ok, out = wezterm.run_child_process(cmd) + 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)) + wezterm.sleep_ms(delay_ms) + end + end + return false +end + +--- Calculate a basic checksum from a list of items (based on `id` string). +-- Used for detecting changes in project listings. +-- @param list table: list of tables with `id` field +-- @return string: 8-digit hex checksum +function M.checksum(list) + local h = 0 + for _, v in ipairs(list) do + for i = 1, #v.id do + h = (h * 31 + v.id:byte(i)) & 0xFFFFFFFF + end + end + return ("%08x"):format(h) +end + +--- Return platform-specific exclude flags for `fd` or PowerShell. +-- Memoized to avoid rebuilding each call. +-- @return table: list of flags for file search +function M.get_exclude_flags() + if State._exclude_flags then + return State._exclude_flags + end + local flags = {} + for _, d in ipairs(State.exclude_dirs) do + table.insert(flags, State.is_windows and "-Exclude" or "--exclude") + table.insert(flags, d) + end + State._exclude_flags = flags + return flags +end + +--- Return max-depth flags for `fd` and `find`. +-- @param req number|nil: requested depth, or fallback to default +-- @return table, table: fd-style flags, find-style flags +function M.depth_flags(req) + if req == -1 then + -- Unlimited depth + return {}, {} + end + local d = req or State.DEFAULT_DEPTH + return { "--max-depth", tostring(d) }, { "-maxdepth", tostring(d) } +end + +--- Return the final component (basename) of a path. +-- Works on both `/` and `\` for cross-platform support. +-- @param path string: full path +-- @return string: base directory or file name +function M.basename(path) + return path:match("([^/\\]+)[/\\]*$") or path +end + +--- Build a list of prune flags for use in a `find` command. +-- These exclude specific subdirectories during traversal. +-- @param base string: base path +-- @param dirs table: list of directory names to exclude +-- @return table: list of flags for `find` +function M.build_prune_flags(base, dirs) + local flags = {} + for _, d in ipairs(dirs or {}) do + table.insert(flags, "(") + table.insert(flags, "-path") + table.insert(flags, base .. "/" .. d) + table.insert(flags, "-prune") + table.insert(flags, ")") + table.insert(flags, "-o") + end + return flags +end + +return M diff --git a/workspace.lua b/workspace.lua new file mode 100644 index 0000000..f7e5499 --- /dev/null +++ b/workspace.lua @@ -0,0 +1,48 @@ +local wezterm = require("wezterm") +local state = require("state") +local command_builder = require("command_builder") +local utils = require("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