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
48 changes: 13 additions & 35 deletions lua/async-remote-write/browse.lua
Original file line number Diff line number Diff line change
Expand Up @@ -352,15 +352,10 @@ function M.warm_single_directory(dir_url, job, callback)
path = path .. "/"
end

-- Use same command as level-based browser
local bash_cmd = [[
cd %s 2>/dev/null && \
find . -maxdepth 1 -not -name "." | while read f; do \
if [ -d "$f" ]; then echo "d $f"; else echo "f $f"; fi \
done | sort
]]
-- Use ssh_utils helper to build directory listing command
local sh_cmd = ssh_utils.build_list_dir_cmd(path, { sorted = false })
Comment on lines +355 to +356
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorted = false currently selects LIST_DIR_SCRIPT_UNSORTED, which removes sorting entirely. In the code being replaced here, the output was sorted (there was a | sort), so this is a behavior change that can affect UI ordering. If the intent is to preserve existing sorted output, call ssh_utils.build_list_dir_cmd(path) (default sorted) or pass { sorted = true }.

Suggested change
-- Use ssh_utils helper to build directory listing command
local sh_cmd = ssh_utils.build_list_dir_cmd(path, { sorted = false })
-- Use ssh_utils helper to build directory listing command (default: sorted)
local sh_cmd = ssh_utils.build_list_dir_cmd(path)

Copilot uses AI. Check for mistakes.

local cmd = { "ssh", host, string.format(bash_cmd, vim.fn.shellescape(path)) }
local cmd = { "ssh", host, sh_cmd }
local output = {}

