detached ce725b2

refactor(copy): share content extent between selection and extraction

commit
ce725b2e96392431d16202e042ba36a1a7213ab2
author
brice · · 2026-10-05 07:12 UTC
parent
668edcc
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +68 -26
M internal/usecase/copy/document.go +20 -2
@@ -299,9 +299,9 @@ func (d *Document) rangeText(r CellRange, logicalEnd bool) (string, bool, bool)
299299 start = head.Col
300300
301301 bound := d.snapshot.Bound(r.Row)
302- soft := bound.Soft && !logicalEnd && end >= len(row)-1
302+ soft := bound.Soft && !logicalEnd && end >= d.contentEnd(r.Row)-1
303303 if soft {
304- end = min(end, min(max(bound.End, 0), len(row))-1)
304+ end = min(end, d.contentEnd(r.Row)-1)
305305 } else {
306306 end = lastContentCol(row, start, end)
307307 }
@@ -316,6 +316,24 @@ func (d *Document) rangeText(r CellRange, logicalEnd bool) (string, bool, bool)
316316 return d.cellsText(row, start, tail.Col), soft, true
317317 }
318318
319+// contentEnd returns the exclusive display-cell end of row's copyable content.
320+// Soft rows end at their logical bound; hard rows drop blank padding.
321+func (d *Document) contentEnd(row int) int {
322+ cells := d.Row(row)
323+ if len(cells) == 0 {
324+ return 0
325+ }
326+ bound := d.snapshot.Bound(row)
327+ if bound.Soft {
328+ return min(max(bound.End, 0), len(cells))
329+ }
330+ last := lastContentCol(cells, 0, len(cells)-1)
331+ if last < 0 {
332+ return 0
333+ }
334+ return glyphEnd(cells, last) + 1
335+}
336+
319337 // lastContentCol returns the rightmost column in [start, end] holding content,
320338 // or start-1 when the span is blank. Continuation cells belong to the glyph on
321339 // their left, so they never count on their own.
@@ -299,9 +299,9 @@ func (d *Document) rangeText(r CellRange, logicalEnd bool) (string, bool, bool)
299 start = head.Col299 start = head.Col
300 300
301 bound := d.snapshot.Bound(r.Row)301 bound := d.snapshot.Bound(r.Row)
302- soft := bound.Soft && !logicalEnd && end >= len(row)-1302+ soft := bound.Soft && !logicalEnd && end >= d.contentEnd(r.Row)-1
303 if soft {303 if soft {
304- end = min(end, min(max(bound.End, 0), len(row))-1)304+ end = min(end, d.contentEnd(r.Row)-1)
305 } else {305 } else {
306 end = lastContentCol(row, start, end)306 end = lastContentCol(row, start, end)
307 }307 }
@@ -316,6 +316,24 @@ func (d *Document) rangeText(r CellRange, logicalEnd bool) (string, bool, bool)
316 return d.cellsText(row, start, tail.Col), soft, true316 return d.cellsText(row, start, tail.Col), soft, true
317 }317 }
318 318
319+// contentEnd returns the exclusive display-cell end of row's copyable content.
320+// Soft rows end at their logical bound; hard rows drop blank padding.
321+func (d *Document) contentEnd(row int) int {
322+ cells := d.Row(row)
323+ if len(cells) == 0 {
324+ return 0
325+ }
326+ bound := d.snapshot.Bound(row)
327+ if bound.Soft {
328+ return min(max(bound.End, 0), len(cells))
329+ }
330+ last := lastContentCol(cells, 0, len(cells)-1)
331+ if last < 0 {
332+ return 0
333+ }
334+ return glyphEnd(cells, last) + 1
335+}
336+
319 // lastContentCol returns the rightmost column in [start, end] holding content,337 // lastContentCol returns the rightmost column in [start, end] holding content,
320 // or start-1 when the span is blank. Continuation cells belong to the glyph on338 // or start-1 when the span is blank. Continuation cells belong to the glyph on
321 // their left, so they never count on their own.339 // their left, so they never count on their own.
M internal/usecase/copy/document_test.go +48 -24
@@ -236,21 +236,45 @@ func TestDocumentExtractRanges(t *testing.T) {
236236 }
237237 }
238238
239-func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {
240- // pad renders s into a width-w row, blank-filled like a real terminal grid.
241- pad := func(s string, w int) []renderer.Cell {
242- cells := make([]renderer.Cell, w)
243- for i := range cells {
244- cells[i] = renderer.BlankCell()
245- }
246- for i, r := range []rune(s) {
247- if i >= w {
248- break
249- }
250- cells[i] = renderer.Cell{Rune: r}
239+// padRow blank-fills a terminal row before adding text.
240+func padRow(s string, w int) []renderer.Cell {
241+ cells := make([]renderer.Cell, w)
242+ for i := range cells {
243+ cells[i] = renderer.BlankCell()
244+ }
245+ for i, r := range []rune(s) {
246+ if i >= w {
247+ break
251248 }
252- return cells
249+ cells[i] = renderer.Cell{Rune: r}
253250 }
251+ return cells
252+}
253+
254+func TestDocumentContentEnd(t *testing.T) {
255+ tests := []struct {
256+ name string
257+ cells []renderer.Cell
258+ bounds []vt.LineBound
259+ want int
260+ }{
261+ {"hard short", padRow("ab", 8), []vt.LineBound{{End: 2}}, 2},
262+ {"inner spaces", padRow("a b", 8), []vt.LineBound{{End: 3}}, 3},
263+ {"blank", padRow("", 8), []vt.LineBound{{}}, 0},
264+ {"soft trailing spaces", padRow("abc ", 8), []vt.LineBound{{End: 8, Soft: true}}, 8},
265+ {"soft partial bound", padRow("abc", 8), []vt.LineBound{{End: 5, Soft: true}}, 5},
266+ {"wide glyph", []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, renderer.BlankCell(), renderer.BlankCell(), renderer.BlankCell()}, []vt.LineBound{{End: 3}}, 3},
267+ {"nil bounds", padRow("ab", 8), nil, 2},
268+ }
269+ for _, tt := range tests {
270+ t.Run(tt.name, func(t *testing.T) {
271+ doc := NewDocument(NewSnapshotFromLines([][]renderer.Cell{tt.cells}, tt.bounds, 8, 1), "")
272+ require.Equal(t, tt.want, doc.contentEnd(0))
273+ })
274+ }
275+}
276+
277+func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {
254278
255279 tests := []struct {
256280 name string
@@ -261,28 +285,28 @@ func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {
261285 }{
262286 {
263287 name: "hard row drops its padding",
264- rows: [][]renderer.Cell{pad("ab", 8)},
288+ rows: [][]renderer.Cell{padRow("ab", 8)},
265289 bounds: []vt.LineBound{{End: 2}},
266290 ranges: []CellRange{{Row: 0, Start: 0, End: 7}},
267291 want: "ab",
268292 },
269293 {
270294 name: "soft row joins without a newline",
271- rows: [][]renderer.Cell{pad("abcdefgh", 8), pad("ij", 8)},
295+ rows: [][]renderer.Cell{padRow("abcdefgh", 8), padRow("ij", 8)},
272296 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
273297 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
274298 want: "abcdefghij",
275299 },
276300 {
277301 name: "soft row keeps a real trailing space",
278- rows: [][]renderer.Cell{pad("abc ", 8), pad("de", 8)},
302+ rows: [][]renderer.Cell{padRow("abc ", 8), padRow("de", 8)},
279303 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
280304 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
281305 want: "abc de",
282306 },
283307 {
284308 name: "three consecutive soft rows",
285- rows: [][]renderer.Cell{pad("aaaa", 4), pad("bbbb", 4), pad("cccc", 4), pad("dd", 4)},
309+ rows: [][]renderer.Cell{padRow("aaaa", 4), padRow("bbbb", 4), padRow("cccc", 4), padRow("dd", 4)},
286310 bounds: []vt.LineBound{
287311 {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 2},
288312 },
@@ -294,35 +318,35 @@ func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {
294318 },
295319 {
296320 name: "hard rows keep their newline",
297- rows: [][]renderer.Cell{pad("ab", 8), pad("cd", 8)},
321+ rows: [][]renderer.Cell{padRow("ab", 8), padRow("cd", 8)},
298322 bounds: []vt.LineBound{{End: 2}, {End: 2}},
299323 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
300324 want: "ab\ncd",
301325 },
302326 {
303327 name: "sparse ranges do not join across a row gap",
304- rows: [][]renderer.Cell{pad("ab", 8), pad("ignored", 8), pad("cd", 8)},
328+ rows: [][]renderer.Cell{padRow("ab", 8), padRow("ignored", 8), padRow("cd", 8)},
305329 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 7}, {End: 2}},
306330 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}},
307331 want: "ab\ncd",
308332 },
309333 {
310334 name: "invalid trailing range does not prevent final emitted row trimming",
311- rows: [][]renderer.Cell{pad("ab", 8)},
335+ rows: [][]renderer.Cell{padRow("ab", 8)},
312336 bounds: []vt.LineBound{{End: 8, Soft: true}},
313337 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 99, Start: 0, End: 7}},
314338 want: "ab",
315339 },
316340 {
317341 name: "blank row survives as an empty line",
318- rows: [][]renderer.Cell{pad("ab", 8), pad("", 8), pad("cd", 8)},
342+ rows: [][]renderer.Cell{padRow("ab", 8), padRow("", 8), padRow("cd", 8)},
319343 bounds: []vt.LineBound{{End: 2}, {End: 0}, {End: 2}},
320344 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}},
321345 want: "ab\n\ncd",
322346 },
323347 {
324348 name: "the last row is trimmed even when soft",
325- rows: [][]renderer.Cell{pad("ab", 8), pad("cd ", 8)},
349+ rows: [][]renderer.Cell{padRow("ab", 8), padRow("cd ", 8)},
326350 bounds: []vt.LineBound{{End: 2}, {End: 8, Soft: true}},
327351 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
328352 want: "ab\ncd",
@@ -333,7 +357,7 @@ func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {
333357 // so End stops at 3 and column 3 is abandoned padding.
334358 rows: [][]renderer.Cell{
335359 {{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, renderer.BlankCell()},
336- pad("b", 4),
360+ padRow("b", 4),
337361 },
338362 bounds: []vt.LineBound{{End: 3, Soft: true}, {End: 1}},
339363 ranges: []CellRange{{Row: 0, Start: 0, End: 3}, {Row: 1, Start: 0, End: 3}},
@@ -341,7 +365,7 @@ func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {
341365 },
342366 {
343367 name: "selection stopping mid soft row",
344- rows: [][]renderer.Cell{pad("abcdefgh", 8), pad("ij", 8)},
368+ rows: [][]renderer.Cell{padRow("abcdefgh", 8), padRow("ij", 8)},
345369 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
346370 ranges: []CellRange{{Row: 0, Start: 2, End: 4}},
347371 want: "cde",
@@ -236,21 +236,45 @@ func TestDocumentExtractRanges(t *testing.T) {
236 }236 }
237 }237 }
238 238
239-func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {239+// padRow blank-fills a terminal row before adding text.
240- // pad renders s into a width-w row, blank-filled like a real terminal grid.240+func padRow(s string, w int) []renderer.Cell {
241- pad := func(s string, w int) []renderer.Cell {241+ cells := make([]renderer.Cell, w)
242- cells := make([]renderer.Cell, w)242+ for i := range cells {
243- for i := range cells {243+ cells[i] = renderer.BlankCell()
244- cells[i] = renderer.BlankCell()244+ }
245- }245+ for i, r := range []rune(s) {
246- for i, r := range []rune(s) {246+ if i >= w {
247- if i >= w {247+ break
248- break
249- }
250- cells[i] = renderer.Cell{Rune: r}
251 }248 }
252- return cells249+ cells[i] = renderer.Cell{Rune: r}
253 }250 }
251+ return cells
252+}
253+
254+func TestDocumentContentEnd(t *testing.T) {
255+ tests := []struct {
256+ name string
257+ cells []renderer.Cell
258+ bounds []vt.LineBound
259+ want int
260+ }{
261+ {"hard short", padRow("ab", 8), []vt.LineBound{{End: 2}}, 2},
262+ {"inner spaces", padRow("a b", 8), []vt.LineBound{{End: 3}}, 3},
263+ {"blank", padRow("", 8), []vt.LineBound{{}}, 0},
264+ {"soft trailing spaces", padRow("abc ", 8), []vt.LineBound{{End: 8, Soft: true}}, 8},
265+ {"soft partial bound", padRow("abc", 8), []vt.LineBound{{End: 5, Soft: true}}, 5},
266+ {"wide glyph", []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, renderer.BlankCell(), renderer.BlankCell(), renderer.BlankCell()}, []vt.LineBound{{End: 3}}, 3},
267+ {"nil bounds", padRow("ab", 8), nil, 2},
268+ }
269+ for _, tt := range tests {
270+ t.Run(tt.name, func(t *testing.T) {
271+ doc := NewDocument(NewSnapshotFromLines([][]renderer.Cell{tt.cells}, tt.bounds, 8, 1), "")
272+ require.Equal(t, tt.want, doc.contentEnd(0))
273+ })
274+ }
275+}
276+
277+func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {
254 278
255 tests := []struct {279 tests := []struct {
256 name string280 name string
@@ -261,28 +285,28 @@ func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {
261 }{285 }{
262 {286 {
263 name: "hard row drops its padding",287 name: "hard row drops its padding",
264- rows: [][]renderer.Cell{pad("ab", 8)},288+ rows: [][]renderer.Cell{padRow("ab", 8)},
265 bounds: []vt.LineBound{{End: 2}},289 bounds: []vt.LineBound{{End: 2}},
266 ranges: []CellRange{{Row: 0, Start: 0, End: 7}},290 ranges: []CellRange{{Row: 0, Start: 0, End: 7}},
267 want: "ab",291 want: "ab",
268 },292 },
269 {293 {
270 name: "soft row joins without a newline",294 name: "soft row joins without a newline",
271- rows: [][]renderer.Cell{pad("abcdefgh", 8), pad("ij", 8)},295+ rows: [][]renderer.Cell{padRow("abcdefgh", 8), padRow("ij", 8)},
272 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},296 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
273 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},297 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
274 want: "abcdefghij",298 want: "abcdefghij",
275 },299 },
276 {300 {
277 name: "soft row keeps a real trailing space",301 name: "soft row keeps a real trailing space",
278- rows: [][]renderer.Cell{pad("abc ", 8), pad("de", 8)},302+ rows: [][]renderer.Cell{padRow("abc ", 8), padRow("de", 8)},
279 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},303 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
280 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},304 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
281 want: "abc de",305 want: "abc de",
282 },306 },
283 {307 {
284 name: "three consecutive soft rows",308 name: "three consecutive soft rows",
285- rows: [][]renderer.Cell{pad("aaaa", 4), pad("bbbb", 4), pad("cccc", 4), pad("dd", 4)},309+ rows: [][]renderer.Cell{padRow("aaaa", 4), padRow("bbbb", 4), padRow("cccc", 4), padRow("dd", 4)},
286 bounds: []vt.LineBound{310 bounds: []vt.LineBound{
287 {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 2},311 {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 2},
288 },312 },
@@ -294,35 +318,35 @@ func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {
294 },318 },
295 {319 {
296 name: "hard rows keep their newline",320 name: "hard rows keep their newline",
297- rows: [][]renderer.Cell{pad("ab", 8), pad("cd", 8)},321+ rows: [][]renderer.Cell{padRow("ab", 8), padRow("cd", 8)},
298 bounds: []vt.LineBound{{End: 2}, {End: 2}},322 bounds: []vt.LineBound{{End: 2}, {End: 2}},
299 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},323 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
300 want: "ab\ncd",324 want: "ab\ncd",
301 },325 },
302 {326 {
303 name: "sparse ranges do not join across a row gap",327 name: "sparse ranges do not join across a row gap",
304- rows: [][]renderer.Cell{pad("ab", 8), pad("ignored", 8), pad("cd", 8)},328+ rows: [][]renderer.Cell{padRow("ab", 8), padRow("ignored", 8), padRow("cd", 8)},
305 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 7}, {End: 2}},329 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 7}, {End: 2}},
306 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}},330 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}},
307 want: "ab\ncd",331 want: "ab\ncd",
308 },332 },
309 {333 {
310 name: "invalid trailing range does not prevent final emitted row trimming",334 name: "invalid trailing range does not prevent final emitted row trimming",
311- rows: [][]renderer.Cell{pad("ab", 8)},335+ rows: [][]renderer.Cell{padRow("ab", 8)},
312 bounds: []vt.LineBound{{End: 8, Soft: true}},336 bounds: []vt.LineBound{{End: 8, Soft: true}},
313 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 99, Start: 0, End: 7}},337 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 99, Start: 0, End: 7}},
314 want: "ab",338 want: "ab",
315 },339 },
316 {340 {
317 name: "blank row survives as an empty line",341 name: "blank row survives as an empty line",
318- rows: [][]renderer.Cell{pad("ab", 8), pad("", 8), pad("cd", 8)},342+ rows: [][]renderer.Cell{padRow("ab", 8), padRow("", 8), padRow("cd", 8)},
319 bounds: []vt.LineBound{{End: 2}, {End: 0}, {End: 2}},343 bounds: []vt.LineBound{{End: 2}, {End: 0}, {End: 2}},
320 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}},344 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}},
321 want: "ab\n\ncd",345 want: "ab\n\ncd",
322 },346 },
323 {347 {
324 name: "the last row is trimmed even when soft",348 name: "the last row is trimmed even when soft",
325- rows: [][]renderer.Cell{pad("ab", 8), pad("cd ", 8)},349+ rows: [][]renderer.Cell{padRow("ab", 8), padRow("cd ", 8)},
326 bounds: []vt.LineBound{{End: 2}, {End: 8, Soft: true}},350 bounds: []vt.LineBound{{End: 2}, {End: 8, Soft: true}},
327 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},351 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
328 want: "ab\ncd",352 want: "ab\ncd",
@@ -333,7 +357,7 @@ func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {
333 // so End stops at 3 and column 3 is abandoned padding.357 // so End stops at 3 and column 3 is abandoned padding.
334 rows: [][]renderer.Cell{358 rows: [][]renderer.Cell{
335 {{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, renderer.BlankCell()},359 {{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, renderer.BlankCell()},
336- pad("b", 4),360+ padRow("b", 4),
337 },361 },
338 bounds: []vt.LineBound{{End: 3, Soft: true}, {End: 1}},362 bounds: []vt.LineBound{{End: 3, Soft: true}, {End: 1}},
339 ranges: []CellRange{{Row: 0, Start: 0, End: 3}, {Row: 1, Start: 0, End: 3}},363 ranges: []CellRange{{Row: 0, Start: 0, End: 3}, {Row: 1, Start: 0, End: 3}},
@@ -341,7 +365,7 @@ func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {
341 },365 },
342 {366 {
343 name: "selection stopping mid soft row",367 name: "selection stopping mid soft row",
344- rows: [][]renderer.Cell{pad("abcdefgh", 8), pad("ij", 8)},368+ rows: [][]renderer.Cell{padRow("abcdefgh", 8), padRow("ij", 8)},
345 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},369 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
346 ranges: []CellRange{{Row: 0, Start: 2, End: 4}},370 ranges: []CellRange{{Row: 0, Start: 2, End: 4}},
347 want: "cde",371 want: "cde",

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close