Skip to content

Commit 66cdd0c

Browse files
committed
feat(navigation): jump to file at cursor in output window
resolve_file_at_cursor parses markdown links, file:// URIs, action lines, and diff hunks. jump_to_file_at_cursor opens the file in state.last_code_win_before_opencode (winnr('#') fallback) using a silent :buffer/:edit helper to avoid re-triggering autocmds.
1 parent 5e70a09 commit 66cdd0c

2 files changed

Lines changed: 148 additions & 0 deletions

File tree

lua/opencode/commands/handlers/workflow.lua

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,10 @@ function M.actions.clear_files()
287287
vim.notify('Mentioned files cleared', vim.log.levels.INFO)
288288
end
289289

290+
function M.actions.jump_to_file()
291+
require('opencode.ui.navigation').jump_to_file_at_cursor()
292+
end
293+
290294
function M.actions.toggle_tool_output()
291295
local action_text = config.ui.output.tools.show_output and 'Hiding' or 'Showing'
292296
vim.notify(action_text .. ' tool output display', vim.log.levels.INFO)
@@ -476,6 +480,10 @@ M.command_defs = {
476480
desc = 'Clear only mentioned files from context',
477481
execute = M.actions.clear_files,
478482
},
483+
jump_to_file = {
484+
desc = 'Jump to file at cursor in output window',
485+
execute = M.actions.jump_to_file,
486+
},
479487
debug_output = {
480488
desc = 'Open raw output debug view',
481489
execute = M.actions.debug_output,

lua/opencode/ui/navigation.lua

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ local M = {}
22

33
local state = require('opencode.state')
44
local renderer = require('opencode.ui.renderer')
5+
local output_window = require('opencode.ui.output_window')
56

67
function M.goto_message_by_id(message_id)
78
require('opencode.ui.ui').focus_output()
@@ -61,4 +62,143 @@ function M.goto_prev_message()
6162
vim.api.nvim_win_set_cursor(win, { 1, 0 })
6263
end
6364

65+
---@param raw string
66+
local function resolve_path(raw)
67+
if vim.uv.fs_stat(raw) then
68+
return raw
69+
end
70+
local absolute = vim.fn.fnamemodify(raw, ':p')
71+
if vim.uv.fs_stat(absolute) then
72+
return absolute
73+
end
74+
local found = vim.fn.findfile(raw, '.;')
75+
if found ~= '' then
76+
return found
77+
end
78+
end
79+
80+
---Resolve file and line number at cursor position in the output buffer.
81+
---@return { path: string, line: number? }?
82+
function M.resolve_file_at_cursor()
83+
local windows = state.windows or {}
84+
local win = windows.output_win
85+
local buf = windows.output_buf
86+
87+
if not win or not buf or not vim.api.nvim_win_is_valid(win) then
88+
return nil
89+
end
90+
91+
local cursor = vim.api.nvim_win_get_cursor(win)
92+
local line_num = cursor[1]
93+
local line = vim.api.nvim_buf_get_lines(buf, line_num - 1, line_num, false)[1]
94+
95+
if not line then
96+
return nil
97+
end
98+
99+
-- 1. Check for markdown-style file links: [`path`](path)
100+
local path = line:match('%[`([^`]+)%`%]%([^%)]+%)')
101+
if path then
102+
return { path = path }
103+
end
104+
105+
-- 2. Check for file:// style links: `file://path/to/file.lua:line`
106+
local f_path, f_line = line:match('`file://([^:`]+):?(%d*)`')
107+
if f_path then
108+
return { path = f_path, line = tonumber(f_line) }
109+
end
110+
111+
-- 3. Check for action lines: **icon tool** `path`
112+
path = line:match('%*%*.-%*%*%s+`([^`]+)`')
113+
if path then
114+
return { path = path }
115+
end
116+
117+
-- 4. Check for diff hunk: look for the nearest file path upwards
118+
local file_path = nil
119+
for i = line_num, 1, -1 do
120+
local l = vim.api.nvim_buf_get_lines(buf, i - 1, i, false)[1]
121+
if l then
122+
local p = l:match('%[`([^`]+)%`%]%([^%)]+%)') or l:match('%*%*.-%*%*%s+`([^`]+)`')
123+
if p then
124+
file_path = p
125+
break
126+
end
127+
end
128+
end
129+
130+
if not file_path then
131+
return nil
132+
end
133+
134+
-- Check if we are on a diff line with a line number in the gutter
135+
local ns = output_window.namespace
136+
local extmarks = vim.api.nvim_buf_get_extmarks(buf, ns, { line_num - 1, 0 }, { line_num - 1, -1 }, { details = true })
137+
local ln ---@type number?
138+
for _, extmark in ipairs(extmarks) do
139+
local details = extmark[4]
140+
if details and details.virt_text then
141+
for _, vt in ipairs(details.virt_text) do
142+
local val = tonumber(vim.trim(vt[1]))
143+
if val then
144+
ln = val
145+
break
146+
end
147+
end
148+
end
149+
if ln then
150+
break
151+
end
152+
end
153+
154+
return { path = file_path, line = ln }
155+
end
156+
157+
---Open a file in the current window without triggering BufRead/BufNew autocmds.
158+
---Falls back to :edit if the file isn't loaded in any buffer yet.
159+
---@param path string
160+
local function open_silent(path)
161+
local escaped = vim.fn.fnameescape(path)
162+
if not pcall(vim.cmd, 'buffer ' .. escaped) then
163+
pcall(vim.cmd, 'edit ' .. escaped)
164+
end
165+
end
166+
167+
local function open_at(win, path, line)
168+
if not win or not vim.api.nvim_win_is_valid(win) then
169+
return
170+
end
171+
vim.api.nvim_set_current_win(win)
172+
open_silent(path)
173+
if line then
174+
local buf = vim.api.nvim_win_get_buf(win)
175+
local line_count = vim.api.nvim_buf_line_count(buf)
176+
line = math.min(line, line_count)
177+
pcall(vim.api.nvim_win_set_cursor, win, { line, 0 })
178+
end
179+
end
180+
181+
local function best_target_win()
182+
local w = state.last_code_win_before_opencode
183+
if w and vim.api.nvim_win_is_valid(w) then
184+
return w
185+
end
186+
local alt = vim.fn.win_getid(vim.fn.winnr('#'))
187+
if alt ~= 0 and vim.api.nvim_win_is_valid(alt) then
188+
return alt
189+
end
190+
end
191+
192+
function M.jump_to_file_at_cursor()
193+
local resolved = M.resolve_file_at_cursor()
194+
if not resolved then
195+
return
196+
end
197+
local path = resolve_path(resolved.path)
198+
if not path then
199+
return
200+
end
201+
open_at(best_target_win(), path, resolved.line)
202+
end
203+
64204
return M

0 commit comments

Comments
 (0)