diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 8a03ae2e..ba219665 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -55,13 +55,29 @@ function M.paste_image() core.paste_image_from_clipboard() end +--- Check if opencode windows are in the current tab page +--- @return boolean +local function are_windows_in_current_tab() + if not state.windows or not state.windows.output_win then + return false + end + + local current_tab = vim.api.nvim_get_current_tabpage() + local ok, win_tab = pcall(vim.api.nvim_win_get_tabpage, state.windows.output_win) + return ok and win_tab == current_tab +end + M.toggle = Promise.async(function(new_session) -- When auto_hide input is enabled, always focus input; otherwise use last focused local focus = 'input' ---@cast focus 'input' | 'output' if not config.ui.input.auto_hide then focus = state.last_focused_opencode_window or 'input' end - if state.windows == nil then + + if state.windows == nil or not are_windows_in_current_tab() then + if state.windows then + M.close() + end core.open({ new_session = new_session == true, focus = focus, start_insert = false }):await() else M.close() diff --git a/lua/opencode/ui/autocmds.lua b/lua/opencode/ui/autocmds.lua index a3301654..7c780eef 100644 --- a/lua/opencode/ui/autocmds.lua +++ b/lua/opencode/ui/autocmds.lua @@ -75,6 +75,7 @@ function M.setup_autocmds(windows) end end +---@param windows OpencodeWindowState? function M.setup_resize_handler(windows) local resize_group = vim.api.nvim_create_augroup('OpencodeResize', { clear = true }) vim.api.nvim_create_autocmd('VimResized', { @@ -82,11 +83,10 @@ function M.setup_resize_handler(windows) callback = function() require('opencode.ui.topbar').render() require('opencode.ui.footer').update_window(windows) - require('opencode.ui.input_window').update_dimensions(windows) - require('opencode.ui.output_window').update_dimensions(windows) + input_window.update_dimensions(windows) + output_window.update_dimensions(windows) end, }) - vim.api.nvim_create_autocmd('WinResized', { group = resize_group, callback = function(args) diff --git a/lua/opencode/ui/buf_fix_win.lua b/lua/opencode/ui/buf_fix_win.lua new file mode 100644 index 00000000..74fdb0c7 --- /dev/null +++ b/lua/opencode/ui/buf_fix_win.lua @@ -0,0 +1,76 @@ +--- Prevents a buffer from appearing in multiple windows (opposite of 'winfixbuf') +--- +--- This module solves the problem where buffers can appear in multiple windows +--- despite having 'winfixbuf' set. The 'winfixbuf' option prevents a window from +--- changing buffers, but doesn't prevent a buffer from appearing in multiple windows. +--- +local M = {} +local buff_to_win_map = {} +local global_autocmd_setup = false + +local function close_duplicates(buf, get_win) + local intended = get_win() + if not intended or not vim.api.nvim_win_is_valid(intended) then + return + end + + local wins = vim.fn.win_findbuf(buf) + if #wins > 1 then + vim.schedule(function() + for _, win in ipairs(wins) do + if win ~= intended and vim.api.nvim_win_is_valid(win) then + pcall(vim.api.nvim_win_close, win, true) + end + end + end) + end +end + +local check_all_buffers = vim.schedule_wrap(function() + for buf, get_win in pairs(buff_to_win_map) do + if vim.api.nvim_buf_is_valid(buf) then + close_duplicates(buf, get_win) + else + buff_to_win_map[buf] = nil + end + end +end) + +local function setup() + if global_autocmd_setup then + return + end + global_autocmd_setup = true + + local augroup = vim.api.nvim_create_augroup('OpenCodeBufFixWin', { clear = false }) + vim.api.nvim_create_autocmd({ 'WinNew', 'VimResized' }, { + group = augroup, + callback = check_all_buffers, + }) + vim.api.nvim_create_autocmd('BufDelete', { + callback = function(args) + if args and args.buf then + buff_to_win_map[args.buf] = nil + end + end, + }) +end + +--- Protect a buffer from appearing in multiple windows +---@param buf integer Buffer number +---@param get_intended_window fun(): integer? Function returning intended window ID +function M.fix_to_win(buf, get_intended_window) + setup() + + buff_to_win_map[buf] = get_intended_window + local augroup = vim.api.nvim_create_augroup('OpenCodeBufFixWin_' .. buf, { clear = true }) + vim.api.nvim_create_autocmd('BufWinEnter', { + group = augroup, + buffer = buf, + callback = function() + close_duplicates(buf, get_intended_window) + end, + }) +end + +return M diff --git a/lua/opencode/ui/input_window.lua b/lua/opencode/ui/input_window.lua index 0818110c..2e7a1fb2 100644 --- a/lua/opencode/ui/input_window.lua +++ b/lua/opencode/ui/input_window.lua @@ -62,6 +62,12 @@ end function M.create_buf() local input_buf = vim.api.nvim_create_buf(false, true) vim.api.nvim_set_option_value('filetype', 'opencode', { buf = input_buf }) + + local buffixwin = require('opencode.ui.buf_fix_win') + buffixwin.fix_to_win(input_buf, function() + return state.windows and state.windows.input_win + end) + return input_buf end @@ -261,6 +267,8 @@ function M.setup(windows) set_win_option('number', false, windows) set_win_option('relativenumber', false, windows) set_buf_option('buftype', 'nofile', windows) + set_buf_option('bufhidden', 'hide', windows) + set_buf_option('buflisted', false, windows) set_buf_option('swapfile', false, windows) if config.ui.position ~= 'current' then diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index b0d8bb4d..09cfca23 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -8,6 +8,12 @@ M.viewport_at_bottom = true function M.create_buf() local output_buf = vim.api.nvim_create_buf(false, true) vim.api.nvim_set_option_value('filetype', 'opencode_output', { buf = output_buf }) + + local buffixwin = require('opencode.ui.buf_fix_win') + buffixwin.fix_to_win(output_buf, function() + return state.windows and state.windows.output_win + end) + return output_buf end @@ -23,6 +29,7 @@ function M._build_output_win_config() } end +---@param windows OpencodeWindowState? function M.mounted(windows) windows = windows or state.windows return windows and windows.output_buf and windows.output_win and vim.api.nvim_win_is_valid(windows.output_win) @@ -96,7 +103,10 @@ function M.setup(windows) set_win_option('relativenumber', false, windows.output_win) set_buf_option('modifiable', false, windows.output_buf) set_buf_option('buftype', 'nofile', windows.output_buf) + set_buf_option('bufhidden', 'hide', windows.output_buf) + set_buf_option('buflisted', false, windows.output_buf) set_buf_option('swapfile', false, windows.output_buf) + if config.ui.position ~= 'current' then set_win_option('winfixbuf', true, windows.output_win) end diff --git a/tests/unit/buf_fix_win_spec.lua b/tests/unit/buf_fix_win_spec.lua new file mode 100644 index 00000000..60c64dd0 --- /dev/null +++ b/tests/unit/buf_fix_win_spec.lua @@ -0,0 +1,483 @@ +local buf_fix_win = require('opencode.ui.buf_fix_win') + +describe('buf_fix_win module', function() + local test_bufs = {} + local test_wins = {} + + local function create_test_buffer() + local buf = vim.api.nvim_create_buf(false, true) + table.insert(test_bufs, buf) + return buf + end + + local function create_test_window(buf) + vim.cmd('split') + local win = vim.api.nvim_get_current_win() + if buf then + vim.api.nvim_win_set_buf(win, buf) + end + table.insert(test_wins, win) + return win + end + + local function cleanup_test_windows() + for _, win in ipairs(test_wins) do + if vim.api.nvim_win_is_valid(win) then + pcall(vim.api.nvim_win_close, win, true) + end + end + test_wins = {} + end + + local function cleanup_test_buffers() + for _, buf in ipairs(test_bufs) do + if vim.api.nvim_buf_is_valid(buf) then + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + end + end + test_bufs = {} + end + + after_each(function() + cleanup_test_windows() + cleanup_test_buffers() + vim.cmd('only') + end) + + describe('fix_to_win', function() + it('should prevent buffer from appearing in multiple windows', function() + local buf = create_test_buffer() + local win1 = create_test_window(buf) + + local get_intended_window = function() + return win1 + end + + buf_fix_win.fix_to_win(buf, get_intended_window) + + local win2 = create_test_window() + vim.api.nvim_win_set_buf(win2, buf) + + vim.wait(100, function() + return not vim.api.nvim_win_is_valid(win2) + end) + + assert.is_false(vim.api.nvim_win_is_valid(win2)) + assert.is_true(vim.api.nvim_win_is_valid(win1)) + end) + + it('should keep buffer in intended window', function() + local buf = create_test_buffer() + local win1 = create_test_window(buf) + + local get_intended_window = function() + return win1 + end + + buf_fix_win.fix_to_win(buf, get_intended_window) + + assert.equal(vim.api.nvim_win_get_buf(win1), buf) + end) + + it('should handle nil intended window gracefully', function() + local buf = create_test_buffer() + local win1 = create_test_window(buf) + + local get_intended_window = function() + return nil + end + + assert.has_no.errors(function() + buf_fix_win.fix_to_win(buf, get_intended_window) + + local other_window = create_test_window() + vim.api.nvim_win_set_buf(other_window, buf) + + -- Give scheduled tasks time to run + vim.wait(100, function() + return false + end) + + assert.is_true(vim.api.nvim_win_is_valid(win1)) + assert.is_true(vim.api.nvim_win_is_valid(other_window)) + end) + end) + + it('should close duplicates when buffer appears in multiple windows', function() + local buf = create_test_buffer() + local win1 = create_test_window(buf) + local win2 = create_test_window(buf) + local win3 = create_test_window(buf) + + assert.equal(vim.api.nvim_win_get_buf(win1), buf) + assert.equal(vim.api.nvim_win_get_buf(win2), buf) + assert.equal(vim.api.nvim_win_get_buf(win3), buf) + + local get_intended_window = function() + return win1 + end + + buf_fix_win.fix_to_win(buf, get_intended_window) + + vim.wait(100, function() + return not vim.api.nvim_win_is_valid(win2) and not vim.api.nvim_win_is_valid(win3) + end) + + -- Only win1 should remain + assert.is_true(vim.api.nvim_win_is_valid(win1)) + + assert.is_false(vim.api.nvim_win_is_valid(win2)) + assert.is_false(vim.api.nvim_win_is_valid(win3)) + end) + + it('should work when buffer has only one window', function() + local buf = create_test_buffer() + local win1 = create_test_window(buf) + + local get_intended_window = function() + return win1 + end + + assert.has_no.errors(function() + buf_fix_win.fix_to_win(buf, get_intended_window) + end) + + assert.is_true(vim.api.nvim_win_is_valid(win1)) + assert.equal(vim.api.nvim_win_get_buf(win1), buf) + end) + + it('should handle dynamic intended window changes', function() + local buf = create_test_buffer() + local win1 = create_test_window(buf) + local win2 = create_test_window() + + local intended_window = win1 + local get_intended_window = function() + return intended_window + end + + buf_fix_win.fix_to_win(buf, get_intended_window) + + -- Change intended window + intended_window = win2 + vim.api.nvim_win_set_buf(win2, buf) + + vim.wait(100, function() + return not vim.api.nvim_win_is_valid(win1) + end) + + -- Win1 should be closed, win2 should remain + assert.is_false(vim.api.nvim_win_is_valid(win1)) + assert.is_true(vim.api.nvim_win_is_valid(win2)) + end) + + it('should handle WinNew autocmd for new windows', function() + local buf = create_test_buffer() + local win1 = create_test_window(buf) + + local get_intended_window = function() + return win1 + end + + buf_fix_win.fix_to_win(buf, get_intended_window) + + -- Create a new window and try to switch to the buffer + vim.cmd('split') + local new_win = vim.api.nvim_get_current_win() + table.insert(test_wins, new_win) + + vim.api.nvim_win_set_buf(new_win, buf) + + vim.wait(100, function() + return not vim.api.nvim_win_is_valid(new_win) + end) + + -- New window should be closed + assert.is_false(vim.api.nvim_win_is_valid(new_win)) + assert.is_true(vim.api.nvim_win_is_valid(win1)) + end) + + it('should not close intended window even if it is a duplicate', function() + local buf = create_test_buffer() + local win1 = create_test_window(buf) + local win2 = create_test_window(buf) + + local get_intended_window = function() + return win1 + end + + buf_fix_win.fix_to_win(buf, get_intended_window) + + vim.wait(100, function() + return not vim.api.nvim_win_is_valid(win2) + end) + + assert.is_true(vim.api.nvim_win_is_valid(win1)) + assert.is_false(vim.api.nvim_win_is_valid(win2)) + end) + + it('should handle multiple buffers with different intended windows', function() + local buf1 = create_test_buffer() + local buf2 = create_test_buffer() + + local win1 = create_test_window(buf1) + local win2 = create_test_window(buf2) + + buf_fix_win.fix_to_win(buf1, function() + return win1 + end) + buf_fix_win.fix_to_win(buf2, function() + return win2 + end) + + -- Try to swap buffers + local win3 = create_test_window() + local win4 = create_test_window() + + vim.api.nvim_win_set_buf(win3, buf1) + vim.api.nvim_win_set_buf(win4, buf2) + + vim.wait(100, function() + return not vim.api.nvim_win_is_valid(win3) and not vim.api.nvim_win_is_valid(win4) + end) + + assert.is_true(vim.api.nvim_win_is_valid(win1)) + assert.is_true(vim.api.nvim_win_is_valid(win2)) + assert.is_false(vim.api.nvim_win_is_valid(win3)) + assert.is_false(vim.api.nvim_win_is_valid(win4)) + end) + + it('should handle invalid window from get_intended_window', function() + local buf = create_test_buffer() + local win1 = create_test_window(buf) + + local get_intended_window = function() + return 99999 -- Invalid window ID + end + + assert.has_no.errors(function() + buf_fix_win.fix_to_win(buf, get_intended_window) + + -- Try to open in another window + local win2 = create_test_window() + vim.api.nvim_win_set_buf(win2, buf) + + vim.wait(100, function() + return false + end) + end) + end) + end) + + describe('autocmd setup behavior', function() + local fresh_buf_fix_win + + before_each(function() + -- Reload module to reset internal state for these tests only + package.loaded['opencode.ui.buf_fix_win'] = nil + fresh_buf_fix_win = require('opencode.ui.buf_fix_win') + end) + + after_each(function() + -- Restore the original module for other tests + package.loaded['opencode.ui.buf_fix_win'] = nil + buf_fix_win = require('opencode.ui.buf_fix_win') + end) + + it('should setup global WinNew autocmd when fix_to_win is first called', function() + local buf = create_test_buffer() + local win = create_test_window(buf) + + local initial_autocmds = #vim.api.nvim_get_autocmds({ event = 'WinNew' }) + + fresh_buf_fix_win.fix_to_win(buf, function() + return win + end) + + local final_autocmds = #vim.api.nvim_get_autocmds({ event = 'WinNew' }) + + assert.equal(initial_autocmds + 1, final_autocmds) + end) + + it('should not duplicate global WinNew autocmd on subsequent calls', function() + local buf1 = create_test_buffer() + local buf2 = create_test_buffer() + local win1 = create_test_window(buf1) + local win2 = create_test_window(buf2) + + fresh_buf_fix_win.fix_to_win(buf1, function() + return win1 + end) + + local after_first_call = #vim.api.nvim_get_autocmds({ event = 'WinNew' }) + + fresh_buf_fix_win.fix_to_win(buf2, function() + return win2 + end) + + local after_second_call = #vim.api.nvim_get_autocmds({ event = 'WinNew' }) + + assert.equal(after_first_call, after_second_call) + end) + + it('should create BufWinEnter autocmd for each buffer', function() + local buf = create_test_buffer() + local win = create_test_window(buf) + + local initial_autocmds = #vim.api.nvim_get_autocmds({ event = 'BufWinEnter', buffer = buf }) + + fresh_buf_fix_win.fix_to_win(buf, function() + return win + end) + + local final_autocmds = #vim.api.nvim_get_autocmds({ event = 'BufWinEnter', buffer = buf }) + + assert.equal(initial_autocmds + 1, final_autocmds) + end) + + it('should create separate BufWinEnter autocmds for different buffers', function() + local buf1 = create_test_buffer() + local buf2 = create_test_buffer() + local win1 = create_test_window(buf1) + local win2 = create_test_window(buf2) + + local initial_buf1 = #vim.api.nvim_get_autocmds({ event = 'BufWinEnter', buffer = buf1 }) + local initial_buf2 = #vim.api.nvim_get_autocmds({ event = 'BufWinEnter', buffer = buf2 }) + + fresh_buf_fix_win.fix_to_win(buf1, function() + return win1 + end) + fresh_buf_fix_win.fix_to_win(buf2, function() + return win2 + end) + + local final_buf1 = #vim.api.nvim_get_autocmds({ event = 'BufWinEnter', buffer = buf1 }) + local final_buf2 = #vim.api.nvim_get_autocmds({ event = 'BufWinEnter', buffer = buf2 }) + + assert.equal(initial_buf1 + 1, final_buf1) + assert.equal(initial_buf2 + 1, final_buf2) + end) + + it('should not accumulate BufWinEnter autocmds when fix_to_win is called multiple times for same buffer', function() + local buf = create_test_buffer() + local win = create_test_window(buf) + + local initial = #vim.api.nvim_get_autocmds({ event = 'BufWinEnter', buffer = buf }) + + fresh_buf_fix_win.fix_to_win(buf, function() + return win + end) + local after_first = #vim.api.nvim_get_autocmds({ event = 'BufWinEnter', buffer = buf }) + fresh_buf_fix_win.fix_to_win(buf, function() + return win + end) + local after_second = #vim.api.nvim_get_autocmds({ event = 'BufWinEnter', buffer = buf }) + + assert.equal(initial + 1, after_first) + assert.equal(initial + 1, after_second) + end) + + it('should setup global VimResized autocmd when fix_to_win is first called', function() + local buf = create_test_buffer() + local win = create_test_window(buf) + + local initial_autocmds = #vim.api.nvim_get_autocmds({ event = 'VimResized' }) + + fresh_buf_fix_win.fix_to_win(buf, function() + return win + end) + + local final_autocmds = #vim.api.nvim_get_autocmds({ event = 'VimResized' }) + + assert.equal(initial_autocmds + 1, final_autocmds) + end) + + it('should not duplicate global VimResized autocmd on subsequent calls', function() + local buf1 = create_test_buffer() + local buf2 = create_test_buffer() + local win1 = create_test_window(buf1) + local win2 = create_test_window(buf2) + + fresh_buf_fix_win.fix_to_win(buf1, function() + return win1 + end) + + local after_first_call = #vim.api.nvim_get_autocmds({ event = 'VimResized' }) + + fresh_buf_fix_win.fix_to_win(buf2, function() + return win2 + end) + + local after_second_call = #vim.api.nvim_get_autocmds({ event = 'VimResized' }) + + assert.equal(after_first_call, after_second_call) + end) + end) + + describe('VimResized event handling', function() + it('should close duplicate windows on VimResized', function() + local buf = create_test_buffer() + local win1 = create_test_window(buf) + local win2 = create_test_window(buf) + + buf_fix_win.fix_to_win(buf, function() + return win1 + end) + + -- Simulate VimResized event + vim.cmd('doautocmd VimResized') + + vim.wait(100, function() + return not vim.api.nvim_win_is_valid(win2) + end) + + assert.is_true(vim.api.nvim_win_is_valid(win1)) + assert.is_false(vim.api.nvim_win_is_valid(win2)) + end) + + it('should handle VimResized with multiple buffers', function() + local buf1 = create_test_buffer() + local buf2 = create_test_buffer() + + local win1 = create_test_window(buf1) + local win2 = create_test_window(buf2) + local win3 = create_test_window(buf1) -- Duplicate of buf1 + local win4 = create_test_window(buf2) -- Duplicate of buf2 + + buf_fix_win.fix_to_win(buf1, function() + return win1 + end) + buf_fix_win.fix_to_win(buf2, function() + return win2 + end) + + vim.cmd('doautocmd VimResized') + + vim.wait(100, function() + return not vim.api.nvim_win_is_valid(win3) and not vim.api.nvim_win_is_valid(win4) + end) + + assert.is_true(vim.api.nvim_win_is_valid(win1)) + assert.is_true(vim.api.nvim_win_is_valid(win2)) + assert.is_false(vim.api.nvim_win_is_valid(win3)) + assert.is_false(vim.api.nvim_win_is_valid(win4)) + end) + + it('should not close windows when no duplicates exist after VimResized', function() + local buf = create_test_buffer() + local win1 = create_test_window(buf) + + buf_fix_win.fix_to_win(buf, function() + return win1 + end) + + vim.cmd('doautocmd VimResized') + + vim.wait(100, function() + return false + end) + + assert.is_true(vim.api.nvim_win_is_valid(win1)) + end) + end) +end) diff --git a/tests/unit/timer_spec.lua b/tests/unit/timer_spec.lua index 934c9291..0873242c 100644 --- a/tests/unit/timer_spec.lua +++ b/tests/unit/timer_spec.lua @@ -64,7 +64,7 @@ describe('Timer', function() assert.is_true(timer:is_running()) -- Wait for multiple ticks - vim.wait(500, function() + vim.wait(1000, function() return tick_count >= 3 end)