Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)`
Expand Down Expand Up @@ -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!
Expand Down
2 changes: 1 addition & 1 deletion plugin/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
100 changes: 100 additions & 0 deletions plugin/resurrect/pane_tree.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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

Expand All @@ -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
Expand Down
157 changes: 157 additions & 0 deletions plugin/resurrect/spec/ensure_folder_exists_spec.lua
Original file line number Diff line number Diff line change
@@ -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)
Loading