-
Notifications
You must be signed in to change notification settings - Fork 61
fix: discussion window teardown and a recursion guard #581
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
jakubbortlik
merged 3 commits into
harrisoncramer:develop
from
seflue:fix/discussion-window-teardown
Aug 11, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| -- Without the nil-parent guard this test does not fail, it hangs, and the suite stops here. | ||
|
|
||
| local NuiTree = require("nui.tree") | ||
| local common = require("gitlab.actions.common") | ||
|
|
||
| describe("actions/common.get_root_node", function() | ||
| it("Gives up on a top level node that is not marked as a root", function() | ||
| local bufnr = vim.api.nvim_create_buf(false, true) | ||
| local tree = NuiTree({ | ||
| bufnr = bufnr, | ||
| nodes = { NuiTree.Node({ id = "a", text = "a", type = "note" }) }, | ||
| }) | ||
| tree:render() | ||
| -- The loop only forms if NuiTree can answer `get_node(nil)`, which it does from the | ||
| -- cursor of a window showing the buffer. | ||
| vim.api.nvim_win_set_buf(0, bufnr) | ||
|
|
||
| assert.is_nil( | ||
| common.get_root_node(tree, tree:get_node("-a")), | ||
| "get_root_node claimed a root for a note node that has no parent" | ||
| ) | ||
|
|
||
| vim.api.nvim_buf_delete(bufnr, { force = true }) | ||
| end) | ||
| end) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| -- close() closes the window itself instead of leaving that to NuiSplit, which gives up on | ||
| -- the last window of a session and ignores every later unmount once one has failed. These | ||
| -- tests check that the window and nui's own buffer are gone afterwards and that | ||
| -- `split_visible` says so. | ||
|
|
||
| local discussions = require("gitlab.actions.discussions") | ||
| local draft_notes = require("gitlab.actions.draft_notes") | ||
| local winbar = require("gitlab.actions.discussions.winbar") | ||
| local state = require("gitlab.state") | ||
|
|
||
| ---Register a split with the given unmount behaviour, in a window of its own. | ||
| ---@param unmount fun(split: table) | ||
| ---@return integer winid | ||
| ---@return integer bufnr The scratch buffer NuiSplit allocates on mount | ||
| local function arrange(unmount) | ||
| vim.cmd("tabnew") | ||
| vim.cmd("split") | ||
| local winid = vim.api.nvim_get_current_win() | ||
| local bufnr = vim.api.nvim_create_buf(false, true) | ||
| discussions.split = { winid = winid, bufnr = bufnr, unmount = unmount } | ||
| discussions.split_visible = true | ||
| return winid, bufnr | ||
| end | ||
|
|
||
| describe("actions/discussions.close", function() | ||
| after_each(function() | ||
| discussions.split = nil | ||
| discussions.split_visible = false | ||
| discussions.discussion_tree = nil | ||
| discussions.linked_bufnr = nil | ||
| discussions.unlinked_bufnr = nil | ||
| winbar.cleanup_timer() | ||
| state.DISCUSSION_DATA = nil | ||
| vim.cmd("tabnew") | ||
| vim.cmd("silent! tabonly") | ||
| vim.cmd("silent! only") | ||
| end) | ||
|
|
||
| it("Closes the window itself when a poisoned split ignores unmount", function() | ||
| local winid, bufnr = arrange(function() end) | ||
|
|
||
| discussions.close() | ||
|
|
||
| assert.is_false(vim.api.nvim_win_is_valid(winid), ("window %d survived close()"):format(winid)) | ||
| assert.is_false(vim.api.nvim_buf_is_valid(bufnr), ("nui buffer %d survived close()"):format(bufnr)) | ||
| assert.is_false(discussions.split_visible) | ||
| end) | ||
|
|
||
| it("Closes the window itself when unmounting raises", function() | ||
| local winid, bufnr = arrange(function() | ||
| error("nui teardown failed") | ||
| end) | ||
|
|
||
| discussions.close() | ||
|
|
||
| assert.is_false(vim.api.nvim_win_is_valid(winid), ("window %d survived close()"):format(winid)) | ||
| assert.is_false(vim.api.nvim_buf_is_valid(bufnr), ("nui buffer %d survived close()"):format(bufnr)) | ||
| assert.is_false(discussions.split_visible) | ||
| end) | ||
|
|
||
| it("Closes the window when it is the last one in the session", function() | ||
| vim.cmd("silent! tabonly") | ||
| vim.cmd("silent! only") | ||
| -- Neovim refuses to close the last window, so close() has to open a sibling first. That | ||
| -- sibling shows the tree buffer, which is wiped a moment later. | ||
| local winid = vim.api.nvim_get_current_win() | ||
| local bufnr = vim.api.nvim_create_buf(true, false) | ||
| vim.api.nvim_win_set_buf(winid, bufnr) | ||
| discussions.split = { winid = winid, unmount = function() end } | ||
| discussions.split_visible = true | ||
| discussions.linked_bufnr = bufnr | ||
|
|
||
| discussions.close() | ||
|
|
||
| assert.is_false(vim.api.nvim_win_is_valid(winid), ("window %d survived close()"):format(winid)) | ||
| assert.is_false(vim.api.nvim_buf_is_valid(bufnr), ("buffer %d survived close()"):format(bufnr)) | ||
| assert.is_false(discussions.split_visible) | ||
| end) | ||
|
|
||
| it("Tears the split down when the user closes the window by hand", function() | ||
| -- M.open calls draft_notes.rebuild_view, which talks to the Go server these tests | ||
| -- cannot connect to. | ||
| local original_rebuild_view = draft_notes.rebuild_view | ||
| draft_notes.rebuild_view = function() end | ||
| vim.cmd("tabnew") | ||
| discussions.open() | ||
| local winid = discussions.split.winid | ||
|
|
||
| vim.api.nvim_win_close(winid, true) | ||
|
|
||
| local torn_down = vim.wait(200, function() | ||
| return discussions.split_visible == false | ||
| end, 10) | ||
|
|
||
| assert.is_true(torn_down, "split_visible is still set 200ms after the window closed") | ||
| draft_notes.rebuild_view = original_rebuild_view | ||
| end) | ||
| end) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| -- The linked and unlinked buffers belong to one open, not to the session, so close() owns | ||
| -- their release. | ||
|
|
||
| local discussions = require("gitlab.actions.discussions") | ||
| local draft_notes = require("gitlab.actions.draft_notes") | ||
| local winbar = require("gitlab.actions.discussions.winbar") | ||
| local state = require("gitlab.state") | ||
|
|
||
| -- Without this precondition the deletion asserts below would also pass if open() never | ||
| -- created the buffers in the first place. | ||
| local function assert_buffers_created(linked, unlinked) | ||
| assert.is_true(linked ~= nil and vim.api.nvim_buf_is_valid(linked), "open() created no linked buffer") | ||
| assert.is_true(unlinked ~= nil and vim.api.nvim_buf_is_valid(unlinked), "open() created no unlinked buffer") | ||
| end | ||
|
|
||
| describe("actions/discussions buffers", function() | ||
| local original_rebuild_view | ||
|
|
||
| before_each(function() | ||
| -- M.open tails into draft_notes.rebuild_view, which talks to the Go server these tests | ||
| -- have no connection to. | ||
| original_rebuild_view = draft_notes.rebuild_view | ||
| draft_notes.rebuild_view = function() end | ||
| end) | ||
|
|
||
| after_each(function() | ||
| draft_notes.rebuild_view = original_rebuild_view | ||
| discussions.split = nil | ||
| discussions.split_visible = false | ||
| discussions.discussion_tree = nil | ||
| discussions.linked_bufnr = nil | ||
| discussions.unlinked_bufnr = nil | ||
| winbar.cleanup_timer() | ||
| state.DISCUSSION_DATA = nil | ||
| vim.cmd("tabnew") | ||
| vim.cmd("silent! tabonly") | ||
| vim.cmd("silent! only") | ||
| end) | ||
|
|
||
| it("Deletes both buffers when the window is closed", function() | ||
| vim.cmd("tabnew") | ||
| discussions.open() | ||
| local linked, unlinked = discussions.linked_bufnr, discussions.unlinked_bufnr | ||
| assert_buffers_created(linked, unlinked) | ||
|
|
||
| discussions.close() | ||
|
|
||
| assert.is_false(vim.api.nvim_buf_is_valid(linked), ("linked buffer %d was not deleted"):format(linked)) | ||
|
seflue marked this conversation as resolved.
|
||
| assert.is_false(vim.api.nvim_buf_is_valid(unlinked), ("unlinked buffer %d was not deleted"):format(unlinked)) | ||
| end) | ||
|
|
||
| it("Leaves no buffer behind over an open/close cycle", function() | ||
| vim.cmd("tabnew") | ||
| discussions.open() | ||
| local first_linked, first_unlinked = discussions.linked_bufnr, discussions.unlinked_bufnr | ||
| assert_buffers_created(first_linked, first_unlinked) | ||
| discussions.close() | ||
|
|
||
| discussions.open() | ||
|
|
||
| assert.are_not.equal(first_linked, discussions.linked_bufnr) | ||
| assert.is_false( | ||
| vim.api.nvim_buf_is_valid(first_linked), | ||
| ("linked buffer %d of the first open leaked"):format(first_linked) | ||
| ) | ||
| assert.is_false( | ||
| vim.api.nvim_buf_is_valid(first_unlinked), | ||
| ("unlinked buffer %d of the first open leaked"):format(first_unlinked) | ||
| ) | ||
| discussions.close() | ||
| end) | ||
| end) | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.