local job_id = vim.fn.jobstart(cmd, {
Expand Down Expand Up @@ -556,17 +551,10 @@ function M.browse_remote_directory(url, reset_selections)
return
end

-- Use a bash script that's compatible with most systems
local bash_cmd = [[
cd %s && \
find . -maxdepth 1 | sort | while read f; do
if [ "$f" != "." ]; then
if [ -d "$f" ]; then echo "d ${f#./}"; else echo "f ${f#./}"; fi
fi
done
]]
-- Use ssh_utils helper to build directory listing command
local sh_cmd = ssh_utils.build_list_dir_cmd(path, { sorted = false })

local cmd = { "ssh", host, string.format(bash_cmd, vim.fn.shellescape(path)) }
local cmd = { "ssh", host, sh_cmd }

-- Create job to execute command
local output = {}
Expand Down Expand Up @@ -1339,15 +1327,10 @@ function M.browse_remote_level_based(url, reset_selections)
return
end

-- Use maxdepth 1 to get ONLY immediate children
local bash_cmd = [[
cd %s 2>/dev/null && \
find . -maxdepth 1 -not -name "." | while read f; do \
if [ -d "$f" ]; then echo "d $f"; else echo "f $f"; fi \
done | sort
]]
-- Use ssh_utils helper to build directory listing command
local sh_cmd = ssh_utils.build_list_dir_cmd(path, { sorted = false })

local cmd = { "ssh", host, string.format(bash_cmd, vim.fn.shellescape(path)) }
local cmd = { "ssh", host, sh_cmd }
local output = {}
local stderr_output = {}

Expand Down Expand Up @@ -1575,11 +1558,8 @@ function M.load_directory_for_tree(url, depth, callback)
path = path .. "/"
end

-- Build the SSH command
local ssh_cmd = string.format(
'cd %s && find . -maxdepth 1 | sort | while read f; do if [ "$f" != "." ]; then if [ -d "$f" ]; then echo "d ${f#./}"; else echo "f ${f#./}"; fi; fi; done',
vim.fn.shellescape(path)
)
-- Use ssh_utils helper to build directory listing command
local ssh_cmd = ssh_utils.build_list_dir_cmd(path, { sorted = false })

local output = {}
local stderr_output = {}
Expand Down Expand Up @@ -4001,10 +3981,8 @@ function M.load_directory_v2(url, callback)
path = path .. "/"
end

local ssh_cmd = string.format(
'cd %s && find . -maxdepth 1 | sort | while read f; do if [ "$f" != "." ]; then if [ -d "$f" ]; then echo "d ${f#./}"; else echo "f ${f#./}"; fi; fi; done',
vim.fn.shellescape(path)
)
-- Use ssh_utils helper to build directory listing command
local ssh_cmd = ssh_utils.build_list_dir_cmd(path, { sorted = false })

local output = {}
local stderr_output = {}
Expand Down
37 changes: 37 additions & 0 deletions lua/async-remote-write/ssh_utils.lua
Original file line number Diff line number Diff line change
Expand Up @@ -228,4 +228,41 @@ end
-- Expose is_localhost for other modules that might need it
M.is_localhost = is_localhost

-- Shell script for listing directory contents (sorted)
-- Uses sh -c to ensure POSIX shell compatibility (works with fish, zsh, etc.)
-- Uses IFS= read -r to handle filenames with spaces/backslashes
local LIST_DIR_SCRIPT = [[
cd "$1" && find . -maxdepth 1 | sort | while IFS= read -r f; do
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using cd \"$1\" can mis-handle paths that begin with - (e.g. - and -P are treated specially by some shells). To make this robust for all valid directory names, use cd -- \"$1\".

Copilot uses AI. Check for mistakes.
if [ "$f" != "." ]; then
if [ -d "$f" ]; then
echo "d ${f#./}"
else
echo "f ${f#./}"
fi
fi
done
]]

-- Shell script for listing directory contents (unsorted, with error suppression)
local LIST_DIR_SCRIPT_UNSORTED = [[
cd "$1" 2>/dev/null && find . -maxdepth 1 -not -name "." | while IFS= read -r f; do
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as the sorted script: cd \"$1\" can treat paths starting with - as options/special args in some shells. Use cd -- \"$1\" (keeping the 2>/dev/null redirection if desired).

Copilot uses AI. Check for mistakes.
if [ -d "$f" ]; then
echo "d ${f#./}"
else
echo "f ${f#./}"
fi
done
]]

--- Build a shell command for listing directory contents via SSH
--- @param path string The remote directory path
--- @param opts? {sorted?: boolean} Options: sorted (default true)
--- @return string The shell command to execute
function M.build_list_dir_cmd(path, opts)
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are updated tests for the default command shape, but no test coverage validating the opts.sorted = false branch (e.g., that it includes 2>/dev/null, omits sort, and excludes .). Adding a focused test for the sorted=false behavior would help prevent accidental behavior changes.

Copilot uses AI. Check for mistakes.
opts = opts or {}
local sorted = opts.sorted ~= false -- default to true
local script = sorted and LIST_DIR_SCRIPT or LIST_DIR_SCRIPT_UNSORTED
Comment on lines +257 to +264
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The opts.sorted flag does more than control sorting: when sorted is false it also changes behaviors like suppressing cd errors (2>/dev/null) and filtering out . via -not -name \".\". This makes the option name misleading for callers. Consider splitting this into separate options (e.g. opts.sorted, opts.suppress_errors, opts.exclude_dot) or keeping the semantics aligned so sorted only affects sorting.

Copilot uses AI. Check for mistakes.
return string.format("sh -c %s _ %s", vim.fn.shellescape(script), vim.fn.shellescape(path))
end

return M
6 changes: 2 additions & 4 deletions lua/async-remote-write/tree_browser.lua
Original file line number Diff line number Diff line change
Expand Up @@ -406,10 +406,8 @@ local function load_directory(url, callback)
path = path .. "/"
end

local ssh_cmd = string.format(
'cd %s && find . -maxdepth 1 | sort | while read f; do if [ "$f" != "." ]; then if [ -d "$f" ]; then echo "d ${f#./}"; else echo "f ${f#./}"; fi; fi; done',
vim.fn.shellescape(path)
)
-- Use ssh_utils helper to build directory listing command
local ssh_cmd = ssh_utils.build_list_dir_cmd(path)

local output = {}
local stderr_output = {}
Expand Down
21 changes: 9 additions & 12 deletions tests/test_file_browser_debug.lua
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
-- Debug test for file browser SSH issues
local test = require("tests.init")
local ssh_utils = require("async-remote-write.ssh_utils")

test.describe("File Browser Debug Tests", function()
test.it("should simulate the exact tree browser load_directory scenario", function()
Expand All @@ -19,13 +20,10 @@ test.describe("File Browser Debug Tests", function()
path = path .. "/"
end

-- Build the exact SSH command from tree_browser.lua
local ssh_cmd = string.format(
'cd %s && find . -maxdepth 1 | sort | while read f; do if [ "$f" != "." ]; then if [ -d "$f" ]; then echo "d ${f#./}"; else echo "f ${f#./}"; fi; fi; done',
vim.fn.shellescape(path)
)
-- Build the SSH command using ssh_utils
local ssh_cmd = ssh_utils.build_list_dir_cmd(path)

test.assert.contains(ssh_cmd, "cd", "SSH command should contain cd")
test.assert.contains(ssh_cmd, "sh -c", "SSH command should use sh -c")
test.assert.contains(ssh_cmd, "/home/testuser/repos/tokio/", "SSH command should contain the path")
test.assert.contains(ssh_cmd, "find . -maxdepth 1", "SSH command should contain find")

Expand Down Expand Up @@ -136,8 +134,7 @@ test.describe("File Browser Debug Tests", function()
local host = "testuser@localhost"
local exit_code = 255
local stderr_output = { "Connection closed by ::1 port 22" }
local ssh_cmd =
'cd \'/home/testuser/repos/tokio/\' && find . -maxdepth 1 | sort | while read f; do if [ "$f" != "." ]; then if [ -d "$f" ]; then echo "d ${f#./}"; else echo "f ${f#./}"; fi; fi; done'
local ssh_cmd = ssh_utils.build_list_dir_cmd("/home/testuser/repos/tokio/")

-- Build error message like tree_browser.lua does
local error_msg = "Failed to list directory: " .. url .. " (exit code: " .. exit_code .. ")"
Expand All @@ -154,20 +151,20 @@ test.describe("File Browser Debug Tests", function()

test.it("should test the actual SSH command construction with IPv4", function()
local host = "testuser@localhost"
local command = "cd '/home/testuser/repos/tokio/' && find . -maxdepth 1"
local command = ssh_utils.build_list_dir_cmd("/home/testuser/repos/tokio/")

-- Mock ssh_utils.build_ssh_cmd behavior
local function build_ssh_cmd(host, cmd)
local function build_ssh_cmd(h, cmd)
local ssh_args = { "ssh" }

-- Check if host contains localhost (even with user@)
local is_localhost = host:match("localhost") or host:match("127%.0%.0%.1") or host:match("::1")
local is_localhost = h:match("localhost") or h:match("127%.0%.0%.1") or h:match("::1")

if is_localhost then
table.insert(ssh_args, "-4")
end

table.insert(ssh_args, host)
table.insert(ssh_args, h)
table.insert(ssh_args, cmd)

return ssh_args
Expand Down
62 changes: 5 additions & 57 deletions tests/test_file_browser_ssh.lua
Original file line number Diff line number Diff line change
@@ -1,55 +1,6 @@
-- Test file browser SSH functionality
local test = require("tests.init")

-- Mock ssh_utils functions for testing
local ssh_utils = {}

ssh_utils.is_localhost = function(host)
return host == "localhost" or host == "127.0.0.1" or host == "::1"
end

ssh_utils.build_ssh_cmd = function(host, command)
local ssh_args = { "ssh" }

-- Add IPv4 preference for localhost connections to avoid IPv6 issues
if ssh_utils.is_localhost(host) then
table.insert(ssh_args, "-4")
end

table.insert(ssh_args, host)
table.insert(ssh_args, command)

return ssh_args
end

ssh_utils.build_scp_cmd = function(source, destination, options)
local scp_args = { "scp" }

-- Add standard options
if options then
for _, opt in ipairs(options) do
table.insert(scp_args, opt)
end
end

-- Extract host from source or destination to check for localhost
local host = nil
if source:match("^[^:]+:") then
host = source:match("^([^:]+):")
elseif destination:match("^[^:]+:") then
host = destination:match("^([^:]+):")
end

-- Add IPv4 preference for localhost connections
if host and ssh_utils.is_localhost(host) then
table.insert(scp_args, "-4")
end

table.insert(scp_args, source)
table.insert(scp_args, destination)

return scp_args
end
local ssh_utils = require("async-remote-write.ssh_utils")

test.describe("File Browser SSH Commands", function()
test.it("should build SSH commands correctly for localhost", function()
Expand Down Expand Up @@ -152,18 +103,15 @@ test.describe("File Browser SSH Commands", function()

test.it("should construct directory listing command correctly", function()
local path = "/home/user/test/"
local escaped_path = vim.fn.shellescape(path)

local ssh_cmd = string.format(
'cd %s && find . -maxdepth 1 | sort | while read f; do if [ "$f" != "." ]; then if [ -d "$f" ]; then echo "d ${f#./}"; else echo "f ${f#./}"; fi; fi; done',
escaped_path
)
-- Build the SSH command using ssh_utils
local ssh_cmd = ssh_utils.build_list_dir_cmd(path)

test.assert.contains(ssh_cmd, "cd", "Command should contain cd")
test.assert.contains(ssh_cmd, "sh -c", "Command should use sh -c")
test.assert.contains(ssh_cmd, "find", "Command should contain find")
test.assert.contains(ssh_cmd, "-maxdepth 1", "Command should contain maxdepth limit")
test.assert.contains(ssh_cmd, "sort", "Command should contain sort")
test.assert.contains(ssh_cmd, "while read", "Command should contain while loop")
test.assert.contains(ssh_cmd, "while IFS= read -r", "Command should contain while loop")
test.assert.contains(ssh_cmd, 'echo "d', "Command should output directory marker")
test.assert.contains(ssh_cmd, 'echo "f', "Command should output file marker")
end)
Expand Down
62 changes: 23 additions & 39 deletions tests/test_ssh_command_escaping.lua
Original file line number Diff line number Diff line change
@@ -1,71 +1,55 @@
-- Test SSH command escaping functionality
local test = require("tests.init")
local ssh_utils = require("async-remote-write.ssh_utils")

test.describe("SSH Command Escaping", function()
test.it("should properly escape paths with spaces", function()
local path = "/home/user/My Documents/test dir/"
local escaped_path = vim.fn.shellescape(path, 1)

-- Test that the escaped path contains proper quoting
test.assert.contains(escaped_path, "My Documents", "Escaped path should contain the directory name")

-- Test SSH command construction
local ssh_cmd = string.format(
'cd %s && find . -maxdepth 1 | sort | while read f; do if [ \\"\\$f\\" != \\".\\" ]; then if [ -d \\"\\$f\\" ]; then echo \\"d \\${f#./}\\"; else echo \\"f \\${f#./}\\"; fi; fi; done',
escaped_path
)
local ssh_cmd = ssh_utils.build_list_dir_cmd(path)

-- Verify command structure
test.assert.contains(ssh_cmd, "cd", "SSH command should contain cd command")
test.assert.contains(ssh_cmd, "sh -c", "SSH command should use sh -c")
test.assert.contains(ssh_cmd, "find", "SSH command should contain find command")
test.assert.contains(ssh_cmd, '\\"\\$f\\"', "SSH command should have properly escaped variables")
test.assert.contains(ssh_cmd, "My Documents", "SSH command should contain the path")
end)

test.it("should properly escape paths with quotes", function()
local path = "/home/user/test's dir/"
local escaped_path = vim.fn.shellescape(path, 1)

-- Test SSH command construction with quotes
local ssh_cmd = string.format(
'cd %s && find . -maxdepth 1 | sort | while read f; do if [ \\"\\$f\\" != \\".\\" ]; then if [ -d \\"\\$f\\" ]; then echo \\"d \\${f#./}\\"; else echo \\"f \\${f#./}\\"; fi; fi; done',
escaped_path
)
local ssh_cmd = ssh_utils.build_list_dir_cmd(path)

-- Verify command doesn't break with quotes
test.assert.contains(ssh_cmd, "cd", "SSH command should contain cd command")
test.assert.truthy(#ssh_cmd > 50, "SSH command should be properly constructed")
-- Verify command structure
test.assert.contains(ssh_cmd, "sh -c", "SSH command should use sh -c")
-- Verify the path is properly shell-escaped and passed as argument
local escaped_path = vim.fn.shellescape(path)
test.assert.contains(ssh_cmd, escaped_path, "SSH command should contain properly escaped path")
end)

test.it("should properly escape paths with special characters", function()
local path = "/home/user/test (dir) & more/"
local escaped_path = vim.fn.shellescape(path, 1)

-- Test SSH command construction with special characters
local ssh_cmd = string.format(
'cd %s && find . -maxdepth 1 | sort | while read f; do if [ \\"\\$f\\" != \\".\\" ]; then if [ -d \\"\\$f\\" ]; then echo \\"d \\${f#./}\\"; else echo \\"f \\${f#./}\\"; fi; fi; done',
escaped_path
)
local ssh_cmd = ssh_utils.build_list_dir_cmd(path)

-- Verify command structure is maintained
test.assert.contains(ssh_cmd, "cd", "SSH command should contain cd command")
test.assert.contains(ssh_cmd, "sh -c", "SSH command should use sh -c")
test.assert.contains(ssh_cmd, "find", "SSH command should contain find command")
test.assert.contains(ssh_cmd, '\\"\\$f\\"', "SSH command should have properly escaped variables")
end)

test.it("should handle simple paths without breaking", function()
local path = "/home/user/simple/"
local escaped_path = vim.fn.shellescape(path, 1)

-- Test SSH command construction
local ssh_cmd = string.format(
'cd %s && find . -maxdepth 1 | sort | while read f; do if [ \\"\\$f\\" != \\".\\" ]; then if [ -d \\"\\$f\\" ]; then echo \\"d \\${f#./}\\"; else echo \\"f \\${f#./}\\"; fi; fi; done',
escaped_path
)
local ssh_cmd = ssh_utils.build_list_dir_cmd(path)

-- Verify command contains expected elements
test.assert.contains(ssh_cmd, "/home/user/simple", "SSH command should contain the path")
test.assert.contains(ssh_cmd, "find . -maxdepth 1", "SSH command should contain find with maxdepth")
test.assert.contains(ssh_cmd, "sort", "SSH command should contain sort")
test.assert.contains(ssh_cmd, "while read f", "SSH command should contain while loop")
test.assert.contains(ssh_cmd, "while IFS= read -r f", "SSH command should contain while loop")
end)

test.it("should pass path as argument to avoid quoting issues", function()
local path = '/home/user/test\'s "quoted" dir/'
local ssh_cmd = ssh_utils.build_list_dir_cmd(path)

-- The key feature: path is passed as $1 argument, not embedded in script
test.assert.contains(ssh_cmd, "_ ", "SSH command should have placeholder for $0")
test.assert.contains(ssh_cmd, 'cd "$1"', "Script should reference $1 for path")
end)
end)
Loading