Skip to content
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,11 @@ require("sshfs").setup({
preferred_picker = "auto", -- one of: "auto", "snacks", "fzf-lua", "telescope", "mini"
},
},
debug = {
enabled = false, -- write SSH/SSHFS diagnostics to a log file
log_file = vim.fn.stdpath("log") .. "/sshfs.nvim.log",
max_size = 1048576, -- rotate to <log_file>.1 past this size (0 disables)
},
lead_prefix = "<leader>m", -- change keymap prefix (default: <leader>m)
keymaps = {
mount = "<leader>mm", -- creates an ssh connection and mounts via sshfs
Expand Down Expand Up @@ -296,6 +301,7 @@ require("sshfs").setup({
- `:SSHDisconnectAll` - Unmount all hosts
- `:SSHConfig` - Edit SSH config files
- `:SSHReload` - Reload SSH configuration
- `:SSHDebug [on|off]` - Toggle debug logging for the current Neovim session
- `:SSHFiles` - Find files with auto-detected picker
- `:SSHGrep [pattern]` - Search files with auto-detected tool
- `:SSHLiveFind [pattern]` - Stream remote `find`/`fd` results over SSH (snacks/fzf-lua/telescope/mini)
Expand All @@ -305,6 +311,8 @@ require("sshfs").setup({
- `:SSHCommand [cmd]` - Run custom command (e.g. `Oil`, `Telescope`)
- `:SSHTerminal` - Open terminal session (reuses auth)

Debug logging records SSH authentication, ControlMaster, remote-home resolution, and SSHFS subprocess diagnostics. See [`docs/debug-logging.md`](docs/debug-logging.md) for details.

## 🎹 Key Mapping

Default keybindings under `<leader>m` (fully customizable):
Expand Down
30 changes: 30 additions & 0 deletions docs/debug-logging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Debug logging

Debug logging is disabled by default. Enable it in setup:

```lua
require("sshfs").setup({
debug = {
enabled = true,
log_file = vim.fn.stdpath("log") .. "/sshfs.nvim.log",
max_size = 1048576,
},
})
```

Once the log passes `max_size` bytes it is moved to `<log_file>.1` and a fresh file is started, so only the last two generations are kept. Set `max_size = 0` to disable rotation and let the file grow.

You can also change debug logging for the current Neovim session without changing configuration:

```vim
:SSHDebug
:SSHDebug on
:SSHDebug off
```

With no argument, `:SSHDebug` toggles logging. The command reports the active log path.

When enabled, sshfs.nvim records timestamped diagnostics for SSH authentication, ControlMaster setup/cleanup, remote home resolution, and SSHFS mount subprocesses, including exit codes, stdout, and stderr. No log file writes are performed while debug logging is disabled.

> [!WARNING]
> Debug logs may contain hostnames, usernames, filesystem paths, SSH/SSHFS output, or authentication-related messages. Review logs before sharing them publicly.
35 changes: 35 additions & 0 deletions lua/sshfs/api.lua
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,31 @@ Api.reload = function()
Session.reload()
end

--- Toggle debug logging for the current Neovim session
--- @param mode string|nil Optional explicit mode: "on" or "off"
Api.debug = function(mode)
local Logger = require("sshfs.lib.logger")
local enabled

if mode == nil or mode == "" then
enabled = Logger.toggle()
elseif mode == "on" then
Logger.enable()
enabled = true
elseif mode == "off" then
Logger.disable()
enabled = false
else
vim.notify("Usage: :SSHDebug [on|off]", vim.log.levels.ERROR)
return
end

vim.notify(
string.format("sshfs.nvim debug logging %s: %s", enabled and "enabled" or "disabled", Logger.path()),
vim.log.levels.INFO
)
end

--- Browse remote files using native file browser
--- @param opts table|nil Picker options
Api.find_files = function(opts)
Expand Down Expand Up @@ -373,6 +398,16 @@ Api.setup = function()
Api.reload()
end, { desc = "Reload SSH configuration" })

vim.api.nvim_create_user_command("SSHDebug", function(opts)
Api.debug(opts.args)
end, {
nargs = "?",
complete = function()
return { "on", "off" }
end,
desc = "Toggle sshfs.nvim debug logging",
})

vim.api.nvim_create_user_command("SSHDisconnect", function()
Api.unmount()
end, { desc = "Disconnect from current SSH host" })
Expand Down
5 changes: 5 additions & 0 deletions lua/sshfs/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ local DEFAULT_CONFIG = {
preferred_picker = "auto", -- "auto", "telescope", "fzf-lua", "snacks", "mini"
},
},
debug = {
enabled = false, -- Enable debug logging
log_file = vim.fn.stdpath("log") .. "/sshfs.nvim.log", -- Debug log destination
max_size = 1048576, -- Rotate the log to .1 once it passes this many bytes
},
keymaps = nil, -- Override individual keymaps (e.g., {mount = "<leader>mm", unmount = "<leader>mu"})
lead_prefix = "<leader>m", -- Prefix for default keymaps
}
Expand Down
4 changes: 4 additions & 0 deletions lua/sshfs/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ function App.setup(user_opts)
Config.setup(user_opts)
local opts = Config.get()

local Logger = require("sshfs.lib.logger")
Logger.debug("sshfs.nvim initialized", { log_file = Logger.path() })

-- Initialize other modules
local MountPoint = require("sshfs.lib.mount_point")
MountPoint.get_or_create()
Expand Down Expand Up @@ -84,6 +87,7 @@ App.explore = Api.explore
App.change_dir = Api.change_dir
App.ssh_terminal = Api.ssh_terminal
App.command = Api.command
App.debug = Api.debug
App.test = Api.test

return App
158 changes: 158 additions & 0 deletions lua/sshfs/lib/logger.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
-- lua/sshfs/lib/logger.lua
-- Centralized debug logging for sshfs.nvim

local Logger = {}
local runtime_enabled = nil
local last_write_error = nil

local function get_config()
-- Logging can be reached before Config.setup() has run, so tolerate no config.
local ok, config = pcall(function()
return require("sshfs.config").get()
end)
if not ok or type(config) ~= "table" then return {} end
return config.debug or {}
end

local NAMED_ESCAPES = { ["\r"] = "\\r", ["\n"] = "\\n", ["\t"] = "\\t" }

local function escape_log_value(value)
-- Remote output reaches this file, so no control byte may survive to a terminal.
return (
tostring(value):gsub("%c", function(char)
return NAMED_ESCAPES[char] or string.format("\\x%02x", char:byte())
end)
)
end

local function ensure_parent_dir(path)
local parent = vim.fn.fnamemodify(path, ":h")
if parent ~= "" and vim.fn.isdirectory(parent) == 0 then
local result = vim.fn.mkdir(parent, "p", "0700")
if result == 0 and vim.fn.isdirectory(parent) == 0 then error("could not create log directory: " .. parent) end
end
end

--- Move the log aside once it passes max_size, keeping a single previous generation
local function rotate_if_needed(path, max_size)
if not max_size or max_size <= 0 then return end

local uv = vim.uv or vim.loop
local stat = uv.fs_stat(path)
if stat and stat.size >= max_size then uv.fs_rename(path, path .. ".1") end
end

local function write_log_line(path, line)
local uv = vim.uv or vim.loop
local fd, open_err = uv.fs_open(path, "a", 384)
if not fd then error("could not open log file: " .. tostring(open_err)) end

local chmod_ok, chmod_err = uv.fs_fchmod(fd, 384)
if not chmod_ok then
uv.fs_close(fd)
error("could not set log file permissions: " .. tostring(chmod_err))
end

local written, write_err = uv.fs_write(fd, line .. "\n", -1)
uv.fs_close(fd)
if not written then error("could not write log file: " .. tostring(write_err)) end
end

local function serialize_context(context)
if not context then return "" end
if type(context) ~= "table" then return " " .. escape_log_value(context) end

local parts = {}
for key, value in pairs(context) do
if value ~= nil then
local rendered = type(value) == "table" and vim.inspect(value) or tostring(value)
table.insert(parts, string.format("%s=%s", key, escape_log_value(rendered)))
end
end
table.sort(parts)

return #parts > 0 and (" " .. table.concat(parts, " ")) or ""
end

local function notify_write_failure(err)
local message = tostring(err)
if message == last_write_error then return end
last_write_error = message

vim.schedule(function()
vim.notify("sshfs.nvim: failed to write debug log: " .. message, vim.log.levels.ERROR)
end)
end

function Logger.is_enabled()
if runtime_enabled ~= nil then return runtime_enabled end
return get_config().enabled == true
end

function Logger.set_enabled(enabled)
runtime_enabled = enabled == true
end

function Logger.enable()
Logger.set_enabled(true)
end

function Logger.disable()
Logger.set_enabled(false)
end

function Logger.toggle()
Logger.set_enabled(not Logger.is_enabled())
return Logger.is_enabled()
end

function Logger.path()
local config = get_config()
return vim.fn.expand(config.log_file or (vim.fn.stdpath("log") .. "/sshfs.nvim.log"))
end

function Logger.log(level, message, context)
if not Logger.is_enabled() then return end

-- Callers pass a builder when the context costs something to assemble.
if type(context) == "function" then context = context() end

local path = Logger.path()
local line = string.format(
"%s [%s] %s%s",
os.date("%Y-%m-%d %H:%M:%S"),
string.upper(level or "debug"),
escape_log_value(message),
serialize_context(context)
)

local ok, result = pcall(function()
ensure_parent_dir(path)
rotate_if_needed(path, get_config().max_size)
write_log_line(path, line)
end)

if not ok then
notify_write_failure(result)
else
last_write_error = nil
end
end

function Logger.debug(message, context)
Logger.log("debug", message, context)
end

function Logger.info(message, context)
Logger.log("info", message, context)
end

function Logger.warn(message, context)
Logger.log("warn", message, context)
end

function Logger.error(message, context)
Logger.log("error", message, context)
end

return Logger
Loading
Loading