diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 295292fdb..63151b851 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -423,6 +423,17 @@ repos: files: ^(configs/|shell/) stages: [pre-commit] + # The nvim config's Lua, each spec driven in a headless Neovim. A spec + # loads the plugins lazy installed, and passes saying SKIP on a machine + # where nvim has not started yet, so lazy installed none. + - id: nvim-specs + name: nvim specs (headless Neovim) + entry: task test:nvim + language: system + pass_filenames: false + files: ^(configs/common/\.config/nvim/|tests/nvim/) + stages: [pre-commit] + # Tests a broken run would satisfy: every assertion a negative claim about # an external effect, with nothing pinning how the run ended. A command # that died before reaching the write leaves exactly that state. An diff --git a/Taskfile.yml b/Taskfile.yml index 019efaeda..4c09506ba 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -40,6 +40,14 @@ tasks: cmds: - uv run pytest tests/e2e --docker {{.CLI_ARGS}} + # Each spec drives a clean headless Neovim against the config's own Lua and the + # plugins lazy installed, and exits non-zero on a failure. Every spec runs, so + # one run reports each that failed. + test:nvim: + desc: The nvim config's Lua specs, each in a headless Neovim + cmds: + - status=0; for spec in tests/nvim/*_spec.lua; do nvim --headless -u NONE -l "$spec" || status=1; done; exit $status + test:watch: desc: Re-run the suite on file changes (requires entr) cmds: diff --git a/configs/common/.config/nvim/init.lua b/configs/common/.config/nvim/init.lua index f7d0ce5dc..e4f31e152 100644 --- a/configs/common/.config/nvim/init.lua +++ b/configs/common/.config/nvim/init.lua @@ -19,5 +19,7 @@ if not profiles.is_vscode then require('core.autocmds') -- Custom floating terminal (Space t t). VSCode has its own terminal. require('core.floaterminal') + -- The pull request beside a diff prs opened. Nothing where prs did not start nvim. + require('core.pull-request-panel').setup() require('lsp') end diff --git a/configs/common/.config/nvim/lua/core/profiles.lua b/configs/common/.config/nvim/lua/core/profiles.lua index 475b099e1..2643bbc41 100644 --- a/configs/common/.config/nvim/lua/core/profiles.lua +++ b/configs/common/.config/nvim/lua/core/profiles.lua @@ -1,5 +1,6 @@ --- Which plugins load, which is now only a question when Neovim is embedded in --- VSCode through vscode-neovim. Everywhere else the whole set loads. +-- Which kind of Neovim this is, and which plugins load in it. Two kinds load +-- less than the whole set: one embedded in VSCode through vscode-neovim, and one +-- prs started to show a pull request's diff. -- -- An allowlist rather than a blocklist, because the spec list is not fully -- enumerable here: colorscheme-manager.lua synthesizes specs at startup by @@ -29,6 +30,32 @@ local vscode_enabled = { ['typos'] = true, } +-- prs starts nvim in a repo's checkout to show one pull request's diff, with +-- PRS_DIFF_DOCUMENT naming the pull request's markdown document and +-- PRS_DIFF_THREADS its review threads. Both are read once and cleared, so an +-- nvim started from this one, such as lazygit's editor, is an ordinary nvim. +local function taken(name) + local value = vim.env[name] + vim.env[name] = nil + if value == nil or value == '' then + return nil + end + return value +end + +local document, threads = taken('PRS_DIFF_DOCUMENT'), taken('PRS_DIFF_THREADS') + +---@type { document: string, threads: string? }? +M.pull_request_diff = document and { document = document, threads = threads } or nil + +-- Each keeps state for the directory nvim started in, which in a pull request's +-- diff is the repo's own checkout. +local pull_request_diff_disabled = { + -- Saving at exit would replace the repo's session with the diff's buffers, + -- which are gone once nvim exits. + ['auto-session'] = true, +} + --- Plugin condition function for lazy.nvim defaults.cond ---@param plugin LazyPlugin ---@return boolean @@ -36,6 +63,9 @@ function M.plugin_enabled(plugin) if M.is_vscode then return vscode_enabled[plugin.name] == true end + if M.pull_request_diff then + return not pull_request_diff_disabled[plugin.name] + end return true end diff --git a/configs/common/.config/nvim/lua/core/pull-request-panel.lua b/configs/common/.config/nvim/lua/core/pull-request-panel.lua new file mode 100644 index 000000000..dc2c17d72 --- /dev/null +++ b/configs/common/.config/nvim/lua/core/pull-request-panel.lua @@ -0,0 +1,369 @@ +-- The pull request beside its diff, in an nvim prs started to show a pull +-- request's diff. +-- +-- prs writes the pull request's markdown document and its review threads to two +-- files, and names them in PRS_DIFF_DOCUMENT and PRS_DIFF_THREADS, which +-- core.profiles reads. Where PRS_DIFF_DOCUMENT was unset, setup() does nothing, so +-- every other nvim is untouched. +-- +-- - gr shows the document in a split at the right edge, and hides it. +-- - gR shows it in a float, and hides it. +-- - or gf on a `path` or `path:line` mention in the document selects that file +-- in the open diffview, through its Lua API, and puts the cursor on the line. +-- Where the cursor is on no file the diff changes, the first backticked one on +-- the line is taken, then the nearest heading above, so a row of the files +-- table, a thread's heading and each line of its comments jump to it. +-- - Each review thread shows as virtual text at the end of its line, on the side of +-- the diff its `side` names: LEFT is the base's copy of the file, RIGHT the head's. +-- octo.nvim's reviews.show_virtual_text is the model. +-- +-- The threads file is JSON. This reads its title, threads_read, and each thread's +-- path, line, side, resolved, comments and comment_count. + +local profiles = require('core.profiles') + +local M = {} + +M.namespace = vim.api.nvim_create_namespace('pull-request-threads') + +local state = { + document = nil, + threads = {}, + title = nil, + buf = -1, + panel = -1, + float = -1, +} + +local function say(message, level) + vim.notify(message, level or vim.log.levels.INFO, { title = 'Pull request' }) +end + +-- A diff with no thread on it reads as a pull request nobody commented on, so each +-- way of ending up with no threads to show says so. +local function read_threads(path) + if not path then + say('prs named no review threads, so none show on the diff.', vim.log.levels.WARN) + return {} + end + if vim.fn.filereadable(path) == 0 then + say(path .. ' is gone, so no review threads show on the diff.', vim.log.levels.WARN) + return {} + end + local ok, decoded = pcall(vim.json.decode, table.concat(vim.fn.readfile(path), '\n'), { + luanil = { object = true, array = true }, + }) + if not ok or type(decoded) ~= 'table' then + say('Could not read the review threads in ' .. path, vim.log.levels.WARN) + return {} + end + state.title = decoded.title + if decoded.threads_read == false then + say('prs had not read the review threads when the diff opened, so none show on it.', vim.log.levels.WARN) + end + return decoded.threads or {} +end + +-- The open DiffView: this tab's, or else the first one open. +local function diff_view() + local loaded, lib = pcall(require, 'diffview.lib') + if not loaded then + return nil + end + local view = lib.get_current_view() + if not view then + view = lib.views[1] + end + if view and view.files and view.cur_layout then + return view + end + return nil +end + +local function thread_text(thread) + local first = thread.comments and thread.comments[1] + local who = first and first.author ~= '' and first.author or 'someone' + local said = first and (first.body or ''):match('[^\r\n]+') or '' + local text = ' ' .. who .. ': ' .. said + local replies = (thread.comment_count or #(thread.comments or {})) - 1 + if replies > 0 then + text = text .. (' (+%d)'):format(replies) + end + if thread.resolved then + return { { text .. ' · resolved', 'Comment' } } + end + return { { text, 'DiagnosticVirtualTextInfo' } } +end + +-- Puts each thread on the file the diff shows now, on both sides, and clears what an +-- earlier call put there. The entry's path is the head's, which is the path GitHub +-- gives a thread, so a renamed file's base side matches it too. +function M.decorate() + local view = diff_view() + local entry = view and view.cur_entry + if not entry or not entry.layout then + return + end + for side, symbol in pairs({ LEFT = 'a', RIGHT = 'b' }) do + local window = entry.layout[symbol] + local bufnr = window and window.file and window.file.bufnr + if bufnr and vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_clear_namespace(bufnr, M.namespace, 0, -1) + local count = vim.api.nvim_buf_line_count(bufnr) + for _, thread in ipairs(state.threads) do + if thread.path == entry.path and thread.side == side and type(thread.line) == 'number' and thread.line <= count then + vim.api.nvim_buf_set_extmark(bufnr, M.namespace, thread.line - 1, 0, { + virt_text = thread_text(thread), + virt_text_pos = 'eol', + hl_mode = 'combine', + }) + end + end + end + end +end + +-- Each mention the cursor could mean, best first: the word under it, then each +-- backticked span on the line. A mention is a path with an optional :line. +local function mentions(text, col) + local found = {} + local function add(token) + token = (token or ''):gsub('^%./', ''):gsub('[.:,;]+$', '') + if token == '' then + return + end + local path, line = token:match('^(.-):(%d+)$') + if not path then + path, line = token:match('^(.-):(%d+):%d+$') + end + table.insert(found, { path = path or token, line = tonumber(line) }) + end + local stop = '[%s`|()%[%]<>"\',;*]' + local first, last = col + 1, col + 1 + while first > 1 and not text:sub(first - 1, first - 1):match(stop) do + first = first - 1 + end + while last <= #text and not text:sub(last, last):match(stop) do + last = last + 1 + end + add(text:sub(first, last - 1)) + for span in text:gmatch('`([^`]+)`') do + add(span) + end + return found +end + +-- The path of the diff's file a mention names: the same path, wherever diffview +-- lists it, or else the one file whose path ends in it. +local function resolve(view, mentioned) + local endings = {} + for _, file in view.files:iter() do + if file.path == mentioned then + return file.path + end + if vim.endswith(file.path, '/' .. mentioned) then + table.insert(endings, file.path) + end + end + if #endings == 1 then + return endings[1] + end + return nil +end + +-- The first of the mentions that names a file the diff changes, and its line. +local function first_resolved(view, found) + for _, mention in ipairs(found) do + local path = resolve(view, mention.path) + if path then + return path, mention.line + end + end + return nil +end + +-- What a jump that found no file says: the first mention shaped like a path, since +-- the word under the cursor may be any word in a comment. +local function refusal(found) + for _, mention in ipairs(found) do + if mention.path:find('[/.]') then + return mention.path .. ' is not a file this diff changes.' + end + end + return 'No file this diff changes is named on this line.' +end + +local function side_of(path, line) + for _, thread in ipairs(state.threads) do + if thread.path == path and thread.line == line then + return thread.side + end + end + return 'RIGHT' +end + +-- An entry's layout holds its files, and the view's current layout holds the windows +-- they are shown in. +local function place_cursor(view, path, line) + local entry = view.cur_entry + if not entry or entry.path ~= path then + return + end + local window = side_of(path, line) == 'LEFT' and view.cur_layout.a or view.cur_layout.b + if not (window and window.id and vim.api.nvim_win_is_valid(window.id)) then + return + end + local count = vim.api.nvim_buf_line_count(vim.api.nvim_win_get_buf(window.id)) + vim.api.nvim_set_current_win(window.id) + vim.api.nvim_win_set_cursor(window.id, { math.min(line, count), 0 }) + vim.cmd('normal! zz') +end + +-- Selects the file the line under the cursor mentions in the open diffview. A line +-- naming none takes the nearest heading above it, which for a thread's comments is +-- the thread's own path:line. +function M.jump() + local view = diff_view() + if not view then + say('No diffview is open to jump in.', vim.log.levels.WARN) + return + end + local row, col = unpack(vim.api.nvim_win_get_cursor(0)) + local here = mentions(vim.api.nvim_get_current_line(), col) + local path, line = first_resolved(view, here) + if not path then + for _, text in ipairs(vim.fn.reverse(vim.api.nvim_buf_get_lines(0, 0, row, false))) do + if text:match('^#+%s') then + path, line = first_resolved(view, mentions(text, 0)) + break + end + end + end + if not path then + say(refusal(here), vim.log.levels.WARN) + return + end + if vim.api.nvim_win_is_valid(state.float) and vim.api.nvim_get_current_win() == state.float then + vim.api.nvim_win_close(state.float, true) + end + if vim.api.nvim_get_current_tabpage() ~= view.tabpage then + vim.api.nvim_set_current_tabpage(view.tabpage) + end + local async = require('diffview.async') + async.void(function() + async.await(view:set_file_by_path(path, true, true)) + if line then + vim.schedule(function() + place_cursor(view, path, line) + end) + end + end)() +end + +local function document_buffer() + if vim.api.nvim_buf_is_valid(state.buf) then + return state.buf + end + if vim.fn.filereadable(state.document) == 0 then + say(state.document .. ' is gone, so there is no pull request to show.', vim.log.levels.WARN) + return nil + end + local buf = vim.fn.bufadd(state.document) + vim.bo[buf].swapfile = false + vim.fn.bufload(buf) + vim.bo[buf].filetype = 'markdown' + vim.bo[buf].modifiable = false + vim.bo[buf].readonly = true + vim.bo[buf].bufhidden = 'hide' + for _, key in ipairs({ '', 'gf' }) do + vim.keymap.set('n', key, M.jump, { buffer = buf, desc = 'Pull request: select this file in the diff' }) + end + state.buf = buf + return buf +end + +-- A window split off a diff window inherits its diff, scrollbind and folds, which +-- would bind the document to the diff's scrolling. +local function dress(win) + local wo = vim.wo[win][0] + wo.diff = false + wo.scrollbind = false + wo.cursorbind = false + wo.foldenable = false + wo.wrap = true + wo.linebreak = true + wo.number = false + wo.relativenumber = false + wo.signcolumn = 'no' +end + +local function shown_here(win) + return vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_tabpage(win) == vim.api.nvim_get_current_tabpage() +end + +function M.toggle_panel() + if shown_here(state.panel) then + vim.api.nvim_win_close(state.panel, true) + state.panel = -1 + return + end + if vim.api.nvim_win_is_valid(state.panel) then + vim.api.nvim_win_close(state.panel, true) + end + local buf = document_buffer() + if not buf then + return + end + state.panel = vim.api.nvim_open_win(buf, true, { + split = 'right', + win = -1, + width = math.max(40, math.floor(vim.o.columns * 0.4)), + }) + dress(state.panel) + vim.wo[state.panel][0].winfixwidth = true +end + +function M.toggle_float() + if vim.api.nvim_win_is_valid(state.float) then + vim.api.nvim_win_close(state.float, true) + state.float = -1 + return + end + local buf = document_buffer() + if not buf then + return + end + local width = math.floor(vim.o.columns * 0.8) + local height = math.floor(vim.o.lines * 0.8) + state.float = vim.api.nvim_open_win(buf, true, { + relative = 'editor', + width = width, + height = height, + col = math.floor((vim.o.columns - width) / 2), + row = math.floor((vim.o.lines - height) / 2), + border = 'rounded', + title = state.title and (' ' .. state.title .. ' ') or nil, + title_pos = state.title and 'center' or nil, + }) + dress(state.float) +end + +-- Maps the keys and hangs the threads on the diff, in a pull request's diff alone. +function M.setup() + local diff = profiles.pull_request_diff + if not diff then + return + end + state.document = diff.document + state.threads = read_threads(diff.threads) + vim.keymap.set('n', 'gr', M.toggle_panel, { desc = 'Git: pull request beside the diff' }) + vim.keymap.set('n', 'gR', M.toggle_float, { desc = 'Git: pull request in a float' }) + vim.api.nvim_create_autocmd('User', { + desc = 'Pull request: show its review threads on the diff', + group = vim.api.nvim_create_augroup('pull-request-threads', { clear = true }), + pattern = { 'DiffviewDiffBufWinEnter', 'DiffviewViewEnter' }, + callback = M.decorate, + }) +end + +return M diff --git a/tests/README.md b/tests/README.md index 6579d72ec..8333ff288 100644 --- a/tests/README.md +++ b/tests/README.md @@ -32,6 +32,20 @@ check that reads what `dotfiles apply` actually put on disk. **Speed:** Fast (~5 seconds) +### nvim Config Specs + +```bash +task test:nvim +``` + +Runs each `tests/nvim/*_spec.lua` in a clean headless Neovim, with no test +framework. A spec prepends the config to `runtimepath`, drives the real API and +exits non-zero on a failure. One that needs a plugin loads the copy lazy +installed. Where nvim has never started with this config, lazy has installed +nothing, so the spec prints SKIP and passes. + +**Speed:** Fast (~2 seconds) + ### Installation Tests #### Validate File References diff --git a/tests/conftest.py b/tests/conftest.py index df7e62182..4c9111a74 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -118,17 +118,8 @@ def answerable_names() -> tuple[str, ...]: return tuple(sorted(set(declared_names()) | set(settings.SHARED_PATHS) | twins)) -GIT_LOCATION = ( - 'GIT_DIR', - 'GIT_WORK_TREE', - 'GIT_COMMON_DIR', - 'GIT_INDEX_FILE', - 'GIT_OBJECT_DIRECTORY', - 'GIT_ALTERNATE_OBJECT_DIRECTORIES', - 'GIT_PREFIX', - 'GIT_NAMESPACE', -) -"""The variables that decide *which repository* a `git` command acts on. +GIT_LOCATION = tuple(subprocess.run(['git', 'rev-parse', '--local-env-vars'], capture_output=True, text=True, check=True).stdout.split()) +"""The variables that decide *which repository* a `git` command acts on, as git itself lists them. Dropped for the whole session, because a dozen fixtures build a throwaway repo in `tmp_path` and every one of them would otherwise operate on the caller's. diff --git a/tests/nvim/pull_request_panel_spec.lua b/tests/nvim/pull_request_panel_spec.lua new file mode 100644 index 000000000..02f40598e --- /dev/null +++ b/tests/nvim/pull_request_panel_spec.lua @@ -0,0 +1,363 @@ +-- The pull request beside a diff prs opened, driven in a headless Neovim against a +-- real git repo and the diffview.nvim lazy installed. Run it directly: +-- nvim --headless -u NONE -l tests/nvim/pull_request_panel_spec.lua +-- It exits non-zero on a failure, so the commit hook gates on it. Where lazy has not +-- installed diffview.nvim there is no diff to drive, so it says so and passes. + +local repo_root = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':p:h:h:h') +vim.opt.runtimepath:prepend(repo_root .. '/configs/common/.config/nvim') + +-- print loses its newline once diffview has drawn, so each line is written whole. +local function say(line) + io.stdout:write(line .. '\n') +end + +local diffview = vim.fn.stdpath('data') .. '/lazy/diffview.nvim' +if vim.fn.isdirectory(diffview) == 0 then + say('SKIP diffview.nvim is not at ' .. diffview .. ', where lazy installs it the first time nvim starts') + vim.cmd('qall!') +end +vim.opt.runtimepath:prepend(diffview) +vim.cmd.runtime('plugin/diffview.lua') +vim.g.mapleader = ' ' + +local failures = {} +local function check(name, ok) + if not ok then + table.insert(failures, name) + end + say((ok and 'ok ' or 'FAIL ') .. name) +end + +-- What the checks stand on. Without it the checks after it say nothing. +local function needs(what, ok) + if not ok then + say('FAIL ' .. what) + vim.cmd('cquit') + end +end + +local said = {} +vim.notify = function(message, level, _) + table.insert(said, { text = message, level = level }) +end + +local function press(keys) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(keys, true, false, true), 'x', false) +end + +-- git exports its repository variables when it runs a hook, naming the repo being +-- committed. Left set, every git below and every git diffview runs would act on +-- that repo rather than the one built here. +for _, name in ipairs(vim.fn.systemlist({ 'git', 'rev-parse', '--local-env-vars' })) do + vim.env[name] = nil +end + +local tmp = vim.fn.tempname() +vim.fn.mkdir(tmp, 'p') + +-- A branch that changes line 3 of cache/cache.go and adds lines 13 to 15, adds +-- cache/metrics.go, and changes a main.go at the root and one in each of cmd/a and +-- cmd/b. +local repo = tmp .. '/widget' +for _, dir in ipairs({ '/cache', '/cmd/a', '/cmd/b' }) do + vim.fn.mkdir(repo .. dir, 'p') +end +local function git(...) + local out = vim.fn.system(vim.list_extend({ + 'git', + '-C', + repo, + '-c', + 'user.name=spec', + '-c', + 'user.email=spec@example.com', + '-c', + 'commit.gpgsign=false', + '-c', + 'core.hooksPath=/dev/null', + }, { ... })) + assert(vim.v.shell_error == 0, out) +end +local base = { 'package cache', '', 'import "time"' } +for n = 4, 12 do + table.insert(base, '// line ' .. n) +end +local head = vim.list_extend(vim.deepcopy(base), { '// line 13', '// line 14 reads the wall clock', '// line 15' }) +head[3] = 'import "sync"' +local mains = { 'main.go', 'cmd/a/main.go', 'cmd/b/main.go' } +git('init', '--quiet', '-b', 'main') +local made = vim.trim(vim.fn.system({ 'git', '-C', repo, 'rev-parse', '--absolute-git-dir' })) +needs('git init made ' .. made .. ' rather than a repo in ' .. repo, made == vim.fn.resolve(repo) .. '/.git') +vim.fn.writefile(base, repo .. '/cache/cache.go') +for _, main in ipairs(mains) do + vim.fn.writefile({ 'package main' }, repo .. '/' .. main) +end +git('add', '.') +git('commit', '--quiet', '-m', 'base') +git('checkout', '--quiet', '-b', 'cache-metrics') +vim.fn.writefile(head, repo .. '/cache/cache.go') +vim.fn.writefile({ 'package cache', '', 'var hits int' }, repo .. '/cache/metrics.go') +for _, main in ipairs(mains) do + vim.fn.writefile({ 'package main', '', 'func main() {}' }, repo .. '/' .. main) +end +git('add', '.') +git('commit', '--quiet', '-m', 'head') + +-- A document and a threads file shaped like the ones prs writes. +local document = tmp .. '/prs-diff-widget-13.md' +vim.fn.writefile({ + '# Count cache hits', + '', + '`acme/widget` #13 · opened by octo · `cache-metrics` into `main`', + '', + '# Description', + '', + 'The counter lives in metrics.go, and the design is in `docs/design.md`.', + '', + '# Files', + '', + '| File | Change | Added | Removed |', + '| --- | --- | ---: | ---: |', + '| `cache/cache.go` | modified | 4 | 1 |', + '| `cache/metrics.go` | added | 3 | 0 |', + '| `cmd/a/main.go` | modified | 2 | 0 |', + '| `cmd/b/main.go` | modified | 2 | 0 |', + '| `main.go` | modified | 2 | 0 |', + '', + '# Comments', + '', + '## Review threads', + '', + '2 of 3 unresolved.', + '', + '### `cache/cache.go:14` · unresolved', + '', + '**mona** · 3h ago', + '', + 'This expiry reads the wall clock.', + 'Take a clock instead.', + '', + '**octo** · 2h ago', + '', + 'Done.', + '', + '### `cache/cache.go`, on a line gone from the diff · unresolved, outdated', + '', + '**mona** · 3h ago', + '', + 'This line moved.', + '', + '### `cache/cache.go:3` · resolved', + '', + '**hubot** · 3h ago', + '', + 'Why did the time import go?', +}, document) +local function comment(author, body) + return { author = author, body = body, created_at = '2026-11-02T10:14:00Z', url = 'https://github.com/acme/widget/pull/13#discussion_r1' } +end +local function threads_file(name, threads_read, threads) + local path = tmp .. '/' .. name + vim.fn.writefile({ + vim.json.encode({ + slug = 'acme/widget', + number = 13, + title = 'Count cache hits', + url = 'https://github.com/acme/widget/pull/13', + base = 'origin/main', + head = '1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b', + threads_read = threads_read, + threads = threads, + }), + }, path) + return path +end +local threads = threads_file('prs-diff-widget-13.threads.json', true, { + { + path = 'cache/cache.go', + line = 14, + side = 'RIGHT', + resolved = false, + outdated = false, + comments = { comment('mona', 'This expiry reads the wall clock.\nTake a clock instead.'), comment('octo', 'Done.') }, + comment_count = 2, + }, + { + path = 'cache/cache.go', + line = 3, + side = 'LEFT', + resolved = true, + outdated = false, + comments = { comment('hubot', 'Why did the time import go?') }, + comment_count = 1, + }, + { + path = 'cache/cache.go', + line = vim.NIL, + side = 'RIGHT', + resolved = false, + outdated = true, + comments = { comment('mona', 'This line moved.') }, + comment_count = 1, + }, +}) +local threads_unread = threads_file('prs-diff-widget-13.unread.threads.json', false, {}) + +-- Loads the config's modules afresh, as an nvim started with these variables would. +local function started_with(document_path, threads_path) + vim.env.PRS_DIFF_DOCUMENT = document_path + vim.env.PRS_DIFF_THREADS = threads_path + package.loaded['core.profiles'] = nil + package.loaded['core.pull-request-panel'] = nil + local panel = require('core.pull-request-panel') + panel.setup() + return panel +end + +started_with(nil, nil) +check('an nvim prs did not start maps no pull request key', vim.fn.maparg(' gr', 'n') == '') + +said = {} +started_with(document, threads_unread) +check('a diff opened before prs read the review threads warns that none show', #said == 1 and said[1].level == vim.log.levels.WARN) + +said = {} +local panel = started_with(document, threads) +check( + 'a program the diff nvim starts is an ordinary one, with no PRS_DIFF_DOCUMENT', + vim.trim(vim.fn.system({ 'printenv', 'PRS_DIFF_DOCUMENT' })) == '' +) + +vim.cmd.cd(repo) +vim.cmd('DiffviewOpen main...cache-metrics') +local lib = require('diffview.lib') +local function view() + return lib.get_current_view() +end +local function showing(path) + return vim.wait(5000, function() + local v = view() + local entry = v and v.cur_entry + local b = entry and entry.path == path and entry.layout.b + return b and b.file and b.file.bufnr and vim.api.nvim_buf_line_count(b.file.bufnr) > 1 or false + end, 20) +end +needs('the diff opens on cache/cache.go', showing('cache/cache.go')) + +local function marks(symbol) + local bufnr = view().cur_entry.layout[symbol].file.bufnr + local found = {} + for _, mark in ipairs(vim.api.nvim_buf_get_extmarks(bufnr, panel.namespace, 0, -1, { details = true })) do + table.insert(found, { row = mark[2], text = mark[4].virt_text[1][1] }) + end + return found +end +vim.wait(2000, function() + return #marks('b') > 0 and #marks('a') > 0 +end, 20) +local right, left = marks('b'), marks('a') +check( + "a RIGHT thread shows on its line of the head's copy, with its reply count", + #right == 1 and right[1].row == 13 and right[1].text == ' mona: This expiry reads the wall clock. (+1)' +) +check( + "a LEFT thread shows on its line of the base's copy, marked resolved", + #left == 1 and left[1].row == 2 and left[1].text == ' hubot: Why did the time import go? · resolved' +) + +local function windows_showing_document() + local found = {} + for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do + if vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(win)) == document then + table.insert(found, win) + end + end + return found +end + +press(' gr') +local shown = windows_showing_document() +local win = shown[1] +check( + 'gr shows the document in a split at the right edge, unbound from the diff', + #shown == 1 + and vim.api.nvim_win_get_config(win).relative == '' + and vim.api.nvim_win_get_position(win)[2] + vim.api.nvim_win_get_width(win) == vim.o.columns + and not vim.wo[win].diff + and not vim.wo[win].scrollbind +) +press(' gr') +check('gr again hides it', #windows_showing_document() == 0) + +press(' gR') +shown = windows_showing_document() +check('gR shows the document in a float', #shown == 1 and vim.api.nvim_win_get_config(shown[1]).relative == 'editor') +press(' gR') +check('gR again hides it', #windows_showing_document() == 0) + +-- Puts the cursor in the document on the first line holding text, at its column. +local function on(text) + local document_win = windows_showing_document()[1] + vim.api.nvim_set_current_win(document_win) + for row, line in ipairs(vim.api.nvim_buf_get_lines(0, 0, -1, false)) do + local col = line:find(text, 1, true) + if col then + vim.api.nvim_win_set_cursor(document_win, { row, col - 1 }) + return + end + end + error(text .. ' is not in the document') +end + +local function at(path, symbol, row) + return vim.wait(5000, function() + local entry = view().cur_entry + if entry.path ~= path then + return false + end + local window = view().cur_layout[symbol] + return vim.api.nvim_get_current_win() == window.id and vim.api.nvim_win_get_cursor(window.id)[1] == row + end, 20) +end + +press(' gr') +on('metrics.go, and') +press('') +check('a bare file name under the cursor selects the one file it ends', showing('cache/metrics.go')) + +on('| `cache/cache.go`') +press('') +check('a row of the files table selects its file from the start of the row', showing('cache/cache.go')) + +on('| `main.go`') +press('') +check('a root file selects itself where nested files share its name', showing('main.go')) + +on('Take a clock instead.') +press('') +check("a RIGHT thread's comment puts the cursor on its heading's line of the head's copy", at('cache/cache.go', 'b', 14)) + +on('### `cache/cache.go:3`') +press('gf') +check("gf on a LEFT thread's path:line puts the cursor on that line of the base's copy", at('cache/cache.go', 'a', 3)) + +said = {} +on('docs/design.md') +press('') +check( + 'a path the diff does not change is named in a notice, and the diff stays on its file', + #said == 1 and said[1].text:find('docs/design.md', 1, true) ~= nil and view().cur_entry.path == 'cache/cache.go' +) + +press(' gr') +press(' gR') +on('| `cache/metrics.go`') +press('') +check('a jump from the float closes it', showing('cache/metrics.go') and #windows_showing_document() == 0) + +vim.fn.delete(tmp, 'rf') +if #failures > 0 then + say('FAILED: ' .. table.concat(failures, ', ')) + vim.cmd('cquit') +end