Skip to content

Commit 044b915

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 3798e7f commit 044b915

2 files changed

Lines changed: 146 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
@@ -277,6 +277,10 @@ function M.actions.prev_message()
277277
require('opencode.ui.navigation').goto_prev_message()
278278
end
279279

280+
function M.actions.jump_to_file()
281+
require('opencode.ui.navigation').jump_to_file_at_cursor()
282+
end
283+
280284
function M.actions.toggle_tool_output()
281285
local action_text = config.ui.output.tools.show_output and 'Hiding' or 'Showing'
282286
vim.notify(action_text .. ' tool output display', vim.log.levels.INFO)
@@ -458,6 +462,10 @@ M.command_defs = {
458462
desc = 'Navigate to previous message in output window',
459463
execute = M.actions.prev_message,
460464
},
465+
jump_to_file = {
466+
desc = 'Jump to file at cursor in output window',
467+
execute = M.actions.jump_to_file,
468+
},
461469
debug_output = {
462470
desc = 'Open raw output debug view',
463471
execute = M.actions.debug_output,

lua/opencode/ui/navigation.lua

Lines changed: 138 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,141 @@ function M.goto_prev_message()
6162
vim.api.nvim_win_set_cursor(win, { 1, 0 })
6263
end
6364

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

0 commit comments

Comments
 (0)