diff --git a/doc/diffview.txt b/doc/diffview.txt index 06fb550a..f2f6b2f1 100644 --- a/doc/diffview.txt +++ b/doc/diffview.txt @@ -809,14 +809,15 @@ view.x.layout *diffview-config-view.x.layout* {diff1_raw} Auto-selected (not directly settable via `view.x.layout`). - Selected per-file by |diffview-config-view.single_pane_for_one_sided| + Selected per-file by |diffview-config-view.one_sided_layout| when the entry's diff is one-sided (added, untracked, or deleted). A single window with no diff highlighting and no diff folding; the buffer reads like a normal file. For additions and untracked files the window holds the working- tree file directly (editable and `:w`-able); for deletions - it holds the pre-deletion content from the index or commit - (read-only since the working-tree file is gone). + it holds the pre-deletion content from `revs.a` (read-only + against a commit, editable against the index — `:w` then + writes back to the index via the usual STAGE-0 path). ┌──────────────┐ │ │ @@ -915,33 +916,42 @@ view.foldlevel *diffview-config-view.foldlevel* value (e.g. `99`) to keep all folds open, which can help when another plugin's ftplugin creates unrelated folds in diff buffers. - *diffview-config-view.single_pane_for_one_sided* -view.single_pane_for_one_sided - Type: `boolean`, Default: `false` - - When true, files whose diff is one-sided open in a single non-diff - window instead of a Diff2 layout with an empty pane. Specifically: - - • Status A or ? (added / untracked): the b-side is opened - directly. When b is `LOCAL` (the usual case in diff views) - the window holds the working-tree file as an editable, - `:w`-able buffer; when b is a commit rev (the usual case in - file-history views) it is read-only. - • Status D (deleted): the pre-deletion content from the index or - commit is shown in a single read-only window. - - Applies to both diff views and file history views. Has no effect for - renamed, modified, or merge-conflict files (those keep their Diff2 or - merge layout). Also no effect when the configured layout is already a - single-window layout (`diff1_plain`, `diff1_inline`) or when file - history's `pin_local` mode owns the right-hand window. - - The auto-selected layout is `diff1_raw`. It is excluded from - |diffview-config-view.cycle_layouts|: cycling with |g| moves - through your configured Diff2 layouts. The cycled-to layout - replaces the entry's layout in place, so re-selecting the same - file simply reuses it; re-applying `diff1_raw` requires a refresh - that rebuilds the entry (see |diffview-actions-refresh_files|). + *diffview-config-view.one_sided_layout* +view.one_sided_layout + Type: `"default"|"raw"`, Default: `"default"` + + Layout used for files whose diff is one-sided (status `A`/`?`/`D`). + + • `"default"`: keep the configured base layout. A Diff2 will + leave one pane empty; a `diff1_plain` keeps its diff-mode + chrome (foldcolumn, foldmethod=diff) on a buffer with no + comparison partner. + • `"raw"`: substitute `diff1_raw`. A single non-diff window: + + • Status A or ? (added / untracked): the b-side is opened + directly. When b is `LOCAL` (the usual case in diff views) + the window holds the working-tree file as an editable, + `:w`-able buffer; when b is a commit rev (the usual case in + file-history views) it is read-only. + • Status D (deleted): the pre-deletion content from `revs.a` + is shown in a single window — read-only when the source is + a commit; editable when it's the index, with `:w` writing + back via the usual STAGE-0 path (same workflow as other + index buffers in diffview). + + Applies to both diff views and file history views, and to + `diff1_plain` and Diff2 base layouts. Has no effect on + `diff1_inline` (which already renders one-sided content coherently + as all-added or all-deleted virt_lines), on renamed/modified files + or merge conflicts, or when file history's `pin_local` mode owns + the right-hand window. + + `diff1_raw` is excluded from |diffview-config-view.cycle_layouts|: + cycling with |g| moves through your configured base layouts. + The cycled-to layout replaces the entry's layout in place, so + re-selecting the same file simply reuses it; re-applying + `diff1_raw` requires a refresh that rebuilds the entry (see + |diffview-actions-refresh_files|). view.cycle_layouts *diffview-config-view.cycle_layouts* Type: `table`, Default: (see defaults) diff --git a/doc/diffview_defaults.txt b/doc/diffview_defaults.txt index 6dfb09ce..4c853ccb 100644 --- a/doc/diffview_defaults.txt +++ b/doc/diffview_defaults.txt @@ -82,10 +82,10 @@ DEFAULT CONFIG *diffview.defaults* pin_local = false, -- See |diffview-config-view.file_history.pin_local| }, foldlevel = 0, -- See |diffview-config-view.foldlevel| - -- See |diffview-config-view.single_pane_for_one_sided|. When true, + -- See |diffview-config-view.one_sided_layout|. When set to "raw", -- one-sided diffs (status A/?/D) open in a single non-diff window - -- instead of a Diff2 layout with an empty pane. - single_pane_for_one_sided = false, + -- (diff1_raw) instead of the configured Diff2 or diff1_plain layout. + one_sided_layout = "default", -- Layouts to cycle through with `cycle_layout` action. Each view's -- configured layout (e.g. view.default.layout) is automatically -- appended to its cycle if missing, so cycling always returns to it. diff --git a/lua/diffview/config.lua b/lua/diffview/config.lua index 294d70b0..e2341677 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -46,6 +46,7 @@ end ---@alias DiffviewStandardLayout "diff1_plain"|"diff1_inline"|"diff2_horizontal"|"diff2_vertical" ---@alias DiffviewMergeLayout "diff1_plain"|"diff3_horizontal"|"diff3_vertical"|"diff3_mixed"|"diff4_mixed" ---@alias DiffviewInferredLayout -1 +---@alias DiffviewOneSidedLayout "default"|"raw" -- Targets consumed by action factories in `actions.lua` (referenced from keymaps). ---@alias DiffviewConflictTarget "ours"|"theirs"|"base"|"all"|"none" @@ -289,7 +290,7 @@ M.defaults = { ---@field merge_tool DiffviewMergeViewTypeConfig ---@field file_history DiffviewStandardViewTypeConfig ---@field foldlevel integer - ---@field single_pane_for_one_sided boolean + ---@field one_sided_layout DiffviewOneSidedLayout ---@field cycle_layouts DiffviewCycleLayouts ---@field inline DiffviewInlineConfig @@ -298,7 +299,7 @@ M.defaults = { ---@field merge_tool? DiffviewMergeViewTypeConfig.user Config for conflicted files in diff views during a merge or rebase. ---@field file_history? DiffviewStandardViewTypeConfig.user Config for changed files in file history views. ---@field foldlevel? integer See `|diffview-config-view.foldlevel|`. - ---@field single_pane_for_one_sided? boolean When true, files whose diff is one-sided (status `A`/`?` or `D`) open in a single non-diff window instead of a Diff2 layout with an empty pane. `A`/`?` shows the b-side directly: editable working-tree buffer when b is `LOCAL` (the usual case in diff views), read-only when b is a commit rev (the usual case in file-history views). `D` shows the pre-deletion content from the index/commit in a read-only window. Applies to both diff views and file history views. Has no effect for renames, modifications, or merge conflicts. See `|diffview-config-view.single_pane_for_one_sided|`. + ---@field one_sided_layout? DiffviewOneSidedLayout Layout used for files whose diff is one-sided (status `A`/`?` or `D`). `"default"` keeps the configured layout (a Diff2 leaves an empty pane; a `diff1_plain` keeps its diff-mode chrome). `"raw"` substitutes `diff1_raw`: a single non-diff window where `A`/`?` shows the b-side directly (editable working-tree buffer when b is `LOCAL`, read-only when b is a commit rev) and `D` shows the pre-deletion content from `revs.a` (read-only when that's a commit; editable when it's the index, with `:w` writing back via the usual STAGE-0 path). Applies to both diff views and file history views, and to `diff1_plain` and Diff2 base layouts. Has no effect on `diff1_inline` (which already renders one-sided content coherently), on renames, modifications, merge conflicts, or when file history's `pin_local` mode owns the right-hand window. See `|diffview-config-view.one_sided_layout|`. ---@field cycle_layouts? DiffviewCycleLayouts.user Layouts to cycle through with `cycle_layout`. ---@field inline? DiffviewInlineConfig.user Options that apply to the `diff1_inline` layout. view = { @@ -351,13 +352,16 @@ M.defaults = { -- Initial 'foldlevel' for diff buffers. Default 0 collapses unchanged -- regions; set to a high value (e.g. 99) to keep all folds open. foldlevel = 0, - -- When true, files whose diff is one-sided (added, untracked, or - -- deleted) open in a single non-diff window. Working-tree additions - -- and untracked files open as editable buffers backed by the file on - -- disk; deletions show the pre-deletion content from the index or - -- commit in a single read-only window. Has no effect for renames, - -- modifications, or merge conflicts. - single_pane_for_one_sided = false, + -- Layout used for files whose diff is one-sided (added, untracked, or + -- deleted). `"default"` keeps the configured layout. `"raw"` substitutes + -- `diff1_raw`: a single non-diff window where additions and untracked + -- files open as editable buffers backed by the file on disk, and + -- deletions show the pre-deletion content from `revs.a` (read-only + -- against a commit; editable against the index, with `:w` writing + -- back via the usual STAGE-0 path). Applies to `diff1_plain` and + -- Diff2 base layouts; `diff1_inline` (which renders one-sided content + -- as all-added or all-deleted virt_lines) is left alone. + one_sided_layout = "default", ---@class DiffviewCycleLayouts ---@field default DiffviewStandardLayout[] @@ -1254,6 +1258,19 @@ function M.setup(user_config) end end + local valid_one_sided_layouts = { "default", "raw" } + if view.one_sided_layout == nil then + view.one_sided_layout = M.defaults.view.one_sided_layout + elseif not vim.tbl_contains(valid_one_sided_layouts, view.one_sided_layout) then + utils.err( + ("Invalid value '%s' for 'view.one_sided_layout'! Must be one of (%s)."):format( + view.one_sided_layout, + fmt_enum(valid_one_sided_layouts) + ) + ) + view.one_sided_layout = M.defaults.view.one_sided_layout + end + -- Validate `view.inline`. A nil style (e.g. user passed `view.inline = {}`) -- silently falls back to the default; only an explicit invalid value errors. -- Reject non-table values (mirrors the `view.cycle_layouts` guard above). diff --git a/lua/diffview/scene/file_entry.lua b/lua/diffview/scene/file_entry.lua index 00f693ee..88f3426b 100644 --- a/lua/diffview/scene/file_entry.lua +++ b/lua/diffview/scene/file_entry.lua @@ -2,6 +2,7 @@ local lazy = require("diffview.lazy") local oop = require("diffview.oop") local Diff1 = lazy.access("diffview.scene.layouts.diff_1", "Diff1") ---@type Diff1|LazyModule +local Diff1Inline = lazy.access("diffview.scene.layouts.diff_1_inline", "Diff1Inline") ---@type Diff1Inline|LazyModule local Diff1Raw = lazy.access("diffview.scene.layouts.diff_1_raw", "Diff1Raw") ---@type Diff1Raw|LazyModule local Diff2 = lazy.access("diffview.scene.layouts.diff_2", "Diff2") ---@type Diff2|LazyModule local File = lazy.access("diffview.vcs.file", "File") ---@type vcs.File|LazyModule @@ -299,16 +300,18 @@ local function class_descends_from(cls, target) end ---Pick the effective layout class for an entry. Substitutes `Diff1Raw` for a ----Diff2 when `view.single_pane_for_one_sided` is on and the file's diff is ----one-sided (status `A`/`?`/`D`). Falls through (returns the input class) ----when the precondition isn't met, leaving every other layout path untouched. ----Bails out for pinned-b mode (the view owns the b-side) and for non-Diff2 ----inputs (merge layouts, user-set `diff1_*` layouts, etc.). +---Diff1 or Diff2 base when `view.one_sided_layout` is `"raw"` and the file's +---diff is one-sided (status `A`/`?`/`D`). Falls through (returns the input +---class) when the precondition isn't met, leaving every other layout path +---untouched. Bails out for pinned-b mode (the view owns the b-side), for +---`diff1_inline` (which already renders one-sided content coherently as +---all-added or all-deleted virt_lines), for `diff1_raw` itself (no-op), and +---for merge layouts (Diff3/Diff4). ---@param default_class Layout (class) ---@param opt FileEntry.with_layout.Opt ---@return Layout (class) local function select_layout_for_status(default_class, opt) - if not config.get_config().view.single_pane_for_one_sided then + if config.get_config().view.one_sided_layout ~= "raw" then return default_class end if opt.pinned_b_file then @@ -317,10 +320,19 @@ local function select_layout_for_status(default_class, opt) if not vim.tbl_contains({ "A", "?", "D" }, opt.status) then return default_class end - if not class_descends_from(default_class, Diff2.__get()) then + if + class_descends_from(default_class, Diff1Inline.__get()) + or class_descends_from(default_class, Diff1Raw.__get()) + then return default_class end - return Diff1Raw.__get() + if + class_descends_from(default_class, Diff1.__get()) + or class_descends_from(default_class, Diff2.__get()) + then + return Diff1Raw.__get() + end + return default_class end ---@param layout_class Layout (class) diff --git a/lua/diffview/scene/layouts/diff_1_raw.lua b/lua/diffview/scene/layouts/diff_1_raw.lua index e8bc3163..f37a614f 100644 --- a/lua/diffview/scene/layouts/diff_1_raw.lua +++ b/lua/diffview/scene/layouts/diff_1_raw.lua @@ -5,7 +5,7 @@ local oop = require("diffview.oop") local M = {} ---Single-window layout that shows the file as a plain (non-diff) buffer. Used ----by `view.single_pane_for_one_sided` when a file's diff would be one-sided +---by `view.one_sided_layout = "raw"` when a file's diff would be one-sided ---(status `A`/`?` or `D`). Window opts disabling `diff`, `scrollbind`, and ---diff folding are merged in by `StandardView` via the `diff1_raw` winopts ---key; the layout itself just wires the single `b` window. diff --git a/lua/diffview/tests/functional/single_pane_for_one_sided_spec.lua b/lua/diffview/tests/functional/one_sided_layout_spec.lua similarity index 67% rename from lua/diffview/tests/functional/single_pane_for_one_sided_spec.lua rename to lua/diffview/tests/functional/one_sided_layout_spec.lua index 1a979043..b0437748 100644 --- a/lua/diffview/tests/functional/single_pane_for_one_sided_spec.lua +++ b/lua/diffview/tests/functional/one_sided_layout_spec.lua @@ -1,4 +1,6 @@ local FileEntry = require("diffview.scene.file_entry").FileEntry +local Diff1 = require("diffview.scene.layouts.diff_1").Diff1 +local Diff1Inline = require("diffview.scene.layouts.diff_1_inline").Diff1Inline local Diff1Raw = require("diffview.scene.layouts.diff_1_raw").Diff1Raw local Diff2Hor = require("diffview.scene.layouts.diff_2_hor").Diff2Hor local RevType = require("diffview.vcs.rev").RevType @@ -39,7 +41,7 @@ local function make_entry(status, opts) }) end -describe("view.single_pane_for_one_sided", function() +describe("view.one_sided_layout", function() local original before_each(function() @@ -51,18 +53,18 @@ describe("view.single_pane_for_one_sided", function() end) describe("config schema", function() - it("defaults to false", function() + it('defaults to "default"', function() config.setup({}) - assert.is_false(config.get_config().view.single_pane_for_one_sided) + assert.equals("default", config.get_config().view.one_sided_layout) end) - it("can be set to true via user config", function() - config.setup({ view = { single_pane_for_one_sided = true } }) - assert.is_true(config.get_config().view.single_pane_for_one_sided) + it('can be set to "raw" via user config', function() + config.setup({ view = { one_sided_layout = "raw" } }) + assert.equals("raw", config.get_config().view.one_sided_layout) end) it("does not affect unrelated view options when toggled", function() - config.setup({ view = { single_pane_for_one_sided = true } }) + config.setup({ view = { one_sided_layout = "raw" } }) local conf = config.get_config() assert.equals("diff2_horizontal", conf.view.default.layout) assert.equals(0, conf.view.foldlevel) @@ -70,44 +72,64 @@ describe("view.single_pane_for_one_sided", function() end) describe("FileEntry.with_layout layout selection", function() - it("falls through to the default layout when the option is off", function() + it('falls through to the default layout when option is "default"', function() config.setup({}) local entry = make_entry("A") assert.equals(Diff2Hor, entry.layout.class) end) - it("substitutes Diff1Raw for an added (A) file when enabled", function() - config.setup({ view = { single_pane_for_one_sided = true } }) + it('substitutes Diff1Raw for an added (A) Diff2 entry when "raw"', function() + config.setup({ view = { one_sided_layout = "raw" } }) local entry = make_entry("A") assert.equals(Diff1Raw, entry.layout.class) end) - it("substitutes Diff1Raw for an untracked (?) file when enabled", function() - config.setup({ view = { single_pane_for_one_sided = true } }) + it('substitutes Diff1Raw for an untracked (?) Diff2 entry when "raw"', function() + config.setup({ view = { one_sided_layout = "raw" } }) local entry = make_entry("?") assert.equals(Diff1Raw, entry.layout.class) end) - it("substitutes Diff1Raw for a deleted (D) file when enabled", function() - config.setup({ view = { single_pane_for_one_sided = true } }) + it('substitutes Diff1Raw for a deleted (D) Diff2 entry when "raw"', function() + config.setup({ view = { one_sided_layout = "raw" } }) local entry = make_entry("D") assert.equals(Diff1Raw, entry.layout.class) end) + it('substitutes Diff1Raw for an added (A) Diff1 entry when "raw"', function() + config.setup({ view = { one_sided_layout = "raw" } }) + local entry = make_entry("A", { layout_class = Diff1 }) + assert.equals(Diff1Raw, entry.layout.class) + end) + + it('substitutes Diff1Raw for a deleted (D) Diff1 entry when "raw"', function() + -- The substitution is more than cosmetic for status D: Diff1.should_null + -- nulls the b-side, but Diff1Raw shows pre-deletion content from revs.a. + config.setup({ view = { one_sided_layout = "raw" } }) + local entry = make_entry("D", { layout_class = Diff1 }) + assert.equals(Diff1Raw, entry.layout.class) + end) + + it("leaves Diff1Inline entries alone (coherent one-sided rendering)", function() + config.setup({ view = { one_sided_layout = "raw" } }) + local entry = make_entry("A", { layout_class = Diff1Inline }) + assert.equals(Diff1Inline, entry.layout.class) + end) + it("leaves modified (M) files on the default Diff2 layout", function() - config.setup({ view = { single_pane_for_one_sided = true } }) + config.setup({ view = { one_sided_layout = "raw" } }) local entry = make_entry("M") assert.equals(Diff2Hor, entry.layout.class) end) it("leaves renamed (R) files on the default Diff2 layout", function() - config.setup({ view = { single_pane_for_one_sided = true } }) + config.setup({ view = { one_sided_layout = "raw" } }) local entry = make_entry("R", { oldpath = "old.txt" }) assert.equals(Diff2Hor, entry.layout.class) end) it("leaves pinned_b_file entries on the pin-aware Diff2 layout", function() - config.setup({ view = { single_pane_for_one_sided = true } }) + config.setup({ view = { one_sided_layout = "raw" } }) local Diff2HorPinned = require("diffview.scene.layouts.diff_2_hor_pinned").Diff2HorPinned local rev_b = local_rev() local shared = { @@ -125,13 +147,13 @@ describe("view.single_pane_for_one_sided", function() describe("b-side rev substitution", function() it("uses the LOCAL b-rev for added files (editable on-disk buffer)", function() - config.setup({ view = { single_pane_for_one_sided = true } }) + config.setup({ view = { one_sided_layout = "raw" } }) local entry = make_entry("A") assert.equals(RevType.LOCAL, entry.layout.b.file.rev.type) end) it("swaps in revs.a for deleted files (pre-deletion content)", function() - config.setup({ view = { single_pane_for_one_sided = true } }) + config.setup({ view = { one_sided_layout = "raw" } }) local rev_a = commit_rev() local entry = make_entry("D", { revs = { a = rev_a, b = local_rev() } }) assert.equals(rev_a, entry.layout.b.file.rev) @@ -139,7 +161,7 @@ describe("view.single_pane_for_one_sided", function() it("drops the unwindowed a-side File when the b-side is substituted", function() -- Avoids fetching the same scratch content twice. - config.setup({ view = { single_pane_for_one_sided = true } }) + config.setup({ view = { one_sided_layout = "raw" } }) local entry = make_entry("D") assert.is_nil(entry.layout.a_file) end) @@ -147,7 +169,7 @@ describe("view.single_pane_for_one_sided", function() it("keeps the unwindowed a-side File for non-substituted Diff1Raw entries", function() -- Needed by `convert_layout` to round-trip back to Diff2 without -- losing the COMMIT-side file metadata. - config.setup({ view = { single_pane_for_one_sided = true } }) + config.setup({ view = { one_sided_layout = "raw" } }) local entry = make_entry("A") assert.is_not_nil(entry.layout.a_file) end) @@ -155,7 +177,7 @@ describe("view.single_pane_for_one_sided", function() describe("Diff1Raw layout shape", function() it("owned_files includes the unwindowed a_file", function() - config.setup({ view = { single_pane_for_one_sided = true } }) + config.setup({ view = { one_sided_layout = "raw" } }) local entry = make_entry("A") local owned = entry.layout:owned_files() assert.is_true(vim.tbl_contains(owned, entry.layout.a_file)) @@ -163,7 +185,7 @@ describe("view.single_pane_for_one_sided", function() end) it("get_file_for('a') returns the unwindowed a_file", function() - config.setup({ view = { single_pane_for_one_sided = true } }) + config.setup({ view = { one_sided_layout = "raw" } }) local entry = make_entry("A") assert.equals(entry.layout.a_file, entry.layout:get_file_for("a")) end) @@ -172,13 +194,13 @@ describe("view.single_pane_for_one_sided", function() -- convert_layout's fallback rebuilds a natural b-side with the right -- nulled flag instead of carrying the substituted COMMIT-rev File -- into a Diff2's b-slot. - config.setup({ view = { single_pane_for_one_sided = true } }) + config.setup({ view = { one_sided_layout = "raw" } }) local entry = make_entry("D") assert.is_nil(entry.layout:get_file_for("b")) end) it("get_file_for('b') delegates to the base when not substituted", function() - config.setup({ view = { single_pane_for_one_sided = true } }) + config.setup({ view = { one_sided_layout = "raw" } }) local entry = make_entry("A") assert.equals(entry.layout.b.file, entry.layout:get_file_for("b")) end)