diff --git a/README.md b/README.md index f1fb48d..bc7596f 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,8 @@ 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` | +| `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 | @@ -219,6 +221,8 @@ require("changeset").setup({ prev_section = "[[", 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 8776a70..2828de6 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 + 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. @@ -167,6 +168,49 @@ restarts. With mini.icons: A future icon-set change propagates everywhere at once. That is the point. +### 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. 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 + 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. + +`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. + +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 The config's outline picker right-aligns a kind label (`Method`, `Class`). A sidebar is @@ -1219,7 +1263,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 @@ -1313,6 +1359,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 @@ -1358,9 +1409,21 @@ 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 +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..6c869ee 100644 --- a/doc/changeset.nvim.txt +++ b/doc/changeset.nvim.txt @@ -660,6 +660,53 @@ 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. +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. 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|, + |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. + - 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. 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`. + +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 @@ -1137,6 +1184,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 @@ -1277,6 +1331,13 @@ ChangesetMatch *ChangesetMatch* ChangesetHidden *ChangesetHidden* A hidden kind in the kind menu. `Comment`'s colour, struck through. +ChangesetViewed *ChangesetViewed* + 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 tinted toward `Statement`. @@ -1478,6 +1539,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-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 its buffer, so another Neovim opening it shows no |E325| warning. - Review comments, and each branch's last 10 submitted batches: @@ -1485,7 +1549,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. diff --git a/lua/changeset/actions.lua b/lua/changeset/actions.lua index 2f39413..803d2be 100644 --- a/lua/changeset/actions.lua +++ b/lua/changeset/actions.lua @@ -10,7 +10,9 @@ 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") -- input() reads a line, so it can never hand one back: free to mean "cancelled". @@ -317,6 +319,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 @@ -344,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") @@ -437,6 +494,27 @@ 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 + 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/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/config.lua b/lua/changeset/config.lua index c29f27b..f2ea66b 100644 --- a/lua/changeset/config.lua +++ b/lua/changeset/config.lua @@ -24,6 +24,8 @@ 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 `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`. @@ -67,6 +69,8 @@ local M = {} ---@field prev_section string|false ---@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 @@ -104,6 +108,8 @@ local DEFAULTS = { prev_section = "[[", refresh = "R", yank = "y", + toggle_viewed = "x", + toggle_viewed_all = "X", delete_comment = "d", help = "?", filter_kinds = "F", diff --git a/lua/changeset/diff.lua b/lua/changeset/diff.lua index 641f7c0..a4b97e3 100644 --- a/lua/changeset/diff.lua +++ b/lua/changeset/diff.lua @@ -19,6 +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 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. @@ -36,6 +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; an untracked +---file's wins where a path is both. -- Any other name-status code (M, T) reads as a modification. ---@type table @@ -131,15 +135,24 @@ 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. 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_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_lines, section_path = {}, nil + end for _, line in ipairs(lines) do if vim.startswith(line, "diff --git ") then + digest_section() current, old, in_header = nil, nil, true elseif in_header and vim.startswith(line, "--- ") then old = side_path(line:sub(5), "a/") @@ -147,7 +160,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], section_path = current, path end else local hunk = parse_hunk_header(line) @@ -160,8 +173,10 @@ function M._parse_hunks(lines) end end end + table.insert(section_lines, line) end - return hunks + digest_section() + return hunks, digests end ---@param path string @@ -177,13 +192,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 +209,7 @@ local function untracked_file(path, lines) added = lines, removed = 0, hunks = lines > 0 and { whole_file } or {}, + digest = digest, } end @@ -242,7 +261,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) @@ -257,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) @@ -271,7 +291,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"), } @@ -310,19 +330,21 @@ 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 +---@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 +404,17 @@ 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, + -- 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) ---@cast files changeset.File[] diff --git a/lua/changeset/draw.lua b/lua/changeset/draw.lua index eced085..3ae172c 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. @@ -46,6 +47,15 @@ local function icon_for(row) return icons.get("lsp", row.kind == "symbol" and row.symbol_kind or "Text") end +---Whether `row` is a file marked viewed in `marks`. +---@param marks changeset.ViewedMarks +---@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 +end + ---The row under the sidebar's cursor. ---@return changeset.Row? function M.row_at_cursor() @@ -204,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, @@ -335,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 "", } @@ -501,8 +515,15 @@ 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 lines, lnum, blocks = state.view:show(tree, { icon = icon_for, width = width, cursor = cursor }) + local lines, lnum, blocks = state.view:show(tree, { + icon = icon_for, + 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..5444f6b 100644 --- a/lua/changeset/highlights.lua +++ b/lua/changeset/highlights.lua @@ -61,6 +61,17 @@ M.REVIEW_COMMENT_DRAFT_CIRCLE = "◌" M.REVIEW_COMMENT_BUBBLE = "󰍩" M.REVIEW_COMMENT_DRAFT_BUBBLE = "󰍪" +---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 = "✔" + ---The review comment's group, or the draft group for a draft. ---@param comment { draft: true? } ---@return string @@ -214,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 @@ -349,6 +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_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 }) @@ -370,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/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..c817132 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, as read when this state was made and as each toggle here leaves them. ---@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/unified_diff.lua b/lua/changeset/unified_diff.lua index 329a4b3..da5386c 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 @@ -241,9 +260,14 @@ 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 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 +320,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 +452,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 +469,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/lua/changeset/view.lua b/lua/changeset/view.lua index 8992fdc..18eafd5 100644 --- a/lua/changeset/view.lua +++ b/lua/changeset/view.lua @@ -7,9 +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 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. @@ -239,6 +241,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 +282,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 +306,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 @@ -318,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 @@ -327,7 +340,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 @@ -447,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/lua/changeset/viewed.lua b/lua/changeset/viewed.lua new file mode 100644 index 0000000..c13688d --- /dev/null +++ b/lua/changeset/viewed.lua @@ -0,0 +1,114 @@ +---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 + +---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 change fun(data: table): table +---@return changeset.ViewedMarks? marks The root's marks now; nil when the record is unreadable or unwritten. +local function rewrite(file, root, change) + local data = jsonfile.read_object(file) + if not data then + return nil + end + 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/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..09c5726 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,17 @@ 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("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() 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/resolve_spec.lua b/tests/resolve_spec.lua index 4dbdfc3..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,8 +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 = {} } + local file = new_file({ path = "mod.lua" }) local cancel = resolve.start({ root = root, base = "HEAD" }, { file }, function(path) seen[#seen + 1] = path end) @@ -211,8 +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 = {} } + local file = new_file({ path = "mod.lua" }) resolve.start({ root = root, base = "HEAD" }, { file }, function(_, items) report = { items = items } @@ -265,8 +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 = {} } + local file = new_file({ path = "mod.lua" }) resolve.start({ root = root, base = "HEAD" }, { file }, function(_, items) report = { items = items } end) @@ -340,13 +350,11 @@ 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" }, { + new_file({ path = path }), + }, function(_, items) + answer, done = items, true + end) vim.wait(2000, function() return done end) @@ -365,8 +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 = {} } + local file = new_file({ path = "mod.lua", status = "modified", removed = 1 }) resolve.start({ root = root, base = "HEAD" }, { file }, function(_, items) report = { items = items } @@ -385,7 +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 = {} } + 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 } @@ -406,7 +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 = {} } + 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 } @@ -527,8 +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 = {} } + local file = new_file({ path = "mod.lua" }) return resolve.start({ root = root, base = "HEAD" }, { file }, on_file) end @@ -924,8 +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" } + 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/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/sidebar_spec.lua b/tests/sidebar_spec.lua index d32fa2d..57a4214 100644 --- a/tests/sidebar_spec.lua +++ b/tests/sidebar_spec.lua @@ -1542,4 +1542,321 @@ 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("x") + 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 + + ---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() + + mark("mod.lua") + + local lnum = line_of(buf, "mod.lua") + assert.truthy((line_named("mod.lua"):find(highlights.VIEWED_ICON, 1, true))) + 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() + 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("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() + + assert.truthy((line_named("mod.lua"):find(highlights.VIEWED_ICON, 1, true))) + end) + + 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("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("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() + + 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) + + 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") + 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/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 diff --git a/tests/unified_diff_viewed_spec.lua b/tests/unified_diff_viewed_spec.lua new file mode 100644 index 0000000..1ec0313 --- /dev/null +++ b/tests/unified_diff_viewed_spec.lua @@ -0,0 +1,155 @@ +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) + + 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) + + 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!") + vim.fn.chdir(previous) + vim.fn.delete(dir, "rf") +end) 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) diff --git a/tests/viewed_spec.lua b/tests/viewed_spec.lua new file mode 100644 index 0000000..7bffdde --- /dev/null +++ b/tests/viewed_spec.lua @@ -0,0 +1,105 @@ +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) + + 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)