Skip to content

Commit 6eeacfc

Browse files
committed
feat(ui): surface copy-mode viewport truncation as virtual lines
Copy mode silently windows source buffers larger than 500 lines around the peek target, so users can think they are looking at a full file when significant portions are hidden. Track the skipped-before/after line counts on the popup model and decorate the buffer with virt_lines markers (linked to PeekstackViewportTruncated, default NonText) so the truncation is visible at the edges where it matters.
1 parent 7c5ecb1 commit 6eeacfc

7 files changed

Lines changed: 299 additions & 4 deletions

File tree

lua/peekstack/core/popup.lua

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ local origin = require("peekstack.core.popup.origin")
33
local window = require("peekstack.core.popup.window")
44
local diagnostics_ui = require("peekstack.ui.diagnostics")
55
local keymaps = require("peekstack.ui.keymaps")
6+
local viewport_ui = require("peekstack.ui.viewport")
67

78
local M = {}
89

@@ -55,6 +56,7 @@ function M.create(location, opts)
5556
pinned = false,
5657
buffer_mode = prepared.buffer_mode,
5758
line_offset = prepared.line_offset,
59+
viewport = prepared.viewport,
5860
created_at = os.time(),
5961
last_active_at = vim.uv.now(),
6062
ephemeral = opts.ephemeral or false,
@@ -67,6 +69,7 @@ function M.create(location, opts)
6769
vim.w[opened.winid].peekstack_popup_id = id
6870

6971
popup.diagnostics = diagnostics_ui.decorate(popup)
72+
popup.viewport_marks = viewport_ui.decorate(popup)
7073

7174
return popup
7275
end
@@ -87,6 +90,7 @@ function M.close(popup)
8790
-- leak into normal editing of the shared buffer.
8891
require("peekstack.ui.keymaps").remove_popup(popup)
8992
diagnostics_ui.clear(popup.diagnostics)
93+
viewport_ui.clear(popup.viewport_marks)
9094
if popup.winid and vim.api.nvim_win_is_valid(popup.winid) then
9195
vim.api.nvim_win_close(popup.winid, true)
9296
end

lua/peekstack/core/popup/buffer.lua

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@ local MAX_VIEWPORT_LINES = 500
1515
---@return integer start_line 0-indexed inclusive
1616
---@return integer end_line 0-indexed exclusive (-1 means all)
1717
---@return integer line_offset lines skipped from the start
18+
---@return integer total total line count of the source buffer
1819
function M.compute_viewport(source_bufnr, target_line)
1920
local total = vim.api.nvim_buf_line_count(source_bufnr)
2021
if total <= MAX_VIEWPORT_LINES then
21-
return 0, -1, 0
22+
return 0, -1, 0, total
2223
end
2324

2425
local half = math.floor(MAX_VIEWPORT_LINES / 2)
@@ -28,7 +29,7 @@ function M.compute_viewport(source_bufnr, target_line)
2829
start_line = math.max(0, end_line - MAX_VIEWPORT_LINES)
2930
end
3031

31-
return start_line, end_line, start_line
32+
return start_line, end_line, start_line, total
3233
end
3334

3435
---@param bufnr integer
@@ -59,7 +60,7 @@ end
5960

6061
---@param location PeekstackLocation
6162
---@param opts? table
62-
---@return { bufnr: integer, source_bufnr: integer, buffer_mode: "copy"|"source", line_offset: integer }?
63+
---@return { bufnr: integer, source_bufnr: integer, buffer_mode: "copy"|"source", line_offset: integer, viewport?: PeekstackPopupViewport }?
6364
function M.prepare(location, opts)
6465
local buffer_mode = M.resolve_buffer_mode(opts or {})
6566

@@ -95,7 +96,7 @@ function M.prepare(location, opts)
9596
vim.bo[bufnr].readonly = false
9697

9798
local target_line = location.range.start.line or 0
98-
local vp_start, vp_end, line_offset = M.compute_viewport(source_bufnr, target_line)
99+
local vp_start, vp_end, line_offset, total = M.compute_viewport(source_bufnr, target_line)
99100
local ok_lines, lines = pcall(vim.api.nvim_buf_get_lines, source_bufnr, vp_start, vp_end, false)
100101
if not ok_lines then
101102
notify.warn("Failed to read buffer contents: " .. fname)
@@ -106,11 +107,25 @@ function M.prepare(location, opts)
106107
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
107108
configure_popup_buffer(bufnr, source_bufnr, opts)
108109

