diff --git a/README.md b/README.md index ee35650..6bc1c9e 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,22 @@ local workspace_picker = wezterm.plugin.require("https://github.com/bugii/worksp workspace_picker.setup({ { path = "~/projects/my-project", type = "directory" }, { path = "~/projects/worktrees", type = "worktreeroot" }, +}, { + -- Optional: customize icons, colors, and fuzzy matching + icons = { + directory = "📁", + worktree = "🌳", + zoxide = "⚡", + workspace = "🖥️", + }, + colors = { + directory = "#61afef", -- Blue + worktree = "#98c379", -- Green + zoxide = "#e5c07b", -- Yellow + workspace = "#c678dd", -- Purple + }, + fuzzy = true, -- Enable/disable fuzzy matching (default: true) + fuzzy_sort = "previous_first", }) -- Apply default keybinding (LEADER + f) @@ -80,11 +96,16 @@ workspace_picker.setup({ worktree = "🌳", zoxide = "⚡", workspace = "🖥️", - } + }, + colors = { + directory = "#61afef", -- Blue + worktree = "#98c379", -- Green + zoxide = "#e5c07b", -- Yellow + workspace = "#c678dd", -- Purple + }, + fuzzy = true, -- Enable/disable fuzzy matching (default: true) + fuzzy_sort = "previous_first", }) - --- Apply to config with custom keybinding -workspace_picker.apply_to_config(config, "f", "CTRL") ``` ### Direct Workspace Switching @@ -120,6 +141,32 @@ config.keys = { | `direction` | string | No | Split direction of (child) panes: `"Right"` or `"Bottom"` (default: `"Right"`) | | `panes` | table | No | Array of pane configurations | +### Color Configuration + +You can customize the colors used for different workspace types in the picker: + +| Field | Type | Required | Description | +| ------------- | ------ | -------- | ---------------------------------------------------------------------------------- | +| `colors` | table | No | Object mapping workspace types to hex color strings | +| `colors.directory` | string | No | Color for directory workspaces (default: `"#61afef"` - blue) | +| `colors.worktree` | string | No | Color for git worktree workspaces (default: `"#98c379"` - green) | +| `colors.zoxide` | string | No | Color for zoxide directory workspaces (default: `"#e5c07b"` - yellow) | +| `colors.workspace` | string | No | Color for existing Wezterm workspaces (default: `"#c678dd"` - purple) | + +### Fuzzy Search Configuration + +You can enable or disable fuzzy matching in the workspace selector. When fuzzy mode is enabled, the workspace list can be optionally reordered to prioritize the current and previous workspaces. + +| Field | Type | Required | Description | +| ------------- | ------- | -------- | ---------------------------------------------------------------------------------- | +| `fuzzy` | boolean | No | Enable/disable fuzzy matching and workspace reordering (default: `true`) | +| `fuzzy_sort` | string | No | Sort order in fuzzy mode: `"previous_first"`, `"current_first"`, or `"none"` (default: `"previous_first"`) | + +**`fuzzy_sort` values:** +- `"previous_first"` — Previous active workspace at top, current second +- `"current_first"` — Current workspace at top, previous second +- `"none"` — No reordering (original priority order preserved) + ### Pane Configuration | Field | Type | Required | Description | diff --git a/plugin/init.lua b/plugin/init.lua index 45a64f1..6163c9c 100644 --- a/plugin/init.lua +++ b/plugin/init.lua @@ -19,21 +19,68 @@ local default_options = { zoxide = "⚡", workspace = "🖥️", }, + --- Default colors for different workspace types + colors = { + directory = "#61afef", -- Blue + worktree = "#98c379", -- Green + zoxide = "#e5c07b", -- Yellow + workspace = "#c678dd", -- Purple + }, + --- Whether to use fuzzy matching in the workspace selector + fuzzy = true, + --- Sort order in fuzzy mode: "current_first" (current at top, previous second), + --- "previous_first" (previous at top, current second), or "none" (no reordering) + fuzzy_sort = "previous_first", } --- Current options (merged with defaults) local options = {} +-- Track workspace history for "switch to previous workspace" functionality +local last_active_workspace = nil +local current_active_workspace = nil + +--- Helper to safely get the active workspace name for a window +--- @param window table: WezTerm window object +--- @return string|nil: active workspace name or nil +local function get_window_active_workspace(window) + if not window then + return nil + end + + -- Try window:active_workspace() if available + local ok, name = pcall(function() + return window:active_workspace() + end) + if ok and type(name) == "string" and name ~= "" then + return name + end + + -- Fallback to mux.get_active_workspace() + local ok2, name2 = pcall(function() + return mux.get_active_workspace() + end) + if ok2 and type(name2) == "string" and name2 ~= "" then + return name2 + end + + return nil +end + --- Expand tilde (~) in paths to home directory --- @param path string: Path that may contain ~ --- @return string|nil: Expanded path or nil if home directory cannot be determined --- @return string|nil: Error message if expansion fails local function expand_home_path(path) - if not path or type(path) ~= "string" then return nil, "Invalid path: expected string" end + if not path or type(path) ~= "string" then + return nil, "Invalid path: expected string" + end if path:sub(1, 1) == "~" then local home = wezterm.home_dir - if not home then return nil, "Unable to determine home directory" end + if not home then + return nil, "Unable to determine home directory" + end path = home .. path:sub(2) end return path, nil @@ -44,10 +91,14 @@ end --- @return string: Command output (empty string on error) --- @return string|nil: Error message if command failed local function run_command(cmd) - if not cmd or type(cmd) ~= "string" then return "", "Invalid command: expected string" end + if not cmd or type(cmd) ~= "string" then + return "", "Invalid command: expected string" + end local args = { os.getenv("SHELL"), "-l", "-c", cmd } - if is_windows then args = { "cmd", "/c", cmd } end + if is_windows then + args = { "cmd", "/c", cmd } + end local ok, stdout, stderr = wezterm.run_child_process(args) if not ok then @@ -62,10 +113,20 @@ end --- Create a formatted label for a workspace entry --- @param path string: Path to format --- @param icon string: Icon to use +--- @param workspace_type string: Type of workspace (directory, worktree, zoxide, workspace) --- @return table: WezTerm formatted text -local function format_workspace_label(path, icon) +local function format_workspace_label(path, icon, workspace_type) local display_path = path:gsub(wezterm.home_dir or "", "~") - return wezterm.format({ { Text = icon .. " " .. display_path } }) + + -- Get color from options, fallback to defaults, then to light gray + local color = options.colors and options.colors[workspace_type] + or default_options.colors[workspace_type] + or "#abb2bf" + + return wezterm.format({ + { Foreground = { Color = color } }, + { Text = icon .. " " .. display_path }, + }) end --- Get static and worktree-based workspace entries from user configuration @@ -78,9 +139,12 @@ local function get_config_entries() -- Handle git worktree root local expanded_path, err = expand_home_path(entry.path) if not expanded_path then - wezterm.log_error("Failed to expand worktree path '" .. entry.path .. "': " .. (err or "unknown error")) + wezterm.log_error( + "Failed to expand worktree path '" .. entry.path .. "': " .. (err or "unknown error") + ) else - local output, cmd_err = run_command("git -C " .. expanded_path .. " worktree list --porcelain") + local output, cmd_err = + run_command("git -C " .. expanded_path .. " worktree list --porcelain") if cmd_err then wezterm.log_error("Failed to get worktrees for '" .. expanded_path .. "': " .. cmd_err) else @@ -90,7 +154,7 @@ local function get_config_entries() if worktree_path and worktree_path ~= expanded_path then table.insert(entries, { id = worktree_path, - label = format_workspace_label(worktree_path, options.icons.worktree), + label = format_workspace_label(worktree_path, options.icons.worktree, "worktree"), type = "worktree", tabs = entry.tabs, }) @@ -103,11 +167,13 @@ local function get_config_entries() -- Handle static directory entry local expanded_path, err = expand_home_path(entry.path) if not expanded_path then - wezterm.log_error("Failed to expand path '" .. entry.path .. "': " .. (err or "unknown error")) + wezterm.log_error( + "Failed to expand path '" .. entry.path .. "': " .. (err or "unknown error") + ) else table.insert(entries, { id = expanded_path, - label = format_workspace_label(expanded_path, options.icons.directory), + label = format_workspace_label(expanded_path, options.icons.directory, "directory"), type = "directory", tabs = entry.tabs, }) @@ -133,7 +199,7 @@ local function get_zoxide_sessions() if line and line ~= "" then table.insert(sessions, { id = line, - label = format_workspace_label(line, options.icons.zoxide), + label = format_workspace_label(line, options.icons.zoxide, "zoxide"), type = "zoxide", tabs = nil, }) @@ -152,7 +218,7 @@ local function get_existing_workspaces() for _, name in ipairs(names or {}) do table.insert(workspaces, { id = name, - label = format_workspace_label(name, options.icons.workspace), + label = format_workspace_label(name, options.icons.workspace, "workspace"), type = "workspace", tabs = nil, }) @@ -165,13 +231,26 @@ end --- @return table: Array of workspace choice objects local function get_all_workspace_choices() local all_items = {} - local seen_ids = {} -- Track seen IDs to avoid duplicates + local seen_ids = {} + local config_by_id = {} + + -- Build map of configured entries by ID for tab merge + for _, entry in ipairs(get_config_entries()) do + config_by_id[entry.id] = entry + end --- Add items to the list, skipping duplicates --- @param items table: Array of workspace items to add - local function add_unique_items(items) + --- @param is_existing boolean: Whether these are existing workspaces + local function add_unique_items(items, is_existing) for _, item in ipairs(items or {}) do if item.id and not seen_ids[item.id] then + -- Merge configured tabs if available + if config_by_id[item.id] and config_by_id[item.id].tabs then + item.tabs = config_by_id[item.id].tabs + end + -- Mark whether workspace currently exists + item.is_existing = is_existing or false table.insert(all_items, item) seen_ids[item.id] = true end @@ -179,9 +258,52 @@ local function get_all_workspace_choices() end -- Add items in priority order (existing workspaces first, then configured, then zoxide) - add_unique_items(get_existing_workspaces()) - add_unique_items(get_config_entries()) - add_unique_items(get_zoxide_sessions()) + add_unique_items(get_existing_workspaces(), true) + add_unique_items(get_config_entries(), false) + add_unique_items(get_zoxide_sessions(), false) + + -- When fuzzy mode is enabled, optionally reorder current/previous workspace + if options.fuzzy and options.fuzzy_sort ~= "none" then + local current_item = nil + local previous_item = nil + + for _, item in ipairs(all_items) do + if + current_active_workspace + and item.id == current_active_workspace + and item.type == "workspace" + then + current_item = item + elseif + last_active_workspace + and item.id == last_active_workspace + and item.type == "workspace" + then + previous_item = item + end + end + + local first, second + if options.fuzzy_sort == "previous_first" then + first, second = previous_item, current_item + else + first, second = current_item, previous_item + end + + local reordered = {} + if first then + table.insert(reordered, first) + end + if second then + table.insert(reordered, second) + end + for _, item in ipairs(all_items) do + if (not first or item.id ~= first.id) and (not second or item.id ~= second.id) then + table.insert(reordered, item) + end + end + return reordered + end return all_items end @@ -192,14 +314,18 @@ end --- @param panes table --- @return table: size values for successive split calls local function compute_split_sizes(panes) - if not panes or #panes < 2 then return {} end + if not panes or #panes < 2 then + return {} + end local weights = {} local total = 0 for i, p in ipairs(panes) do local w = p.size if type(w) ~= "number" or w <= 0 then - if w ~= nil then wezterm.log_error("Invalid pane size (must be positive number weight): index " .. i) end + if w ~= nil then + wezterm.log_error("Invalid pane size (must be positive number weight): index " .. i) + end w = 1 end weights[i] = w @@ -234,7 +360,9 @@ end local function create_pane_splits(mux_pane, node) -- Base case: leaf node with optional command if not node or not node.panes or #node.panes == 0 then - if node and node.command then mux_pane:send_text(node.command .. "\r") end + if node and node.command then + mux_pane:send_text(node.command .. "\r") + end return end @@ -317,7 +445,15 @@ end --- @param window table: WezTerm window object --- @param pane table: WezTerm pane object local function create_or_switch_workspace(item, window, pane) - if item.type == "workspace" then + local active = get_window_active_workspace(window) + if active then + if current_active_workspace ~= active then + last_active_workspace = current_active_workspace + current_active_workspace = active + end + end + + if item.is_existing then -- just switch in case already existing workspace window:perform_action( act.SwitchToWorkspace({ @@ -325,26 +461,18 @@ local function create_or_switch_workspace(item, window, pane) }), pane ) + local new_active = item.id + if new_active and new_active ~= current_active_workspace then + last_active_workspace = current_active_workspace + current_active_workspace = new_active + end else - -- For new workspaces (not existing ones), create the window first local cwd, err = expand_home_path(item.id) if err then wezterm.log_error("Failed to expand workspace path '" .. item.id .. "': " .. err) cwd = item.id -- fallback to original path end - local _, _, new_window = mux.spawn_window({ - workspace = item.id, - cwd = cwd, - }) - - if not new_window then - wezterm.log_error("Failed to create workspace window for '" .. item.id .. "'") - return - end - - -- Switch to the workspace - -- NOTE: has to be done before creating panes due to sizing information i guess window:perform_action( act.SwitchToWorkspace({ name = item.id, @@ -355,8 +483,15 @@ local function create_or_switch_workspace(item, window, pane) pane ) - -- Create tabs and panes if configured - if item.tabs and #item.tabs > 0 then create_tabs_with_panes(new_window, item.tabs) end + local new_active = item.id + if new_active and new_active ~= current_active_workspace then + last_active_workspace = current_active_workspace + current_active_workspace = new_active + end + + if item.tabs and #item.tabs > 0 then + create_tabs_with_panes(new_window, item.tabs) + end end end @@ -380,7 +515,7 @@ end --- Create the workspace switcher action --- @return table: WezTerm action callback -local function switch_workspace_action() +function M.switch_workspace_action() return wezterm.action_callback(function(window, pane) local choices = get_all_workspace_choices() @@ -397,9 +532,11 @@ local function switch_workspace_action() act.InputSelector({ title = "Select Workspace", choices = selector_choices, - fuzzy = true, - action = wezterm.action_callback(function(win, p, id) - if not id then return end + fuzzy = options.fuzzy, + action = wezterm.action_callback(function(win, p, id, label) + if not id then + return + end -- Find the selected item local selected_item = nil @@ -409,6 +546,7 @@ local function switch_workspace_action() break end end + wezterm.log_info("Selected workspace: " .. selected_item.id) if not selected_item then wezterm.log_error("Selected workspace not found: " .. id) @@ -423,20 +561,60 @@ local function switch_workspace_action() end) end +--- Switch to previously active workspace +--- @return table: WezTerm action callback +function M.switch_to_previous_workspace() + return wezterm.action_callback(function(window, pane) + if not last_active_workspace or last_active_workspace == "" then + wezterm.log_info("No previously active workspace recorded") + return + end + + -- Find matching choice among all known workspaces; if not found, treat as name + local choices = get_all_workspace_choices() + local found = nil + for _, choice in ipairs(choices) do + if choice.id == last_active_workspace then + found = choice + break + end + end + + if found then + create_or_switch_workspace(found, window, pane) + else + -- If choice isn't found, attempt a direct switch by name + window:perform_action(act.SwitchToWorkspace({ name = last_active_workspace }), pane) + -- update history after switch + local new_active = last_active_workspace + if new_active and new_active ~= current_active_workspace then + last_active_workspace = current_active_workspace + current_active_workspace = new_active + end + end + end) +end + --- Validate a single workspace configuration entry --- @param entry table: Configuration entry to validate --- @return boolean: True if valid --- @return string|nil: Error message if invalid local function validate_config_entry(entry) - if type(entry) ~= "table" then return false, "Configuration entry must be a table" end + if type(entry) ~= "table" then + return false, "Configuration entry must be a table" + end - if not entry.path then return false, "'path' is required for each workspace entry" end + if not entry.path then + return false, "'path' is required for each workspace entry" + end if entry.type and entry.type ~= "directory" and entry.type ~= "worktreeroot" then return false, "Invalid type '" .. entry.type .. "'. Must be 'directory' or 'worktreeroot'" end - if entry.tabs and type(entry.tabs) ~= "table" then return false, "'tabs' must be a table if provided" end + if entry.tabs and type(entry.tabs) ~= "table" then + return false, "'tabs' must be a table if provided" + end return true, nil end @@ -465,28 +643,27 @@ function M.setup(config, opts) wezterm.log_error(string.format("Configuration entry %d: %s", i, error_msg)) else -- Set default type if not specified - if not entry.type then entry.type = "directory" end + if not entry.type then + entry.type = "directory" + end -- Ensure tabs is a table - if not entry.tabs then entry.tabs = {} end + if not entry.tabs then + entry.tabs = {} + end table.insert(user_config, entry) end end end + + -- initialize current active workspace if available + current_active_workspace = get_window_active_workspace( + wezterm.gui and wezterm.gui.get_window and wezterm.gui.get_window() or nil + ) or mux.get_active_workspace() end ---- Apply default keybindings to WezTerm configuration --- @param config table: WezTerm configuration table ---- @param key string|nil: Key to bind (default: "f") ---- @param mods string|nil: Modifiers for keybinding (default: "LEADER") -function M.apply_to_config(config, key, mods) - config.keys = config.keys or {} - table.insert(config.keys, { - key = key or "f", - mods = mods or "LEADER", - action = switch_workspace_action(), - }) -end +function M.apply_to_config(config) end return M