Merge pull request #74 from barrettruth/feat/vscode-diff
feat(config): replace algorithm 'auto'/'native' with 'default'/'vscode'
This commit is contained in:
commit
b6f1c5b749
6 changed files with 90 additions and 47 deletions
|
|
@ -17,6 +17,7 @@ syntax highlighting.
|
||||||
- Background-only diff colors for any `&diff` buffer (`:diffthis`, `vimdiff`)
|
- Background-only diff colors for any `&diff` buffer (`:diffthis`, `vimdiff`)
|
||||||
- Vim syntax fallback for languages without a treesitter parser
|
- Vim syntax fallback for languages without a treesitter parser
|
||||||
- Hunk header context highlighting (`@@ ... @@ function foo()`)
|
- Hunk header context highlighting (`@@ ... @@ function foo()`)
|
||||||
|
- Character-level (intra-line) diff highlighting for changed characters
|
||||||
- Configurable debouncing, max lines, and diff prefix concealment
|
- Configurable debouncing, max lines, and diff prefix concealment
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
|
||||||
},
|
},
|
||||||
intra = {
|
intra = {
|
||||||
enabled = true,
|
enabled = true,
|
||||||
algorithm = 'auto',
|
algorithm = 'default',
|
||||||
max_lines = 500,
|
max_lines = 500,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -158,12 +158,12 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
|
||||||
an intense background overlay while the rest of the
|
an intense background overlay while the rest of the
|
||||||
line keeps the softer line-level background.
|
line keeps the softer line-level background.
|
||||||
|
|
||||||
{algorithm} (string, default: 'auto')
|
{algorithm} (string, default: 'default')
|
||||||
Diff algorithm for character-level analysis.
|
Diff algorithm for character-level analysis.
|
||||||
`'auto'`: use libvscodediff if available, else
|
`'default'`: use |vim.diff()| with settings
|
||||||
native `vim.diff()`. `'native'`: always use
|
inherited from |'diffopt'| (`algorithm` and
|
||||||
`vim.diff()`. `'vscode'`: require libvscodediff
|
`linematch`). `'vscode'`: use libvscodediff FFI
|
||||||
(falls back to native if not available).
|
(falls back to default if not available).
|
||||||
|
|
||||||
{max_lines} (integer, default: 500)
|
{max_lines} (integer, default: 500)
|
||||||
Skip character-level highlighting for hunks larger
|
Skip character-level highlighting for hunks larger
|
||||||
|
|
@ -285,8 +285,8 @@ Summary / commit detail views: ~
|
||||||
- Code is parsed with |vim.treesitter.get_string_parser()|
|
- Code is parsed with |vim.treesitter.get_string_parser()|
|
||||||
- If no treesitter parser and `vim.enabled`: vim syntax fallback via
|
- If no treesitter parser and `vim.enabled`: vim syntax fallback via
|
||||||
scratch buffer and |synID()|
|
scratch buffer and |synID()|
|
||||||
- Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 198
|
- `Normal` extmarks at priority 198 clear underlying diff foreground
|
||||||
- `Normal` extmarks at priority 199 clear underlying diff foreground
|
- Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 199
|
||||||
- Syntax highlights are applied as extmarks at priority 200
|
- Syntax highlights are applied as extmarks at priority 200
|
||||||
- Character-level diff extmarks (`DiffsAddText`/`DiffsDeleteText`) at
|
- Character-level diff extmarks (`DiffsAddText`/`DiffsDeleteText`) at
|
||||||
priority 201 overlay changed characters with an intense background
|
priority 201 overlay changed characters with an intense background
|
||||||
|
|
@ -383,15 +383,14 @@ Fugitive unified diff highlights: ~
|
||||||
*DiffsAddText*
|
*DiffsAddText*
|
||||||
DiffsAddText Character-level background for changed characters
|
DiffsAddText Character-level background for changed characters
|
||||||
within `+` lines. Derived by blending `diffAdded`
|
within `+` lines. Derived by blending `diffAdded`
|
||||||
foreground with `Normal` background at 40% alpha.
|
foreground with `Normal` background at 70% alpha.
|
||||||
Uses the same base color as `DiffsAddNr` foreground,
|
Only sets `bg`, so treesitter foreground colors show
|
||||||
making changed characters clearly visible. Only sets
|
through.
|
||||||
`bg`, so treesitter foreground colors show through.
|
|
||||||
|
|
||||||
*DiffsDeleteText*
|
*DiffsDeleteText*
|
||||||
DiffsDeleteText Character-level background for changed characters
|
DiffsDeleteText Character-level background for changed characters
|
||||||
within `-` lines. Derived by blending `diffRemoved`
|
within `-` lines. Derived by blending `diffRemoved`
|
||||||
foreground with `Normal` background at 40% alpha.
|
foreground with `Normal` background at 70% alpha.
|
||||||
|
|
||||||
Diff mode window highlights: ~
|
Diff mode window highlights: ~
|
||||||
These are used for |winhighlight| remapping in `&diff` windows.
|
These are used for |winhighlight| remapping in `&diff` windows.
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@
|
||||||
---@field del_lines {idx: integer, text: string}[]
|
---@field del_lines {idx: integer, text: string}[]
|
||||||
---@field add_lines {idx: integer, text: string}[]
|
---@field add_lines {idx: integer, text: string}[]
|
||||||
|
|
||||||
|
---@class diffs.DiffOpts
|
||||||
|
---@field algorithm? string
|
||||||
|
---@field linematch? integer
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
local dbg = require('diffs.log').dbg
|
local dbg = require('diffs.log').dbg
|
||||||
|
|
@ -60,11 +64,35 @@ function M.extract_change_groups(hunk_lines)
|
||||||
return groups
|
return groups
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@return diffs.DiffOpts
|
||||||
|
local function parse_diffopt()
|
||||||
|
local opts = {}
|
||||||
|
for _, item in ipairs(vim.split(vim.o.diffopt, ',')) do
|
||||||
|
local key, val = item:match('^(%w+):(.+)$')
|
||||||
|
if key == 'algorithm' then
|
||||||
|
opts.algorithm = val
|
||||||
|
elseif key == 'linematch' then
|
||||||
|
opts.linematch = tonumber(val)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return opts
|
||||||
|
end
|
||||||
|
|
||||||
---@param old_text string
|
---@param old_text string
|
||||||
---@param new_text string
|
---@param new_text string
|
||||||
|
---@param diff_opts? diffs.DiffOpts
|
||||||
---@return {old_start: integer, old_count: integer, new_start: integer, new_count: integer}[]
|
---@return {old_start: integer, old_count: integer, new_start: integer, new_count: integer}[]
|
||||||
local function byte_diff(old_text, new_text)
|
local function byte_diff(old_text, new_text, diff_opts)
|
||||||
local ok, result = pcall(vim.diff, old_text, new_text, { result_type = 'indices' })
|
local vim_opts = { result_type = 'indices' }
|
||||||
|
if diff_opts then
|
||||||
|
if diff_opts.algorithm then
|
||||||
|
vim_opts.algorithm = diff_opts.algorithm
|
||||||
|
end
|
||||||
|
if diff_opts.linematch then
|
||||||
|
vim_opts.linematch = diff_opts.linematch
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local ok, result = pcall(vim.diff, old_text, new_text, vim_opts)
|
||||||
if not ok or not result then
|
if not ok or not result then
|
||||||
return {}
|
return {}
|
||||||
end
|
end
|
||||||
|
|
@ -95,8 +123,9 @@ end
|
||||||
---@param new_line string
|
---@param new_line string
|
||||||
---@param del_idx integer
|
---@param del_idx integer
|
||||||
---@param add_idx integer
|
---@param add_idx integer
|
||||||
|
---@param diff_opts? diffs.DiffOpts
|
||||||
---@return diffs.CharSpan[], diffs.CharSpan[]
|
---@return diffs.CharSpan[], diffs.CharSpan[]
|
||||||
local function char_diff_pair(old_line, new_line, del_idx, add_idx)
|
local function char_diff_pair(old_line, new_line, del_idx, add_idx, diff_opts)
|
||||||
---@type diffs.CharSpan[]
|
---@type diffs.CharSpan[]
|
||||||
local del_spans = {}
|
local del_spans = {}
|
||||||
---@type diffs.CharSpan[]
|
---@type diffs.CharSpan[]
|
||||||
|
|
@ -108,7 +137,7 @@ local function char_diff_pair(old_line, new_line, del_idx, add_idx)
|
||||||
local old_text = table.concat(old_bytes, '\n') .. '\n'
|
local old_text = table.concat(old_bytes, '\n') .. '\n'
|
||||||
local new_text = table.concat(new_bytes, '\n') .. '\n'
|
local new_text = table.concat(new_bytes, '\n') .. '\n'
|
||||||
|
|
||||||
local char_hunks = byte_diff(old_text, new_text)
|
local char_hunks = byte_diff(old_text, new_text, diff_opts)
|
||||||
|
|
||||||
for _, ch in ipairs(char_hunks) do
|
for _, ch in ipairs(char_hunks) do
|
||||||
if ch.old_count > 0 then
|
if ch.old_count > 0 then
|
||||||
|
|
@ -132,8 +161,9 @@ local function char_diff_pair(old_line, new_line, del_idx, add_idx)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param group diffs.ChangeGroup
|
---@param group diffs.ChangeGroup
|
||||||
|
---@param diff_opts? diffs.DiffOpts
|
||||||
---@return diffs.CharSpan[], diffs.CharSpan[]
|
---@return diffs.CharSpan[], diffs.CharSpan[]
|
||||||
local function diff_group_native(group)
|
local function diff_group_native(group, diff_opts)
|
||||||
---@type diffs.CharSpan[]
|
---@type diffs.CharSpan[]
|
||||||
local all_del = {}
|
local all_del = {}
|
||||||
---@type diffs.CharSpan[]
|
---@type diffs.CharSpan[]
|
||||||
|
|
@ -147,7 +177,8 @@ local function diff_group_native(group)
|
||||||
group.del_lines[1].text,
|
group.del_lines[1].text,
|
||||||
group.add_lines[1].text,
|
group.add_lines[1].text,
|
||||||
group.del_lines[1].idx,
|
group.del_lines[1].idx,
|
||||||
group.add_lines[1].idx
|
group.add_lines[1].idx,
|
||||||
|
diff_opts
|
||||||
)
|
)
|
||||||
vim.list_extend(all_del, ds)
|
vim.list_extend(all_del, ds)
|
||||||
vim.list_extend(all_add, as)
|
vim.list_extend(all_add, as)
|
||||||
|
|
@ -166,7 +197,7 @@ local function diff_group_native(group)
|
||||||
local old_block = table.concat(old_texts, '\n') .. '\n'
|
local old_block = table.concat(old_texts, '\n') .. '\n'
|
||||||
local new_block = table.concat(new_texts, '\n') .. '\n'
|
local new_block = table.concat(new_texts, '\n') .. '\n'
|
||||||
|
|
||||||
local line_hunks = byte_diff(old_block, new_block)
|
local line_hunks = byte_diff(old_block, new_block, diff_opts)
|
||||||
|
|
||||||
---@type table<integer, integer>
|
---@type table<integer, integer>
|
||||||
local old_to_new = {}
|
local old_to_new = {}
|
||||||
|
|
@ -184,7 +215,8 @@ local function diff_group_native(group)
|
||||||
group.del_lines[old_i].text,
|
group.del_lines[old_i].text,
|
||||||
group.add_lines[new_i].text,
|
group.add_lines[new_i].text,
|
||||||
group.del_lines[old_i].idx,
|
group.del_lines[old_i].idx,
|
||||||
group.add_lines[new_i].idx
|
group.add_lines[new_i].idx,
|
||||||
|
diff_opts
|
||||||
)
|
)
|
||||||
vim.list_extend(all_del, ds)
|
vim.list_extend(all_del, ds)
|
||||||
vim.list_extend(all_add, as)
|
vim.list_extend(all_add, as)
|
||||||
|
|
@ -202,7 +234,8 @@ local function diff_group_native(group)
|
||||||
group.del_lines[oi].text,
|
group.del_lines[oi].text,
|
||||||
group.add_lines[ni].text,
|
group.add_lines[ni].text,
|
||||||
group.del_lines[oi].idx,
|
group.del_lines[oi].idx,
|
||||||
group.add_lines[ni].idx
|
group.add_lines[ni].idx,
|
||||||
|
diff_opts
|
||||||
)
|
)
|
||||||
vim.list_extend(all_del, ds)
|
vim.list_extend(all_del, ds)
|
||||||
vim.list_extend(all_add, as)
|
vim.list_extend(all_add, as)
|
||||||
|
|
@ -295,16 +328,26 @@ function M.compute_intra_hunks(hunk_lines, algorithm)
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
algorithm = algorithm or 'auto'
|
algorithm = algorithm or 'default'
|
||||||
|
|
||||||
local lib = require('diffs.lib')
|
|
||||||
local vscode_handle = nil
|
local vscode_handle = nil
|
||||||
if algorithm ~= 'native' then
|
if algorithm == 'vscode' then
|
||||||
vscode_handle = lib.load()
|
vscode_handle = require('diffs.lib').load()
|
||||||
|
if not vscode_handle then
|
||||||
|
dbg('vscode algorithm requested but library not available, falling back to default')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if algorithm == 'vscode' and not vscode_handle then
|
---@type diffs.DiffOpts?
|
||||||
dbg('vscode algorithm requested but library not available, falling back to native')
|
local diff_opts = nil
|
||||||
|
if not vscode_handle then
|
||||||
|
diff_opts = parse_diffopt()
|
||||||
|
if diff_opts.algorithm then
|
||||||
|
dbg('diffopt algorithm: %s', diff_opts.algorithm)
|
||||||
|
end
|
||||||
|
if diff_opts.linematch then
|
||||||
|
dbg('diffopt linematch: %d', diff_opts.linematch)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
---@type diffs.CharSpan[]
|
---@type diffs.CharSpan[]
|
||||||
|
|
@ -325,7 +368,7 @@ function M.compute_intra_hunks(hunk_lines, algorithm)
|
||||||
if vscode_handle then
|
if vscode_handle then
|
||||||
ds, as = diff_group_vscode(group, vscode_handle)
|
ds, as = diff_group_vscode(group, vscode_handle)
|
||||||
else
|
else
|
||||||
ds, as = diff_group_native(group)
|
ds, as = diff_group_native(group, diff_opts)
|
||||||
end
|
end
|
||||||
dbg('group %d result: %d del spans, %d add spans', gi, #ds, #as)
|
dbg('group %d result: %d del spans, %d add spans', gi, #ds, #as)
|
||||||
for _, s in ipairs(ds) do
|
for _, s in ipairs(ds) do
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ local default_config = {
|
||||||
},
|
},
|
||||||
intra = {
|
intra = {
|
||||||
enabled = true,
|
enabled = true,
|
||||||
algorithm = 'auto',
|
algorithm = 'default',
|
||||||
max_lines = 500,
|
max_lines = 500,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -259,9 +259,9 @@ local function init()
|
||||||
['highlights.intra.algorithm'] = {
|
['highlights.intra.algorithm'] = {
|
||||||
opts.highlights.intra.algorithm,
|
opts.highlights.intra.algorithm,
|
||||||
function(v)
|
function(v)
|
||||||
return v == nil or v == 'auto' or v == 'native' or v == 'vscode'
|
return v == nil or v == 'default' or v == 'vscode'
|
||||||
end,
|
end,
|
||||||
"'auto', 'native', or 'vscode'",
|
"'default' or 'vscode'",
|
||||||
},
|
},
|
||||||
['highlights.intra.max_lines'] = { opts.highlights.intra.max_lines, 'number', true },
|
['highlights.intra.max_lines'] = { opts.highlights.intra.max_lines, 'number', true },
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -73,17 +73,17 @@ describe('diff', function()
|
||||||
|
|
||||||
describe('compute_intra_hunks', function()
|
describe('compute_intra_hunks', function()
|
||||||
it('returns nil for all-addition hunks', function()
|
it('returns nil for all-addition hunks', function()
|
||||||
local result = diff.compute_intra_hunks({ '+line1', '+line2' }, 'native')
|
local result = diff.compute_intra_hunks({ '+line1', '+line2' }, 'default')
|
||||||
assert.is_nil(result)
|
assert.is_nil(result)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('returns nil for all-deletion hunks', function()
|
it('returns nil for all-deletion hunks', function()
|
||||||
local result = diff.compute_intra_hunks({ '-line1', '-line2' }, 'native')
|
local result = diff.compute_intra_hunks({ '-line1', '-line2' }, 'default')
|
||||||
assert.is_nil(result)
|
assert.is_nil(result)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('returns nil for context-only hunks', function()
|
it('returns nil for context-only hunks', function()
|
||||||
local result = diff.compute_intra_hunks({ ' line1', ' line2' }, 'native')
|
local result = diff.compute_intra_hunks({ ' line1', ' line2' }, 'default')
|
||||||
assert.is_nil(result)
|
assert.is_nil(result)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -91,7 +91,7 @@ describe('diff', function()
|
||||||
local result = diff.compute_intra_hunks({
|
local result = diff.compute_intra_hunks({
|
||||||
'-local x = 1',
|
'-local x = 1',
|
||||||
'+local x = 2',
|
'+local x = 2',
|
||||||
}, 'native')
|
}, 'default')
|
||||||
assert.is_not_nil(result)
|
assert.is_not_nil(result)
|
||||||
assert.is_true(#result.del_spans > 0)
|
assert.is_true(#result.del_spans > 0)
|
||||||
assert.is_true(#result.add_spans > 0)
|
assert.is_true(#result.add_spans > 0)
|
||||||
|
|
@ -101,7 +101,7 @@ describe('diff', function()
|
||||||
local result = diff.compute_intra_hunks({
|
local result = diff.compute_intra_hunks({
|
||||||
'-local x = 1',
|
'-local x = 1',
|
||||||
'+local x = 2',
|
'+local x = 2',
|
||||||
}, 'native')
|
}, 'default')
|
||||||
assert.is_not_nil(result)
|
assert.is_not_nil(result)
|
||||||
|
|
||||||
assert.are.equal(1, #result.del_spans)
|
assert.are.equal(1, #result.del_spans)
|
||||||
|
|
@ -121,7 +121,7 @@ describe('diff', function()
|
||||||
' local b = 3',
|
' local b = 3',
|
||||||
'-local c = 4',
|
'-local c = 4',
|
||||||
'+local c = 5',
|
'+local c = 5',
|
||||||
}, 'native')
|
}, 'default')
|
||||||
assert.is_not_nil(result)
|
assert.is_not_nil(result)
|
||||||
assert.is_true(#result.del_spans >= 2)
|
assert.is_true(#result.del_spans >= 2)
|
||||||
assert.is_true(#result.add_spans >= 2)
|
assert.is_true(#result.add_spans >= 2)
|
||||||
|
|
@ -132,7 +132,7 @@ describe('diff', function()
|
||||||
'-line one',
|
'-line one',
|
||||||
'-line two',
|
'-line two',
|
||||||
'+line combined',
|
'+line combined',
|
||||||
}, 'native')
|
}, 'default')
|
||||||
assert.is_not_nil(result)
|
assert.is_not_nil(result)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -140,7 +140,7 @@ describe('diff', function()
|
||||||
local result = diff.compute_intra_hunks({
|
local result = diff.compute_intra_hunks({
|
||||||
'-local x = "héllo"',
|
'-local x = "héllo"',
|
||||||
'+local x = "wörld"',
|
'+local x = "wörld"',
|
||||||
}, 'native')
|
}, 'default')
|
||||||
assert.is_not_nil(result)
|
assert.is_not_nil(result)
|
||||||
assert.is_true(#result.del_spans > 0)
|
assert.is_true(#result.del_spans > 0)
|
||||||
assert.is_true(#result.add_spans > 0)
|
assert.is_true(#result.add_spans > 0)
|
||||||
|
|
@ -150,7 +150,7 @@ describe('diff', function()
|
||||||
local result = diff.compute_intra_hunks({
|
local result = diff.compute_intra_hunks({
|
||||||
'-local x = 1',
|
'-local x = 1',
|
||||||
'+local x = 1',
|
'+local x = 1',
|
||||||
}, 'native')
|
}, 'default')
|
||||||
assert.is_nil(result)
|
assert.is_nil(result)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ describe('highlight', function()
|
||||||
},
|
},
|
||||||
intra = {
|
intra = {
|
||||||
enabled = false,
|
enabled = false,
|
||||||
algorithm = 'native',
|
algorithm = 'default',
|
||||||
max_lines = 500,
|
max_lines = 500,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -828,7 +828,7 @@ describe('highlight', function()
|
||||||
default_opts({
|
default_opts({
|
||||||
highlights = {
|
highlights = {
|
||||||
background = true,
|
background = true,
|
||||||
intra = { enabled = true, algorithm = 'native', max_lines = 500 },
|
intra = { enabled = true, algorithm = 'default', max_lines = 500 },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
@ -873,7 +873,7 @@ describe('highlight', function()
|
||||||
ns,
|
ns,
|
||||||
hunk,
|
hunk,
|
||||||
default_opts({
|
default_opts({
|
||||||
highlights = { intra = { enabled = true, algorithm = 'native', max_lines = 500 } },
|
highlights = { intra = { enabled = true, algorithm = 'default', max_lines = 500 } },
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -913,7 +913,7 @@ describe('highlight', function()
|
||||||
ns,
|
ns,
|
||||||
hunk,
|
hunk,
|
||||||
default_opts({
|
default_opts({
|
||||||
highlights = { intra = { enabled = false, algorithm = 'native', max_lines = 500 } },
|
highlights = { intra = { enabled = false, algorithm = 'default', max_lines = 500 } },
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -947,7 +947,7 @@ describe('highlight', function()
|
||||||
ns,
|
ns,
|
||||||
hunk,
|
hunk,
|
||||||
default_opts({
|
default_opts({
|
||||||
highlights = { intra = { enabled = true, algorithm = 'native', max_lines = 500 } },
|
highlights = { intra = { enabled = true, algorithm = 'default', max_lines = 500 } },
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -984,7 +984,7 @@ describe('highlight', function()
|
||||||
default_opts({
|
default_opts({
|
||||||
highlights = {
|
highlights = {
|
||||||
background = true,
|
background = true,
|
||||||
intra = { enabled = true, algorithm = 'native', max_lines = 500 },
|
intra = { enabled = true, algorithm = 'default', max_lines = 500 },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue