diff --git a/lua/opencode/ui/completion/files.lua b/lua/opencode/ui/completion/files.lua index 249d2868..2761da52 100644 --- a/lua/opencode/ui/completion/files.lua +++ b/lua/opencode/ui/completion/files.lua @@ -85,7 +85,9 @@ local function create_file_item(file, suffix, priority) local dir = vim.fn.fnamemodify(file, ':h') local file_path = dir == '.' and filename or dir .. '/' .. filename local detail = dir == '.' and filename or dir .. '/' .. filename - local full_path = vim.fn.fnamemodify(file, ':p') + -- Build absolute path without resolving symlinks so that files inside + -- symlinked directories within cwd pass the is_path_in_cwd check. + local full_path = file:sub(1, 1) == '/' and file or (vim.fn.getcwd() .. '/' .. file) local display_label = file_path local file_config = config.ui.completion.file_sources diff --git a/lua/opencode/util.lua b/lua/opencode/util.lua index 77c3ed44..cde1b4ee 100644 --- a/lua/opencode/util.lua +++ b/lua/opencode/util.lua @@ -591,9 +591,17 @@ function M.pcall_trace(fn, ...) end function M.is_path_in_cwd(path) - local cwd = vim.fn.getcwd() - local abs_path = vim.fn.fnamemodify(path, ':p') - return abs_path:sub(1, #cwd) == cwd + local cwd = vim.fn.simplify(vim.fn.getcwd()) + local cwd_prefix = cwd == '/' and cwd or (cwd .. '/') + + local logical_path + if path:sub(1, 1) == '/' then + logical_path = vim.fn.simplify(path) + else + logical_path = vim.fn.simplify(cwd .. '/' .. path) + end + + return logical_path == cwd or logical_path:sub(1, #cwd_prefix) == cwd_prefix end --- Check if a given path is in the system temporary directory. diff --git a/tests/unit/util_spec.lua b/tests/unit/util_spec.lua index 42e83d22..778b5aa8 100644 --- a/tests/unit/util_spec.lua +++ b/tests/unit/util_spec.lua @@ -378,3 +378,49 @@ describe('util.parse_quick_context_args', function() end) end end) + +describe('util.is_path_in_cwd', function() + local original_getcwd + local test_cwd = '/tmp/test_project' + + before_each(function() + original_getcwd = vim.fn.getcwd + vim.fn.getcwd = function() + return test_cwd + end + end) + + after_each(function() + vim.fn.getcwd = original_getcwd + end) + + it('accepts a relative path inside cwd', function() + assert.is_true(util.is_path_in_cwd('src/foo.lua')) + end) + + it('accepts an absolute path inside cwd', function() + assert.is_true(util.is_path_in_cwd(test_cwd .. '/src/foo.lua')) + end) + + it('accepts an absolute path through a symlinked directory in cwd', function() + assert.is_true(util.is_path_in_cwd(test_cwd .. '/linked_folder/test.txt')) + end) + + it('rejects an absolute path outside cwd', function() + assert.is_false(util.is_path_in_cwd('/tmp/outside/foo.lua')) + end) + + it('rejects a path that only shares the cwd prefix', function() + assert.is_false(util.is_path_in_cwd('/tmp/test_project2/src/foo.lua')) + end) + + it('rejects a relative path that escapes cwd via ..', function() + assert.is_false(util.is_path_in_cwd('../outside/foo.lua')) + end) + + it('accepts a relative path through a symlinked directory in cwd', function() + -- Simulate the symlink case: relative path stays logical (not resolved) + -- e.g. linked_folder -> /tmp/external, linked_folder/test.txt is valid + assert.is_true(util.is_path_in_cwd('linked_folder/test.txt')) + end) +end)