Skip to content

Commit 81f09f4

Browse files
authored
Merge pull request #36 from mhiro2/docs/persist-privacy-and-truncated-viewport
patch: Document persist privacy and surface copy-mode viewport truncation
2 parents d4c5809 + 6eeacfc commit 81f09f4

9 files changed

Lines changed: 318 additions & 4 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,14 @@ for a name instead of using the default.
331331
> Persistence uses repository storage when the current working directory is inside a git repository.
332332
> Outside a git repository, sessions fall back to cwd-based storage.
333333
334+
> [!IMPORTANT]
335+
> Sessions are written as plain JSON under `vim.fn.stdpath("state") .. "/peekstack/"`. Each entry
336+
> stores the file URI, line/column range, title, provider name, pin/buffer-mode flags, parent
337+
> popup id, and the timestamp the entry was captured. Each session also tracks `created_at` and
338+
> `updated_at` metadata. Any path you peek at while persistence is enabled is recorded on disk in
339+
> cleartext, so avoid enabling persistence on shared machines or for repositories whose file paths
340+
> or symbol names are sensitive.
341+
334342
### Auto persist (optional)
335343

336344
When `persist.auto.enabled = true`, peekstack can automatically restore and save a session:

doc/peekstack.txt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,17 @@ Auto persistence (`persist.auto`) requires `persist.enabled = true`:
449449
Auto persistence runs only inside a git repository and always uses
450450
the repository session storage.
451451

452+
PRIVACY NOTE *peekstack-persist-privacy*
453+
454+
Sessions are written as plain JSON under
455+
`vim.fn.stdpath("state") .. "/peekstack/"`. Each entry stores the file
456+
URI, line/column range, title, provider name, pin/buffer-mode flags,
457+
parent popup id, and the timestamp the entry was captured. Each session
458+
also tracks `created_at` and `updated_at` metadata. Any path you peek at
459+
while persistence is enabled is recorded on disk in cleartext, so avoid
460+
enabling persistence on shared machines or for repositories whose file
461+
paths or symbol names are sensitive.
462+
452463
==============================================================================
453464
SETUP RELOAD *peekstack-setup-reload*
454465

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)