From b734e57d0f5e730679249095c44e6a6508bd175e Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 7 Oct 2026 08:26:02 -0400 Subject: [PATCH 1/4] feat: reuse panel keymap utility for session diffs --- README.md | 21 +++++++ lua/opencode/config.lua | 4 ++ lua/opencode/keymap.lua | 30 +++++++--- lua/opencode/types.lua | 15 ++++- lua/opencode/ui/session_diff.lua | 30 ++++++---- .../ui/session_diff/comment_input.lua | 13 +---- lua/opencode/ui/session_diff/help.lua | 10 +++- tests/unit/keymap_spec.lua | 40 ++++++++++++++ .../unit/session_diff_comment_input_spec.lua | 25 +++++++-- tests/unit/session_diff_spec.lua | 55 +++++++++++++++++++ 10 files changed, 204 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index ff2936d2..b7c6be82 100644 --- a/README.md +++ b/README.md @@ -1191,6 +1191,27 @@ Press `r` to select a range of turns/messages: move through user prompts, mark t Press `q` to close the review tab. `:Opencode diff next` and `:Opencode diff prev` also select files in the open review tab. +### Customize session-diff keymaps + +Like panel mappings, `keymap.session_diff` binds keys to `{ action, { arguments }, mode = ..., desc = ..., nowait = ... }` entries, using the same keymap utility. Actions can be local diff action names, panel command names, or Lua callbacks; local diff actions take precedence. Set a default key to `false` to disable it. `` / `` select the next / previous file in the list and diff panes. + +```lua +keymap = { + session_diff = { + preview = { + [']f'] = { 'next_file', desc = 'Next file' }, + ['[f'] = { 'prev_file', desc = 'Previous file' }, + [''] = { function() vim.cmd('wincmd h') end, desc = 'Focus file list' }, + }, + list = { + [''] = { function() vim.cmd('wincmd l') end, desc = 'Focus diff' }, + }, + }, +} +``` + +Dedicated filetypes are `opencode_diff_list`, `opencode_diff_messages`, `opencode_diff_help`, and `opencode_diff_comment`. Preview buffers use composite filetypes such as `lua.opencode_diff_preview`, `diff.opencode_diff_preview`, and `markdown.opencode_diff_message_preview`, keeping source syntax highlighting. Use `FileType` patterns `*.opencode_diff_preview` or `*.opencode_diff_message_preview` to attach your own buffer-local mappings; configured mappings are installed before these hooks run. + ### Comment on a session diff In a diff preview, press `c` on a line or visual selection to add or edit a review comment. Use `dc` to remove one; `]r` and `[r` move between comments. In the comment editor, `` or `:w` saves, while `q` or `` cancels. Comments appear as signs and in the file tree; closing the review focuses the input when comments are pending. Comments are sent with the next prompt and include the reviewed snapshot and a best-effort current-file status. diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index a52500d2..60679cb4 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -33,6 +33,8 @@ M.defaults = { session_diff = { list = { [''] = { 'activate', desc = 'Open file or toggle folder' }, + [''] = { 'next_file', desc = 'Next file' }, + [''] = { 'prev_file', desc = 'Previous file' }, ['r'] = { 'toggle_range', desc = 'Choose message range', nowait = true }, ['p'] = { 'toggle_view', desc = 'Toggle diff layout' }, ['q'] = { 'close', desc = 'Close diff', nowait = true }, @@ -49,6 +51,8 @@ M.defaults = { ['g?'] = { 'toggle_help', desc = 'Toggle keymap help' }, }, preview = { + [''] = { 'next_file', desc = 'Next file' }, + [''] = { 'prev_file', desc = 'Previous file' }, ['q'] = { 'close', desc = 'Close diff', nowait = true }, ['p'] = { 'toggle_view', desc = 'Toggle diff layout' }, ['g?'] = { 'toggle_help', desc = 'Toggle keymap help' }, diff --git a/lua/opencode/keymap.lua b/lua/opencode/keymap.lua index a705031f..8e1e0f71 100644 --- a/lua/opencode/keymap.lua +++ b/lua/opencode/keymap.lua @@ -1,5 +1,4 @@ local M = {} -local commands = require('opencode.commands') local store = require('opencode.state.store') local window_keymaps = {} @@ -24,13 +23,21 @@ end ---@param func_name string|function ---@param func_args any +---@param actions? table Buffer-local overrides take precedence over panel commands ---@return function|nil -local function resolve_callback(func_name, func_args) +local function resolve_callback(func_name, func_args, actions) + if type(func_name) == 'string' and actions and actions[func_name] then + func_name = actions[func_name] + end if type(func_name) == 'function' then - return func_name + return function() + local args = func_args and (type(func_args) == 'table' and vim.deepcopy(func_args) or { func_args }) or {} + return func_name(unpack(args)) + end end if type(func_name) == 'string' then + local commands = require('opencode.commands') local command_defs = commands.get_commands() if command_defs[func_name] then return function() @@ -51,7 +58,10 @@ end ---@param default_modes table Default modes for these keymaps ---@param base_opts table Base options to use for all keymaps ---@param preserve_existing? boolean -local function process_keymap_entry(keymap_config, default_modes, base_opts, preserve_existing) +---@param actions? table +local function process_keymap_entry(keymap_config, default_modes, base_opts, preserve_existing, actions) + -- Commands load diff handlers, which also use this mapper for their buffers. + local commands = require('opencode.commands') local command_defs = commands.get_commands() for key_binding, config_entry in pairs(keymap_config) do @@ -60,7 +70,7 @@ local function process_keymap_entry(keymap_config, default_modes, base_opts, pre elseif config_entry then local func_name = config_entry[1] local func_args = config_entry[2] - local callback = resolve_callback(func_name, func_args) + local callback = resolve_callback(func_name, func_args, actions) local modes = config_entry.mode or default_modes if preserve_existing and base_opts.buffer then @@ -81,7 +91,10 @@ local function process_keymap_entry(keymap_config, default_modes, base_opts, pre end local opts = vim.tbl_deep_extend('force', {}, base_opts) opts.nowait = config_entry.nowait - opts.desc = config_entry.desc or vim.tbl_get(command_defs, func_name, 'desc') or '' + local command_desc = type(func_name) == 'string' + and not (actions and actions[func_name]) + and vim.tbl_get(command_defs, func_name, 'desc') + opts.desc = config_entry.desc or command_desc or '' if not callback then if type(func_name) ~= 'string' then @@ -131,12 +144,13 @@ end ---@param keymap_config table Window keymap configuration ---@param buf_id integer Buffer ID to set keymaps for ---@param preserve_existing? boolean -function M.setup_window_keymaps(keymap_config, buf_id, preserve_existing) +---@param actions? table Local action overrides; unknown names use command resolution and its error notification +function M.setup_window_keymaps(keymap_config, buf_id, preserve_existing, actions) if not vim.api.nvim_buf_is_valid(buf_id) then return end - process_keymap_entry(keymap_config or {}, { 'n' }, { silent = true, buffer = buf_id }, preserve_existing) + process_keymap_entry(keymap_config or {}, { 'n' }, { silent = true, buffer = buf_id }, preserve_existing, actions) end return M diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index fdacc27e..a06e6016 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -159,7 +159,8 @@ ---@field project SessionProjectInfo|nil ---@class OpencodeKeymapEntry ----@field [1] string # Function name +---@field [1] string|function # Action name or callback +---@field [2]? any # Arguments passed to the action or callback ---@field mode? string|string[] # Mode(s) for the keymap ---@field desc? string # Keymap description ---@field nowait? boolean # Execute without waiting for longer mappings @@ -169,11 +170,21 @@ ---@class OpencodeKeymapInputWindow : table ---@class OpencodeKeymapOutputWindow : table +---@alias OpencodeSessionDiffMappings table + +---@class OpencodeSessionDiffKeymaps +---@field list OpencodeSessionDiffMappings +---@field messages OpencodeSessionDiffMappings +---@field preview OpencodeSessionDiffMappings +---@field comment OpencodeSessionDiffMappings +---@field message_preview OpencodeSessionDiffMappings +---@field help OpencodeSessionDiffMappings + ---@class OpencodeKeymap ---@field editor OpencodeKeymapEditor ---@field input_window OpencodeKeymapInputWindow ---@field output_window OpencodeKeymapOutputWindow ----@field session_diff {list: table, messages: table, preview: table, comment: table, message_preview: table, help: table} +---@field session_diff OpencodeSessionDiffKeymaps ---@field tab_strip_window table ---@field session_picker OpencodeSessionPickerKeymap ---@field session_tab_picker OpencodeSessionTabPickerKeymap diff --git a/lua/opencode/ui/session_diff.lua b/lua/opencode/ui/session_diff.lua index 84d850e4..038be4d6 100644 --- a/lua/opencode/ui/session_diff.lua +++ b/lua/opencode/ui/session_diff.lua @@ -2,6 +2,7 @@ local patch = require('opencode.session_patch') local config = require('opencode.config') local float_layout = require('opencode.ui.float_layout') local help = require('opencode.ui.session_diff.help') +local keymaps = require('opencode.keymap') local icons = require('opencode.ui.icons') local render = require('opencode.ui.session_diff.render') local context = require('opencode.context') @@ -112,17 +113,15 @@ local function map_actions(buf, scope) prev_comment = function() M.jump_comment(-1) end, + next_file = function() + M.select(1) + end, + prev_file = function() + M.select(-1) + end, + select = M.select, } - for key, entry in pairs(config.keymap.session_diff[scope]) do - if entry ~= false then - vim.keymap.set(entry.mode or 'n', key, actions[entry[1]], { - buffer = buf, - silent = true, - desc = entry.desc, - nowait = entry.nowait, - }) - end - end + keymaps.setup_window_keymaps(config.keymap.session_diff[scope], buf, false, actions) end local function help_key() @@ -216,6 +215,7 @@ function M.toggle_help() local buf, width, height = help.create(config.keymap.session_diff) map_actions(buf, 'help') + vim.bo[buf].filetype = 'opencode_diff_help' view.help_return_win = vim.api.nvim_get_current_win() view.help_win = vim.api.nvim_open_win(buf, true, { relative = 'editor', @@ -234,7 +234,6 @@ end local function open_turn_window() local buf = vim.api.nvim_create_buf(false, true) vim.bo[buf].bufhidden = 'wipe' - vim.bo[buf].filetype = '' vim.bo[buf].modifiable = false local win_config = float_layout.window_configs(nil, false) win_config.width = math.min(assert(win_config.width), math.floor(vim.o.columns * 0.75)) --[[@as integer]] @@ -251,6 +250,7 @@ local function open_turn_window() vim.wo[view.turn_win].relativenumber = false vim.wo[view.turn_win].signcolumn = 'no' map_actions(buf, 'messages') + vim.bo[buf].filetype = 'opencode_diff_messages' end local function close_turn_window() @@ -388,9 +388,15 @@ scratch = function(lines, filetype, scope) local buf = vim.api.nvim_create_buf(false, true) vim.bo[buf].bufhidden = 'wipe' vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) - vim.bo[buf].filetype = filetype vim.bo[buf].modifiable = false map_actions(buf, scope or 'preview') + if scope == 'list' then + vim.bo[buf].filetype = 'opencode_diff_list' + else + local suffix = scope == 'message_preview' and 'opencode_diff_message_preview' or 'opencode_diff_preview' + vim.bo[buf].filetype = filetype ~= '' and (filetype .. '.' .. suffix) or suffix + vim.bo[buf].syntax = filetype + end return buf end diff --git a/lua/opencode/ui/session_diff/comment_input.lua b/lua/opencode/ui/session_diff/comment_input.lua index 906f88d6..0b963516 100644 --- a/lua/opencode/ui/session_diff/comment_input.lua +++ b/lua/opencode/ui/session_diff/comment_input.lua @@ -1,4 +1,5 @@ local config = require('opencode.config') +local keymaps = require('opencode.keymap') local M = {} @@ -71,16 +72,8 @@ function M.open(opts) end vim.api.nvim_create_autocmd('BufWriteCmd', { buffer = buf, callback = submit }) local actions = { submit_comment = submit, cancel_comment = cancel } - for key, entry in pairs(config.keymap.session_diff.comment) do - if entry ~= false then - vim.keymap.set(entry.mode or 'n', key, actions[entry[1]], { - buffer = buf, - silent = true, - desc = entry.desc, - nowait = entry.nowait, - }) - end - end + keymaps.setup_window_keymaps(config.keymap.session_diff.comment, buf, false, actions) + vim.bo[buf].filetype = 'opencode_diff_comment' vim.cmd('startinsert') end diff --git a/lua/opencode/ui/session_diff/help.lua b/lua/opencode/ui/session_diff/help.lua index fb4b3403..e00b1785 100644 --- a/lua/opencode/ui/session_diff/help.lua +++ b/lua/opencode/ui/session_diff/help.lua @@ -17,6 +17,9 @@ local action_order = { prev_comment = 13, submit_comment = 14, cancel_comment = 15, + next_file = 16, + prev_file = 17, + select = 18, } ---@class OpencodeSessionDiffHelpSection @@ -25,13 +28,14 @@ local action_order = { ---@field width integer ---@param title string ----@param mappings table +---@param mappings OpencodeSessionDiffMappings ---@return OpencodeSessionDiffHelpSection local function section(title, mappings) local entries = {} for key, entry in pairs(mappings) do if entry ~= false then - entries[#entries + 1] = { key = key, action = entry[1], label = entry.desc or entry[1] } + local action = type(entry[1]) == 'string' and entry[1] or 'Custom callback' + entries[#entries + 1] = { key = key, action = action, label = entry.desc or action } end end table.sort(entries, function(a, b) @@ -45,7 +49,7 @@ local function section(title, mappings) return { title = title, entries = entries, width = width } end ----@param keymaps {list: table, messages: table, preview: table, comment: table, message_preview: table, help: table} +---@param keymaps OpencodeSessionDiffKeymaps ---@return integer, integer, integer function M.create(keymaps) local sections = { diff --git a/tests/unit/keymap_spec.lua b/tests/unit/keymap_spec.lua index 6f115f1e..9348a139 100644 --- a/tests/unit/keymap_spec.lua +++ b/tests/unit/keymap_spec.lua @@ -140,6 +140,46 @@ describe('opencode.keymap', function() package.loaded['opencode.config'] = nil end) + describe('local actions', function() + it('prefers local overrides and passes fresh arguments on each invocation', function() + local arguments = { { value = 'original' } } + local received = {} + keymap.setup_window_keymaps({ x = { 'toggle', arguments, mode = 'n', nowait = true } }, 0, false, { + toggle = function(value) + received[#received + 1] = value.value + value.value = 'changed' + end, + }) + set_keymaps[1].callback() + set_keymaps[1].callback() + assert.same({ 'original', 'original' }, received) + assert.equals(0, #executed_parsed) + assert.equals('', set_keymaps[1].opts.desc) + assert.is_true(set_keymaps[1].opts.nowait) + assert.equals('n', set_keymaps[1].modes) + end) + + it('falls back to panel commands for names outside the local action table', function() + keymap.setup_window_keymaps({ x = { 'toggle' } }, 0, false, {}) + set_keymaps[1].callback() + assert.equals('toggle', executed_parsed[1].intent.name) + end) + + it('passes configured arguments to custom callbacks', function() + local received + keymap.setup_window_keymaps({ + x = { + function(value) + received = value + end, + { 'argument' }, + }, + }, 0) + set_keymaps[1].callback() + assert.equals('argument', received) + end) + end) + describe('panel lifecycle', function() local function panel() local windows = { input_win = vim.api.nvim_get_current_win(), output_win = vim.api.nvim_get_current_win() } diff --git a/tests/unit/session_diff_comment_input_spec.lua b/tests/unit/session_diff_comment_input_spec.lua index 583e1478..157f1a62 100644 --- a/tests/unit/session_diff_comment_input_spec.lua +++ b/tests/unit/session_diff_comment_input_spec.lua @@ -1,6 +1,23 @@ local comment_input = require('opencode.ui.session_diff.comment_input') describe('session diff comment input', function() + it('accepts custom callbacks with arguments in comment mappings', function() + local config = require('opencode.config') + local received + config.keymap.session_diff.comment['X'] = { + function(value) + received = value + end, + { 'comment callback' }, + } + comment_input.open({ title = 'file.lua:1-1 (after)', on_submit = function() end }) + config.keymap.session_diff.comment['X'] = nil + vim.cmd('stopinsert') + vim.api.nvim_feedkeys('X', 'xt', false) + assert.equals('comment callback', received) + vim.api.nvim_feedkeys('q', 'xt', false) + end) + it('submits a written comment from the real acwrite float', function() local origin = vim.api.nvim_get_current_win() local submitted @@ -18,11 +35,11 @@ describe('session diff comment input', function() assert.equals(origin, vim.api.nvim_get_current_win()) end) - it('keeps temporary comment editor free of filetype hooks', function() + it('identifies the temporary comment editor with a dedicated filetype', function() local triggered = false local group = vim.api.nvim_create_augroup('OpencodeCommentInputSpec', { clear = true }) vim.api.nvim_create_autocmd('FileType', { - pattern = '*', + pattern = 'opencode_diff_comment', group = group, callback = function(args) if vim.bo[args.buf].buftype == 'acwrite' then @@ -33,9 +50,9 @@ describe('session diff comment input', function() comment_input.open({ title = 'file.lua:1-1 (after)', on_submit = function() end }) local buf = vim.api.nvim_get_current_buf() assert.equals('acwrite', vim.bo[buf].buftype) - assert.equals('', vim.bo[buf].filetype) + assert.equals('opencode_diff_comment', vim.bo[buf].filetype) assert.is_false(vim.b[buf].completion) - assert.is_false(triggered) + assert.is_true(triggered) vim.api.nvim_win_close(vim.api.nvim_get_current_win(), true) vim.api.nvim_del_augroup_by_id(group) end) diff --git a/tests/unit/session_diff_spec.lua b/tests/unit/session_diff_spec.lua index cf9f1183..ea951e02 100644 --- a/tests/unit/session_diff_spec.lua +++ b/tests/unit/session_diff_spec.lua @@ -34,6 +34,7 @@ describe('session diff', function() end, }) local list_buf = vim.api.nvim_get_current_buf() + assert.equals('opencode_diff_list', vim.bo[list_buf].filetype) local function mapped(buf, key) return vim.tbl_contains( vim.tbl_map(function(entry) @@ -46,6 +47,8 @@ describe('session diff', function() assert.is_false(mapped(list_buf, 'p')) local preview_win = vim.api.nvim_tabpage_list_wins(0)[2] local preview_buf = vim.api.nvim_win_get_buf(preview_win) + assert.equals('lua.opencode_diff_preview', vim.bo[preview_buf].filetype) + assert.equals('lua', vim.bo[preview_buf].syntax) assert.is_true(mapped(preview_buf, 'v')) assert.is_false(mapped(preview_buf, 'p')) @@ -54,16 +57,67 @@ describe('session diff', function() return vim.api.nvim_get_current_buf() ~= list_buf end)) local turns_buf = vim.api.nvim_get_current_buf() + assert.equals('opencode_diff_messages', vim.bo[turns_buf].filetype) assert.is_true(mapped(turns_buf, 's')) assert.is_false(mapped(turns_buf, 'f')) assert.equals(' Messages (s/t mark, apply, close) ', vim.api.nvim_win_get_config(0).title[1][1]) diff.show_turn_preview() local message_buf = vim.api.nvim_get_current_buf() + assert.equals('markdown.opencode_diff_message_preview', vim.bo[message_buf].filetype) assert.is_true(mapped(message_buf, 'x')) assert.is_false(mapped(message_buf, 'q')) assert.is_false(mapped(message_buf, 'p')) end) + it('binds custom callbacks and file navigation with arguments', function() + local received + config.keymap.session_diff.preview['X'] = { + function(value) + received = value + end, + { 'custom argument' }, + desc = 'Custom preview action', + } + config.keymap.session_diff.preview['N'] = { 'select', { 1 } } + local cwd = vim.fn.getcwd() + diff.open({ + file(cwd .. '/one.lua', '@@ -1 +1 @@\n-old\n+first'), + file(cwd .. '/two.lua', '@@ -1 +1 @@\n-old\n+second'), + }, { id = 'ses_callbacks' }) + local list_win = vim.api.nvim_get_current_win() + local preview_win = vim.api.nvim_tabpage_list_wins(0)[2] + vim.api.nvim_set_current_win(preview_win) + vim.api.nvim_feedkeys('X', 'xt', false) + assert.equals('custom argument', received) + vim.api.nvim_feedkeys('N', 'xt', false) + assert.is_true(vim.wo[preview_win].winbar:find('two.lua', 1, true) ~= nil) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'xt', false) + assert.is_true(vim.wo[preview_win].winbar:find('one.lua', 1, true) ~= nil) + vim.api.nvim_set_current_win(list_win) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'xt', false) + assert.is_true(vim.wo[preview_win].winbar:find('two.lua', 1, true) ~= nil) + diff.toggle_help() + local lines = table.concat(vim.api.nvim_buf_get_lines(0, 0, -1, false), '\n') + assert.is_true(lines:find('Custom preview action', 1, true) ~= nil) + end) + + it('allows FileType hooks to override configured buffer-local mappings', function() + local called = false + local autocmd = vim.api.nvim_create_autocmd('FileType', { + pattern = '*.opencode_diff_preview', + callback = function(args) + vim.keymap.set('n', 'p', function() + called = true + end, { buffer = args.buf }) + end, + }) + diff.open({ file(vim.fn.getcwd() .. '/one.lua', '@@ -1 +1 @@\n-old\n+new') }, { id = 'ses_hooks' }) + vim.api.nvim_del_autocmd(autocmd) + vim.api.nvim_set_current_win(vim.api.nvim_tabpage_list_wins(0)[2]) + vim.api.nvim_feedkeys('p', 'xt', false) + assert.is_true(called) + end) + it('aligns folders and files at the same tree depth', function() local cwd = vim.fn.getcwd() diff.open({ @@ -98,6 +152,7 @@ describe('session diff', function() assert.is_true(vim.wo[list_win].winbar:find('?', 1, true) ~= nil) vim.api.nvim_feedkeys('?', 'xt', false) local help_win = vim.api.nvim_get_current_win() + assert.equals('opencode_diff_help', vim.bo.filetype) assert.not_equals(list_win, help_win) local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) assert.is_true(vim.tbl_contains( From a6fb3776adc404e525dc137a04808292d2374a1f Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 7 Oct 2026 08:26:24 -0400 Subject: [PATCH 2/4] fix: disable wrapping in session diff file list --- lua/opencode/ui/session_diff.lua | 1 + tests/unit/session_diff_spec.lua | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/lua/opencode/ui/session_diff.lua b/lua/opencode/ui/session_diff.lua index 038be4d6..653be926 100644 --- a/lua/opencode/ui/session_diff.lua +++ b/lua/opencode/ui/session_diff.lua @@ -862,6 +862,7 @@ function M.open(files, session, options) vim.wo[list_win].relativenumber = false vim.wo[list_win].signcolumn = 'no' vim.wo[list_win].cursorline = true + vim.wo[list_win].wrap = false vim.cmd('rightbelow vsplit') local preview_win = vim.api.nvim_get_current_win() vim.api.nvim_set_current_win(list_win) diff --git a/tests/unit/session_diff_spec.lua b/tests/unit/session_diff_spec.lua index ea951e02..3ac744a6 100644 --- a/tests/unit/session_diff_spec.lua +++ b/tests/unit/session_diff_spec.lua @@ -118,6 +118,15 @@ describe('session diff', function() assert.is_true(called) end) + it('disables list wrapping even when enabled globally', function() + local wrap = vim.o.wrap + vim.o.wrap = true + diff.open({ file(vim.fn.getcwd() .. '/one.lua', '@@ -1 +1 @@\n-old\n+new') }, { id = 'ses_wrap' }) + local list_wrap = vim.wo.wrap + vim.o.wrap = wrap + assert.is_false(list_wrap) + end) + it('aligns folders and files at the same tree depth', function() local cwd = vim.fn.getcwd() diff.open({ From 5b1bd950a97ed7890aa91c009bc901660b4ef8cb Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 7 Oct 2026 08:27:40 -0400 Subject: [PATCH 3/4] feat: open session diff working-tree files with gf --- README.md | 2 ++ lua/opencode/config.lua | 2 ++ lua/opencode/ui/session_diff.lua | 22 ++++++++++++++++++++++ lua/opencode/ui/session_diff/help.lua | 1 + tests/unit/session_diff_spec.lua | 26 ++++++++++++++++++++++++++ 5 files changed, 53 insertions(+) diff --git a/README.md b/README.md index b7c6be82..e8957d4c 100644 --- a/README.md +++ b/README.md @@ -1191,6 +1191,8 @@ Press `r` to select a range of turns/messages: move through user prompts, mark t Press `q` to close the review tab. `:Opencode diff next` and `:Opencode diff prev` also select files in the open review tab. +Press `gf` in the file list or diff preview to open the selected working-tree file in a new tab, keeping the snapshot review intact. If the file no longer exists on disk, a warning is shown instead. Remap this using the `open_file` action. + ### Customize session-diff keymaps Like panel mappings, `keymap.session_diff` binds keys to `{ action, { arguments }, mode = ..., desc = ..., nowait = ... }` entries, using the same keymap utility. Actions can be local diff action names, panel command names, or Lua callbacks; local diff actions take precedence. Set a default key to `false` to disable it. `` / `` select the next / previous file in the list and diff panes. diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 60679cb4..2e79765e 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -33,6 +33,7 @@ M.defaults = { session_diff = { list = { [''] = { 'activate', desc = 'Open file or toggle folder' }, + ['gf'] = { 'open_file', desc = 'Open working-tree file in a new tab' }, [''] = { 'next_file', desc = 'Next file' }, [''] = { 'prev_file', desc = 'Previous file' }, ['r'] = { 'toggle_range', desc = 'Choose message range', nowait = true }, @@ -51,6 +52,7 @@ M.defaults = { ['g?'] = { 'toggle_help', desc = 'Toggle keymap help' }, }, preview = { + ['gf'] = { 'open_file', desc = 'Open working-tree file in a new tab' }, [''] = { 'next_file', desc = 'Next file' }, [''] = { 'prev_file', desc = 'Previous file' }, ['q'] = { 'close', desc = 'Close diff', nowait = true }, diff --git a/lua/opencode/ui/session_diff.lua b/lua/opencode/ui/session_diff.lua index 653be926..1e830697 100644 --- a/lua/opencode/ui/session_diff.lua +++ b/lua/opencode/ui/session_diff.lua @@ -120,6 +120,7 @@ local function map_actions(buf, scope) M.select(-1) end, select = M.select, + open_file = M.open_file, } keymaps.setup_window_keymaps(config.keymap.session_diff[scope], buf, false, actions) end @@ -738,6 +739,27 @@ function M.activate() vim.api.nvim_win_set_cursor(view.list_win, { row, 0 }) end +function M.open_file() + if not in_view() or #view.files == 0 then + return + end + local index = view.index + if vim.api.nvim_get_current_win() == view.list_win then + local node = view.rows[vim.api.nvim_win_get_cursor(view.list_win)[1]] + if not node or not node.file_index then + return + end + index = node.file_index + end + local file = view.files[index] + if vim.fn.filereadable(file.file) == 0 then + vim.notify('Working-tree file not found: ' .. file.file, vim.log.levels.WARN) + return + end + -- Keep snapshot previews intact while editing the current working-tree revision. + vim.cmd('tabedit ' .. vim.fn.fnameescape(file.file)) +end + function M.select(direction) if not active() then return false diff --git a/lua/opencode/ui/session_diff/help.lua b/lua/opencode/ui/session_diff/help.lua index e00b1785..c15fe521 100644 --- a/lua/opencode/ui/session_diff/help.lua +++ b/lua/opencode/ui/session_diff/help.lua @@ -20,6 +20,7 @@ local action_order = { next_file = 16, prev_file = 17, select = 18, + open_file = 19, } ---@class OpencodeSessionDiffHelpSection diff --git a/tests/unit/session_diff_spec.lua b/tests/unit/session_diff_spec.lua index 3ac744a6..49ea5873 100644 --- a/tests/unit/session_diff_spec.lua +++ b/tests/unit/session_diff_spec.lua @@ -118,6 +118,32 @@ describe('session diff', function() assert.is_true(called) end) + it('opens the working-tree file with gf from the list and preview without replacing snapshots', function() + local path = vim.fn.getcwd() .. '/lua/opencode/config.lua' + diff.open({ file(path, '@@ -1 +1 @@\n-old\n+new') }, { id = 'ses_gf' }) + local tab = vim.api.nvim_get_current_tabpage() + local list_win = vim.api.nvim_get_current_win() + local preview_win = vim.api.nvim_tabpage_list_wins(tab)[2] + local preview_buf = vim.api.nvim_win_get_buf(preview_win) + for _, win in ipairs({ list_win, preview_win }) do + vim.api.nvim_set_current_win(win) + vim.api.nvim_feedkeys('gf', 'xt', false) + assert.equals(path, vim.api.nvim_buf_get_name(0)) + assert.not_equals(tab, vim.api.nvim_get_current_tabpage()) + vim.cmd('tabclose') + assert.equals(tab, vim.api.nvim_get_current_tabpage()) + assert.equals(preview_buf, vim.api.nvim_win_get_buf(preview_win)) + end + end) + + it('does not open a new empty file when gf targets a deleted file', function() + local path = vim.fn.getcwd() .. '/missing-session-diff-gf.lua' + diff.open({ file(path, '@@ -1 +0,0 @@\n-old') }, { id = 'ses_gf_missing' }) + local tab = vim.api.nvim_get_current_tabpage() + vim.api.nvim_feedkeys('gf', 'xt', false) + assert.equals(tab, vim.api.nvim_get_current_tabpage()) + end) + it('disables list wrapping even when enabled globally', function() local wrap = vim.o.wrap vim.o.wrap = true From 1bfd8b01a0349676b5c55cd2c416b24d1d31505e Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 7 Oct 2026 08:44:39 -0400 Subject: [PATCH 4/4] feat(session-diff): jump gf to corresponding post-change line Pressing gf from a diff preview or patch pane now opens the working-tree file at the line corresponding to the selected diff line --- README.md | 2 +- lua/opencode/ui/session_diff.lua | 40 ++++++++++++++++++++++++++- tests/unit/session_diff_spec.lua | 47 ++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e8957d4c..2f0c7790 100644 --- a/README.md +++ b/README.md @@ -1191,7 +1191,7 @@ Press `r` to select a range of turns/messages: move through user prompts, mark t Press `q` to close the review tab. `:Opencode diff next` and `:Opencode diff prev` also select files in the open review tab. -Press `gf` in the file list or diff preview to open the selected working-tree file in a new tab, keeping the snapshot review intact. If the file no longer exists on disk, a warning is shown instead. Remap this using the `open_file` action. +Press `gf` in the file list or diff preview to open the selected working-tree file in a new tab, keeping the snapshot review intact. From a preview, it targets the corresponding post-change line (deleted lines use the next surviving line, or the last one), clamped to the current file's length. This is best-effort if the working tree has changed since the snapshot. Diff panes show absolute line numbers. If the file no longer exists on disk, a warning is shown instead. Remap this using the `open_file` action. ### Customize session-diff keymaps diff --git a/lua/opencode/ui/session_diff.lua b/lua/opencode/ui/session_diff.lua index 1e830697..e5414da1 100644 --- a/lua/opencode/ui/session_diff.lua +++ b/lua/opencode/ui/session_diff.lua @@ -405,6 +405,8 @@ local function set_preview(win, lines, filetype) vim.api.nvim_win_set_buf(win, scratch(lines, filetype)) vim.api.nvim_win_set_cursor(win, { 1, 0 }) vim.wo[win].signcolumn = 'yes:1' + vim.wo[win].number = true + vim.wo[win].relativenumber = false end local function refresh_comments() @@ -739,17 +741,52 @@ function M.activate() vim.api.nvim_win_set_cursor(view.list_win, { row, 0 }) end +---@param win integer +---@return integer +local function working_tree_line(win) + local row = vim.api.nvim_win_get_cursor(win)[1] + if view.sides[win] == 'after' then + return row + end + if view.sides[win] == 'before' then + for index, entry in ipairs(view.patch_map) do + if entry.old == row then + row = index + break + end + end + end + -- Deleted lines and patch headers target the next surviving line, or the last one. + for index = row, #view.patch_map do + local number = view.patch_map[index].new + if number then + return number + end + end + for index = math.min(row - 1, #view.patch_map), 1, -1 do + local number = view.patch_map[index].new + if number then + return number + end + end + return 1 +end + function M.open_file() if not in_view() or #view.files == 0 then return end local index = view.index - if vim.api.nvim_get_current_win() == view.list_win then + local win = vim.api.nvim_get_current_win() + local line = 1 + if win == view.list_win then local node = view.rows[vim.api.nvim_win_get_cursor(view.list_win)[1]] if not node or not node.file_index then return end index = node.file_index + elseif win == view.preview_win or win == view.right_win then + line = working_tree_line(win) end local file = view.files[index] if vim.fn.filereadable(file.file) == 0 then @@ -758,6 +795,7 @@ function M.open_file() end -- Keep snapshot previews intact while editing the current working-tree revision. vim.cmd('tabedit ' .. vim.fn.fnameescape(file.file)) + vim.api.nvim_win_set_cursor(0, { math.min(line, vim.api.nvim_buf_line_count(0)), 0 }) end function M.select(direction) diff --git a/tests/unit/session_diff_spec.lua b/tests/unit/session_diff_spec.lua index 49ea5873..bf1d75d9 100644 --- a/tests/unit/session_diff_spec.lua +++ b/tests/unit/session_diff_spec.lua @@ -144,6 +144,53 @@ describe('session diff', function() assert.equals(tab, vim.api.nvim_get_current_tabpage()) end) + it('maps gf to post-change lines from both sides and unified patches', function() + local path = vim.fn.getcwd() .. '/lua/opencode/config.lua' + local text = '@@ -1,4 +1,5 @@\n+inserted\n first\n second\n-removed\n+replacement\n last' + diff.open({ file(path, text) }, { id = 'ses_gf_lines' }) + local tab = vim.api.nvim_get_current_tabpage() + local before, after + for _, win in ipairs(vim.api.nvim_tabpage_list_wins(tab)) do + if vim.wo[win].winbar:find('Before:', 1, true) then + before = win + elseif vim.wo[win].winbar:find('After:', 1, true) then + after = win + end + end + local function jump(win, row, expected) + assert.is_true(vim.wo[win].number) + assert.is_false(vim.wo[win].relativenumber) + vim.api.nvim_set_current_win(win) + vim.api.nvim_win_set_cursor(win, { row, 0 }) + vim.api.nvim_feedkeys('gf', 'xt', false) + assert.equals(expected, vim.api.nvim_win_get_cursor(0)[1]) + vim.cmd('tabclose') + assert.equals(tab, vim.api.nvim_get_current_tabpage()) + end + jump(before, 2, 3) + jump(before, 3, 4) + jump(after, 5, 5) + diff.show_patch() + jump(before, 1, 1) + jump(before, 4, 3) + jump(before, 5, 4) + jump(before, 7, 5) + end) + + it('clamps gf line navigation to the current working-tree file length', function() + local path = vim.fn.getcwd() .. '/lua/opencode/session_patch.lua' + local count = #vim.fn.readfile(path) + local text = '@@ -1,100 +1,100 @@\n' .. string.rep(' context\n', 99) .. '-old\n+new' + diff.open({ file(path, text) }, { id = 'ses_gf_clamp' }) + diff.show_patch() + local preview = vim.api.nvim_tabpage_list_wins(0)[2] + vim.api.nvim_set_current_win(preview) + vim.api.nvim_win_set_cursor(preview, { 102, 0 }) + vim.api.nvim_feedkeys('gf', 'xt', false) + assert.equals(count, vim.api.nvim_win_get_cursor(0)[1]) + vim.cmd('tabclose') + end) + it('disables list wrapping even when enabled globally', function() local wrap = vim.o.wrap vim.o.wrap = true