110+
---@type PeekstackPopupViewport?
111+
local viewport = nil
112+
local effective_end = vp_end == -1 and total or vp_end
113+
local skipped_before = vp_start
114+
local skipped_after = math.max(0, total - effective_end)
115+
if skipped_before > 0 or skipped_after > 0 then
116+
viewport = {
117+
total = total,
118+
skipped_before = skipped_before,
119+
skipped_after = skipped_after,
120+
}
121+
end
122+
109123
return {
110124
bufnr = bufnr,
111125
source_bufnr = source_bufnr,
112126
buffer_mode = buffer_mode,
113127
line_offset = line_offset,
128+
viewport = viewport,
114129
}
115130
end
116131

lua/peekstack/highlights.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ local HIGHLIGHT_LINKS = {
1515
PeekstackStackViewEmpty = "Comment",
1616
PeekstackStackViewCursorLine = "CursorLine",
1717
PeekstackInlinePreview = "Comment",
18+
PeekstackViewportTruncated = "NonText",
1819
PeekstackTitleProvider = "Type",
1920
PeekstackTitlePath = "Directory",
2021
PeekstackTitleIcon = "Special",

lua/peekstack/types.lua

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,19 @@
3939
---@field items PeekstackSessionItem[]
4040
---@field meta PeekstackSessionMeta
4141

42+
---@class PeekstackPopupViewport
43+
---@field total integer total line count of the source buffer
44+
---@field skipped_before integer lines hidden above the popup viewport
45+
---@field skipped_after integer lines hidden below the popup viewport
46+
4247
---@class PeekstackPopupModel
4348
---@field id integer
4449
---@field bufnr integer
4550
---@field source_bufnr integer
4651
---@field winid integer?
4752
---@field location PeekstackLocation
4853
---@field diagnostics? PeekstackDiagnosticExtmarks
54+
---@field viewport_marks? PeekstackViewportExtmarks
4955
---@field origin { winid: integer, bufnr: integer, row: integer, col: integer }
5056
---@field origin_bufnr integer
5157
---@field origin_is_popup boolean
@@ -55,6 +61,7 @@
5561
---@field pinned boolean
5662
---@field buffer_mode "copy"|"source"
5763
---@field line_offset integer
64+
---@field viewport? PeekstackPopupViewport
5865
---@field created_at integer
5966
---@field last_active_at integer
6067
---@field ephemeral boolean

lua/peekstack/ui/viewport.lua

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
local M = {}
2+
3+
local NS = vim.api.nvim_create_namespace("peekstack_viewport")
4+
5+
---@class PeekstackViewportExtmarks
6+
---@field bufnr integer
7+
---@field ns integer
8+
---@field ids integer[]
9+
10+
---@param count integer
11+
---@param direction "above"|"below"
12+
---@return string
13+
local function format_marker(count, direction)
14+
local arrow = direction == "above" and "" or ""
15+
local label = direction == "above" and "earlier" or "later"
16+
local plural = count == 1 and "line" or "lines"
17+
return string.format("%s %d %s %s hidden", arrow, count, label, plural)
18+
end
19+
20+
---Decorate a popup buffer with virt_lines markers describing how many lines
21+
---are hidden above and below the visible viewport. Returns nil for popups
22+
---whose buffer reflects the full source (no truncation, source mode, etc.).
23+
---@param popup PeekstackPopupModel
24+
---@return PeekstackViewportExtmarks?
25+
function M.decorate(popup)
26+
if not popup or popup.buffer_mode ~= "copy" then
27+
return nil
28+
end
29+
30+
local viewport = popup.viewport
31+
if not viewport then
32+
return nil
33+
end
34+
35+
local bufnr = popup.bufnr
36+
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
37+
return nil
38+
end
39+
40+
local line_count = vim.api.nvim_buf_line_count(bufnr)
41+
if line_count == 0 then
42+
return nil
43+
end
44+
45+
local ids = {}
46+
47+
if viewport.skipped_before and viewport.skipped_before > 0 then
48+
local id = vim.api.nvim_buf_set_extmark(bufnr, NS, 0, 0, {
49+
virt_lines = { { { format_marker(viewport.skipped_before, "above"), "PeekstackViewportTruncated" } } },
50+
virt_lines_above = true,
51+
})
52+
ids[#ids + 1] = id
53+
end
54+
55+
if viewport.skipped_after and viewport.skipped_after > 0 then
56+
local last = line_count - 1
57+
local id = vim.api.nvim_buf_set_extmark(bufnr, NS, last, 0, {
58+
virt_lines = { { { format_marker(viewport.skipped_after, "below"), "PeekstackViewportTruncated" } } },
59+
})
60+
ids[#ids + 1] = id
61+
end
62+
63+
if #ids == 0 then
64+
return nil
65+
end
66+
67+
return { bufnr = bufnr, ns = NS, ids = ids }
68+
end
69+
70+
---@param marks PeekstackViewportExtmarks?
71+
function M.clear(marks)
72+
if not marks or not marks.bufnr then
73+
return
74+
end
75+
if not vim.api.nvim_buf_is_valid(marks.bufnr) then
76+
return
77+
end
78+
for _, id in ipairs(marks.ids or {}) do
79+
pcall(vim.api.nvim_buf_del_extmark, marks.bufnr, marks.ns, id)
80+
end
81+
end
82+
83+
return M

tests/popup_buffer_spec.lua

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,41 @@ describe("peekstack.core.popup.buffer", function()
8585
assert.equals(500, vim.api.nvim_buf_line_count(prepared.bufnr))
8686
assert.equals("line101", vim.api.nvim_buf_get_lines(prepared.bufnr, 0, 1, false)[1])
8787
assert.equals("line600", vim.api.nvim_buf_get_lines(prepared.bufnr, 499, 500, false)[1])
88+
assert.is_not_nil(prepared.viewport)
89+
assert.equals(600, prepared.viewport.total)
90+
assert.equals(100, prepared.viewport.skipped_before)
91+
assert.equals(0, prepared.viewport.skipped_after)
92+
end)
93+
94+
it("reports trailing skipped lines when the target sits near the start", function()
95+
local lines = {}
96+
for i = 1, 600 do
97+
lines[i] = "line" .. i
98+
end
99+
100+
local path = make_file(lines)
101+
local prepared = buffer.prepare(make_location(path, 10), { buffer_mode = "copy" })
102+
103+
assert.is_not_nil(prepared)
104+
temp_bufnrs[#temp_bufnrs + 1] = prepared.bufnr
105+
temp_bufnrs[#temp_bufnrs + 1] = prepared.source_bufnr
106+
107+
assert.equals(0, prepared.line_offset)
108+
assert.is_not_nil(prepared.viewport)
109+
assert.equals(600, prepared.viewport.total)
110+
assert.equals(0, prepared.viewport.skipped_before)
111+
assert.equals(100, prepared.viewport.skipped_after)
112+
end)
113+
114+
it("does not report a viewport when the source fits in copy mode", function()
115+
local path = make_file({ "alpha", "beta", "gamma" })
116+
local prepared = buffer.prepare(make_location(path, 1), { buffer_mode = "copy" })
117+
118+
assert.is_not_nil(prepared)
119+
temp_bufnrs[#temp_bufnrs + 1] = prepared.bufnr
120+
temp_bufnrs[#temp_bufnrs + 1] = prepared.source_bufnr
121+
122+
assert.is_nil(prepared.viewport)
88123
end)
89124

90125
it("reuses the source buffer in source mode", function()
@@ -98,5 +133,6 @@ describe("peekstack.core.popup.buffer", function()
98133
assert.equals(prepared.source_bufnr, prepared.bufnr)
99134
assert.equals(0, prepared.line_offset)
100135
assert.is_not.equals("nofile", vim.bo[prepared.bufnr].buftype)
136+
assert.is_nil(prepared.viewport)
101137
end)
102138
end)

0 commit comments

Comments
 (0)