From 09c48474b8a72af3012215e03898a79113ff44b8 Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Sat, 10 Oct 2026 23:54:51 -0400 Subject: [PATCH 01/12] feat(diff): fingerprint each file's diff - Give each File a digest: the sha256 of its section of the hunks diff (now run with --full-index), of an untracked file's content, or of its status and old path when the patch has no text. - Add digest to FILE_FIELDS, so rewriting a changed line at the same counts gives the file a new identity. --- lua/changeset/build.lua | 2 +- lua/changeset/diff.lua | 47 ++++++++++++++----- tests/band_spec.lua | 1 + tests/build_spec.lua | 12 +++++ tests/cache_spec.lua | 1 + tests/diff_spec.lua | 89 +++++++++++++++++++++++++++++++---- tests/resolve_spec.lua | 99 ++++++++++++++++++++++++++++++--------- tests/rows_spec.lua | 1 + tests/support/changes.lua | 1 + 9 files changed, 211 insertions(+), 42 deletions(-) diff --git a/lua/changeset/build.lua b/lua/changeset/build.lua index 7c63345..519f845 100644 --- a/lua/changeset/build.lua +++ b/lua/changeset/build.lua @@ -148,7 +148,7 @@ local function file_answer(root, path, answer, stamp) end local HUNK_FIELDS = { "lnum", "count", "added", "removed", "old_lnum" } -local FILE_FIELDS = { "path", "oldpath", "status", "section", "added", "removed" } +local FILE_FIELDS = { "path", "oldpath", "status", "section", "added", "removed", "digest" } ---Whether `a` and `b` hold the same diff of the same file. ---@param a changeset.File diff --git a/lua/changeset/diff.lua b/lua/changeset/diff.lua index 641f7c0..e130593 100644 --- a/lua/changeset/diff.lua +++ b/lua/changeset/diff.lua @@ -19,6 +19,7 @@ local M = {} ---@field added integer ---@field removed integer ---@field hunks changeset.Hunk[] Ascending by line, as git emits them; empty for a binary or pure rename. +---@field digest string Changes whenever the file's diff does. ---@class changeset.File : changeset.diff.Unfiled A changed file and the hunks inside it. ---@field section changeset.SectionKey The section `sections.classify` files it under, decided once when the diff is read. @@ -36,6 +37,7 @@ local M = {} ---@field statuses table By new path. ---@field hunks table By new path. ---@field untracked table Line count by path. +---@field digests table Each file's diff section or untracked content, hashed, by path. -- Any other name-status code (M, T) reads as a modification. ---@type table @@ -135,11 +137,20 @@ end ---its `---` line when it was deleted; the `diff --git` line can't be split when a path holds ` b/`. ---@param lines string[] ---@return table hunks By new path; a file with no text hunks is absent. +---@return table digests The sha256 of each keyed file's section, by new path. function M._parse_hunks(lines) - local hunks, old, in_header = {}, nil, false + local hunks, digests, old, in_header = {}, {}, nil, false local current ---@type changeset.Hunk[]? + local section, keyed = {}, nil ---@type string[], string? + local function close() + if keyed then + digests[keyed] = vim.fn.sha256(table.concat(section, "\n")) + end + section, keyed = {}, nil + end for _, line in ipairs(lines) do if vim.startswith(line, "diff --git ") then + close() current, old, in_header = nil, nil, true elseif in_header and vim.startswith(line, "--- ") then old = side_path(line:sub(5), "a/") @@ -147,7 +158,7 @@ function M._parse_hunks(lines) local path = side_path(line:sub(5), "b/") or old current = {} if path then - hunks[path] = current + hunks[path], keyed = current, path end else local hunk = parse_hunk_header(line) @@ -160,8 +171,10 @@ function M._parse_hunks(lines) end end end + table.insert(section, line) end - return hunks + close() + return hunks, digests end ---@param path string @@ -177,13 +190,16 @@ local function tracked_file(path, entry, parts) added = stat.added, removed = stat.removed, hunks = parts.hunks[path] or {}, + -- ponytail: a binary file edited in place keeps its digest; parse its `Binary files … differ` line if that matters. + digest = parts.digests[path] or vim.fn.sha256(entry.status .. "\0" .. (entry.oldpath or "")), } end ---@param path string ---@param lines integer +---@param digest string ---@return changeset.diff.Unfiled -local function untracked_file(path, lines) +local function untracked_file(path, lines, digest) local whole_file = { lnum = 1, count = lines, added = lines, removed = 0, old_lnum = 0 } return { path = path, @@ -191,6 +207,7 @@ local function untracked_file(path, lines) added = lines, removed = 0, hunks = lines > 0 and { whole_file } or {}, + digest = digest, } end @@ -242,7 +259,7 @@ function M._assemble(parts) table.insert(files, tracked_file(path, entry, parts)) end for path, lines in pairs(parts.untracked) do - table.insert(files, untracked_file(path, lines)) + table.insert(files, untracked_file(path, lines, parts.digests[path])) end table.sort(files, function(a, b) local a_dir, a_name = sort_key(a.path) @@ -271,7 +288,7 @@ local function git_commands(base) return { numstat = diff_cmd("--numstat", "-z", base), name_status = diff_cmd("--name-status", "-z", base), - hunks = diff_cmd("--unified=0", "--inter-hunk-context=0", base), + hunks = diff_cmd("--unified=0", "--inter-hunk-context=0", "--full-index", base), untracked = cmd("ls-files", "-z", "--others", "--exclude-standard"), commits = cmd("rev-list", "--count", base .. "..HEAD"), } @@ -313,16 +330,18 @@ end ---Line count per readable file; directories (untracked nested repos) and dangling symlinks are skipped. ---@param paths string[] Relative to `cwd`. ---@param cwd string ----@return table +---@return table counts +---@return table digests The sha256 of each counted file's lines. local function count_lines(paths, cwd) - local counts = {} + local counts, digests = {}, {} for _, path in ipairs(paths) do local abs = vim.fs.joinpath(cwd, path) if vim.fn.filereadable(abs) == 1 then - counts[path] = #vim.fn.readfile(abs) + local lines = vim.fn.readfile(abs) + counts[path], digests[path] = #lines, vim.fn.sha256(table.concat(lines, "\n")) end end - return counts + return counts, digests end ---Whether the Go file at `abs` has a generated header; false when it can't be read. @@ -382,11 +401,15 @@ function M.collect(base, cwd, callback) if err then return callback(nil, err) end + local hunks, digests = M._parse_hunks(stdout_lines(results.hunks)) + local untracked, untracked_digests = + count_lines(vim.split(results.untracked.stdout or "", "\0", { trimempty = true }), cwd) local files = M._assemble({ numstat = M._parse_numstat(results.numstat.stdout or ""), statuses = M._parse_name_status(results.name_status.stdout or ""), - hunks = M._parse_hunks(stdout_lines(results.hunks)), - untracked = count_lines(vim.split(results.untracked.stdout or "", "\0", { trimempty = true }), cwd), + hunks = hunks, + untracked = untracked, + digests = vim.tbl_extend("force", digests, untracked_digests), }) generated_paths(files, cwd, function(marked) ---@cast files changeset.File[] diff --git a/tests/band_spec.lua b/tests/band_spec.lua index 210c165..607b95b 100644 --- a/tests/band_spec.lua +++ b/tests/band_spec.lua @@ -343,6 +343,7 @@ describe("changeset preview band's band_for", function() added = 3, removed = 1, hunks = hunks, + digest = "d", } local folded = Rows.compress(Rows.build({ file }, { ["mod.lua"] = chain })) --[[@as changeset.Row[] ]] local top = present(folded[1]) diff --git a/tests/build_spec.lua b/tests/build_spec.lua index 673bfe3..42dbb2d 100644 --- a/tests/build_spec.lua +++ b/tests/build_spec.lua @@ -285,6 +285,18 @@ describe("changeset.build", function() assert.not_equal(file, moved) end) + it("hands a refresh a file of its own once a changed line's text changes with the same counts", function() + build_and_collect() + local file = present(present(build.current()).files[1]) + vim.fn.writefile({ "return 3" }, "mod.lua") + + refresh_and_collect() + + local edited = present(present(build.current()).files[1]) + assert.same({ file.added, file.removed, #file.hunks }, { edited.added, edited.removed, #edited.hunks }) + assert.not_equal(file, edited) + end) + it("does not cache the symbols read from a buffer holding unwritten edits", function() answer({ Changes.sym("f", "Function", 0, 1, 1) }) vim.api.nvim_buf_set_lines(0, 0, -1, false, { "return 3" }) diff --git a/tests/cache_spec.lua b/tests/cache_spec.lua index 67a04f2..cdbc065 100644 --- a/tests/cache_spec.lua +++ b/tests/cache_spec.lua @@ -12,6 +12,7 @@ local function file(path, added) added = added or 1, removed = 0, hunks = {}, + digest = "d", } end diff --git a/tests/diff_spec.lua b/tests/diff_spec.lua index d1f7d2d..a76efa6 100644 --- a/tests/diff_spec.lua +++ b/tests/diff_spec.lua @@ -255,17 +255,45 @@ describe("changeset.diff._parse_hunks", function() end) it("drops a hunk header that arrives before any file header", function() - assert.same({}, diff._parse_hunks({ "@@ -1 +1 @@" })) + assert.same({}, (diff._parse_hunks({ "@@ -1 +1 @@" }))) end) it("returns an empty table for an empty diff", function() - assert.same({}, diff._parse_hunks({})) + assert.same({}, (diff._parse_hunks({}))) + end) + + ---@param path string + ---@param added string + ---@return string[] + local function section(path, added) + return { + "diff --git a/" .. path .. " b/" .. path, + "--- a/" .. path, + "+++ b/" .. path, + "@@ -1 +1 @@", + "-old", + "+" .. added, + } + end + + it("digests each file's section on its own", function() + local _, digests = diff._parse_hunks(vim.list_extend(section("a.lua", "x"), section("b.lua", "y"))) + + assert.not_equal(digests["a.lua"], digests["b.lua"]) + assert.equal(select(2, diff._parse_hunks(section("a.lua", "x")))["a.lua"], digests["a.lua"]) + end) + + it("digests a changed line's text, not only the hunk counts", function() + local _, before = diff._parse_hunks(section("a.lua", "x")) + local _, after = diff._parse_hunks(section("a.lua", "z")) + + assert.not_equal(before["a.lua"], after["a.lua"]) end) end) describe("changeset.diff._assemble", function() ---@type changeset.diff.Parts - local NO_PARTS = { numstat = {}, statuses = {}, hunks = {}, untracked = {} } + local NO_PARTS = { numstat = {}, statuses = {}, hunks = {}, untracked = {}, digests = {} } ---@param overrides table local function assemble(overrides) @@ -280,15 +308,24 @@ describe("changeset.diff._assemble", function() numstat = { ["new.lua"] = { added = 1, removed = 1 } }, statuses = { ["new.lua"] = { status = "renamed", oldpath = "old.lua" } }, hunks = { ["new.lua"] = { hunk } }, + digests = { ["new.lua"] = "d" }, }) assert.same({ - { path = "new.lua", oldpath = "old.lua", status = "renamed", added = 1, removed = 1, hunks = { hunk } }, + { + path = "new.lua", + oldpath = "old.lua", + status = "renamed", + added = 1, + removed = 1, + hunks = { hunk }, + digest = "d", + }, }, files) end) it("reports an untracked file as one hunk over all of its lines", function() - local files = assemble({ untracked = { ["scratch.txt"] = 3 } }) + local files = assemble({ untracked = { ["scratch.txt"] = 3 }, digests = { ["scratch.txt"] = "d" } }) assert.same({ { @@ -297,6 +334,7 @@ describe("changeset.diff._assemble", function() added = 3, removed = 0, hunks = { { lnum = 1, count = 3, added = 3, removed = 0, old_lnum = 0 } }, + digest = "d", }, }, files) end) @@ -330,12 +368,22 @@ describe("changeset.diff._assemble", function() it("zero-fills a tracked path the other git calls did not report", function() local files = assemble({ statuses = { ["edited-mid-run.lua"] = { status = "modified" } } }) + present(files[1]).digest = nil assert.same({ { path = "edited-mid-run.lua", status = "modified", added = 0, removed = 0, hunks = {} }, }, files) end) + it("digests a file the patch gives no text for from its status and old path", function() + local function digest(entry) + return present(assemble({ statuses = { ["img.png"] = entry } })[1]).digest + end + + assert.equal(digest({ status = "modified" }), digest({ status = "modified" })) + assert.not_equal(digest({ status = "modified" }), digest({ status = "renamed", oldpath = "old.png" })) + end) + it("returns an empty list when nothing changed", function() assert.same({}, assemble({})) end) @@ -426,6 +474,17 @@ local function collect(base, cwd) return present(files, err) end +---`files` with their digests dropped, for comparing against literals. +---@param files changeset.File[] +---@return table[] +local function undigested(files) + return vim.tbl_map(function(file) + local copy = vim.deepcopy(file) + copy.digest = nil + return copy + end, files) +end + ---The sorted paths `diff.collect` marks generated. ---@param base string ---@param cwd string @@ -482,7 +541,21 @@ describe("changeset.diff.collect", function() { lnum = 8, count = 1, added = 1, removed = 1, old_lnum = 8 }, }, }, - }, collect(base, tmp)) + }, undigested(collect(base, tmp))) + end) + + it("lists a file removed from the index but left on disk as both deleted and untracked", function() + Fixture.init_repo("trunk", tmp) + write("foo", { "a" }) + local base = Fixture.commit("seed", tmp) + Fixture.git({ "rm", "-q", "--cached", "foo" }, tmp) + + assert.same( + { { "foo", "deleted" }, { "foo", "untracked" } }, + vim.tbl_map(function(file) + return { file.path, file.status } + end, collect(base, tmp)) + ) end) it("keeps two edits three lines apart in separate hunks under a wider diff.interHunkContext", function() @@ -629,7 +702,7 @@ describe("changeset.diff.collect", function() removed = 0, hunks = {}, }, - }, collect(base, tmp)) + }, undigested(collect(base, tmp))) end) it("leaves gitignored paths out of the untracked files", function() @@ -649,7 +722,7 @@ describe("changeset.diff.collect", function() removed = 0, hunks = { { lnum = 1, count = 3, added = 3, removed = 0, old_lnum = 0 } }, }, - }, collect(base, tmp)) + }, undigested(collect(base, tmp))) end) it("counts only the untracked paths it can read", function() diff --git a/tests/resolve_spec.lua b/tests/resolve_spec.lua index 4dbdfc3..4eda915 100644 --- a/tests/resolve_spec.lua +++ b/tests/resolve_spec.lua @@ -167,8 +167,15 @@ describe("changeset.resolve", function() ---@return string[] local function reported_at_once() local seen = {} - local file = - { path = "mod.lua", status = "added", section = "implementation", added = 1, removed = 0, hunks = {} } + local file = { + path = "mod.lua", + status = "added", + section = "implementation", + added = 1, + removed = 0, + hunks = {}, + digest = "d", + } local cancel = resolve.start({ root = root, base = "HEAD" }, { file }, function(path) seen[#seen + 1] = path end) @@ -211,8 +218,15 @@ describe("changeset.resolve", function() vim.lsp.enable("stub_lua") enabled = "stub_lua" local report ---@type { items: changeset.Symbol[]?, comments: changeset.Comments?, timed_out: boolean? }? - local file = - { path = "mod.lua", status = "added", section = "implementation", added = 1, removed = 0, hunks = {} } + local file = { + path = "mod.lua", + status = "added", + section = "implementation", + added = 1, + removed = 0, + hunks = {}, + digest = "d", + } resolve.start({ root = root, base = "HEAD" }, { file }, function(_, items) report = { items = items } @@ -265,8 +279,15 @@ describe("changeset.resolve", function() }, { bufnr = buf }) end local report ---@type { items: changeset.Symbol[]?, comments: changeset.Comments?, timed_out: boolean? }? - local file = - { path = "mod.lua", status = "added", section = "implementation", added = 1, removed = 0, hunks = {} } + local file = { + path = "mod.lua", + status = "added", + section = "implementation", + added = 1, + removed = 0, + hunks = {}, + digest = "d", + } resolve.start({ root = root, base = "HEAD" }, { file }, function(_, items) report = { items = items } end) @@ -340,13 +361,19 @@ describe("changeset.resolve", function() ---@return table? by name local function resolved(path) local answer, done - resolve.start( - { root = root, base = "HEAD" }, - { { path = path, status = "added", section = "implementation", added = 1, removed = 0, hunks = {} } }, - function(_, items) - answer, done = items, true - end - ) + resolve.start({ root = root, base = "HEAD" }, { + { + path = path, + status = "added", + section = "implementation", + added = 1, + removed = 0, + hunks = {}, + digest = "d", + }, + }, function(_, items) + answer, done = items, true + end) vim.wait(2000, function() return done end) @@ -365,8 +392,15 @@ describe("changeset.resolve", function() Fixture.commit("base", root) vim.fn.writefile({ "return { 1 }" }, root .. "/mod.lua") local report ---@type { items: changeset.Symbol[]?, comments: changeset.Comments?, timed_out: boolean? }? - local file = - { path = "mod.lua", status = "modified", section = "implementation", added = 1, removed = 1, hunks = {} } + local file = { + path = "mod.lua", + status = "modified", + section = "implementation", + added = 1, + removed = 1, + hunks = {}, + digest = "d", + } resolve.start({ root = root, base = "HEAD" }, { file }, function(_, items) report = { items = items } @@ -385,7 +419,15 @@ describe("changeset.resolve", function() Fixture.commit("base", root) vim.fn.writefile({ "# new note", "x = 1" }, root .. "/conf.toml") local report ---@type { items: changeset.Symbol[]?, comments: changeset.Comments?, timed_out: boolean? }? - local file = { path = "conf.toml", status = "modified", section = "config", added = 1, removed = 1, hunks = {} } + local file = { + path = "conf.toml", + status = "modified", + section = "config", + added = 1, + removed = 1, + hunks = {}, + digest = "d", + } resolve.start({ root = root, base = "HEAD" }, { file }, function(_, items, found) report = { items = items, comments = found } @@ -406,7 +448,8 @@ describe("changeset.resolve", function() Fixture.commit("base", root) vim.fn.writefile({ "# Title", "" }, root .. "/README.md") local report ---@type { items: changeset.Symbol[]?, comments: changeset.Comments?, timed_out: boolean? }? - local file = { path = "README.md", status = "modified", section = "docs", added = 1, removed = 0, hunks = {} } + local file = + { path = "README.md", status = "modified", section = "docs", added = 1, removed = 0, hunks = {}, digest = "d" } resolve.start({ root = root, base = "HEAD" }, { file }, function(_, items, found) report = { items = items, comments = found } @@ -527,8 +570,15 @@ describe("changeset.resolve", function() end local function start(on_file) - local file = - { path = "mod.lua", status = "added", section = "implementation", added = 1, removed = 0, hunks = {} } + local file = { + path = "mod.lua", + status = "added", + section = "implementation", + added = 1, + removed = 0, + hunks = {}, + digest = "d", + } return resolve.start({ root = root, base = "HEAD" }, { file }, on_file) end @@ -924,8 +974,15 @@ describe("changeset.resolve", function() end return real_parser(text, ...) end - local file = - { path = "big.lua", status = "modified", added = 1, removed = 0, hunks = {}, section = "implementation" } + local file = { + path = "big.lua", + status = "modified", + added = 1, + removed = 0, + hunks = {}, + section = "implementation", + digest = "d", + } local got resolve.start( { root = tmp, base = Fixture.git({ "merge-base", "HEAD", "trunk" }, tmp) }, diff --git a/tests/rows_spec.lua b/tests/rows_spec.lua index 2bd573a..8c13824 100644 --- a/tests/rows_spec.lua +++ b/tests/rows_spec.lua @@ -50,6 +50,7 @@ local function file(path, hunks, status) added = added, removed = removed, hunks = hunks, + digest = "d", } end diff --git a/tests/support/changes.lua b/tests/support/changes.lua index 431e898..5ce95e8 100644 --- a/tests/support/changes.lua +++ b/tests/support/changes.lua @@ -35,6 +35,7 @@ function M.file(path, lines) added = #lines, removed = 0, hunks = hunks, + digest = path, } end From b242f6f12e33b051037b9e13fe492285412b8499 Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Sun, 11 Oct 2026 00:07:37 -0400 Subject: [PATCH 02/12] feat(sidebar): mark files viewed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `v` (keymaps.toggle_viewed) marks the file under the cursor viewed, or clears its mark, from its file row or any row under it. - A viewed file draws ✔ in ChangesetViewed (linked to Comment) in its icon's place. - Marks live in stdpath("state")/changeset/viewed.json by repository root and path, each against the digest of the diff it was viewed at, so a mark stops showing once that diff changes. --- lua/changeset/actions.lua | 20 ++++++ lua/changeset/config.lua | 3 + lua/changeset/draw.lua | 16 ++++- lua/changeset/highlights.lua | 8 +++ lua/changeset/rows.lua | 2 + lua/changeset/sidebar_state.lua | 3 + lua/changeset/view.lua | 18 +++++- lua/changeset/viewed.lua | 85 ++++++++++++++++++++++++ tests/sidebar_spec.lua | 110 ++++++++++++++++++++++++++++++++ tests/viewed_spec.lua | 79 +++++++++++++++++++++++ 10 files changed, 340 insertions(+), 4 deletions(-) create mode 100644 lua/changeset/viewed.lua create mode 100644 tests/viewed_spec.lua diff --git a/lua/changeset/actions.lua b/lua/changeset/actions.lua index 2f39413..5cf0f5b 100644 --- a/lua/changeset/actions.lua +++ b/lua/changeset/actions.lua @@ -11,6 +11,7 @@ local review_comment = require("changeset.review_comment") local reviewing = require("changeset.reviewing") local sidebar_state = require("changeset.sidebar_state") local view = require("changeset.view") +local viewed = require("changeset.viewed") local window = require("changeset.window") -- input() reads a line, so it can never hand one back: free to mean "cancelled". @@ -437,6 +438,25 @@ function M.set_keymaps(buf, keys, hooks) or row.path Paths.copy(text, "relative path:line") end, "Yank path:line") + map(keys.toggle_viewed, function(state) + local row = draw.row_at_cursor() + if not row or row.kind == "section" or row.kind == "comment" then + return + end + local file = vim.iter(state.tree.files):find(function(f) + return f.path == row.path + end) + if not file then + return + end + local record = viewed.path() + local marks = viewed.toggle(record, state.tree.root, file.path, file.digest) + if not marks then + return vim.notify("Changeset: could not write " .. record, vim.log.levels.ERROR) + end + state.marks = marks + redraw() + end, "Mark the file viewed, or not") map(keys.help, function() help.show(buf, own) end, "Show these keymaps") diff --git a/lua/changeset/config.lua b/lua/changeset/config.lua index c29f27b..290db6f 100644 --- a/lua/changeset/config.lua +++ b/lua/changeset/config.lua @@ -24,6 +24,7 @@ local M = {} ---@field prev_section? string|false Previous section. Default `[[`. ---@field refresh? string|false Rebuild the tree. Default `R`. ---@field yank? string|false Yank path:line. Default `y`. +---@field toggle_viewed? string|false Mark the file viewed, or not. Default `v`. ---@field delete_comment? string|false Delete the review comment a Comments row lists. Default `d`. ---@field help? string|false Show these keymaps. Default `?`. ---@field filter_kinds? string|false Filter by symbol kind. Default `F`. @@ -67,6 +68,7 @@ local M = {} ---@field prev_section string|false ---@field refresh string|false ---@field yank string|false +---@field toggle_viewed string|false ---@field delete_comment string|false ---@field help string|false ---@field filter_kinds string|false @@ -104,6 +106,7 @@ local DEFAULTS = { prev_section = "[[", refresh = "R", yank = "y", + toggle_viewed = "v", delete_comment = "d", help = "?", filter_kinds = "F", diff --git a/lua/changeset/draw.lua b/lua/changeset/draw.lua index eced085..8569d8e 100644 --- a/lua/changeset/draw.lua +++ b/lua/changeset/draw.lua @@ -9,6 +9,7 @@ local render = require("changeset.render") local Rows = require("changeset.rows") local sidebar_state = require("changeset.sidebar_state") local view = require("changeset.view") +local viewed = require("changeset.viewed") local window = require("changeset.window") -- The totals row over the tree and the blank one under it. @@ -502,7 +503,20 @@ function M.draw(kinds_key) local cursor, top = vim.api.nvim_win_get_cursor(win)[1], vim.fn.line("w0", win) comments_section = comments_for(state.tree) local tree = laid_out(state.rows) - local lines, lnum, blocks = state.view:show(tree, { icon = icon_for, width = width, cursor = cursor }) + local function is_viewed(row) + return row.kind == "file" and viewed.holds(state.marks, row.path, row.digest) + end + local lines, lnum, blocks = state.view:show(tree, { + icon = function(row) + if is_viewed(row) then + return highlights.VIEWED_ICON, highlights.VIEWED_HL + end + return icon_for(row) + end, + viewed = is_viewed, + width = width, + cursor = cursor, + }) -- Read before the lines go, which takes the header's filler rows with them. local saved = vim.api.nvim_win_call(win, vim.fn.winsaveview) diff --git a/lua/changeset/highlights.lua b/lua/changeset/highlights.lua index 182e45d..207d42b 100644 --- a/lua/changeset/highlights.lua +++ b/lua/changeset/highlights.lua @@ -61,6 +61,13 @@ M.REVIEW_COMMENT_DRAFT_CIRCLE = "◌" M.REVIEW_COMMENT_BUBBLE = "󰍩" M.REVIEW_COMMENT_DRAFT_BUBBLE = "󰍪" +---Group for a viewed file's check. Created by `define_highlights`. +---@type string +M.VIEWED_HL = "ChangesetViewed" + +---Drawn in place of a viewed file's icon. +M.VIEWED_ICON = "✔" + ---The review comment's group, or the draft group for a draft. ---@param comment { draft: true? } ---@return string @@ -349,6 +356,7 @@ function M.define_highlights() -- The saved green faded halfway to Comment and unbolded: the same note, not yet committed to. set_default(M.REVIEW_COMMENT_DRAFT_HL, { fg = ok and comment.fg and mix(ok, comment.fg, 0.5) or ok or comment.fg }) set_default(M.REVIEW_COMMENT_BODY_HL, { link = M.META_HL }) + set_default(M.VIEWED_HL, { link = "Comment" }) set_default(M.BADGE_HL, { fg = directory, reverse = true, bold = true }) local statusline = vim.api.nvim_get_hl(0, { name = "StatusLine", link = false }) set_default(M.FOOTER_HL, { fg = comment.fg, bg = statusline.bg }) diff --git a/lua/changeset/rows.lua b/lua/changeset/rows.lua index 2fa0765..001c8b4 100644 --- a/lua/changeset/rows.lua +++ b/lua/changeset/rows.lua @@ -42,6 +42,7 @@ end ---@field range { [1]: integer, [2]: integer }? Lines a symbol's body or an orphan hunk covers, inclusive. ---@field status string? File rows only. ---@field read changeset.ReadStatus? File rows only: how far this file's symbols have been read. +---@field digest string? File rows only: the fingerprint of the file's diff. ---@field files integer? Section rows only: how many files the section holds, before any filter. ---@field icon string? Section rows only: the directory name its section header's icon is looked up by. ---@field comments integer? The Comments section's row only: how many rows it lists, before any filter. @@ -493,6 +494,7 @@ local function file_row(file, read, section) removed = not deleted and file.removed or nil, ancestor = false, status = file.status, + digest = file.digest, children = {}, } end diff --git a/lua/changeset/sidebar_state.lua b/lua/changeset/sidebar_state.lua index 42c49a9..2b53096 100644 --- a/lua/changeset/sidebar_state.lua +++ b/lua/changeset/sidebar_state.lua @@ -4,6 +4,7 @@ local build = require("changeset.build") local position = require("changeset.position") local prefs = require("changeset.prefs") local view = require("changeset.view") +local viewed = require("changeset.viewed") local M = {} @@ -13,6 +14,7 @@ local M = {} ---@field rows changeset.Row[] ---@field view changeset.View What the sidebar shows of the tree. ---@field position changeset.Position Where the user stands in this tree. +---@field marks changeset.ViewedMarks The files marked viewed in this repository. ---@type changeset.SidebarState? local state @@ -33,6 +35,7 @@ function M.current() rows = {}, view = view.for_root(tree.root, prefs.resolve(prefs.load(prefs_file), tree.root, tree.branch)), position = position.new(), + marks = viewed.marks(viewed.load(viewed.path()), tree.root), } end return state diff --git a/lua/changeset/view.lua b/lua/changeset/view.lua index 8992fdc..c935f31 100644 --- a/lua/changeset/view.lua +++ b/lua/changeset/view.lua @@ -10,6 +10,7 @@ local Rows = require("changeset.rows") ---@class changeset.view.Layout ---@field icon fun(row: changeset.Row): string, string Glyph and highlight for a row. +---@field viewed? fun(row: changeset.Row): boolean Whether a row is drawn as viewed. ---@field width integer ---@field cursor integer The cursor's line before this redraw. @@ -239,6 +240,7 @@ end ---@field query string ---@field hidden table ---@field folds string +---@field viewed boolean? ---Each row under a section as last drawn, by the row `Rows.build` made, which stays the same table while the row does. ---@type table @@ -279,10 +281,18 @@ end ---@param opts changeset.RenderOpts ---@param hidden table ---@param is_open fun(id: string): boolean +---@param viewed boolean? Whether `child` is drawn as viewed, which its row alone does not say. ---@return changeset.view.Drawn -local function draw_child(section, child, folds, opts, hidden, is_open) +local function draw_child(section, child, folds, opts, hidden, is_open, viewed) local was, query = drawn[child], opts.query or "" - if was and was.width == opts.width and was.query == query and was.hidden == hidden and was.folds == folds then + if + was + and was.width == opts.width + and was.query == query + and was.hidden == hidden + and was.folds == folds + and was.viewed == viewed + then return was end local kept = M.filter(M.by_kind({ child }, hidden), query)[1] @@ -295,6 +305,7 @@ local function draw_child(section, child, folds, opts, hidden, is_open) query = query, hidden = hidden, folds = folds, + viewed = viewed, } -- A comment row is made anew on every draw, so it never hits; and its entry, holding its own weak key, never goes. if child.kind ~= "comment" then @@ -327,7 +338,8 @@ function View:show(rows, layout) for _, section in ipairs(rows) do local children, under = {}, {} for _, child in ipairs(section.children) do - local d = draw_child(section, child, folds[child.id] or "", opts, self.kinds_hidden, is_open) + local viewed = layout.viewed and layout.viewed(child) + local d = draw_child(section, child, folds[child.id] or "", opts, self.kinds_hidden, is_open, viewed) if d.laid then children[#children + 1] = d.laid under[#under + 1] = d.block diff --git a/lua/changeset/viewed.lua b/lua/changeset/viewed.lua new file mode 100644 index 0000000..a74ac74 --- /dev/null +++ b/lua/changeset/viewed.lua @@ -0,0 +1,85 @@ +---Which files a reviewer marked viewed, each against the exact diff it was viewed at, remembered across sessions. + +local jsonfile = require("changeset.jsonfile") + +local M = {} + +---@alias changeset.ViewedMarks table The digest of the diff each path was marked on. + +---Where the record lives, shaped `{ [root] = { [path] = digest } }`. +---@return string +function M.path() + return vim.fs.joinpath(vim.fn.stdpath("state") --[[@as string]], "changeset", "viewed.json") +end + +---Read the record from `file`. +---@param file string +---@return table data Empty when the file is missing or corrupt. +function M.load(file) + return jsonfile.read(file) +end + +---The marks of `root` in `data`, malformed entries skipped. +---@param data table +---@param root string +---@return changeset.ViewedMarks +function M.marks(data, root) + local marks = {} + local entries = data[root] + if type(entries) ~= "table" then + return marks + end + for path, digest in pairs(entries) do + if type(path) == "string" and type(digest) == "string" then + marks[path] = digest + end + end + return marks +end + +---Whether `path` is marked viewed at `digest`. +---@param marks changeset.ViewedMarks +---@param path string +---@param digest string? +---@return boolean +function M.holds(marks, path, digest) + return digest ~= nil and marks[path] == digest +end + +---`data` with `path` of `root` marked at `digest`, or cleared when it is nil. +---@param data table Left unmodified. +---@param root string +---@param path string +---@param digest string? +---@return table +function M.apply(data, root, path, digest) + local out = vim.deepcopy(data) + out[root] = type(out[root]) == "table" and out[root] or {} + out[root][path] = digest + if vim.tbl_isempty(out[root]) then + out[root] = nil + end + return out +end + +---Mark `path` viewed at `digest` in `file`, or clear its mark when one holds there. +---@param file string +---@param root string +---@param path string +---@param digest string +---@return changeset.ViewedMarks? marks The root's marks now; nil when the record is unreadable or unwritten. +function M.toggle(file, root, path, digest) + -- Never written over when unreadable: the marks in it can't be derived again. + local data = jsonfile.read_object(file) + if not data then + return nil + end + local marked = M.holds(M.marks(data, root), path, digest) + local out = M.apply(data, root, path, not marked and digest or nil) + if not jsonfile.write(file, vim.tbl_isempty(out) and vim.empty_dict() or out) then + return nil + end + return M.marks(out, root) +end + +return M diff --git a/tests/sidebar_spec.lua b/tests/sidebar_spec.lua index d32fa2d..5cc05e3 100644 --- a/tests/sidebar_spec.lua +++ b/tests/sidebar_spec.lua @@ -1542,4 +1542,114 @@ describe("changeset sidebar", function() assert.same(narrowed, lines_of(buf)) end) + + describe("marking files viewed", function() + local viewed = require("changeset.viewed") + + before_each(function() + os.remove(viewed.path()) + end) + + ---@param name string + ---@return string + local function line_named(name) + return present(Sidebar.lines()[line_of(present(window.buf()), name)]) + end + + ---@param name string + local function mark(name) + Sidebar.cursor_to(name) + press("v") + end + + ---@param path string + ---@param lines string[] + local function rewrite(path, lines) + local before = present(build.current()).files + write(path, lines) + build.refresh() + assert.is_true(vim.wait(10000, function() + return present(build.current()).files ~= before + end, 25)) + Sidebar.settle() + end + + it("checks the file's row in the viewed group, leaving the others' icons", function() + local buf = open_sidebar() + local other = line_named("other.lua") + + mark("mod.lua") + + local lnum = line_of(buf, "mod.lua") + assert.truthy((line_named("mod.lua"):find(highlights.VIEWED_ICON, 1, true))) + assert.same(other, line_named("other.lua")) + local groups = vim.tbl_map(function(m) + return present(m[4]).hl_group + end, vim.api.nvim_buf_get_extmarks(buf, ns, { lnum - 1, 0 }, { lnum - 1, -1 }, { details = true })) + assert.truthy(vim.tbl_contains(groups, highlights.VIEWED_HL)) + end) + + it("restores the icon when marked again", function() + open_sidebar() + local unmarked = line_named("mod.lua") + + mark("mod.lua") + mark("mod.lua") + + assert.same(unmarked, line_named("mod.lua")) + end) + + it("keeps a file checked while another file's diff changes", function() + open_sidebar() + mark("mod.lua") + + rewrite("other.lua", { "return { a = 1, b = 3 }" }) + + assert.truthy((line_named("mod.lua"):find(highlights.VIEWED_ICON, 1, true))) + end) + + it("clears the check once the file's own diff changes", function() + open_sidebar() + mark("mod.lua") + + rewrite("mod.lua", { "local M = {}", "", "function M.one()", " return 3", "end", "", "return M" }) + + assert.falsy((line_named("mod.lua"):find(highlights.VIEWED_ICON, 1, true))) + end) + + it("marks the file a symbol row under it belongs to", function() + local source = Symbols.install() + local restore_due = Sidebar.draw_at_once() + vim.cmd.edit("mod.lua") + changeset.open() + assert.is_truthy(vim.wait(10000, function() + local buf = window.buf() + return buf ~= nil and table.concat(lines_of(buf), "\n"):find("other.lua", 1, true) ~= nil + end, 25)) + source.answer("mod.lua", { Changes.sym("one", "Function", 0, 3, 5) }) + source.answer("other.lua", {}) + Sidebar.flush() + + Sidebar.cursor_to("one") + press("v") + local got = line_named("mod.lua") + restore_due() + source.restore() + + assert.truthy((got:find(highlights.VIEWED_ICON, 1, true))) + end) + + it("reports a mark it could not write", function() + open_sidebar() + vim.fn.mkdir(vim.fs.dirname(viewed.path()), "p") + vim.fn.writefile({ "[1,2]" }, viewed.path()) + local notify = require("support.notify") + local notes, restore = notify.capture() + + mark("mod.lua") + restore() + + assert.same({ "Changeset: could not write " .. viewed.path() }, notify.messages(notes, vim.log.levels.ERROR)) + end) + end) end) diff --git a/tests/viewed_spec.lua b/tests/viewed_spec.lua new file mode 100644 index 0000000..a5754dc --- /dev/null +++ b/tests/viewed_spec.lua @@ -0,0 +1,79 @@ +local viewed = require("changeset.viewed") + +local ROOT = "/src/app" + +---@param text string +local function write_record(text) + vim.fn.mkdir(vim.fs.dirname(viewed.path()), "p") + vim.fn.writefile({ text }, viewed.path()) +end + +describe("changeset.viewed", function() + before_each(function() + os.remove(viewed.path()) + end) + + describe("apply", function() + it("marks a path with its digest", function() + assert.are.same({ [ROOT] = { ["a.lua"] = "d1" } }, viewed.apply({}, ROOT, "a.lua", "d1")) + end) + + it("clears a mark and prunes the root it empties", function() + local data = { [ROOT] = { ["a.lua"] = "d1" }, ["/other"] = { ["b.lua"] = "d2" } } + + assert.are.same({ ["/other"] = { ["b.lua"] = "d2" } }, viewed.apply(data, ROOT, "a.lua", nil)) + end) + + it("leaves its input unmodified", function() + local data = { [ROOT] = { ["a.lua"] = "d1" } } + + viewed.apply(data, ROOT, "a.lua", nil) + + assert.are.same({ [ROOT] = { ["a.lua"] = "d1" } }, data) + end) + end) + + it("skips malformed entries in a root's marks", function() + local data = { [ROOT] = { ["a.lua"] = "d1", ["b.lua"] = 3, [4] = "d4" } } + + assert.are.same({ ["a.lua"] = "d1" }, viewed.marks(data, ROOT)) + assert.are.same({}, viewed.marks({ [ROOT] = "x" }, ROOT)) + end) + + describe("holds", function() + it("holds for the digest marked", function() + assert.is_true(viewed.holds({ ["a.lua"] = "d1" }, "a.lua", "d1")) + end) + + it("does not hold for a different digest", function() + assert.is_false(viewed.holds({ ["a.lua"] = "d1" }, "a.lua", "d2")) + end) + + it("does not hold for a nil digest on an unmarked path", function() + assert.is_false(viewed.holds({}, "a.lua", nil)) + end) + end) + + describe("toggle", function() + it("marks, then unmarks, through the record", function() + assert.are.same({ ["a.lua"] = "d1" }, viewed.toggle(viewed.path(), ROOT, "a.lua", "d1")) + assert.are.same({ ["a.lua"] = "d1" }, viewed.marks(viewed.load(viewed.path()), ROOT)) + + assert.are.same({}, viewed.toggle(viewed.path(), ROOT, "a.lua", "d1")) + assert.are.same({}, viewed.load(viewed.path())) + end) + + it("marks a path whose mark has a stale digest under the new one", function() + viewed.toggle(viewed.path(), ROOT, "a.lua", "old") + + assert.are.same({ ["a.lua"] = "new" }, viewed.toggle(viewed.path(), ROOT, "a.lua", "new")) + end) + + it("never writes over a record that is not a JSON object", function() + write_record("[1,2]") + + assert.is_nil(viewed.toggle(viewed.path(), ROOT, "a.lua", "d1")) + assert.are.same({ "[1,2]" }, vim.fn.readfile(viewed.path())) + end) + end) +end) From 920dcffcc94c43c24eb5025cfa2c6aa303f16b58 Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Sun, 11 Oct 2026 00:09:50 -0400 Subject: [PATCH 03/12] docs: say how to mark files viewed - Document `v` / keymaps.toggle_viewed, ChangesetViewed and viewed.json in the vimdoc, and the key in the README. - Record in design.md why a viewed file wears a check in its icon's place, and why a mark holds by digest rather than File identity. --- README.md | 2 ++ doc/agents/design.md | 30 ++++++++++++++++++++++++++++++ doc/changeset.nvim.txt | 21 +++++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/README.md b/README.md index f1fb48d..805f610 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,7 @@ has a `(changeset-…)` map named after its words, and these default keys: | `f` | `filter` | Filter the tree as you type | | `F` | `filter_kinds` | Hide symbol kinds, remembered for this branch, repository or everywhere | | `y` | `yank` | Copy the row's `path:line`, or a ranged comment's `path:first-last` | +| `v` | `toggle_viewed` | Mark the file viewed, or not, until its diff changes | | `d` | `delete_comment` | Delete the review comment on a Comments row | | `R` | `refresh` | Rebuild the tree | | `?` | `help` | List the sidebar's keys | @@ -219,6 +220,7 @@ require("changeset").setup({ prev_section = "[[", refresh = "R", yank = "y", + toggle_viewed = "v", delete_comment = "d", help = "?", filter_kinds = "F", diff --git a/doc/agents/design.md b/doc/agents/design.md index 8776a70..dd215ee 100644 --- a/doc/agents/design.md +++ b/doc/agents/design.md @@ -167,6 +167,26 @@ restarts. With mini.icons: A future icon-set change propagates everywhere at once. That is the point. +### A viewed file wears a check in its icon's place + +A file marked viewed (`v`) draws `✔` in `ChangesetViewed`, linked to `Comment`, in the cell +its file icon holds: + +- **It reads as a checklist.** The checks fill the icon column, so finished files read down + one edge. A grey check among coloured icons lets a viewed file recede, as GitHub's Viewed + does. +- **It changes no layout.** It is one cell, like an icon, so the row's width budget is + unchanged. +- **It is the config's own check.** The config already uses `✔` as its "selected" marker, + and no icon plugin has a "viewed" category to ask for. +- **Its colour means nothing else on a row.** `DiagnosticOk` green already means "review + comment". + +Placements rejected: the left margin, where the check would butt against the rail; after +the name, where it would move with the name's length and could be matched by the filter; +the right gutter, which the row-state glyphs own; and dimming the name, since `Comment` on +a name already means "context". + ### Kind labels are dropped; the icon carries kind The config's outline picker right-aligns a kind label (`Method`, `Class`). A sidebar is @@ -1361,6 +1381,16 @@ repository for as long as Neovim is running, so reopening looks like you left it starts expanded, except Generated, which starts folded. Per repository because a row is identified by a repo-relative path, which two checkouts can easily both have. +Files marked viewed are kept in `stdpath("state")/changeset/viewed.json`, per repository +root and path, so every copy of a file (its path section, Tests, Docs) shows the mark. Each +mark holds the digest of the diff it was viewed at, not the File's identity: identity is +per tree and per session, while a digest names the exact diff, so a mark stops showing once +the diff changes and shows again if the change is reverted. A patch with no text, as a +binary file's, digests its status and old path, so editing it in place keeps its mark. The +digest is one of the fields `keep_identity` compares, so rewriting a line at the same counts +gives the file a new identity and redraws its row. An unreadable record is never written +over, since its marks can't be derived again. + ## Hidden kinds Which symbol kinds are hidden is not a setting: it is a choice made in the menu and diff --git a/doc/changeset.nvim.txt b/doc/changeset.nvim.txt index 5796dfe..72d8bfa 100644 --- a/doc/changeset.nvim.txt +++ b/doc/changeset.nvim.txt @@ -660,6 +660,20 @@ keymaps.yank *changeset-option-keymaps.yank* provider. A Comments row on a range copies `path:first-last`. A row with no line copies the path alone. +v *changeset-sidebar-v* +keymaps.toggle_viewed *changeset-option-keymaps.toggle_viewed* + Mark the file viewed, or clear its mark. It acts on the file the row + belongs to: its file row, or a symbol or orphan row under it. Nothing + on a section or Comments row. A viewed file draws a |ChangesetViewed| + `✔` in its icon's place, in every section it shows in. + + A mark holds only while the file's diff is the one you viewed. It + stops showing once that diff changes, and shows again if the change + is reverted or the base switched back. `v` on a file whose mark no + longer holds marks its new diff. A file whose patch has no text, such + as a binary file edited in place, keeps its mark. Marks another Neovim + writes show from the next rebuild. + d *changeset-sidebar-d* keymaps.delete_comment *changeset-option-keymaps.delete_comment* On a Comments row, ask, then delete its review comment. Nothing on any @@ -1291,6 +1305,10 @@ ChangesetHover *ChangesetHover* The row under the mouse pointer, while 'mousemoveevent' is on. `Normal`'s background tinted toward `DiagnosticInfo`. +ChangesetViewed *ChangesetViewed* + The `✔` in a viewed file's icon place (|changeset-sidebar-v|). + Linked to `Comment`. + ChangesetSelectedIcon *ChangesetSelectedIcon* The `◀` on the selected row. `Statement`'s colour. @@ -1478,6 +1496,9 @@ nothing, starts no language server and makes no network request. once. - Hidden symbol kinds: `stdpath("state")/changeset/filters.json`. - Bases set with |:Changeset-base|: `stdpath("state")/changeset/bases.json`. +- Files marked viewed with |changeset-sidebar-v|: + `stdpath("state")/changeset/viewed.json`. An unreadable one is never + written over. - Swap files: a file changeset reads or previews gets none until you enter its buffer, so another Neovim opening it shows no |E325| warning. - Review comments, and each branch's last 10 submitted batches: From eda0ea4a2fee206d55beea3695ae3c8fdd963b71 Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Sun, 11 Oct 2026 00:29:45 -0400 Subject: [PATCH 04/12] refactor: tighten viewed marks after review - Cover loading marks from an earlier session and the untracked file's digest with specs. - Build resolve_spec's File literals through one helper, and wait for the diff with Sidebar.await_diff. - Move the viewed-icon policy out of draw.draw, and expose viewed.apply only to specs as viewed._apply. - Say when another Neovim's marks show, and fix design.md's colour rationale, the vimdoc's row names and ChangesetViewed's place. --- doc/agents/design.md | 10 ++-- doc/changeset.nvim.txt | 18 ++++--- lua/changeset/diff.lua | 17 +++--- lua/changeset/draw.lua | 28 ++++++---- lua/changeset/sidebar_state.lua | 2 +- lua/changeset/view.lua | 2 +- lua/changeset/viewed.lua | 4 +- tests/diff_spec.lua | 10 ++++ tests/resolve_spec.lua | 96 ++++++++------------------------- tests/sidebar_spec.lua | 58 +++++++++++++------- tests/viewed_spec.lua | 8 +-- 11 files changed, 127 insertions(+), 126 deletions(-) diff --git a/doc/agents/design.md b/doc/agents/design.md index dd215ee..c386294 100644 --- a/doc/agents/design.md +++ b/doc/agents/design.md @@ -154,7 +154,8 @@ restarts. With mini.icons: - Symbol rows: `MiniIcons.get("lsp", kind)` — the exact call that config's outline picker makes, so a method is the same glyph in the same hue in both the outline picker and the sidebar. -- File rows: `MiniIcons.get("file", path)`. +- File rows: `MiniIcons.get("file", path)`, or the viewed check (§ A viewed file wears a + check in its icon's place). - Section headers: `MiniIcons.get("directory", …)` with `src`, `tests`, `docs`, `.config` and `build`, so each header wears the icon its kind of directory already has. - Orphan-hunk groups: the `lsp`/`Text` icon, dimmed. Not a bespoke glyph. @@ -179,8 +180,11 @@ its file icon holds: unchanged. - **It is the config's own check.** The config already uses `✔` as its "selected" marker, and no icon plugin has a "viewed" category to ask for. -- **Its colour means nothing else on a row.** `DiagnosticOk` green already means "review - comment". +- **Its colour recedes.** `Comment`'s grey is the tree's "not the point" colour, for context + and meta, which is what a viewed file has become. `DiagnosticOk` green already means + "review comment". +- **Its key is `v`.** It is mnemonic, and Visual mode has no use in the sidebar, since no + sidebar key takes a range. Placements rejected: the left margin, where the check would butt against the rail; after the name, where it would move with the name's length and could be matched by the filter; diff --git a/doc/changeset.nvim.txt b/doc/changeset.nvim.txt index 72d8bfa..8f53ae6 100644 --- a/doc/changeset.nvim.txt +++ b/doc/changeset.nvim.txt @@ -663,16 +663,18 @@ keymaps.yank *changeset-option-keymaps.yank* v *changeset-sidebar-v* keymaps.toggle_viewed *changeset-option-keymaps.toggle_viewed* Mark the file viewed, or clear its mark. It acts on the file the row - belongs to: its file row, or a symbol or orphan row under it. Nothing - on a section or Comments row. A viewed file draws a |ChangesetViewed| - `✔` in its icon's place, in every section it shows in. + belongs to: its file row, or a symbol's, a change's or an "Other + changes" row under it. Nothing on a section or Comments row. A viewed + file draws a |ChangesetViewed| `✔` in its icon's place, in every + section it shows in. A mark holds only while the file's diff is the one you viewed. It stops showing once that diff changes, and shows again if the change is reverted or the base switched back. `v` on a file whose mark no longer holds marks its new diff. A file whose patch has no text, such as a binary file edited in place, keeps its mark. Marks another Neovim - writes show from the next rebuild. + writes show once the tree is built again for another branch or base, + or after your next `v`. d *changeset-sidebar-d* keymaps.delete_comment *changeset-option-keymaps.delete_comment* @@ -1291,6 +1293,10 @@ ChangesetMatch *ChangesetMatch* ChangesetHidden *ChangesetHidden* A hidden kind in the kind menu. `Comment`'s colour, struck through. +ChangesetViewed *ChangesetViewed* + The `✔` in place of a viewed file's icon (|changeset-sidebar-v|). + Links to `Comment`. + ChangesetSelected *ChangesetSelected* The selected row while the sidebar has focus. `Normal`'s background tinted toward `Statement`. @@ -1305,10 +1311,6 @@ ChangesetHover *ChangesetHover* The row under the mouse pointer, while 'mousemoveevent' is on. `Normal`'s background tinted toward `DiagnosticInfo`. -ChangesetViewed *ChangesetViewed* - The `✔` in a viewed file's icon place (|changeset-sidebar-v|). - Linked to `Comment`. - ChangesetSelectedIcon *ChangesetSelectedIcon* The `◀` on the selected row. `Statement`'s colour. diff --git a/lua/changeset/diff.lua b/lua/changeset/diff.lua index e130593..f6b71f5 100644 --- a/lua/changeset/diff.lua +++ b/lua/changeset/diff.lua @@ -19,7 +19,8 @@ local M = {} ---@field added integer ---@field removed integer ---@field hunks changeset.Hunk[] Ascending by line, as git emits them; empty for a binary or pure rename. ----@field digest string Changes whenever the file's diff does. +---@field digest string The sha256 of the file's patch, or of an untracked file's content; for a patch with no text, of +---its status and old path. ---@class changeset.File : changeset.diff.Unfiled A changed file and the hunks inside it. ---@field section changeset.SectionKey The section `sections.classify` files it under, decided once when the diff is read. @@ -37,7 +38,8 @@ local M = {} ---@field statuses table By new path. ---@field hunks table By new path. ---@field untracked table Line count by path. ----@field digests table Each file's diff section or untracked content, hashed, by path. +---@field digests table Each file's diff section or untracked content, hashed, by path; an untracked +---file's wins where a path is both. -- Any other name-status code (M, T) reads as a modification. ---@type table @@ -133,8 +135,8 @@ local function side_path(side, prefix) return vim.startswith(path, prefix) and path:sub(#prefix + 1) or nil end ----Hunks per path from `git diff --unified=0 -M`. Each file is keyed from its `+++` line, or ----its `---` line when it was deleted; the `diff --git` line can't be split when a path holds ` b/`. +---Hunks, and a digest of each file's section, per path from `git diff --unified=0 -M --full-index`. +---Each file is keyed from its `+++` line, or its `---` line when it was deleted; the `diff --git` line can't be split when a path holds ` b/`. ---@param lines string[] ---@return table hunks By new path; a file with no text hunks is absent. ---@return table digests The sha256 of each keyed file's section, by new path. @@ -274,7 +276,8 @@ end -- These pin output the user's own git config can otherwise reshape: quotepath keeps -- non-ASCII paths as real filenames, and the prefix, colour and external-driver flags --- keep the `diff --git a/x b/x` header the hunk parser reads. +-- keep the `diff --git a/x b/x` header the hunk parser reads, and `--full-index` keeps each file's `index` line, +-- which its digest hashes, the same length whatever `core.abbrev` or the repository's size. ---@param base string ---@return table argv By result name. local function git_commands(base) @@ -327,7 +330,7 @@ local function stdout_lines(result) return vim.split(result.stdout or "", "\n", { trimempty = true }) end ----Line count per readable file; directories (untracked nested repos) and dangling symlinks are skipped. +---Line count and content digest per readable file; directories (untracked nested repos) and dangling symlinks are skipped. ---@param paths string[] Relative to `cwd`. ---@param cwd string ---@return table counts @@ -409,6 +412,8 @@ function M.collect(base, cwd, callback) statuses = M._parse_name_status(results.name_status.stdout or ""), hunks = hunks, untracked = untracked, + -- A path is both deleted and untracked after `git rm --cached`; the untracked digest wins so both rows share + -- one mark. digests = vim.tbl_extend("force", digests, untracked_digests), }) generated_paths(files, cwd, function(marked) diff --git a/lua/changeset/draw.lua b/lua/changeset/draw.lua index 8569d8e..60a98db 100644 --- a/lua/changeset/draw.lua +++ b/lua/changeset/draw.lua @@ -47,6 +47,23 @@ local function icon_for(row) return icons.get("lsp", row.kind == "symbol" and row.symbol_kind or "Text") end +---The icon for each row, checking files viewed in `marks`, and whether it checks `row`. +---@param marks changeset.ViewedMarks +---@return fun(row: changeset.Row): string, string icon +---@return fun(row: changeset.Row): boolean is_viewed +local function viewed_layout(marks) + local function is_viewed(row) + return row.kind == "file" and viewed.holds(marks, row.path, row.digest) + end + return function(row) + if is_viewed(row) then + return highlights.VIEWED_ICON, highlights.VIEWED_HL + end + return icon_for(row) + end, + is_viewed +end + ---The row under the sidebar's cursor. ---@return changeset.Row? function M.row_at_cursor() @@ -503,16 +520,9 @@ function M.draw(kinds_key) local cursor, top = vim.api.nvim_win_get_cursor(win)[1], vim.fn.line("w0", win) comments_section = comments_for(state.tree) local tree = laid_out(state.rows) - local function is_viewed(row) - return row.kind == "file" and viewed.holds(state.marks, row.path, row.digest) - end + local icon, is_viewed = viewed_layout(state.marks) local lines, lnum, blocks = state.view:show(tree, { - icon = function(row) - if is_viewed(row) then - return highlights.VIEWED_ICON, highlights.VIEWED_HL - end - return icon_for(row) - end, + icon = icon, viewed = is_viewed, width = width, cursor = cursor, diff --git a/lua/changeset/sidebar_state.lua b/lua/changeset/sidebar_state.lua index 2b53096..c817132 100644 --- a/lua/changeset/sidebar_state.lua +++ b/lua/changeset/sidebar_state.lua @@ -14,7 +14,7 @@ local M = {} ---@field rows changeset.Row[] ---@field view changeset.View What the sidebar shows of the tree. ---@field position changeset.Position Where the user stands in this tree. ----@field marks changeset.ViewedMarks The files marked viewed in this repository. +---@field marks changeset.ViewedMarks The files marked viewed in this repository, as read when this state was made and as each toggle here leaves them. ---@type changeset.SidebarState? local state diff --git a/lua/changeset/view.lua b/lua/changeset/view.lua index c935f31..5bedf45 100644 --- a/lua/changeset/view.lua +++ b/lua/changeset/view.lua @@ -10,7 +10,7 @@ local Rows = require("changeset.rows") ---@class changeset.view.Layout ---@field icon fun(row: changeset.Row): string, string Glyph and highlight for a row. ----@field viewed? fun(row: changeset.Row): boolean Whether a row is drawn as viewed. +---@field viewed? fun(row: changeset.Row): boolean Whether `icon` draws `row` as viewed; a change redraws its block, as the row alone does not. ---@field width integer ---@field cursor integer The cursor's line before this redraw. diff --git a/lua/changeset/viewed.lua b/lua/changeset/viewed.lua index a74ac74..2f8a274 100644 --- a/lua/changeset/viewed.lua +++ b/lua/changeset/viewed.lua @@ -52,7 +52,7 @@ end ---@param path string ---@param digest string? ---@return table -function M.apply(data, root, path, digest) +function M._apply(data, root, path, digest) local out = vim.deepcopy(data) out[root] = type(out[root]) == "table" and out[root] or {} out[root][path] = digest @@ -75,7 +75,7 @@ function M.toggle(file, root, path, digest) return nil end local marked = M.holds(M.marks(data, root), path, digest) - local out = M.apply(data, root, path, not marked and digest or nil) + local out = M._apply(data, root, path, not marked and digest or nil) if not jsonfile.write(file, vim.tbl_isempty(out) and vim.empty_dict() or out) then return nil end diff --git a/tests/diff_spec.lua b/tests/diff_spec.lua index a76efa6..09c5726 100644 --- a/tests/diff_spec.lua +++ b/tests/diff_spec.lua @@ -725,6 +725,16 @@ describe("changeset.diff.collect", function() }, undigested(collect(base, tmp))) end) + it("digests an untracked file's content, so editing it changes the digest", function() + local base = Fixture.init_repo("trunk", tmp) + write("scratch.txt", { "a" }) + local before = present(collect(base, tmp)[1]).digest + write("scratch.txt", { "b" }) + + assert.is_truthy(before) + assert.are_not.equal(before, present(collect(base, tmp)[1]).digest) + end) + it("counts only the untracked paths it can read", function() local base = Fixture.init_repo("trunk", tmp) write("scratch.txt", { "a", "b", "c" }) diff --git a/tests/resolve_spec.lua b/tests/resolve_spec.lua index 4eda915..cd5992f 100644 --- a/tests/resolve_spec.lua +++ b/tests/resolve_spec.lua @@ -3,6 +3,19 @@ local Fixture = require("support.git") local resolve = require("changeset.resolve") local present = require("support.present") +---@param fields table +---@return changeset.File +local function new_file(fields) + return vim.tbl_extend("keep", fields, { + status = "added", + section = "implementation", + added = 1, + removed = 0, + hunks = {}, + digest = "d", + }) --[[@as changeset.File]] +end + ---A step that parks each call so the spec decides when it answers. ---@return fun(path: string, done: fun(items: changeset.Symbol[]?, comments: changeset.Comments?)) run ---@return table[] pending @@ -167,15 +180,7 @@ describe("changeset.resolve", function() ---@return string[] local function reported_at_once() local seen = {} - local file = { - path = "mod.lua", - status = "added", - section = "implementation", - added = 1, - removed = 0, - hunks = {}, - digest = "d", - } + local file = new_file({ path = "mod.lua" }) local cancel = resolve.start({ root = root, base = "HEAD" }, { file }, function(path) seen[#seen + 1] = path end) @@ -218,15 +223,7 @@ describe("changeset.resolve", function() vim.lsp.enable("stub_lua") enabled = "stub_lua" local report ---@type { items: changeset.Symbol[]?, comments: changeset.Comments?, timed_out: boolean? }? - local file = { - path = "mod.lua", - status = "added", - section = "implementation", - added = 1, - removed = 0, - hunks = {}, - digest = "d", - } + local file = new_file({ path = "mod.lua" }) resolve.start({ root = root, base = "HEAD" }, { file }, function(_, items) report = { items = items } @@ -279,15 +276,7 @@ describe("changeset.resolve", function() }, { bufnr = buf }) end local report ---@type { items: changeset.Symbol[]?, comments: changeset.Comments?, timed_out: boolean? }? - local file = { - path = "mod.lua", - status = "added", - section = "implementation", - added = 1, - removed = 0, - hunks = {}, - digest = "d", - } + local file = new_file({ path = "mod.lua" }) resolve.start({ root = root, base = "HEAD" }, { file }, function(_, items) report = { items = items } end) @@ -362,15 +351,7 @@ describe("changeset.resolve", function() local function resolved(path) local answer, done resolve.start({ root = root, base = "HEAD" }, { - { - path = path, - status = "added", - section = "implementation", - added = 1, - removed = 0, - hunks = {}, - digest = "d", - }, + new_file({ path = path }), }, function(_, items) answer, done = items, true end) @@ -392,15 +373,7 @@ describe("changeset.resolve", function() Fixture.commit("base", root) vim.fn.writefile({ "return { 1 }" }, root .. "/mod.lua") local report ---@type { items: changeset.Symbol[]?, comments: changeset.Comments?, timed_out: boolean? }? - local file = { - path = "mod.lua", - status = "modified", - section = "implementation", - added = 1, - removed = 1, - hunks = {}, - digest = "d", - } + local file = new_file({ path = "mod.lua", status = "modified", removed = 1 }) resolve.start({ root = root, base = "HEAD" }, { file }, function(_, items) report = { items = items } @@ -419,15 +392,7 @@ describe("changeset.resolve", function() Fixture.commit("base", root) vim.fn.writefile({ "# new note", "x = 1" }, root .. "/conf.toml") local report ---@type { items: changeset.Symbol[]?, comments: changeset.Comments?, timed_out: boolean? }? - local file = { - path = "conf.toml", - status = "modified", - section = "config", - added = 1, - removed = 1, - hunks = {}, - digest = "d", - } + local file = new_file({ path = "conf.toml", status = "modified", section = "config", removed = 1 }) resolve.start({ root = root, base = "HEAD" }, { file }, function(_, items, found) report = { items = items, comments = found } @@ -448,8 +413,7 @@ describe("changeset.resolve", function() Fixture.commit("base", root) vim.fn.writefile({ "# Title", "" }, root .. "/README.md") local report ---@type { items: changeset.Symbol[]?, comments: changeset.Comments?, timed_out: boolean? }? - local file = - { path = "README.md", status = "modified", section = "docs", added = 1, removed = 0, hunks = {}, digest = "d" } + local file = new_file({ path = "README.md", status = "modified", section = "docs" }) resolve.start({ root = root, base = "HEAD" }, { file }, function(_, items, found) report = { items = items, comments = found } @@ -570,15 +534,7 @@ describe("changeset.resolve", function() end local function start(on_file) - local file = { - path = "mod.lua", - status = "added", - section = "implementation", - added = 1, - removed = 0, - hunks = {}, - digest = "d", - } + local file = new_file({ path = "mod.lua" }) return resolve.start({ root = root, base = "HEAD" }, { file }, on_file) end @@ -974,15 +930,7 @@ describe("changeset.resolve", function() end return real_parser(text, ...) end - local file = { - path = "big.lua", - status = "modified", - added = 1, - removed = 0, - hunks = {}, - section = "implementation", - digest = "d", - } + local file = new_file({ path = "big.lua", status = "modified" }) local got resolve.start( { root = tmp, base = Fixture.git({ "merge-base", "HEAD", "trunk" }, tmp) }, diff --git a/tests/sidebar_spec.lua b/tests/sidebar_spec.lua index 5cc05e3..8aa908d 100644 --- a/tests/sidebar_spec.lua +++ b/tests/sidebar_spec.lua @@ -1617,26 +1617,48 @@ describe("changeset sidebar", function() assert.falsy((line_named("mod.lua"):find(highlights.VIEWED_ICON, 1, true))) end) - it("marks the file a symbol row under it belongs to", function() - local source = Symbols.install() - local restore_due = Sidebar.draw_at_once() - vim.cmd.edit("mod.lua") - changeset.open() - assert.is_truthy(vim.wait(10000, function() - local buf = window.buf() - return buf ~= nil and table.concat(lines_of(buf), "\n"):find("other.lua", 1, true) ~= nil - end, 25)) - source.answer("mod.lua", { Changes.sym("one", "Function", 0, 3, 5) }) - source.answer("other.lua", {}) - Sidebar.flush() + it("checks a file marked in an earlier session", function() + open_sidebar() + local tree = present(build.current()) + local file = present(vim.iter(tree.files):find(function(f) + return f.path == "mod.lua" + end)) + changeset.close() + viewed.toggle(viewed.path(), tree.root, "mod.lua", file.digest) + -- A new branch builds a new tree, which reads the record again. Cut from the commit, it shows the same diff. + Fixture.git({ "checkout", "-q", "-b", "other", Fixture.git({ "rev-parse", "HEAD" }, tmp) }, tmp) + open_sidebar() - Sidebar.cursor_to("one") - press("v") - local got = line_named("mod.lua") - restore_due() - source.restore() + assert.truthy((line_named("mod.lua"):find(highlights.VIEWED_ICON, 1, true))) + end) - assert.truthy((got:find(highlights.VIEWED_ICON, 1, true))) + describe("on a symbol row", function() + local source ---@type support.symbols.Source + local restore_due ---@type fun() + + before_each(function() + source = Symbols.install() + restore_due = Sidebar.draw_at_once() + end) + + after_each(function() + restore_due() + source.restore() + end) + + it("marks the file the row belongs to", function() + vim.cmd.edit("mod.lua") + changeset.open() + Sidebar.await_diff() + source.answer("mod.lua", { Changes.sym("one", "Function", 0, 3, 5) }) + source.answer("other.lua", {}) + Sidebar.flush() + + Sidebar.cursor_to("one") + press("v") + + assert.truthy((line_named("mod.lua"):find(highlights.VIEWED_ICON, 1, true))) + end) end) it("reports a mark it could not write", function() diff --git a/tests/viewed_spec.lua b/tests/viewed_spec.lua index a5754dc..a5a70f6 100644 --- a/tests/viewed_spec.lua +++ b/tests/viewed_spec.lua @@ -13,21 +13,21 @@ describe("changeset.viewed", function() os.remove(viewed.path()) end) - describe("apply", function() + describe("_apply", function() it("marks a path with its digest", function() - assert.are.same({ [ROOT] = { ["a.lua"] = "d1" } }, viewed.apply({}, ROOT, "a.lua", "d1")) + assert.are.same({ [ROOT] = { ["a.lua"] = "d1" } }, viewed._apply({}, ROOT, "a.lua", "d1")) end) it("clears a mark and prunes the root it empties", function() local data = { [ROOT] = { ["a.lua"] = "d1" }, ["/other"] = { ["b.lua"] = "d2" } } - assert.are.same({ ["/other"] = { ["b.lua"] = "d2" } }, viewed.apply(data, ROOT, "a.lua", nil)) + assert.are.same({ ["/other"] = { ["b.lua"] = "d2" } }, viewed._apply(data, ROOT, "a.lua", nil)) end) it("leaves its input unmodified", function() local data = { [ROOT] = { ["a.lua"] = "d1" } } - viewed.apply(data, ROOT, "a.lua", nil) + viewed._apply(data, ROOT, "a.lua", nil) assert.are.same({ [ROOT] = { ["a.lua"] = "d1" } }, data) end) From 95dafe320b6732dbc5e51791c2a98353db4d0625 Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Sun, 11 Oct 2026 00:32:46 -0400 Subject: [PATCH 05/12] refactor(diff): name _parse_hunks' section locals Rename keyed, section and close to section_path, section_lines and digest_section, so the digest step reads as what it does. --- lua/changeset/diff.lua | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lua/changeset/diff.lua b/lua/changeset/diff.lua index f6b71f5..a4b97e3 100644 --- a/lua/changeset/diff.lua +++ b/lua/changeset/diff.lua @@ -143,16 +143,16 @@ end function M._parse_hunks(lines) local hunks, digests, old, in_header = {}, {}, nil, false local current ---@type changeset.Hunk[]? - local section, keyed = {}, nil ---@type string[], string? - local function close() - if keyed then - digests[keyed] = vim.fn.sha256(table.concat(section, "\n")) + local section_lines, section_path = {}, nil ---@type string[], string? + local function digest_section() + if section_path then + digests[section_path] = vim.fn.sha256(table.concat(section_lines, "\n")) end - section, keyed = {}, nil + section_lines, section_path = {}, nil end for _, line in ipairs(lines) do if vim.startswith(line, "diff --git ") then - close() + digest_section() current, old, in_header = nil, nil, true elseif in_header and vim.startswith(line, "--- ") then old = side_path(line:sub(5), "a/") @@ -160,7 +160,7 @@ function M._parse_hunks(lines) local path = side_path(line:sub(5), "b/") or old current = {} if path then - hunks[path], keyed = current, path + hunks[path], section_path = current, path end else local hunk = parse_hunk_header(line) @@ -173,9 +173,9 @@ function M._parse_hunks(lines) end end end - table.insert(section, line) + table.insert(section_lines, line) end - close() + digest_section() return hunks, digests end From 9589ff5dac7b54e53494017ac05d65fa841475b0 Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Sun, 11 Oct 2026 16:21:32 -0400 Subject: [PATCH 06/12] feat(sidebar): let viewed files recede - The toggle key moves to `x`. - A viewed file's rows take a faint grey wash (`ChangesetViewed`, now background-only), drawn under the row-state tints; its check moves to `ChangesetViewedIcon`. - Its row is labelled `viewed` beside the stat. - Marking folds every copy of the file, once per digest: a restart starts it folded, and an unfold sticks. Unmarking unfolds it. - Marking moves the cursor to the next file not yet viewed. --- lua/changeset/actions.lua | 35 +++++++++ lua/changeset/config.lua | 4 +- lua/changeset/draw.lua | 25 +++---- lua/changeset/highlights.lua | 12 ++- lua/changeset/render.lua | 40 +++++++--- lua/changeset/view.lua | 42 ++++++++++- tests/palette_spec.lua | 13 ++++ tests/render_spec.lua | 42 +++++++++++ tests/sidebar_spec.lua | 141 ++++++++++++++++++++++++++++++++--- tests/view_spec.lua | 74 ++++++++++++++++++ 10 files changed, 389 insertions(+), 39 deletions(-) diff --git a/lua/changeset/actions.lua b/lua/changeset/actions.lua index 5cf0f5b..788ae2a 100644 --- a/lua/changeset/actions.lua +++ b/lua/changeset/actions.lua @@ -318,6 +318,28 @@ local function prompt_filter(state, redraw) redraw() end +---The row to put the cursor on after marking `path` viewed from line `lnum` of `shown`: the next file row below that +---is not viewed and is not another copy of `path`, else `path`'s own row in the section `lnum` is in. +---@param shown changeset.Row[] The row on each line. +---@param lnum integer +---@param path string +---@param is_viewed fun(row: changeset.Row): boolean +---@return changeset.Row? +function M._after_viewing(shown, lnum, path, is_viewed) + for i = lnum + 1, #shown do + local row = shown[i] + if row.kind == "file" and row.path ~= path and not is_viewed(row) then + return row + end + end + for i = lnum, 1, -1 do + local row = assert(shown[i], "changeset: a line above the cursor without a row") + if row.kind == "file" and row.path == path then + return row + end + end +end + ---Bind `keys` on the sidebar's buffer. `?` lists exactly these. ---@param buf integer ---@param keys changeset.Options.Keymaps @@ -455,7 +477,20 @@ function M.set_keymaps(buf, keys, hooks) return vim.notify("Changeset: could not write " .. record, vim.log.levels.ERROR) end state.marks = marks + if not viewed.holds(marks, file.path, file.digest) then + state.view:unview(state.rows, file.path) + return redraw() + end + local shown, lnum = state.view:visible(), assert(window.cursor(), "changeset: sidebar is closed") + local target = M._after_viewing(shown, lnum, file.path, function(r) + return viewed.holds(marks, r.path, r.digest) + end) redraw() + for at, r in ipairs(state.view:visible()) do + if target and r.id == target.id then + return move(at) + end + end end, "Mark the file viewed, or not") map(keys.help, function() help.show(buf, own) diff --git a/lua/changeset/config.lua b/lua/changeset/config.lua index 290db6f..9c6fc86 100644 --- a/lua/changeset/config.lua +++ b/lua/changeset/config.lua @@ -24,7 +24,7 @@ local M = {} ---@field prev_section? string|false Previous section. Default `[[`. ---@field refresh? string|false Rebuild the tree. Default `R`. ---@field yank? string|false Yank path:line. Default `y`. ----@field toggle_viewed? string|false Mark the file viewed, or not. Default `v`. +---@field toggle_viewed? string|false Mark the file viewed, or not. Default `x`. ---@field delete_comment? string|false Delete the review comment a Comments row lists. Default `d`. ---@field help? string|false Show these keymaps. Default `?`. ---@field filter_kinds? string|false Filter by symbol kind. Default `F`. @@ -106,7 +106,7 @@ local DEFAULTS = { prev_section = "[[", refresh = "R", yank = "y", - toggle_viewed = "v", + toggle_viewed = "x", delete_comment = "d", help = "?", filter_kinds = "F", diff --git a/lua/changeset/draw.lua b/lua/changeset/draw.lua index 60a98db..3ae172c 100644 --- a/lua/changeset/draw.lua +++ b/lua/changeset/draw.lua @@ -47,21 +47,13 @@ local function icon_for(row) return icons.get("lsp", row.kind == "symbol" and row.symbol_kind or "Text") end ----The icon for each row, checking files viewed in `marks`, and whether it checks `row`. +---Whether `row` is a file marked viewed in `marks`. ---@param marks changeset.ViewedMarks ----@return fun(row: changeset.Row): string, string icon ----@return fun(row: changeset.Row): boolean is_viewed -local function viewed_layout(marks) - local function is_viewed(row) +---@return fun(row: changeset.Row): boolean +local function viewed_in(marks) + return function(row) return row.kind == "file" and viewed.holds(marks, row.path, row.digest) end - return function(row) - if is_viewed(row) then - return highlights.VIEWED_ICON, highlights.VIEWED_HL - end - return icon_for(row) - end, - is_viewed end ---The row under the sidebar's cursor. @@ -222,6 +214,9 @@ local function apply_marks(buf, lines, first) for _, mark in ipairs(line.marks or {}) do vim.api.nvim_buf_set_extmark(buf, ns, first + i - 1, mark.col or 0, { end_col = mark.end_col, + end_row = mark.hl_eol and first + i or nil, + hl_eol = mark.hl_eol, + strict = not mark.hl_eol, hl_group = mark.hl, virt_text = mark.virt_text, virt_text_pos = mark.pos, @@ -353,6 +348,7 @@ local function mark_key(mark) mark.hl or "", mark.pos or "", mark.hl_mode or "", + mark.hl_eol and "eol" or "", mark.priority or "", mark.virt_lines and vim.inspect(mark.virt_lines) or "", } @@ -519,10 +515,11 @@ function M.draw(kinds_key) local width = vim.api.nvim_win_get_width(win) local cursor, top = vim.api.nvim_win_get_cursor(win)[1], vim.fn.line("w0", win) comments_section = comments_for(state.tree) + local is_viewed = viewed_in(state.marks) + state.view:fold_viewed(state.rows, is_viewed) local tree = laid_out(state.rows) - local icon, is_viewed = viewed_layout(state.marks) local lines, lnum, blocks = state.view:show(tree, { - icon = icon, + icon = icon_for, viewed = is_viewed, width = width, cursor = cursor, diff --git a/lua/changeset/highlights.lua b/lua/changeset/highlights.lua index 207d42b..5444f6b 100644 --- a/lua/changeset/highlights.lua +++ b/lua/changeset/highlights.lua @@ -61,10 +61,14 @@ M.REVIEW_COMMENT_DRAFT_CIRCLE = "◌" M.REVIEW_COMMENT_BUBBLE = "󰍩" M.REVIEW_COMMENT_DRAFT_BUBBLE = "󰍪" ----Group for a viewed file's check. Created by `define_highlights`. +---Background of a viewed file's rows. Created by `define_highlights`. ---@type string M.VIEWED_HL = "ChangesetViewed" +---Group for a viewed file's check. Created by `define_highlights`. +---@type string +M.VIEWED_ICON_HL = "ChangesetViewedIcon" + ---Drawn in place of a viewed file's icon. M.VIEWED_ICON = "✔" @@ -221,6 +225,9 @@ local SELECTED_TINT, HERE_TINT, PICKED_TINT = 0.2, 0.12, 0.06 -- How far the row under the pointer moves from the window's background toward the info colour. local HOVER_TINT = 0.15 +-- How far a viewed file's rows move from the window's background toward Comment's grey: a wash, under every state's. +local VIEWED_TINT = 0.08 + -- How far a dialog's button moves from the float's background toward its text, when it can't take the editor's. local BUTTON_SHADE = 0.15 @@ -356,7 +363,7 @@ function M.define_highlights() -- The saved green faded halfway to Comment and unbolded: the same note, not yet committed to. set_default(M.REVIEW_COMMENT_DRAFT_HL, { fg = ok and comment.fg and mix(ok, comment.fg, 0.5) or ok or comment.fg }) set_default(M.REVIEW_COMMENT_BODY_HL, { link = M.META_HL }) - set_default(M.VIEWED_HL, { link = "Comment" }) + set_default(M.VIEWED_ICON_HL, { link = "Comment" }) set_default(M.BADGE_HL, { fg = directory, reverse = true, bold = true }) local statusline = vim.api.nvim_get_hl(0, { name = "StatusLine", link = false }) set_default(M.FOOTER_HL, { fg = comment.fg, bg = statusline.bg }) @@ -378,6 +385,7 @@ function M.define_highlights() -- A cool hue no state, diff colour or badge carries, so the pointer's row never reads as one of them. local info = vim.api.nvim_get_hl(0, { name = "DiagnosticInfo", link = false }).fg or accent set_default(M.HOVER_HL, { bg = mix(base, info, HOVER_TINT) }) + set_default(M.VIEWED_HL, { bg = mix(base, comment.fg or accent, VIEWED_TINT) }) set_default(M.SELECTED_ICON_HL, { fg = accent }) set_default(M.HERE_ICON_HL, { fg = accent }) set_default(M.PICKED_ICON_HL, { fg = accent }) diff --git a/lua/changeset/render.lua b/lua/changeset/render.lua index 2f45dd5..1e035e0 100644 --- a/lua/changeset/render.lua +++ b/lua/changeset/render.lua @@ -16,6 +16,7 @@ local symbols = require("changeset.symbols") ---@field virt_text? table[] `nvim_buf_set_extmark` virtual-text chunks. ---@field pos? "inline"|"right_align" Where the virtual text is drawn. ---@field hl_mode? "combine" Lays the virtual text over the line's background instead of blanking it. +---@field hl_eol? boolean Carries `hl` on to the window's edge, over the whole line. ---@field virt_lines? table[] `nvim_buf_set_extmark` virtual lines, hung below the line. ---@class changeset.Line @@ -29,6 +30,7 @@ local symbols = require("changeset.symbols") ---@field collapsed fun(id: string): boolean Whether the row with this id hides its children. ---@field width integer Window width in cells; long names and directories are trimmed so stats stay visible. ---@field query? string Filter text; every occurrence of it in a line is marked. +---@field viewed? fun(row: changeset.Row): boolean Whether a file row is marked viewed. ---@class changeset.Summary ---@field ref string What the branch is compared against, e.g. "origin/trunk". @@ -99,6 +101,10 @@ local TINT_PRIORITY = M.MARK_PRIORITY - 1 -- Beneath a state's tint: a state says something about the tree, the pointer only where it is. local HOVER_PRIORITY = TINT_PRIORITY - 1 +-- Beneath the row states' and the pointer's tints, so a viewed row still shows which one is selected and where the +-- pointer is. +local VIEWED_PRIORITY = HOVER_PRIORITY - 1 + -- Over the stat: its right-aligned text ends in the gutter's blanks, which would -- otherwise be drawn over the glyph. local GLYPH_PRIORITY = MATCH_PRIORITY + 1 @@ -266,11 +272,17 @@ end ---@param file changeset.Row ---@param opts changeset.RenderOpts ---@param width fun(text: string): integer +---@param viewed boolean Whether the file is marked viewed. ---@return changeset.Line -local function file_line(file, opts, width) +local function file_line(file, opts, width, viewed) local glyph, icon_hl = opts.icon(file) local marker = STATUS_MARKER[file.status] local stat = M.stat_chunks(file) + if viewed then + glyph, icon_hl = highlights.VIEWED_ICON, highlights.VIEWED_ICON_HL + local label = { "viewed", highlights.META_HL } + stat = stat and vim.list_extend({ label, { " " } }, stat) or { label } + end local room = opts.width - width(MARGIN .. RAIL .. " " .. glyph .. " ") - (marker and width(marker) or 0) @@ -415,17 +427,23 @@ end ---@param opts changeset.RenderOpts ---@param width fun(text: string): integer local function append_file(out, file, opts, width) - out[#out + 1] = file_line(file, opts, width) - if opts.collapsed(file.id) then - return + local first = #out + 1 + local viewed = opts.viewed ~= nil and opts.viewed(file) + out[first] = file_line(file, opts, width, viewed) + if not opts.collapsed(file.id) then + -- A file with nothing under it is either waiting on a server or genuinely has + -- nothing to show — a 100% rename, a binary change. Only the first gets the + -- placeholder, so the read status decides it, not an empty child list. + if file.read == "reading" then + out[#out + 1] = placeholder_line(file) + else + append_children(out, file, "", opts, width) + end end - -- A file with nothing under it is either waiting on a server or genuinely has - -- nothing to show — a 100% rename, a binary change. Only the first gets the - -- placeholder, so the read status decides it, not an empty child list. - if file.read == "reading" then - out[#out + 1] = placeholder_line(file) - else - append_children(out, file, "", opts, width) + if viewed then + for i = first, #out do + table.insert(out[i].marks, { col = 0, hl = highlights.VIEWED_HL, hl_eol = true, priority = VIEWED_PRIORITY }) + end end end diff --git a/lua/changeset/view.lua b/lua/changeset/view.lua index 5bedf45..18eafd5 100644 --- a/lua/changeset/view.lua +++ b/lua/changeset/view.lua @@ -7,10 +7,11 @@ local Rows = require("changeset.rows") ---@class changeset.view.Folds ---@field collapsed table Rows whose children are hidden. ---@field chains table Compressed chains the user opened out. +---@field viewed_at? table The digest each path was last folded at for being viewed, by path. ---@class changeset.view.Layout ---@field icon fun(row: changeset.Row): string, string Glyph and highlight for a row. ----@field viewed? fun(row: changeset.Row): boolean Whether `icon` draws `row` as viewed; a change redraws its block, as the row alone does not. +---@field viewed? fun(row: changeset.Row): boolean Whether a file row is marked viewed; a change redraws its block, as the row alone does not. ---@field width integer ---@field cursor integer The cursor's line before this redraw. @@ -329,6 +330,7 @@ function View:show(rows, layout) end, width = layout.width, query = self.narrowed, + viewed = layout.viewed, } local function is_open(id) return self.folds.chains[id] == true @@ -459,6 +461,44 @@ function View:fold_files(rows) end end +---Fold each file row of `rows` that `is_viewed` holds for at a digest not yet folded at, every copy of it, and +---record the digest: once folded, the user's own fold wins until the file is marked at another digest. +---@param rows changeset.Row[] Section rows from `Rows.build`. +---@param is_viewed fun(row: changeset.Row): boolean +---@param folds changeset.view.Folds Updated in place. +function M._fold_viewed(rows, is_viewed, folds) + local at = folds.viewed_at or {} + folds.viewed_at = at + local fresh = vim.tbl_filter(function(row) + return is_viewed(row) and at[row.path] ~= row.digest + end, Rows.files(rows)) + for _, row in ipairs(fresh) do + folds.collapsed[row.id] = true + at[row.path] = row.digest + end +end + +---Fold the viewed files of `rows` that have not been folded for it at their digest. +---@param rows changeset.Row[] Section rows from `Rows.build`. +---@param is_viewed fun(row: changeset.Row): boolean +function View:fold_viewed(rows, is_viewed) + M._fold_viewed(rows, is_viewed, self.folds) +end + +---Unfold every copy of `path`'s row in `rows` and forget it was folded for being viewed. +---@param rows changeset.Row[] Section rows from `Rows.build`. +---@param path string +function View:unview(rows, path) + if self.folds.viewed_at then + self.folds.viewed_at[path] = nil + end + for _, row in ipairs(Rows.files(rows)) do + if row.path == path then + self.folds.collapsed[row.id] = nil + end + end +end + ---Unfold every file and symbol row. Each section keeps its fold, even one empty for now, and opened chains stay open. function View:unfold_files() local kept = {} diff --git a/tests/palette_spec.lua b/tests/palette_spec.lua index 9aeb8f7..2d26e55 100644 --- a/tests/palette_spec.lua +++ b/tests/palette_spec.lua @@ -293,6 +293,19 @@ describe("define_highlights", function() assert.is_true(b > 0x10 and b < 0xf0) end) + it("washes a viewed file's rows toward Comment, fainter than the selection", function() + vim.api.nvim_set_hl(0, "Normal", { fg = 0xcccccc, bg = BACKGROUND }) + vim.api.nvim_set_hl(0, "Comment", { fg = 0x10f010 }) + vim.api.nvim_set_hl(0, "Statement", { fg = 0x10f010 }) + + highlights.define_highlights() + + local viewed, selected = + channels(present(group(highlights.VIEWED_HL).bg)), channels(present(group(highlights.SELECTED_HL).bg)) + assert.is_true(viewed[2] > 0x10 and viewed[2] < selected[2]) + assert.same({ 0x10, 0x10 }, { viewed[1], viewed[3] }) + end) + it("tints over the theme's chrome when Normal is transparent", function() vim.api.nvim_set_hl(0, "Statement", { fg = RED }) vim.api.nvim_set_hl(0, "Normal", { fg = 0xcccccc, bg = BACKGROUND }) diff --git a/tests/render_spec.lua b/tests/render_spec.lua index c4ec65c..4dbc74d 100644 --- a/tests/render_spec.lua +++ b/tests/render_spec.lua @@ -722,6 +722,48 @@ describe("changeset.render", function() end) end) + describe("a viewed file", function() + local viewing = opts({ + viewed = function(row) + return row.kind == "file" + end, + }) + + it("draws the check in the icon's cell", function() + local line = present(file_lines({ file() }, viewing)[1]) + + assert.same(highlights.VIEWED_ICON_HL, present(mark_over(line, highlights.VIEWED_ICON)).hl) + end) + + it("labels the file row viewed, left of its stat", function() + local chunks = present(present(stat_mark(present(file_lines({ file() }, viewing)[1]))).virt_text) + + assert.same({ { "viewed", highlights.META_HL }, { " " } }, vim.list_slice(chunks, 1, 2)) + assert.same("+12", present(chunks[3])[1]) + end) + + it("tints the file row and every line under it, beneath the row states", function() + local lines = file_lines({ file({ children = { symbol() } }) }, viewing) + + for _, line in ipairs(lines) do + local tint = present(vim.iter(line.marks):find(function(mark) + return mark.hl == highlights.VIEWED_HL + end)) + assert.is_true(tint.hl_eol) + assert.is_true(present(tint.priority) < present(present(render.state_marks("picked", 80)[1]).priority)) + end + end) + + it("trims the name before the label", function() + local path = "src/" .. ("n"):rep(40) .. ".lua" + local plain = present(file_lines({ file({ path = path, name = path }) }, opts({ width = 40 }))[1]) + local marked = + present(file_lines({ file({ path = path, name = path }) }, vim.tbl_extend("force", viewing, { width = 40 }))[1]) + + assert.equal(vim.fn.strdisplaywidth(plain.text) - #"viewed ", vim.fn.strdisplaywidth(marked.text)) + end) + end) + describe("filter highlighting", function() it("marks the characters a filter query matched", function() local lines = file_lines({ file() }, opts({ query = "a.lua" })) diff --git a/tests/sidebar_spec.lua b/tests/sidebar_spec.lua index 8aa908d..20fd1ea 100644 --- a/tests/sidebar_spec.lua +++ b/tests/sidebar_spec.lua @@ -1559,7 +1559,7 @@ describe("changeset sidebar", function() ---@param name string local function mark(name) Sidebar.cursor_to(name) - press("v") + press("x") end ---@param path string @@ -1574,19 +1574,44 @@ describe("changeset sidebar", function() Sidebar.settle() end - it("checks the file's row in the viewed group, leaving the others' icons", function() + ---The extmarks in `ns` on 1-based line `lnum`. + ---@param buf integer + ---@param lnum integer + ---@return vim.api.keyset.extmark_details[] + local function marks_on(buf, lnum) + return vim.tbl_map(function(m) + return present(m[4]) + end, vim.api.nvim_buf_get_extmarks(buf, ns, { lnum - 1, 0 }, { lnum - 1, -1 }, { details = true })) + end + + ---@param buf integer + ---@param lnum integer + ---@return boolean + local function tinted(buf, lnum) + return vim.iter(marks_on(buf, lnum)):any(function(m) + return m.hl_group == highlights.VIEWED_HL and m.hl_eol == true + end) + end + + it("checks the file's row, tints it and labels it viewed, leaving the others", function() local buf = open_sidebar() - local other = line_named("other.lua") mark("mod.lua") local lnum = line_of(buf, "mod.lua") assert.truthy((line_named("mod.lua"):find(highlights.VIEWED_ICON, 1, true))) - assert.same(other, line_named("other.lua")) - local groups = vim.tbl_map(function(m) - return present(m[4]).hl_group - end, vim.api.nvim_buf_get_extmarks(buf, ns, { lnum - 1, 0 }, { lnum - 1, -1 }, { details = true })) - assert.truthy(vim.tbl_contains(groups, highlights.VIEWED_HL)) + assert.is_true(tinted(buf, lnum)) + assert.is_false(tinted(buf, line_of(buf, "other.lua"))) + local marks = marks_on(buf, lnum) + assert.is_true(vim.iter(marks):any(function(m) + return m.hl_group == highlights.VIEWED_ICON_HL + end)) + assert.is_true(vim.iter(marks):any(function(m) + return m.virt_text_pos == "right_align" + and vim.iter(m.virt_text):any(function(chunk) + return chunk[1] == "viewed" + end) + end)) end) it("restores the icon when marked again", function() @@ -1655,10 +1680,108 @@ describe("changeset sidebar", function() Sidebar.flush() Sidebar.cursor_to("one") - press("v") + press("x") assert.truthy((line_named("mod.lua"):find(highlights.VIEWED_ICON, 1, true))) end) + + local function open_with_symbols() + vim.cmd.edit("mod.lua") + changeset.open() + Sidebar.await_diff() + source.answer("mod.lua", { Changes.sym("one", "Function", 0, 3, 5) }) + source.answer("other.lua", {}) + Sidebar.flush() + end + + ---@return boolean + local function shows(text) + return Sidebar.text():find(text, 1, true) ~= nil + end + + it("tints every line under the file", function() + open_with_symbols() + local buf = present(window.buf()) + + mark("mod.lua") + Sidebar.cursor_to("mod.lua") + press("l") + + assert.is_true(tinted(buf, line_of(buf, "one"))) + end) + + it("folds the file on marking and unfolds it on unmarking", function() + open_with_symbols() + + mark("mod.lua") + assert.is_false(shows("one")) + + mark("mod.lua") + assert.is_true(shows("one")) + end) + + it("moves the cursor to the next file not viewed", function() + open_with_symbols() + + Sidebar.cursor_to("one") + press("x") + + assert.truthy((Sidebar.cursor_line():find("other.lua", 1, true))) + end) + + it("keeps the cursor on the marked file's row when no file below is left to view", function() + open_with_symbols() + mark("other.lua") + assert.truthy((Sidebar.cursor_line():find("other.lua", 1, true))) + + Sidebar.cursor_to("one") + press("x") + + assert.truthy((Sidebar.cursor_line():find("mod.lua", 1, true))) + end) + + it("starts a file marked in an earlier session folded, and keeps it open once opened", function() + open_with_symbols() + local tree = present(build.current()) + local file = present(vim.iter(tree.files):find(function(f) + return f.path == "mod.lua" + end)) + changeset.close() + viewed.toggle(viewed.path(), tree.root, "mod.lua", file.digest) + Fixture.git({ "checkout", "-q", "-b", "other", Fixture.git({ "rev-parse", "HEAD" }, tmp) }, tmp) + open_with_symbols() + assert.is_false(shows("one")) + + Sidebar.cursor_to("mod.lua") + press("l") + assert.is_true(shows("one")) + build.refresh() + Sidebar.flush() + + assert.is_true(shows("one")) + end) + + it("draws the selection's tint over a viewed row's", function() + open_with_symbols() + mark("other.lua") + local buf = present(window.buf()) + Sidebar.cursor_to("other.lua") + Sidebar.flush() + + local lnum = line_of(buf, "other.lua") + local rows_ns = vim.api.nvim_get_namespaces()["changeset.rows"] + local selected = vim + .iter(vim.api.nvim_buf_get_extmarks(buf, rows_ns, { lnum - 1, 0 }, { lnum - 1, -1 }, { + details = true, + })) + :find(function(m) + return present(m[4]).hl_group == highlights.SELECTED_HL + end) + local tint = vim.iter(marks_on(buf, lnum)):find(function(m) + return m.hl_group == highlights.VIEWED_HL + end) + assert.is_true(present(present(selected)[4]).priority > present(tint).priority) + end) end) it("reports a mark it could not write", function() diff --git a/tests/view_spec.lua b/tests/view_spec.lua index f3c345c..4edbf8f 100644 --- a/tests/view_spec.lua +++ b/tests/view_spec.lua @@ -681,4 +681,78 @@ describe("changeset.view", function() assert.same({ "Generated" }, vim.list_slice(show(v, rows), 4)) end) end) + + describe("folding viewed files", function() + ---@param section string + ---@param digest string + ---@return changeset.Row + local function copy(section, digest) + local partial = { id = section .. "\0a.lua", kind = "file", path = "a.lua", digest = digest, children = {} } + return partial --[[@as changeset.Row]] + end + + ---@param files changeset.Row[] + ---@return changeset.Row[] + local function sections(files) + return vim.tbl_map(function(f) + return row(present(f.id:match("^[^%z]*")), "section", { f }) + end, files) + end + + local function all() + return true + end + + it("folds every copy of a viewed file", function() + local folds = { collapsed = {}, chains = {} } + + view._fold_viewed(sections({ copy("#tests", "d1"), copy("#implementation", "d1") }), all, folds) + + assert.same({ ["#tests\0a.lua"] = true, ["#implementation\0a.lua"] = true }, folds.collapsed) + end) + + it("leaves a viewed file the user opened open while its digest stays", function() + local folds = { collapsed = {}, chains = {} } + local rows = sections({ copy("#tests", "d1") }) + view._fold_viewed(rows, all, folds) + folds.collapsed = {} + + view._fold_viewed(rows, all, folds) + + assert.same({}, folds.collapsed) + end) + + it("folds a viewed file again at a new digest", function() + local folds = { collapsed = {}, chains = {} } + view._fold_viewed(sections({ copy("#tests", "d1") }), all, folds) + folds.collapsed = {} + + view._fold_viewed(sections({ copy("#tests", "d2") }), all, folds) + + assert.same({ ["#tests\0a.lua"] = true }, folds.collapsed) + end) + + it("leaves a file that is not viewed alone", function() + local folds = { collapsed = {}, chains = {} } + + view._fold_viewed(sections({ copy("#tests", "d1") }), function() + return false + end, folds) + + assert.same({}, folds.collapsed) + end) + + it("unfolds every copy of an unviewed file, and folds it again once it is marked", function() + local folds = { collapsed = {}, chains = {} } + local rows = sections({ copy("#tests", "d1"), copy("#implementation", "d1") }) + local v = view.new(folds, {}) + view._fold_viewed(rows, all, folds) + + v:unview(rows, "a.lua") + assert.same({}, folds.collapsed) + + view._fold_viewed(rows, all, folds) + assert.same({ ["#tests\0a.lua"] = true, ["#implementation\0a.lua"] = true }, folds.collapsed) + end) + end) end) From 9a243a40e42bd3f27313212f06d0022efe3abfbc Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Sun, 11 Oct 2026 16:27:00 -0400 Subject: [PATCH 07/12] feat(diff): leave viewed files without the unified diff - While a file's viewed mark holds, the unified diff doesn't reach it: marking closes its views in every window, so the buffer is back to gitsigns' own signs, and none opens again. - Unmarking, or the file's diff changing, reopens them; changing it back to the viewed diff closes them again. - One `reaches` predicate replaces each `reach:allows` call, so every path that opens or covers a view asks the same question. --- lua/changeset/actions.lua | 2 + lua/changeset/unified_diff.lua | 41 +++++++-- tests/unified_diff_viewed_spec.lua | 129 +++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 7 deletions(-) create mode 100644 tests/unified_diff_viewed_spec.lua diff --git a/lua/changeset/actions.lua b/lua/changeset/actions.lua index 788ae2a..efbd74a 100644 --- a/lua/changeset/actions.lua +++ b/lua/changeset/actions.lua @@ -10,6 +10,7 @@ local icons = require("changeset.icons") local review_comment = require("changeset.review_comment") local reviewing = require("changeset.reviewing") local sidebar_state = require("changeset.sidebar_state") +local unified_diff = require("changeset.unified_diff") local view = require("changeset.view") local viewed = require("changeset.viewed") local window = require("changeset.window") @@ -477,6 +478,7 @@ function M.set_keymaps(buf, keys, hooks) return vim.notify("Changeset: could not write " .. record, vim.log.levels.ERROR) end state.marks = marks + unified_diff.refresh_viewed() if not viewed.holds(marks, file.path, file.digest) then state.view:unview(state.rows, file.path) return redraw() diff --git a/lua/changeset/unified_diff.lua b/lua/changeset/unified_diff.lua index 329a4b3..18bb01c 100644 --- a/lua/changeset/unified_diff.lua +++ b/lua/changeset/unified_diff.lua @@ -6,6 +6,8 @@ local build = require("changeset.build") local diff_reach = require("changeset.diff_reach") local highlights = require("changeset.highlights") local Paths = require("changeset.paths") +local sidebar_state = require("changeset.sidebar_state") +local viewed = require("changeset.viewed") local M = {} @@ -70,12 +72,29 @@ local function tree_file(name) end end +---Whether the diff reaches `path` now: as far as the reach allows, and not while the file's viewed mark holds. +---@param path string An absolute path. +---@return boolean +local function reaches(path) + local state = sidebar_state.current() + if state then + local relative = Paths.relative(state.tree.root, path) + local file = vim.iter(state.tree.files):find(function(f) + return f.path == relative + end) + if file and viewed.holds(state.marks, file.path, file.digest) then + return false + end + end + return reach:allows(path) +end + ---Whether the diff reaches the file `buf` holds. ---@param buf integer ---@return boolean local function allowed(buf) local path = tree_file(vim.api.nvim_buf_get_name(buf)) - return path ~= nil and reach:allows(path) + return path ~= nil and reaches(path) end ---Whether the view this module opened in `win` went while `win` still shows its buffer, which only the user does. @@ -215,7 +234,7 @@ local function open(view) if not base then return end - if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == buf and reach:allows(path) then + if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == buf and reaches(path) then if table.concat(text, "\n") == "" then -- gitsigns writes a base without the file as one blank line, which the view reads back with its newline. vim.bo[base].endofline = false @@ -243,7 +262,7 @@ function M.sync(win) opened[win] = nil end local path = tree_file(vim.api.nvim_buf_get_name(buf)) - if not (path and reach:allows(path)) then + if not (path and reaches(path)) then return end -- gitsigns drops a buffer's status before the last update it publishes as it lets go, mid-unload, of the buffer. @@ -296,7 +315,7 @@ local function start() build.subscribe(function(event) if event == "diff" then close_unless(function(path) - return tree_file(path) ~= nil + return tree_file(path) ~= nil and reaches(path) end) sync_all() end @@ -428,9 +447,7 @@ end ---@param keep changeset.DiffReach.Keep function M.limit(keep) reach:limit(keep) - close_unless(function(path) - return reach:allows(path) - end) + close_unless(reaches) if untracked_was ~= nil and not reach:on() then require("gitsigns.config").config.attach_to_untracked = untracked_was untracked_was = nil @@ -447,6 +464,16 @@ function M.enter(path) end end +---Follow the sidebar's viewed marks changing: close the views on files they now keep the diff from, and open the +---rest where the diff reaches. +function M.refresh_viewed() + if not started then + return + end + close_unless(reaches) + sync_all() +end + ---Whether the diff reaches any file. ---@return boolean function M.on() diff --git a/tests/unified_diff_viewed_spec.lua b/tests/unified_diff_viewed_spec.lua new file mode 100644 index 0000000..e35b889 --- /dev/null +++ b/tests/unified_diff_viewed_spec.lua @@ -0,0 +1,129 @@ +vim.opt.rtp:prepend(require("support.deps").path("gitsigns.nvim")) +require("gitsigns").setup() +require("support.gh") + +local build = require("changeset.build") +local changeset = require("changeset") +local Fixture = require("support.git") +local Sidebar = require("support.sidebar") +local present = require("support.present") + +-- One session, told case by case, each starting where the one before left it. +describe("the unified diff of a file marked viewed", function() + local dir, previous = Fixture.enter_tempdir() + Fixture.feature_two_files(dir) + local quiet = vim.notify + vim.notify = function() end + vim.cmd.edit("mod.lua") + local file_win = vim.api.nvim_get_current_win() + local mod = vim.api.nvim_get_current_buf() + changeset.open() + Sidebar.settle() + + ---@param win integer + ---@return boolean + local function viewing(win) + return require("gitsigns.unified").get_view(win) ~= nil + end + + ---Whether `win` comes to show a unified diff, or with `want` false, comes to show none. + ---@param want boolean + ---@return boolean + local function becomes(want) + return (vim.wait(5000, function() + return viewing(file_win) == want + end, 20)) + end + + ---Whether it stays without a view for half a second. + ---@return boolean + local function stays_bare() + return not vim.wait(500, function() + return viewing(file_win) + end, 20) + end + + ---The marks changeset lays over gitsigns' signs in the file's window. + ---@return integer + local function covers() + vim.cmd.redraw() + local ns = vim.api.nvim_create_namespace("changeset.unified_diff." .. file_win) + return #vim.api.nvim_buf_get_extmarks(mod, ns, 0, -1, {}) + end + + local function toggle() + Sidebar.cursor_to("mod.lua") + vim.api.nvim_feedkeys("x", "xt", false) + vim.api.nvim_set_current_win(file_win) + end + + ---The digest of mod.lua's diff the tree holds. + ---@return string? + local function digest() + local file = vim.iter(present(build.current()).files):find(function(f) + return f.path == "mod.lua" + end) + return file and file.digest + end + + ---Write `lines` to mod.lua and wait for the tree to read its diff again. + ---@param lines string[] + local function rewrite(lines) + local was = digest() + vim.api.nvim_buf_set_lines(mod, 0, -1, false, lines) + vim.api.nvim_buf_call(mod, function() + vim.cmd("silent write") + end) + build.refresh() + assert.is_true(vim.wait(5000, function() + return digest() ~= was + end, 20)) + end + + it("closes the file's view and its cover once marked", function() + assert.is_true(becomes(true)) + + toggle() + + assert.is_true(becomes(false)) + assert.equal(0, covers()) + end) + + it("opens no view on the file as its window is entered", function() + vim.cmd.wincmd("p") + vim.api.nvim_set_current_win(file_win) + + assert.is_true(stays_bare()) + end) + + it("keeps the file without a view as the diff turns off and on", function() + changeset.diff() + changeset.diff() + + assert.is_true(stays_bare()) + end) + + it("reopens the view once the file's diff changes", function() + rewrite({ "local M = {}", "", "function M.one()", " return 3", "end", "", "return M" }) + + assert.is_true(becomes(true)) + end) + + it("closes it again once the file is back to the diff it was viewed at", function() + rewrite({ "local M = {}", "", "function M.one()", " return 2", "end", "", "return M" }) + + assert.is_true(becomes(false)) + end) + + it("reopens the view once unmarked", function() + toggle() + + assert.is_true(becomes(true)) + end) + + vim.notify = quiet + changeset.close() + vim.cmd("silent! %bwipeout!") + vim.fn.chdir(previous) + vim.fn.delete(dir, "rf") +end) From 3eab7b3f8e4075eeaeab1c130b362874b80c46f9 Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Sun, 11 Oct 2026 16:31:41 -0400 Subject: [PATCH 08/12] docs: say how a viewed file recedes - The viewed key is `x` in the vimdoc tags, the README's key table and its `setup()` block. - The key's entry lists the wash, the check, the label, the fold, the cursor moving on and the unified diff closing. `ChangesetViewed` is the row wash; `ChangesetViewedIcon` is new. - design.md gives the reasons, and records the per-session fold digest beside the folds. --- README.md | 4 +-- doc/agents/design.md | 73 +++++++++++++++++++++++++++--------------- doc/changeset.nvim.txt | 56 ++++++++++++++++++++++++-------- 3 files changed, 93 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 805f610..9449ad4 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ has a `(changeset-…)` map named after its words, and these default keys: | `f` | `filter` | Filter the tree as you type | | `F` | `filter_kinds` | Hide symbol kinds, remembered for this branch, repository or everywhere | | `y` | `yank` | Copy the row's `path:line`, or a ranged comment's `path:first-last` | -| `v` | `toggle_viewed` | Mark the file viewed, or not, until its diff changes | +| `x` | `toggle_viewed` | Mark the file viewed, or not, until its diff changes | | `d` | `delete_comment` | Delete the review comment on a Comments row | | `R` | `refresh` | Rebuild the tree | | `?` | `help` | List the sidebar's keys | @@ -220,7 +220,7 @@ require("changeset").setup({ prev_section = "[[", refresh = "R", yank = "y", - toggle_viewed = "v", + toggle_viewed = "x", delete_comment = "d", help = "?", filter_kinds = "F", diff --git a/doc/agents/design.md b/doc/agents/design.md index c386294..0ffb74b 100644 --- a/doc/agents/design.md +++ b/doc/agents/design.md @@ -154,8 +154,8 @@ restarts. With mini.icons: - Symbol rows: `MiniIcons.get("lsp", kind)` — the exact call that config's outline picker makes, so a method is the same glyph in the same hue in both the outline picker and the sidebar. -- File rows: `MiniIcons.get("file", path)`, or the viewed check (§ A viewed file wears a - check in its icon's place). +- File rows: `MiniIcons.get("file", path)`, or the viewed check (§ A viewed file + recedes). - Section headers: `MiniIcons.get("directory", …)` with `src`, `tests`, `docs`, `.config` and `build`, so each header wears the icon its kind of directory already has. - Orphan-hunk groups: the `lsp`/`Text` icon, dimmed. Not a bespoke glyph. @@ -168,28 +168,44 @@ restarts. With mini.icons: A future icon-set change propagates everywhere at once. That is the point. -### A viewed file wears a check in its icon's place - -A file marked viewed (`v`) draws `✔` in `ChangesetViewed`, linked to `Comment`, in the cell -its file icon holds: - -- **It reads as a checklist.** The checks fill the icon column, so finished files read down - one edge. A grey check among coloured icons lets a viewed file recede, as GitHub's Viewed - does. -- **It changes no layout.** It is one cell, like an icon, so the row's width budget is - unchanged. -- **It is the config's own check.** The config already uses `✔` as its "selected" marker, - and no icon plugin has a "viewed" category to ask for. -- **Its colour recedes.** `Comment`'s grey is the tree's "not the point" colour, for context - and meta, which is what a viewed file has become. `DiagnosticOk` green already means - "review comment". -- **Its key is `v`.** It is mnemonic, and Visual mode has no use in the sidebar, since no - sidebar key takes a range. - -Placements rejected: the left margin, where the check would butt against the rail; after -the name, where it would move with the name's length and could be matched by the filter; -the right gutter, which the row-state glyphs own; and dimming the name, since `Comment` on -a name already means "context". +### A viewed file recedes + +A file marked viewed (`x`) is one the user has finished with, so it steps back in four ways, +in every section it shows in. Three of them (wash, check, label) are quiet signals that add +no new hue, so the status rail stays the one bold element. + +- **A wash.** The file row and every line under it take `ChangesetViewed`, a background 8% + of the way from the window's to `Comment`'s grey. Every row state tints toward + `Statement`'s accent (selected 20%, here 12%, picked 6%), so a neutral wash never reads as + one of them. It is clearly fainter than the selection, about as quiet as picked, and draws + beneath all three, which still show over it. +- **A check in the icon's place.** `✔` in `ChangesetViewedIcon`, linked to `Comment`, mirroring + the `ChangesetSelected`/`ChangesetSelectedIcon` pair. The checks fill the icon column, so + finished files read down one edge, as a checklist. It is one cell, so the width budget is + unchanged. The config already uses `✔` as its "selected" marker, and no icon plugin has a + "viewed" category to ask for. `Comment`'s grey is the tree's "not the point" colour; + `DiagnosticOk` green already means "review comment". +- **A label.** `viewed`, right-aligned just left of the stat, in `ChangesetMeta`: the "not + content" level of the three levels of emphasis. It counts in the width budget, so the + name trims first. It says `viewed`, not "(read)", so the feature keeps one name: the + `toggle_viewed` option, `viewed.json`, GitHub's "Viewed". +- **A fold.** Marking folds every copy of the file, to give the rest the room; unmarking + unfolds them. A file whose mark holds the first time the session draws it at that digest + starts folded, which covers a restart and a mark another Neovim wrote. After that the + user's fold wins: one opened with `l` stays open until it is marked again. A mark that + stops holding leaves the fold alone, since the user may already be reading the change. + +Marking then moves the cursor to the next file row below that is not viewed, skipping other +copies of the marked file, and previews it as a cursor move does: reviewing is a walk down +the files. With none below, the cursor stays on the marked row. Unmarking leaves it put. + +The key is `x`, which the kind menu already uses to hide or show the kind under the cursor, +so `x` means "toggle this row" in both places. + +Placements rejected for the check: the left margin, where it would butt against the rail; +after the name, where it would move with the name's length and could be matched by the +filter; the right gutter, which the row-state glyphs own; and dimming the name, since +`Comment` on a name already means "context". ### Kind labels are dropped; the icon carries kind @@ -1337,6 +1353,11 @@ there with gitsigns' signs drawn beside it, and they stay closed until the diff on, by the sidebar or the toggle, each the user asking for the diff again; nothing writes it down. Turning every file off for it would make one stray toggle cost the session's diff. +A file marked viewed has no diff while its mark holds: the user has read it, so its buffer +goes back to how gitsigns alone draws it. The diff returns when the file's diff changes, +since there is something new to read, or when the mark is cleared. Viewed is its own +condition, apart from the reach and from a view closed by hand. + ## What it remembers The tree is built the first time something asks for it — `:Changeset`, `:Changeset @@ -1382,7 +1403,9 @@ is never parsed again and its base is never read again. The file name carries a format number, bumped whenever what an entry holds or how it is derived changes, because an older entry's stamp would otherwise still match. Folds — a section's as well as a file's — are remembered per repository for as long as Neovim is running, so reopening looks like you left it; a restart -starts expanded, except Generated, which starts folded. Per repository because a row is +starts expanded, except Generated, which starts folded. Beside the folds, each viewed file +records the digest it was last folded at, so a viewed file folds once per diff and the +user's unfold sticks. Per repository because a row is identified by a repo-relative path, which two checkouts can easily both have. Files marked viewed are kept in `stdpath("state")/changeset/viewed.json`, per repository diff --git a/doc/changeset.nvim.txt b/doc/changeset.nvim.txt index 8f53ae6..9c9aa76 100644 --- a/doc/changeset.nvim.txt +++ b/doc/changeset.nvim.txt @@ -660,21 +660,40 @@ keymaps.yank *changeset-option-keymaps.yank* provider. A Comments row on a range copies `path:first-last`. A row with no line copies the path alone. -v *changeset-sidebar-v* +x *changeset-sidebar-x* keymaps.toggle_viewed *changeset-option-keymaps.toggle_viewed* Mark the file viewed, or clear its mark. It acts on the file the row belongs to: its file row, or a symbol's, a change's or an "Other - changes" row under it. Nothing on a section or Comments row. A viewed - file draws a |ChangesetViewed| `✔` in its icon's place, in every - section it shows in. + changes" row under it. Nothing on a section or Comments row. If the + mark can't be written, changeset reports an error and changes nothing. + + A viewed file changes in every section it shows in: + - Its file row and every line under it take a faint |ChangesetViewed| + wash, under the |ChangesetSelected|, |ChangesetHere| and + |ChangesetPicked| tints. + - A |ChangesetViewedIcon| `✔` takes its icon's place. + - The word `viewed` sits left of its stat, in |ChangesetMeta|. A long + name is trimmed to make room. + - The file folds. + Its unified diff closes too (|changeset-unified-diff|). + + After marking, the cursor moves to the next file row below that isn't + viewed, skipping other copies of the file you marked, and previews it. + With none below, it stays on the marked file. Clearing a mark unfolds + the file and leaves the cursor where it is. + + A file whose mark holds starts folded the first time the sidebar draws + it at that diff, as after a restart. After that your own fold wins: a + viewed file you open with `l` stays open until you mark it again. A mark holds only while the file's diff is the one you viewed. It stops showing once that diff changes, and shows again if the change - is reverted or the base switched back. `v` on a file whose mark no - longer holds marks its new diff. A file whose patch has no text, such - as a binary file edited in place, keeps its mark. Marks another Neovim - writes show once the tree is built again for another branch or base, - or after your next `v`. + is reverted or the base switched back. A mark that stops holding + leaves the fold as it is. `x` on a file whose mark no longer holds + marks its new diff. A file whose patch has no text, such as a binary + file edited in place, keeps its mark. Marks another Neovim writes show + once the tree is built again for another branch or base, or after + your next `x`. d *changeset-sidebar-d* keymaps.delete_comment *changeset-option-keymaps.delete_comment* @@ -1153,6 +1172,13 @@ anything else gitsigns offers, closes changeset's views of that file in every window as you leave it, and keeps them closed until the diff next turns on, by the sidebar opening or |:Changeset-diff|. Other files keep theirs. +changeset keeps the diff off a file marked viewed (|changeset-sidebar-x|) +while its mark holds. Marking it closes its views in every window, and its +buffer goes back to how gitsigns alone draws it, signs included. Clearing the +mark opens them again where the diff reaches the file. So does editing the +file, once the sidebar has read the new diff. Undoing that edit, so the diff +is the one you viewed, closes them again. + A file new since that base, untracked or not, shows every line added. While the diff is on, gitsigns attaches to untracked files, as its `attach_to_untracked` option would have it; turning the diff off puts that @@ -1294,8 +1320,11 @@ ChangesetHidden *ChangesetHidden* A hidden kind in the kind menu. `Comment`'s colour, struck through. ChangesetViewed *ChangesetViewed* - The `✔` in place of a viewed file's icon (|changeset-sidebar-v|). - Links to `Comment`. + The background of a viewed file's rows (|changeset-sidebar-x|). + `Normal`'s background tinted faintly toward `Comment`'s colour. + +ChangesetViewedIcon *ChangesetViewedIcon* + The `✔` in place of a viewed file's icon. Links to `Comment`. ChangesetSelected *ChangesetSelected* The selected row while the sidebar has focus. `Normal`'s background @@ -1498,7 +1527,7 @@ nothing, starts no language server and makes no network request. once. - Hidden symbol kinds: `stdpath("state")/changeset/filters.json`. - Bases set with |:Changeset-base|: `stdpath("state")/changeset/bases.json`. -- Files marked viewed with |changeset-sidebar-v|: +- Files marked viewed with |changeset-sidebar-x|: `stdpath("state")/changeset/viewed.json`. An unreadable one is never written over. - Swap files: a file changeset reads or previews gets none until you enter @@ -1508,7 +1537,8 @@ nothing, starts no language server and makes no network request. - Sessions: |:mksession| restores the sidebar, in whichever tabpage it stood, when 'sessionoptions' contains `blank`, as it does by default, and its cursor row and scroll too when it also contains `globals`. -- Kept in memory until Neovim exits: folds, the agent last submitted to, how +- Kept in memory until Neovim exits: folds, which diff each viewed file + was last folded at, the agent last submitted to, how far the unified diff reaches, the files you entered (|changeset-entered-files|), and the files whose view you closed. From a3efe0b733b2dd2b09b823755b610afb517eeaff Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Sun, 11 Oct 2026 16:50:48 -0400 Subject: [PATCH 09/12] feat(sidebar): mark every file viewed with X - `X` (`keymaps.toggle_viewed_all`) marks every file the tree lists viewed, or clears every mark when all already are, in one write. - `x` and `X` share the step that shows new marks: the error on a failed write, the unified diff refresh, the fold or unfold, and the cursor's move. --- lua/changeset/actions.lua | 63 ++++++++++++++++++++---------- lua/changeset/config.lua | 3 ++ lua/changeset/viewed.lua | 43 ++++++++++++++++---- tests/sidebar_spec.lua | 62 +++++++++++++++++++++++++++++ tests/unified_diff_viewed_spec.lua | 12 ++++++ tests/viewed_spec.lua | 26 ++++++++++++ 6 files changed, 181 insertions(+), 28 deletions(-) diff --git a/lua/changeset/actions.lua b/lua/changeset/actions.lua index efbd74a..803d2be 100644 --- a/lua/changeset/actions.lua +++ b/lua/changeset/actions.lua @@ -368,6 +368,39 @@ function M.set_keymaps(buf, keys, hooks) end, desc) end + ---Show `marks`, just written for `paths`, or report they could not be: unview `paths` when the mark of `path`, the + ---cursor's file, no longer holds, else move the cursor past it. + ---@param state changeset.SidebarState + ---@param marks changeset.ViewedMarks? + ---@param paths string[] + ---@param path string? + local function show_marks(state, marks, paths, path) + if not marks then + return vim.notify("Changeset: could not write " .. viewed.path(), vim.log.levels.ERROR) + end + state.marks = marks + unified_diff.refresh_viewed() + local file = path and vim.iter(state.tree.files):find(function(f) + return f.path == path + end) + if not (file and viewed.holds(marks, file.path, file.digest)) then + for _, p in ipairs(paths) do + state.view:unview(state.rows, p) + end + return redraw() + end + local shown, lnum = state.view:visible(), assert(window.cursor(), "changeset: sidebar is closed") + local target = M._after_viewing(shown, lnum, file.path, function(r) + return viewed.holds(marks, r.path, r.digest) + end) + redraw() + for at, r in ipairs(state.view:visible()) do + if target and r.id == target.id then + return move(at) + end + end + end + map(keys.jump, function() jump("reuse", hooks) end, "Go to this change") @@ -472,28 +505,16 @@ function M.set_keymaps(buf, keys, hooks) if not file then return end - local record = viewed.path() - local marks = viewed.toggle(record, state.tree.root, file.path, file.digest) - if not marks then - return vim.notify("Changeset: could not write " .. record, vim.log.levels.ERROR) - end - state.marks = marks - unified_diff.refresh_viewed() - if not viewed.holds(marks, file.path, file.digest) then - state.view:unview(state.rows, file.path) - return redraw() - end - local shown, lnum = state.view:visible(), assert(window.cursor(), "changeset: sidebar is closed") - local target = M._after_viewing(shown, lnum, file.path, function(r) - return viewed.holds(marks, r.path, r.digest) - end) - redraw() - for at, r in ipairs(state.view:visible()) do - if target and r.id == target.id then - return move(at) - end - end + show_marks(state, viewed.toggle(viewed.path(), state.tree.root, file.path, file.digest), { file.path }, file.path) end, "Mark the file viewed, or not") + map(keys.toggle_viewed_all, function(state) + local row = draw.row_at_cursor() + local files = state.tree.files + local paths = vim.tbl_map(function(f) + return f.path + end, files) + show_marks(state, viewed.toggle_all(viewed.path(), state.tree.root, files), paths, row and row.path) + end, "Mark every file viewed, or none") map(keys.help, function() help.show(buf, own) end, "Show these keymaps") diff --git a/lua/changeset/config.lua b/lua/changeset/config.lua index 9c6fc86..f2ea66b 100644 --- a/lua/changeset/config.lua +++ b/lua/changeset/config.lua @@ -25,6 +25,7 @@ local M = {} ---@field refresh? string|false Rebuild the tree. Default `R`. ---@field yank? string|false Yank path:line. Default `y`. ---@field toggle_viewed? string|false Mark the file viewed, or not. Default `x`. +---@field toggle_viewed_all? string|false Mark every file viewed, or none. Default `X`. ---@field delete_comment? string|false Delete the review comment a Comments row lists. Default `d`. ---@field help? string|false Show these keymaps. Default `?`. ---@field filter_kinds? string|false Filter by symbol kind. Default `F`. @@ -69,6 +70,7 @@ local M = {} ---@field refresh string|false ---@field yank string|false ---@field toggle_viewed string|false +---@field toggle_viewed_all string|false ---@field delete_comment string|false ---@field help string|false ---@field filter_kinds string|false @@ -107,6 +109,7 @@ local DEFAULTS = { refresh = "R", yank = "y", toggle_viewed = "x", + toggle_viewed_all = "X", delete_comment = "d", help = "?", filter_kinds = "F", diff --git a/lua/changeset/viewed.lua b/lua/changeset/viewed.lua index 2f8a274..c13688d 100644 --- a/lua/changeset/viewed.lua +++ b/lua/changeset/viewed.lua @@ -62,24 +62,53 @@ function M._apply(data, root, path, digest) return out end ----Mark `path` viewed at `digest` in `file`, or clear its mark when one holds there. +---Rewrite `file` with `change` applied to its data, never writing over an unreadable record: the marks in it can't +---be derived again. ---@param file string ---@param root string ----@param path string ----@param digest string +---@param change fun(data: table): table ---@return changeset.ViewedMarks? marks The root's marks now; nil when the record is unreadable or unwritten. -function M.toggle(file, root, path, digest) - -- Never written over when unreadable: the marks in it can't be derived again. +local function rewrite(file, root, change) local data = jsonfile.read_object(file) if not data then return nil end - local marked = M.holds(M.marks(data, root), path, digest) - local out = M._apply(data, root, path, not marked and digest or nil) + local out = change(data) if not jsonfile.write(file, vim.tbl_isempty(out) and vim.empty_dict() or out) then return nil end return M.marks(out, root) end +---Mark `path` viewed at `digest` in `file`, or clear its mark when one holds there. +---@param file string +---@param root string +---@param path string +---@param digest string +---@return changeset.ViewedMarks? marks The root's marks now; nil when the record is unreadable or unwritten. +function M.toggle(file, root, path, digest) + return rewrite(file, root, function(data) + local marked = M.holds(M.marks(data, root), path, digest) + return M._apply(data, root, path, not marked and digest or nil) + end) +end + +---Clear the mark of every one of `files` in `file` when each holds, else mark each viewed at its digest. +---@param file string +---@param root string +---@param files { path: string, digest: string? }[] +---@return changeset.ViewedMarks? marks The root's marks now; nil when the record is unreadable or unwritten. +function M.toggle_all(file, root, files) + return rewrite(file, root, function(data) + local marks = M.marks(data, root) + local all = vim.iter(files):all(function(f) + return M.holds(marks, f.path, f.digest) + end) + for _, f in ipairs(files) do + data = M._apply(data, root, f.path, not all and f.digest or nil) + end + return data + end) +end + return M diff --git a/tests/sidebar_spec.lua b/tests/sidebar_spec.lua index 20fd1ea..57a4214 100644 --- a/tests/sidebar_spec.lua +++ b/tests/sidebar_spec.lua @@ -1720,6 +1720,18 @@ describe("changeset sidebar", function() assert.is_true(shows("one")) end) + it("folds every file on marking all and unfolds them on clearing all", function() + open_with_symbols() + Sidebar.cursor_to("one") + + press("X") + assert.is_false(shows("one")) + assert.truthy((Sidebar.cursor_line():find("mod.lua", 1, true))) + + press("X") + assert.is_true(shows("one")) + end) + it("moves the cursor to the next file not viewed", function() open_with_symbols() @@ -1784,6 +1796,56 @@ describe("changeset sidebar", function() end) end) + describe("every file at once", function() + ---@param name string + ---@return boolean + local function checked(name) + return line_named(name):find(highlights.VIEWED_ICON, 1, true) ~= nil + end + + it("marks every file when none is viewed", function() + open_sidebar() + + press("X") + + assert.is_true(checked("mod.lua")) + assert.is_true(checked("other.lua")) + end) + + it("marks the rest when some are viewed", function() + open_sidebar() + mark("mod.lua") + + press("X") + + assert.is_true(checked("mod.lua")) + assert.is_true(checked("other.lua")) + end) + + it("clears every mark when every file is viewed", function() + open_sidebar() + press("X") + + press("X") + + assert.is_false(checked("mod.lua")) + assert.is_false(checked("other.lua")) + end) + + it("reports marks it could not write", function() + open_sidebar() + vim.fn.mkdir(vim.fs.dirname(viewed.path()), "p") + vim.fn.writefile({ "[1,2]" }, viewed.path()) + local notify = require("support.notify") + local notes, restore = notify.capture() + + press("X") + restore() + + assert.same({ "Changeset: could not write " .. viewed.path() }, notify.messages(notes, vim.log.levels.ERROR)) + end) + end) + it("reports a mark it could not write", function() open_sidebar() vim.fn.mkdir(vim.fs.dirname(viewed.path()), "p") diff --git a/tests/unified_diff_viewed_spec.lua b/tests/unified_diff_viewed_spec.lua index e35b889..2aa65e3 100644 --- a/tests/unified_diff_viewed_spec.lua +++ b/tests/unified_diff_viewed_spec.lua @@ -121,6 +121,18 @@ describe("the unified diff of a file marked viewed", function() assert.is_true(becomes(true)) end) + it("closes the view once every file is marked, and reopens it once every mark is cleared", function() + Sidebar.cursor_to("mod.lua") + vim.api.nvim_feedkeys("X", "xt", false) + vim.api.nvim_set_current_win(file_win) + assert.is_true(becomes(false)) + + Sidebar.cursor_to("mod.lua") + vim.api.nvim_feedkeys("X", "xt", false) + vim.api.nvim_set_current_win(file_win) + assert.is_true(becomes(true)) + end) + vim.notify = quiet changeset.close() vim.cmd("silent! %bwipeout!") diff --git a/tests/viewed_spec.lua b/tests/viewed_spec.lua index a5a70f6..7bffdde 100644 --- a/tests/viewed_spec.lua +++ b/tests/viewed_spec.lua @@ -76,4 +76,30 @@ describe("changeset.viewed", function() assert.are.same({ "[1,2]" }, vim.fn.readfile(viewed.path())) end) end) + + describe("toggle_all", function() + local files = { { path = "a.lua", digest = "d1" }, { path = "b.lua", digest = "d2" } } + + it("marks every path in one write when one is not viewed", function() + viewed.toggle(viewed.path(), ROOT, "a.lua", "d1") + + local want = { ["a.lua"] = "d1", ["b.lua"] = "d2" } + assert.are.same(want, viewed.toggle_all(viewed.path(), ROOT, files)) + assert.are.same(want, viewed.marks(viewed.load(viewed.path()), ROOT)) + end) + + it("clears every mark when every path is viewed", function() + viewed.toggle_all(viewed.path(), ROOT, files) + + assert.are.same({}, viewed.toggle_all(viewed.path(), ROOT, files)) + assert.are.same({}, viewed.load(viewed.path())) + end) + + it("never writes over a record that is not a JSON object", function() + write_record("[1,2]") + + assert.is_nil(viewed.toggle_all(viewed.path(), ROOT, files)) + assert.are.same({ "[1,2]" }, vim.fn.readfile(viewed.path())) + end) + end) end) From 2d06b4ca3a4362ac498012d9d2af76782a393d5f Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Sun, 11 Oct 2026 16:50:54 -0400 Subject: [PATCH 10/12] docs: say how X marks every file viewed - The vimdoc tags `X` and `keymaps.toggle_viewed_all`, and says what it covers and where the cursor goes. - The README's key table and `setup()` block list it. - design.md says why one key both finishes and restarts a review. --- README.md | 2 ++ doc/agents/design.md | 3 +++ doc/changeset.nvim.txt | 12 ++++++++++++ 3 files changed, 17 insertions(+) diff --git a/README.md b/README.md index 9449ad4..bc7596f 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,7 @@ has a `(changeset-…)` map named after its words, and these default keys: | `F` | `filter_kinds` | Hide symbol kinds, remembered for this branch, repository or everywhere | | `y` | `yank` | Copy the row's `path:line`, or a ranged comment's `path:first-last` | | `x` | `toggle_viewed` | Mark the file viewed, or not, until its diff changes | +| `X` | `toggle_viewed_all` | Mark every file viewed, or none | | `d` | `delete_comment` | Delete the review comment on a Comments row | | `R` | `refresh` | Rebuild the tree | | `?` | `help` | List the sidebar's keys | @@ -221,6 +222,7 @@ require("changeset").setup({ refresh = "R", yank = "y", toggle_viewed = "x", + toggle_viewed_all = "X", delete_comment = "d", help = "?", filter_kinds = "F", diff --git a/doc/agents/design.md b/doc/agents/design.md index 0ffb74b..bd3c310 100644 --- a/doc/agents/design.md +++ b/doc/agents/design.md @@ -199,6 +199,9 @@ Marking then moves the cursor to the next file row below that is not viewed, ski copies of the marked file, and previews it as a cursor move does: reviewing is a walk down the files. With none below, the cursor stays on the marked row. Unmarking leaves it put. +`X` marks every file viewed, or clears every mark once all are, so one key both finishes a +review and starts it over. + The key is `x`, which the kind menu already uses to hide or show the kind under the cursor, so `x` means "toggle this row" in both places. diff --git a/doc/changeset.nvim.txt b/doc/changeset.nvim.txt index 9c9aa76..37aa6aa 100644 --- a/doc/changeset.nvim.txt +++ b/doc/changeset.nvim.txt @@ -695,6 +695,18 @@ keymaps.toggle_viewed *changeset-option-keymaps.toggle_viewed* once the tree is built again for another branch or base, or after your next `x`. +X *changeset-sidebar-X* +keymaps.toggle_viewed_all *changeset-option-keymaps.toggle_viewed_all* + Mark every file the tree lists viewed, or, when every one already is, + clear every mark. It covers each file whatever a filter, a hidden kind + or a folded section hides, deleted files too. A viewed file looks as + |changeset-sidebar-x| describes. If the marks can't be written, + changeset reports an error and changes nothing. + + After marking, every file folds and the cursor moves to its file's own + row in the section it was in. Clearing unfolds every file and leaves + the cursor where it is. + d *changeset-sidebar-d* keymaps.delete_comment *changeset-option-keymaps.delete_comment* On a Comments row, ask, then delete its review comment. Nothing on any From 595552a7053d52fde16af04e74bc4b878dd11978 Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Sun, 11 Oct 2026 17:02:35 -0400 Subject: [PATCH 11/12] fix(diff): close the view a preview swaps away A preview swaps the buffer in a window without `BufWinLeave`, so gitsigns kept changeset's view there while `sync` forgot it. When the file came back, gitsigns handed the view back, and changeset took it for the user's own: no cover, and `x`/`X` couldn't close it, which left the diff half off. `sync` now closes that stale view as it lets go of it. --- doc/agents/design.md | 4 +++- lua/changeset/unified_diff.lua | 5 +++++ tests/unified_diff_viewed_spec.lua | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/doc/agents/design.md b/doc/agents/design.md index bd3c310..6c05d81 100644 --- a/doc/agents/design.md +++ b/doc/agents/design.md @@ -1262,7 +1262,9 @@ open as it does. gitsigns keeps one view per window and drops it when the buffer leaves the window, so changeset opens one wherever a file comes to be shown: a window taking a buffer, a buffer or window entered, which covers a split of a window already showing it, and a preview, whose -buffer swap fires no autocommand and so opens its own. A file gitsigns has not read yet +buffer swap fires no autocommand and so opens its own. Nor does that swap close the view +on the file it swaps out, so changeset closes it then, or gitsigns would hand it back +uncovered when the file returns. A file gitsigns has not read yet gets its view on its `GitSignsUpdate`, once the base text is in. The same event, and entering the window, reopen a view whose base gitsigns has since replaced, as the watcher's own move of the base does. A move that leaves the counts alone diff --git a/lua/changeset/unified_diff.lua b/lua/changeset/unified_diff.lua index 18bb01c..da5386c 100644 --- a/lua/changeset/unified_diff.lua +++ b/lua/changeset/unified_diff.lua @@ -260,6 +260,11 @@ function M.sync(win) local buf = vim.api.nvim_win_get_buf(win) if opened[win] and opened[win].buf ~= buf then opened[win] = nil + -- A preview swaps the buffer without BufWinLeave, so gitsigns still holds our view, and would hand it back once + -- the buffer returns. One showing now is on this buffer, so the user's own. + if not showing(win) then + require("gitsigns.unified").close(win) + end end local path = tree_file(vim.api.nvim_buf_get_name(buf)) if not (path and reaches(path)) then diff --git a/tests/unified_diff_viewed_spec.lua b/tests/unified_diff_viewed_spec.lua index 2aa65e3..1ec0313 100644 --- a/tests/unified_diff_viewed_spec.lua +++ b/tests/unified_diff_viewed_spec.lua @@ -133,6 +133,20 @@ describe("the unified diff of a file marked viewed", function() assert.is_true(becomes(true)) end) + it("covers and closes the view on the file once a preview of another file has come and gone", function() + rewrite({ "local M = {}", "", "function M.one()", " return 4", "end", "", "return M" }) + Sidebar.cursor_to("other.lua") + Sidebar.cursor_to("mod.lua") + vim.api.nvim_set_current_win(file_win) + assert.is_true(vim.wait(5000, function() + return covers() > 0 + end, 20)) + + toggle() + + assert.is_true(becomes(false)) + end) + vim.notify = quiet changeset.close() vim.cmd("silent! %bwipeout!") From df56f9a093697b8a8773dff8a3608f16f4828e7c Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Sun, 11 Oct 2026 17:11:29 -0400 Subject: [PATCH 12/12] docs: say the viewed wash draws under the hover tint Main's ChangesetHover and the viewed wash both drew one step below the state tints. The wash now sits one step lower still, so the pointer's tint shows across a viewed file's rows; the vimdoc and design.md name it among the tints the wash draws under. --- doc/agents/design.md | 3 ++- doc/changeset.nvim.txt | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/doc/agents/design.md b/doc/agents/design.md index 6c05d81..2828de6 100644 --- a/doc/agents/design.md +++ b/doc/agents/design.md @@ -178,7 +178,8 @@ no new hue, so the status rail stays the one bold element. of the way from the window's to `Comment`'s grey. Every row state tints toward `Statement`'s accent (selected 20%, here 12%, picked 6%), so a neutral wash never reads as one of them. It is clearly fainter than the selection, about as quiet as picked, and draws - beneath all three, which still show over it. + beneath all three, which still show over it. It draws beneath the pointer's + `ChangesetHover` too, which would otherwise vanish across a viewed file's rows. - **A check in the icon's place.** `✔` in `ChangesetViewedIcon`, linked to `Comment`, mirroring the `ChangesetSelected`/`ChangesetSelectedIcon` pair. The checks fill the icon column, so finished files read down one edge, as a checklist. It is one cell, so the width budget is diff --git a/doc/changeset.nvim.txt b/doc/changeset.nvim.txt index 37aa6aa..6c869ee 100644 --- a/doc/changeset.nvim.txt +++ b/doc/changeset.nvim.txt @@ -669,8 +669,8 @@ keymaps.toggle_viewed *changeset-option-keymaps.toggle_viewed* A viewed file changes in every section it shows in: - Its file row and every line under it take a faint |ChangesetViewed| - wash, under the |ChangesetSelected|, |ChangesetHere| and - |ChangesetPicked| tints. + wash, under the |ChangesetSelected|, |ChangesetHere|, + |ChangesetPicked| and |ChangesetHover| tints. - A |ChangesetViewedIcon| `✔` takes its icon's place. - The word `viewed` sits left of its stat, in |ChangesetMeta|. A long name is trimmed to make room.