diff --git a/README.md b/README.md index 7042d03..a7b378e 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,48 @@ You can add the `opts` table to change the behaviour. It exposes the following o `save_windows` will save windows if true otherwise not. `save_tabs` will save tabs if true otherwise not. +### Event-driven saving of state + +`resurrect.state_manager.event_driven_save(opts?)` saves state immediately whenever +the pane or tab structure changes (new split, new tab, closed pane), rather than +waiting for a periodic timer. This is the recommended approach when you want +state to always be current. + +```lua +resurrect.state_manager.event_driven_save({ + save_workspaces = true, -- default: true + save_windows = false, -- default: false + save_tabs = false, -- default: false + user_var = nil, -- optional: name of a user variable to also trigger saves +}) +``` + +`save_workspaces`, `save_windows`, and `save_tabs` mirror the same options in `periodic_save`. + +`user_var` enables an additional save trigger via shell integration. When set, a save +fires whenever the shell sends an OSC 1337 `SetUserVar` sequence with that variable name. +This is useful for saving on directory change. Example shell integration (zsh/bash): + +```sh +# In your .zshrc / .bashrc — fires only when $PWD changes +_wezterm_precmd() { + if [[ "$PWD" != "$_WEZTERM_LAST_PWD" ]]; then + _WEZTERM_LAST_PWD="$PWD" + printf "\033]1337;SetUserVar=WEZTERM_SAVE=%s\007" "$(printf 1 | base64)" + fi +} +precmd_functions+=(_wezterm_precmd) +``` + +Then pass the matching variable name to `event_driven_save`: + +```lua +resurrect.state_manager.event_driven_save({ user_var = "WEZTERM_SAVE" }) +``` + +`event_driven_save` also keeps `current_state` up to date on every save, which is +required for `resurrect_on_gui_startup` to restore the correct workspace. + ### Resurrecting on startup You can resume from where you left off by resurrecting on startup with @@ -344,6 +386,8 @@ This plugin emits the following events that you can use for your own callback fu - `resurrect.state_manager.delete_state.start(file_path)` - `resurrect.state_manager.load_state.finished(name, type)` - `resurrect.state_manager.load_state.start(name, type)` +- `resurrect.state_manager.event_driven_save.start(opts)` +- `resurrect.state_manager.event_driven_save.finished(opts)` - `resurrect.state_manager.periodic_save.start(opts)` - `resurrect.state_manager.periodic_save.finished(opts)` - `resurrect.file_io.write_state.finished(file_path, event_type)` @@ -505,6 +549,35 @@ to see where they are stored. You can then update them individually using git pu Add `wezterm.plugin.update_all()` to your Wezterm config. +## Testing + +Tests are run with Busted via LuaRocks. + +All OSes: + +```sh +luarocks install busted +``` + +Run tests: + +```sh +eval "$(luarocks path)" +busted +``` + +Windows notes: + +- PowerShell is the most reliable shell for running LuaRocks and Busted (Git Bash/MSYS can mangle arguments and paths). +- If `luarocks install busted` fails while building native dependencies (for example `luasystem`), install a GCC toolchain (MinGW-w64 or MSYS2 MinGW64) and make sure its `bin` directory is on `PATH` for the PowerShell session. + +PowerShell usage: + +```powershell +Invoke-Expression (luarocks path) +busted +``` + ## Contributions Suggestions, Issues and PRs are welcome! diff --git a/plugin/init.lua b/plugin/init.lua index 799c249..99c09a5 100644 --- a/plugin/init.lua +++ b/plugin/init.lua @@ -11,7 +11,7 @@ local function init() -- enable_sub_modules() local opts = { auto = true, - keywords = { "github", "MLFlexer", "resurrect", "wezterm" }, + keywords = { "resurrect", "wezterm" }, } local plugin_path = dev.setup(opts) diff --git a/plugin/resurrect/pane_tree.lua b/plugin/resurrect/pane_tree.lua index 98c7aea..c7598a2 100644 --- a/plugin/resurrect/pane_tree.lua +++ b/plugin/resurrect/pane_tree.lua @@ -75,6 +75,13 @@ local function insert_panes(root, panes) return nil end + -- Guard against duplicate processing in symmetric layouts + -- In a perfect cross layout, a pane can appear in both right and bottom branches + -- If already processed by another branch, skip to avoid nil pane access + if root.pane == nil then + return root + end + local domain = root.pane:get_domain_name() if not wezterm.mux.get_domain(domain):is_spawnable() then wezterm.log_warn("Domain " .. domain .. " is not spawnable") @@ -87,7 +94,13 @@ local function insert_panes(root, panes) else root.cwd = root.pane:get_current_working_dir().file_path if utils.is_windows then + -- WezTerm returns file_path as /C:/... on Windows; strip the leading slash. root.cwd = root.cwd:gsub("^/([a-zA-Z]):", "%1:") + -- WSL mounts Windows drives at /mnt/c/...; convert to C:\... so that + -- WezTerm's mux can validate the path in Windows context before spawning. + root.cwd = root.cwd:gsub("^/mnt/([a-zA-Z])(.*)", function(drive, rest) + return drive:upper() .. ":" .. rest:gsub("/", "\\") + end) end end @@ -101,6 +114,93 @@ local function insert_panes(root, panes) process_info.children = nil process_info.pid = nil process_info.ppid = nil + + local nix_store = '/nix/store/' + + -- Since NixOS uses immutable paths for executables, + -- we need to sanitize them before saving, + -- otherwise restoring sessions will be a pain. + if process_info.executable and process_info.executable:find(nix_store) then + -- Replace executable path with `process_info.name`, + -- because nix store paths are not stable across sessions, + -- as well as being long and ugly. + -- + -- Plus they pollute shell history if restored as part of `executable` + `argv`. + process_info.executable = process_info.name or process_info.executable + + -- Clean up `process_info.argv` by removing command flags followed by `*/nix/store/*` paths. + -- + -- Original `argv` stored by `resurrect.wezterm` before sanitization: + -- + -- [ + -- "/nix/store/jx332jllgyrqbnzi8svnk8xbygc9nbmp-neovim-unwrapped-0.11.5/bin/nvim", + -- "--cmd", + -- "lua vim.g.loaded_node_provider=0;vim.g.loaded_perl_provider=0;vim.g.loaded_python_provider=0;vim.g.python3_host_prog='/nix/store/252cmdyhmr8ai7qz266yrawgmx7nfz5h-neovim-0.11.5/bin/nvim-python3';vim.g.ruby_host_prog='/nix/store/252cmdyhmr8ai7qz266yrawgmx7nfz5h-neovim-0.11.5/bin/nvim-ruby'", + -- "--cmd", + -- "set packpath^=/nix/store/g0f4d93y9q79q84qq4g41lyfcw3i1z7h-vim-pack-dir", + -- "--cmd", + -- "set rtp^=/nix/store/g0f4d93y9q79q84qq4g41lyfcw3i1z7h-vim-pack-dir", + -- "Cargo.toml" + -- ] + -- + -- Sanitized `argv` after processing: + -- [ + -- "nvim", + -- "Cargo.toml", + -- ] + -- + -- Meaning that any `--cmd` or `-c` flags containing `/nix/store/*` paths are removed entirely from `argv`, + -- while keeping other arguments intact. + -- + -- On restoration, the executable will be resolved via `PATH`, + -- so as long as `nvim`/`vim`/`gvim` is available in `PATH`, it should work fine. + if process_info.argv then + local args = {} + local flag = nil + local executables = { + nvim = true, + vim = true, + gvim = true, + } + local is_vim = executables[process_info.executable] + + for i, arg in ipairs(process_info.argv) do + if i == 1 then + -- Ensure first element of `argv` is the `executable` path, + -- which we have already sanitized above. + args[#args + 1] = process_info.executable + else + if is_vim == nil then + -- For non-vim executables, we only need to sanitize the `executable` path, + -- so we can keep the rest of `argv` as is. + + args[#args + 1] = arg + else + if arg == '--cmd' or arg == '-c' then + -- Save current flag for later use, in case next `arg` is `/nix/store/*` path (see next condition). + flag = arg + elseif flag ~= nil then + if arg:find(nix_store) then + -- Skip this `arg` as it contains `/nix/store/*` path + -- Do not add anything to `args` + else + -- Not a nix store path, keep both `flag` and `arg` (value). + args[#args + 1] = flag + args[#args + 1] = arg + end + + flag = nil + else + args[#args + 1] = arg + end + end + end + end + + process_info.argv = args + end + end + root.process = process_info else local nlines = root.pane:get_dimensions().scrollback_rows diff --git a/plugin/resurrect/spec/ensure_folder_exists_spec.lua b/plugin/resurrect/spec/ensure_folder_exists_spec.lua new file mode 100644 index 0000000..e3c7138 --- /dev/null +++ b/plugin/resurrect/spec/ensure_folder_exists_spec.lua @@ -0,0 +1,157 @@ +local function is_windows() + return package.config:sub(1, 1) == "\\" +end + +-- Minimal wezterm stub for utils.lua. +local wezterm_stub = { + target_triple = is_windows() and "x86_64-pc-windows-msvc" or "x86_64-unknown-linux-gnu", +} +_G.wezterm = wezterm_stub +package.preload["wezterm"] = function() + return wezterm_stub +end + +local search_paths = { + -- repo root + "./plugin/?.lua", + "./plugin/?/init.lua", + "./plugin/?/?.lua", + -- when cwd is plugin/resurrect + "../../plugin/?.lua", + "../../plugin/?/init.lua", + "../../plugin/?/?.lua", +} + +package.path = table.concat(search_paths, ";") .. ";" .. package.path + +local utils = require("resurrect.utils") + +local sep = utils.is_windows and "\\" or "/" + +-- Probe by writing a temp file inside the directory. +-- os.rename(dir, dir) can return nil on Windows for permission/lock reasons +-- even when the directory exists, giving a misleading false negative. +local function dir_exists(path) + local probe = path .. sep .. ".probe" + local f = io.open(probe, "w") + if f then + f:close() + os.remove(probe) + return true + end + return false +end + +local function rmdir_recursive(path) + if utils.is_windows then + if not path:find('"') then + os.execute('rmdir /s /q "' .. path .. '" >nul 2>&1') + end + else + local quoted = "'" .. path:gsub("'", "'\\''") .. "'" + os.execute("rm -rf " .. quoted) + end +end + +-- Returns a unique absolute temp path without creating it. +-- tostring({}) yields a unique table address within this process; combined with +-- os.time() it is extremely unlikely to collide across concurrent processes. +local function unique_tmp_base() + local id = tostring(os.time()) .. "_" .. tostring({}):gsub("[^%w]", "") + if utils.is_windows then + local tmp_dir = os.getenv("TEMP") or os.getenv("TMP") or "C:\\Temp" + return tmp_dir .. "\\_resurrect_test_" .. id + else + return "/tmp/_resurrect_test_" .. id + end +end + +describe("utils.ensure_folder_exists", function() + local test_base + local cleanup_extras -- additional paths cleaned up by after_each + + before_each(function() + test_base = unique_tmp_base() + cleanup_extras = {} + end) + + after_each(function() + rmdir_recursive(test_base) + for _, path in ipairs(cleanup_extras) do + rmdir_recursive(path) + end + end) + + it("creates a nested directory structure", function() + local nested = test_base .. sep .. "a" .. sep .. "b" + assert.is_true(utils.ensure_folder_exists(nested)) + assert.is_true(dir_exists(test_base)) + assert.is_true(dir_exists(nested)) + end) + + -- The open-handle false-negative scenario (Windows only, triggered when + -- WezTerm holds a handle to the directory) cannot be tested portably without + -- monkey-patching io.open. The idempotency test below exercises the + -- happy-path re-entry but not the open-handle scenario. + it("is idempotent on an existing path", function() + local nested = test_base .. sep .. "a" .. sep .. "b" + assert.is_true(utils.ensure_folder_exists(nested)) + assert.is_true(utils.ensure_folder_exists(nested)) + end) + + it("handles directory names containing spaces", function() + local spaced = test_base .. sep .. "dir with spaces" .. sep .. "nested dir" + assert.is_true(utils.ensure_folder_exists(spaced)) + assert.is_true(dir_exists(spaced)) + end) + + it("returns false when a path component is a file, not a directory", function() + assert.is_true(utils.ensure_folder_exists(test_base)) + local obstacle = test_base .. sep .. "obstacle.txt" + local f = assert(io.open(obstacle, "w")) + f:write("x") + f:close() + assert.is_false(utils.ensure_folder_exists(obstacle .. sep .. "child")) + end) + + it("handles relative paths", function() + local id = tostring({}):gsub("[^%w]", "") + local rel_base = "_resurrect_rel_" .. id + -- Register before asserting so after_each cleans up even on failure. + -- This path lands in CWD rather than the temp root, so it cannot be + -- covered by the test_base cleanup. + table.insert(cleanup_extras, rel_base) + local rel_nested = rel_base .. sep .. "a" .. sep .. "b" + assert.is_true(utils.ensure_folder_exists(rel_nested)) + assert.is_true(dir_exists(rel_nested)) + end) + + -- Windows-only path form tests. + -- UNC paths (\\server\share\...) are not tested: the server and share + -- components cannot be created via mkdir, so a meaningful test would require + -- a live network share or privileged loopback (\\localhost\c$\...) that is + -- not suitable for a local or CI environment. + if utils.is_windows then + it("handles absolute paths with a drive letter", function() + local drive = (os.getenv("TEMP") or "C:\\"):match("^(%a:)") or "C:" + local abs_base = drive .. "\\_resurrect_abs_" .. tostring({}):gsub("[^%w]", "") + table.insert(cleanup_extras, abs_base) + local abs_nested = abs_base .. "\\x\\y" + assert.is_true(utils.ensure_folder_exists(abs_nested)) + assert.is_true(dir_exists(abs_nested)) + end) + + it("normalises drive-relative paths (C:foo) to absolute from drive root", function() + local drive = (os.getenv("TEMP") or "C:\\"):match("^(%a:)") or "C:" + local id = tostring({}):gsub("[^%w]", "") + local abs_base = drive .. "\\_resurrect_driverel_" .. id + table.insert(cleanup_extras, abs_base) + -- Pass the path without a separator after the drive letter. + local driverel = drive .. "_resurrect_driverel_" .. id .. "\\sub" + -- The function should normalise this to drive:\... and create it there. + local expected = abs_base .. "\\sub" + assert.is_true(utils.ensure_folder_exists(driverel)) + assert.is_true(dir_exists(expected)) + end) + end +end) diff --git a/plugin/resurrect/spec/state_manager_spec.lua b/plugin/resurrect/spec/state_manager_spec.lua new file mode 100644 index 0000000..b26c340 --- /dev/null +++ b/plugin/resurrect/spec/state_manager_spec.lua @@ -0,0 +1,75 @@ +local function is_windows() + return package.config:sub(1, 1) == "\\" +end + +-- Minimal wezterm stub. +local wezterm_stub = { + target_triple = is_windows() and "x86_64-pc-windows-msvc" or "x86_64-unknown-linux-gnu", + emit = function() end, +} +_G.wezterm = wezterm_stub +package.preload["wezterm"] = function() + return wezterm_stub +end + +-- Stub file_io and capture the path passed to load_json so we can assert +-- on what get_file_path (a local function) actually produced. +local last_load_path +package.preload["resurrect.file_io"] = function() + return { + load_json = function(path) + last_load_path = path + return {} + end, + write_state = function() end, + write_file = function() end, + } +end + +local search_paths = { + -- repo root + "./plugin/?.lua", + "./plugin/?/init.lua", + "./plugin/?/?.lua", + -- when cwd is plugin/resurrect + "../../plugin/?.lua", + "../../plugin/?/init.lua", + "../../plugin/?/?.lua", +} + +package.path = table.concat(search_paths, ";") .. ";" .. package.path + +local state_manager = require("resurrect.state_manager") + +local sep = is_windows() and "\\" or "/" +local base = is_windows() + and ((os.getenv("TEMP") or os.getenv("TMP") or "C:\\Temp") .. "\\resurrect_sm_test") + or "/tmp/resurrect_sm_test" + +-- get_file_path is a local function and cannot be called directly. +-- These tests exercise it via load_state(), which passes its return value +-- straight to file_io.load_json() with no intervening transformation. +describe("state_manager path construction (via load_state)", function() + before_each(function() + last_load_path = nil + state_manager.save_state_dir = base + end) + + it("separates save_state_dir and type with a path separator", function() + state_manager.load_state("myworkspace", "workspace") + assert.equals(base .. sep .. "workspace" .. sep .. "myworkspace.json", last_load_path) + end) + + it("replaces path separator characters in file names with +", function() + state_manager.load_state("foo" .. sep .. "bar", "workspace") + assert.equals(base .. sep .. "workspace" .. sep .. "foo+bar.json", last_load_path) + end) + + it("replaces reserved characters : [ ] ? / in file names with +", function() + state_manager.load_state("name:with[reserved]chars?and/slashes", "window") + assert.equals( + base .. sep .. "window" .. sep .. "name+with+reserved+chars+and+slashes.json", + last_load_path + ) + end) +end) diff --git a/plugin/resurrect/state_manager.lua b/plugin/resurrect/state_manager.lua index f9af610..25be54f 100644 --- a/plugin/resurrect/state_manager.lua +++ b/plugin/resurrect/state_manager.lua @@ -13,10 +13,10 @@ local function get_file_path(file_name, type, opt_name) file_name = opt_name end return string.format( - "%s%s" .. utils.separator .. "%s.json", + "%s" .. utils.separator .. "%s" .. utils.separator .. "%s.json", pub.save_state_dir, type, - file_name:gsub(utils.separator, "+") + file_name:gsub("[" .. utils.separator .. ":%[%]?/]", "+") ) end @@ -90,6 +90,80 @@ function pub.periodic_save(opts) end) end +---Saves the state whenever the pane or tab structure changes. +---More responsive than periodic_save: fires immediately on splits, new tabs, +---and closed panes rather than waiting for a timer. +---Also supports an optional user variable trigger for shell-reported events +---such as directory changes (requires shell integration to send the OSC 1337 +---SetUserVar sequence; see the README for details). +---@param opts? { save_workspaces: boolean?, save_windows: boolean?, save_tabs: boolean?, user_var: string? } +function pub.event_driven_save(opts) + opts = opts or {} + if opts.save_workspaces == nil then + opts.save_workspaces = true + end + + local last_structure = {} + + local function do_save(window) + wezterm.emit("resurrect.state_manager.event_driven_save.start", opts) + + if opts.save_workspaces then + local workspace_state = require("resurrect.workspace_state").get_workspace_state() + pub.save_state(workspace_state) + pub.write_current_state(workspace_state.workspace, "workspace") + end + + if opts.save_windows then + local mux_win = window:mux_window() + local title = mux_win:get_title() + if title ~= "" and title ~= nil then + pub.save_state(require("resurrect.window_state").get_window_state(mux_win)) + end + end + + if opts.save_tabs then + local mux_win = window:mux_window() + for _, mux_tab in ipairs(mux_win:tabs()) do + local title = mux_tab:get_title() + if title ~= "" and title ~= nil then + pub.save_state(require("resurrect.tab_state").get_tab_state(mux_tab)) + end + end + end + + wezterm.emit("resurrect.state_manager.event_driven_save.finished", opts) + end + + -- Save when the pane/tab structure changes (new split, new tab, closed pane). + -- pane-focus-changed fires on every focus move, so we compare tab+pane counts + -- and only save when the structure actually changes. + wezterm.on("pane-focus-changed", function(window, pane) + local win_id = tostring(window:window_id()) + local tabs = window:mux_window():tabs() + local pane_count = 0 + for _, tab in ipairs(tabs) do + pane_count = pane_count + #tab:panes() + end + local sig = #tabs .. ":" .. pane_count + if last_structure[win_id] ~= sig then + last_structure[win_id] = sig + do_save(window) + end + end) + + -- Optional: also save when the shell reports a user-defined variable change. + -- Useful for saving on directory change. Example shell integration (zsh/bash): + -- precmd() { printf "\033]1337;SetUserVar=WEZTERM_SAVE=%s\007" "$(printf 1 | base64)"; } + if opts.user_var then + wezterm.on("user-var-changed", function(window, pane, name, value) + if name == opts.user_var then + do_save(window) + end + end) + end +end + ---Writes the current state name and type ---@param name string ---@param type string diff --git a/plugin/resurrect/tab_state.lua b/plugin/resurrect/tab_state.lua index c312333..83eade4 100644 --- a/plugin/resurrect/tab_state.lua +++ b/plugin/resurrect/tab_state.lua @@ -1,5 +1,6 @@ local wezterm = require("wezterm") --[[@as Wezterm]] --- this type cast invokes the LSP module for Wezterm local pane_tree_mod = require("resurrect.pane_tree") +local state_manager_mod = require("resurrect.state_manager") local pub = {} ---Function used to split panes when mapping over the pane_tree @@ -121,7 +122,6 @@ end function pub.save_tab_action() return wezterm.action_callback(function(win, pane) - local resurrect = require("resurrect") local tab = pane:tab() if tab:get_title() == "" then win:perform_action( @@ -131,7 +131,7 @@ function pub.save_tab_action() if title then callback_pane:tab():set_title(title) local state = pub.get_tab_state(tab) - resurrect.save_state(state) + state_manager_mod.save_state(state) end end), }), @@ -139,7 +139,7 @@ function pub.save_tab_action() ) elseif tab:get_title() then local state = pub.get_tab_state(tab) - resurrect.state_manager.save_state(state) + state_manager_mod.save_state(state) end end) end diff --git a/plugin/resurrect/utils.lua b/plugin/resurrect/utils.lua index 1fa20b0..312312c 100644 --- a/plugin/resurrect/utils.lua +++ b/plugin/resurrect/utils.lua @@ -68,14 +68,128 @@ function utils.execute(cmd) end end --- Create the folder if it does not exist +-- Shell-safe wrapper around mkdir for a single already-assembled path segment. +-- Uses wezterm.run_child_process instead of os.execute to avoid visible +-- cmd.exe window flashes on Windows (fixes #125). +local function shell_mkdir(path) + if utils.is_windows then + if path:find('"') then + return false + end + local success, _, _ = wezterm.run_child_process({ "cmd.exe", "/c", "mkdir", path }) + return success + else + local quoted = "'" .. path:gsub("'", "'\\''") .. "'" + local success, _, _ = wezterm.run_child_process({ "sh", "-c", "mkdir " .. quoted }) + return success + end +end + +-- Normalise separators and strip the root prefix from path. +-- Returns the platform separator, the root component (e.g. "C:\", "/", "\\"), +-- and the remaining path with the root removed. +-- On Windows forward slashes are converted to backslashes before parsing. ---@param path string -function utils.ensure_folder_exists(path) +---@return string sep, string root, string stripped +local function parse_root(path) + local sep if utils.is_windows then - os.execute('mkdir /p "' .. path:gsub("/", "\\" .. '"')) + sep = "\\" + path = path:gsub("/", sep) else - os.execute('mkdir -p "' .. path .. '"') + sep = "/" + end + + local root = "" + if utils.is_windows then + local drive = path:match("^(%a:)[/\\]") + if drive then + -- Absolute path (e.g. C:\foo): capture "C:", append sep to form "C:\", + -- then strip the 3-char prefix so the remainder is "foo\...". + root = drive .. sep + path = path:sub(4) + elseif path:match("^%a:[^/\\]") then + -- Drive-relative path (e.g. C:foo); normalise to absolute from drive root. + -- Strip the 2-char "C:" prefix; root gets the explicit separator added. + root = path:sub(1, 2) .. sep + path = path:sub(3) + elseif path:sub(1, 2) == "\\\\" then + -- UNC path (e.g. \\server\share\...): strip the 2-char "\\" prefix. + -- The server and share components cannot be created via mkdir; + -- this only works when the share already exists. + root = "\\\\" + path = path:sub(3) + end + else + if path:sub(1, 1) == "/" then + -- Absolute Unix path: strip the leading separator; root is "/". + root = "/" + path = path:sub(2) + end + end + + return sep, root, path +end + +-- Probe-write check: attempts to create and immediately remove a temp file +-- inside path. More reliable than os.rename on Windows, where open handles +-- held by WezTerm itself cause os.rename(dir, dir) to return nil even when +-- the directory exists and is fully usable. +-- A unique suffix from tostring({}) (table address) avoids collisions across +-- concurrent processes or calls. +local function dir_is_accessible(path) + local probe = path .. utils.separator .. ".resurrect_probe_" .. tostring({}):gsub("[^%w]", "") + local f = io.open(probe, "w") + if f then + f:close() + os.remove(probe) + return true + end + return false +end + +-- Ensure a single already-assembled path exists, creating it if necessary. +-- Returns false if the directory could not be created or verified. +---@param path string +---@return boolean +local function mkdir_if_missing(path) + -- Probe-write is the primary existence check. os.rename is skipped because + -- it gives false negatives on Windows when WezTerm holds open handles, + -- which would cause shell_mkdir to be called on every startup for + -- directories that already exist, producing visible cmd.exe window flashes. + if dir_is_accessible(path) then + return true + end + if shell_mkdir(path) then + -- Post-verify: confirm the directory is actually usable after creation. + return dir_is_accessible(path) + end + return false +end + +-- Create the folder if it does not exist. +-- Drive-relative paths on Windows (e.g. C:foo\bar) are normalised to absolute +-- from the drive root (C:\foo\bar). UNC paths (\\server\share\...) are +-- supported only when the server and share components already exist. +-- Path components are not sanitized; . and .. segments produce undefined behavior. +---@param path string +---@return boolean success +function utils.ensure_folder_exists(path) + local sep, root, stripped = parse_root(path) + local current = root + for part in string.gmatch(stripped, "[^" .. sep .. "]+") do + if current == "" then + current = part + elseif current:sub(-1) == sep then + current = current .. part + else + current = current .. sep .. part + end + if not mkdir_if_missing(current) then + return false + end end + return true end -- deep copy diff --git a/plugin/resurrect/window_state.lua b/plugin/resurrect/window_state.lua index d556f2a..fa94601 100644 --- a/plugin/resurrect/window_state.lua +++ b/plugin/resurrect/window_state.lua @@ -1,7 +1,9 @@ local wezterm = require("wezterm") --[[@as Wezterm]] --- this type cast invokes the LSP module for Wezterm local tab_state_mod = require("resurrect.tab_state") +local state_manager_mod = require("resurrect.state_manager") local pub = {} + ---Returns the state of the window ---@param window MuxWindow ---@return window_state @@ -95,7 +97,7 @@ function pub.save_window_action() if title then window:mux_window():set_title(title) local state = pub.get_window_state(mux_win) - resurrect.save_state(state) + state_manager_mod.save_state(state) end end), }), @@ -103,7 +105,7 @@ function pub.save_window_action() ) elseif mux_win:get_title() then local state = pub.get_window_state(mux_win) - resurrect.save_state(state) + state_manager_mod.save_state(state) end end) end diff --git a/plugin/resurrect/workspace_state.lua b/plugin/resurrect/workspace_state.lua index 319f92b..7b732f9 100644 --- a/plugin/resurrect/workspace_state.lua +++ b/plugin/resurrect/workspace_state.lua @@ -42,6 +42,11 @@ function pub.restore_workspace(workspace_state, opts) window_state_mod.restore_window(opts.window, window_state, opts) end + if opts.spawn_in_workspace then + wezterm.mux.set_active_workspace(workspace_state.workspace) + else + wezterm.mux.rename_workspace(wezterm.mux.get_active_workspace(), workspace_state.workspace) + end wezterm.emit("resurrect.workspace_state.restore_workspace.finished") end