detached ef1f76f

perf(copy): avoid full document word scans

commit
ef1f76fe7df932d4bd100c1e41c7ccaa26105ad7
author
brice · · 2026-07-15 07:14 UTC
committer
brice · · 2026-07-15 09:45 UTC
parent
6af2c31
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +187 -85
M internal/usecase/copy/document.go +102 -85
@@ -121,29 +121,40 @@ func (d *Document) WordBounds(pos Pos) (Pos, Pos, bool) {
121121 }
122122
123123 // NextWordStart returns the beginning of the next word after pos, crossing
124-// physical rows as necessary.
124+// physical rows as necessary. It walks only the rows between pos and the
125+// target instead of materializing every glyph in the document.
125126 func (d *Document) NextWordStart(pos Pos) (Pos, bool) {
126127 pos, ok := d.Normalize(pos)
127128 if !ok {
128129 return Pos{}, false
129130 }
130- glyphs := d.glyphs()
131- i := d.glyphIndex(glyphs, pos)
132- if i < 0 {
133- return d.firstWordAfterRow(glyphs, pos.Row)
134- }
135- if !d.isSeparator(d.runeAt(glyphs[i])) {
136- for i < len(glyphs) && d.sameWord(glyphs[i], pos) {
137- i++
131+
132+ candidate := pos
133+ if d.isGlyph(candidate) && !d.isSeparator(d.runeAt(candidate)) {
134+ for {
135+ next, ok := d.nextDocumentGlyph(candidate)
136+ if !ok {
137+ return Pos{}, false
138+ }
139+ if !d.sameWord(next, candidate) {
140+ candidate = next
141+ break
142+ }
143+ candidate = next
144+ }
145+ } else {
146+ candidate, ok = d.nextDocumentGlyph(candidate)
147+ if !ok {
148+ return Pos{}, false
138149 }
139150 }
140- for i < len(glyphs) && d.isSeparator(d.runeAt(glyphs[i])) {
141- i++
142- }
143- if i < len(glyphs) {
144- return glyphs[i], true
151+ for d.isSeparator(d.runeAt(candidate)) {
152+ candidate, ok = d.nextDocumentGlyph(candidate)
153+ if !ok {
154+ return Pos{}, false
155+ }
145156 }
146- return Pos{}, false
157+ return candidate, true
147158 }
148159
149160 // PreviousWordStart returns the beginning of the current word, or of the
@@ -153,58 +164,71 @@ func (d *Document) PreviousWordStart(pos Pos) (Pos, bool) {
153164 if !ok {
154165 return Pos{}, false
155166 }
156- glyphs := d.glyphs()
157- i := d.glyphIndex(glyphs, pos)
158- if i < 0 {
159- return d.lastWordBeforeRow(glyphs, pos.Row)
160- }
161- if !d.isSeparator(d.runeAt(glyphs[i])) && i > 0 && d.sameWord(glyphs[i-1], glyphs[i]) {
162- for i > 0 && d.sameWord(glyphs[i-1], glyphs[i]) {
163- i--
167+
168+ if d.isGlyph(pos) && !d.isSeparator(d.runeAt(pos)) {
169+ previous, ok := d.previousDocumentGlyph(pos)
170+ if ok && d.sameWord(previous, pos) {
171+ for d.sameWord(previous, pos) {
172+ pos = previous
173+ previous, ok = d.previousDocumentGlyph(pos)
174+ if !ok {
175+ break
176+ }
177+ }
178+ return pos, true
164179 }
165- return glyphs[i], true
166- }
167- i--
168- for i >= 0 && d.isSeparator(d.runeAt(glyphs[i])) {
169- i--
170180 }
171- if i < 0 {
181+
182+ candidate, ok := d.previousDocumentGlyph(pos)
183+ if !ok {
172184 return Pos{}, false
173185 }
174- for i > 0 && d.sameWord(glyphs[i-1], glyphs[i]) {
175- i--
186+ for d.isSeparator(d.runeAt(candidate)) {
187+ candidate, ok = d.previousDocumentGlyph(candidate)
188+ if !ok {
189+ return Pos{}, false
190+ }
191+ }
192+ for {
193+ previous, ok := d.previousDocumentGlyph(candidate)
194+ if !ok || !d.sameWord(previous, candidate) {
195+ return candidate, true
196+ }
197+ candidate = previous
176198 }
177- return glyphs[i], true
178199 }
179200
180201 // NextWordEnd returns the inclusive end of the current word, or of the next
181202 // word when pos is on a separator or at the end of a word.
182203 func (d *Document) NextWordEnd(pos Pos) (Pos, bool) {
183204 pos, ok := d.Normalize(pos)
184- if !ok {
205+ if !ok || !d.isGlyph(pos) {
185206 return Pos{}, false
186207 }
187- glyphs := d.glyphs()
188- i := d.glyphIndex(glyphs, pos)
189- if i < 0 {
190- return Pos{}, false
191- }
192- if !d.isSeparator(d.runeAt(glyphs[i])) &&
193- (i+1 == len(glyphs) || !d.sameWord(glyphs[i+1], glyphs[i])) {
194- i++
195- }
196- for i < len(glyphs) && d.isSeparator(d.runeAt(glyphs[i])) {
197- i++
208+
209+ candidate := pos
210+ if !d.isSeparator(d.runeAt(candidate)) {
211+ next, ok := d.nextDocumentGlyph(candidate)
212+ if !ok {
213+ return Pos{}, false
214+ }
215+ if !d.sameWord(next, candidate) {
216+ candidate = next
217+ }
198218 }
199- if i == len(glyphs) {
200- return Pos{}, false
219+ for d.isSeparator(d.runeAt(candidate)) {
220+ candidate, ok = d.nextDocumentGlyph(candidate)
221+ if !ok {
222+ return Pos{}, false
223+ }
201224 }
202- end := glyphs[i]
203- for i+1 < len(glyphs) && d.sameWord(glyphs[i+1], glyphs[i]) {
204- i++
205- end = glyphs[i]
225+ for {
226+ next, ok := d.nextDocumentGlyph(candidate)
227+ if !ok || !d.sameWord(next, candidate) {
228+ return candidate, true
229+ }
230+ candidate = next
206231 }
207- return end, true
208232 }
209233
210234 // LineText returns row text without duplicated continuation cells and retains
@@ -299,49 +323,42 @@ func (d *Document) runeAt(pos Pos) rune {
299323 return cell.Rune
300324 }
301325
302-func (d *Document) glyphs() []Pos {
303- glyphs := make([]Pos, 0)
304- for row := 0; row < d.Len(); row++ {
305- for col, cell := range d.Row(row) {
306- if !cell.Continuation {
307- glyphs = append(glyphs, Pos{Row: row, Col: col})
326+// nextDocumentGlyph finds the next glyph in document order without building
327+// an index for unrelated scrollback rows.
328+func (d *Document) nextDocumentGlyph(pos Pos) (Pos, bool) {
329+ for row := pos.Row; row < d.Len(); row++ {
330+ cells := d.Row(row)
331+ start := 0
332+ if row == pos.Row {
333+ start = pos.Col + 1
334+ }
335+ for col := start; col < len(cells); col++ {
336+ if !cells[col].Continuation {
337+ return Pos{Row: row, Col: col}, true
308338 }
309339 }
310340 }
311- return glyphs
341+ return Pos{}, false
312342 }
313343
314-func (d *Document) glyphIndex(glyphs []Pos, pos Pos) int {
315- for i, glyph := range glyphs {
316- if glyph == pos {
317- return i
344+// previousDocumentGlyph finds the previous glyph in document order without
345+// building an index for unrelated scrollback rows.
346+func (d *Document) previousDocumentGlyph(pos Pos) (Pos, bool) {
347+ for row := pos.Row; row >= 0; row-- {
348+ cells := d.Row(row)
349+ start := len(cells) - 1
350+ if row == pos.Row {
351+ start = min(pos.Col-1, start)
318352 }
319- }
320- return -1
321-}
322-
323-func (d *Document) sameWord(left, right Pos) bool {
324- return left.Row == right.Row && !d.isSeparator(d.runeAt(left)) && !d.isSeparator(d.runeAt(right))
325-}
326-
327-func (d *Document) firstWordAfterRow(glyphs []Pos, row int) (Pos, bool) {
328- for _, glyph := range glyphs {
329- if glyph.Row > row && !d.isSeparator(d.runeAt(glyph)) {
330- return glyph, true
353+ for col := start; col >= 0; col-- {
354+ if !cells[col].Continuation {
355+ return Pos{Row: row, Col: col}, true
356+ }
331357 }
332358 }
333359 return Pos{}, false
334360 }
335361
336-func (d *Document) lastWordBeforeRow(glyphs []Pos, row int) (Pos, bool) {
337- for i := len(glyphs) - 1; i >= 0; i-- {
338- if glyphs[i].Row >= row || d.isSeparator(d.runeAt(glyphs[i])) {
339- continue
340- }
341- for i > 0 && d.sameWord(glyphs[i-1], glyphs[i]) {
342- i--
343- }
344- return glyphs[i], true
345- }
346- return Pos{}, false
362+func (d *Document) sameWord(left, right Pos) bool {
363+ return left.Row == right.Row && !d.isSeparator(d.runeAt(left)) && !d.isSeparator(d.runeAt(right))
347364 }
@@ -121,29 +121,40 @@ func (d *Document) WordBounds(pos Pos) (Pos, Pos, bool) {
121 }121 }
122 122
123 // NextWordStart returns the beginning of the next word after pos, crossing123 // NextWordStart returns the beginning of the next word after pos, crossing
124-// physical rows as necessary.124+// physical rows as necessary. It walks only the rows between pos and the
125+// target instead of materializing every glyph in the document.
125 func (d *Document) NextWordStart(pos Pos) (Pos, bool) {126 func (d *Document) NextWordStart(pos Pos) (Pos, bool) {
126 pos, ok := d.Normalize(pos)127 pos, ok := d.Normalize(pos)
127 if !ok {128 if !ok {
128 return Pos{}, false129 return Pos{}, false
129 }130 }
130- glyphs := d.glyphs()131+
131- i := d.glyphIndex(glyphs, pos)132+ candidate := pos
132- if i < 0 {133+ if d.isGlyph(candidate) && !d.isSeparator(d.runeAt(candidate)) {
133- return d.firstWordAfterRow(glyphs, pos.Row)134+ for {
134- }135+ next, ok := d.nextDocumentGlyph(candidate)
135- if !d.isSeparator(d.runeAt(glyphs[i])) {136+ if !ok {
136- for i < len(glyphs) && d.sameWord(glyphs[i], pos) {137+ return Pos{}, false
137- i++138+ }
139+ if !d.sameWord(next, candidate) {
140+ candidate = next
141+ break
142+ }
143+ candidate = next
144+ }
145+ } else {
146+ candidate, ok = d.nextDocumentGlyph(candidate)
147+ if !ok {
148+ return Pos{}, false
138 }149 }
139 }150 }
140- for i < len(glyphs) && d.isSeparator(d.runeAt(glyphs[i])) {151+ for d.isSeparator(d.runeAt(candidate)) {
141- i++152+ candidate, ok = d.nextDocumentGlyph(candidate)
142- }153+ if !ok {
143- if i < len(glyphs) {154+ return Pos{}, false
144- return glyphs[i], true155+ }
145 }156 }
146- return Pos{}, false157+ return candidate, true
147 }158 }
148 159
149 // PreviousWordStart returns the beginning of the current word, or of the160 // PreviousWordStart returns the beginning of the current word, or of the
@@ -153,58 +164,71 @@ func (d *Document) PreviousWordStart(pos Pos) (Pos, bool) {
153 if !ok {164 if !ok {
154 return Pos{}, false165 return Pos{}, false
155 }166 }
156- glyphs := d.glyphs()167+
157- i := d.glyphIndex(glyphs, pos)168+ if d.isGlyph(pos) && !d.isSeparator(d.runeAt(pos)) {
158- if i < 0 {169+ previous, ok := d.previousDocumentGlyph(pos)
159- return d.lastWordBeforeRow(glyphs, pos.Row)170+ if ok && d.sameWord(previous, pos) {
160- }171+ for d.sameWord(previous, pos) {
161- if !d.isSeparator(d.runeAt(glyphs[i])) && i > 0 && d.sameWord(glyphs[i-1], glyphs[i]) {172+ pos = previous
162- for i > 0 && d.sameWord(glyphs[i-1], glyphs[i]) {173+ previous, ok = d.previousDocumentGlyph(pos)
163- i--174+ if !ok {
175+ break
176+ }
177+ }
178+ return pos, true
164 }179 }
165- return glyphs[i], true
166- }
167- i--
168- for i >= 0 && d.isSeparator(d.runeAt(glyphs[i])) {
169- i--
170 }180 }
171- if i < 0 {181+
182+ candidate, ok := d.previousDocumentGlyph(pos)
183+ if !ok {
172 return Pos{}, false184 return Pos{}, false
173 }185 }
174- for i > 0 && d.sameWord(glyphs[i-1], glyphs[i]) {186+ for d.isSeparator(d.runeAt(candidate)) {
175- i--187+ candidate, ok = d.previousDocumentGlyph(candidate)
188+ if !ok {
189+ return Pos{}, false
190+ }
191+ }
192+ for {
193+ previous, ok := d.previousDocumentGlyph(candidate)
194+ if !ok || !d.sameWord(previous, candidate) {
195+ return candidate, true
196+ }
197+ candidate = previous
176 }198 }
177- return glyphs[i], true
178 }199 }
179 200
180 // NextWordEnd returns the inclusive end of the current word, or of the next201 // NextWordEnd returns the inclusive end of the current word, or of the next
181 // word when pos is on a separator or at the end of a word.202 // word when pos is on a separator or at the end of a word.
182 func (d *Document) NextWordEnd(pos Pos) (Pos, bool) {203 func (d *Document) NextWordEnd(pos Pos) (Pos, bool) {
183 pos, ok := d.Normalize(pos)204 pos, ok := d.Normalize(pos)
184- if !ok {205+ if !ok || !d.isGlyph(pos) {
185 return Pos{}, false206 return Pos{}, false
186 }207 }
187- glyphs := d.glyphs()208+
188- i := d.glyphIndex(glyphs, pos)209+ candidate := pos
189- if i < 0 {210+ if !d.isSeparator(d.runeAt(candidate)) {
190- return Pos{}, false211+ next, ok := d.nextDocumentGlyph(candidate)
191- }212+ if !ok {
192- if !d.isSeparator(d.runeAt(glyphs[i])) &&213+ return Pos{}, false
193- (i+1 == len(glyphs) || !d.sameWord(glyphs[i+1], glyphs[i])) {214+ }
194- i++215+ if !d.sameWord(next, candidate) {
195- }216+ candidate = next
196- for i < len(glyphs) && d.isSeparator(d.runeAt(glyphs[i])) {217+ }
197- i++
198 }218 }
199- if i == len(glyphs) {219+ for d.isSeparator(d.runeAt(candidate)) {
200- return Pos{}, false220+ candidate, ok = d.nextDocumentGlyph(candidate)
221+ if !ok {
222+ return Pos{}, false
223+ }
201 }224 }
202- end := glyphs[i]225+ for {
203- for i+1 < len(glyphs) && d.sameWord(glyphs[i+1], glyphs[i]) {226+ next, ok := d.nextDocumentGlyph(candidate)
204- i++227+ if !ok || !d.sameWord(next, candidate) {
205- end = glyphs[i]228+ return candidate, true
229+ }
230+ candidate = next
206 }231 }
207- return end, true
208 }232 }
209 233
210 // LineText returns row text without duplicated continuation cells and retains234 // LineText returns row text without duplicated continuation cells and retains
@@ -299,49 +323,42 @@ func (d *Document) runeAt(pos Pos) rune {
299 return cell.Rune323 return cell.Rune
300 }324 }
301 325
302-func (d *Document) glyphs() []Pos {326+// nextDocumentGlyph finds the next glyph in document order without building
303- glyphs := make([]Pos, 0)327+// an index for unrelated scrollback rows.
304- for row := 0; row < d.Len(); row++ {328+func (d *Document) nextDocumentGlyph(pos Pos) (Pos, bool) {
305- for col, cell := range d.Row(row) {329+ for row := pos.Row; row < d.Len(); row++ {
306- if !cell.Continuation {330+ cells := d.Row(row)
307- glyphs = append(glyphs, Pos{Row: row, Col: col})331+ start := 0
332+ if row == pos.Row {
333+ start = pos.Col + 1
334+ }
335+ for col := start; col < len(cells); col++ {
336+ if !cells[col].Continuation {
337+ return Pos{Row: row, Col: col}, true
308 }338 }
309 }339 }
310 }340 }
311- return glyphs341+ return Pos{}, false
312 }342 }
313 343
314-func (d *Document) glyphIndex(glyphs []Pos, pos Pos) int {344+// previousDocumentGlyph finds the previous glyph in document order without
315- for i, glyph := range glyphs {345+// building an index for unrelated scrollback rows.
316- if glyph == pos {346+func (d *Document) previousDocumentGlyph(pos Pos) (Pos, bool) {
317- return i347+ for row := pos.Row; row >= 0; row-- {
348+ cells := d.Row(row)
349+ start := len(cells) - 1
350+ if row == pos.Row {
351+ start = min(pos.Col-1, start)
318 }352 }
319- }353+ for col := start; col >= 0; col-- {
320- return -1354+ if !cells[col].Continuation {
321-}355+ return Pos{Row: row, Col: col}, true
322-356+ }
323-func (d *Document) sameWord(left, right Pos) bool {
324- return left.Row == right.Row && !d.isSeparator(d.runeAt(left)) && !d.isSeparator(d.runeAt(right))
325-}
326-
327-func (d *Document) firstWordAfterRow(glyphs []Pos, row int) (Pos, bool) {
328- for _, glyph := range glyphs {
329- if glyph.Row > row && !d.isSeparator(d.runeAt(glyph)) {
330- return glyph, true
331 }357 }
332 }358 }
333 return Pos{}, false359 return Pos{}, false
334 }360 }
335 361
336-func (d *Document) lastWordBeforeRow(glyphs []Pos, row int) (Pos, bool) {362+func (d *Document) sameWord(left, right Pos) bool {
337- for i := len(glyphs) - 1; i >= 0; i-- {363+ return left.Row == right.Row && !d.isSeparator(d.runeAt(left)) && !d.isSeparator(d.runeAt(right))
338- if glyphs[i].Row >= row || d.isSeparator(d.runeAt(glyphs[i])) {
339- continue
340- }
341- for i > 0 && d.sameWord(glyphs[i-1], glyphs[i]) {
342- i--
343- }
344- return glyphs[i], true
345- }
346- return Pos{}, false
347 }364 }
M internal/usecase/copy/document_test.go +85 -0
@@ -154,6 +154,63 @@ func TestDocumentNextWordEndCrossesEmptyRow(t *testing.T) {
154154 require.Equal(t, Pos{Row: 2, Col: 3}, got)
155155 }
156156
157+func TestDocumentWordMovementPreservesWideSeparatorsEmptyRowsAndEndProgression(t *testing.T) {
158+ wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: '/'}, {Rune: 'b'}}
159+ doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{wide, {}, documentCells(" cd")}, 5, 3), "/")
160+
161+ tests := []struct {
162+ name string
163+ move func(Pos) (Pos, bool)
164+ in Pos
165+ want Pos
166+ }{
167+ {"next start skips wide word and separator", doc.NextWordStart, Pos{Row: 0, Col: 1}, Pos{Row: 0, Col: 4}},
168+ {"previous start crosses separator", doc.PreviousWordStart, Pos{Row: 0, Col: 4}, Pos{Row: 0, Col: 0}},
169+ {"end advances from wide word", doc.NextWordEnd, Pos{Row: 0, Col: 1}, Pos{Row: 0, Col: 4}},
170+ {"next start crosses empty row", doc.NextWordStart, Pos{Row: 0, Col: 4}, Pos{Row: 2, Col: 1}},
171+ {"previous start crosses empty row", doc.PreviousWordStart, Pos{Row: 2, Col: 1}, Pos{Row: 0, Col: 4}},
172+ {"end crosses empty row", doc.NextWordEnd, Pos{Row: 0, Col: 4}, Pos{Row: 2, Col: 2}},
173+ }
174+ for _, tt := range tests {
175+ t.Run(tt.name, func(t *testing.T) {
176+ got, ok := tt.move(tt.in)
177+ require.True(t, ok)
178+ require.Equal(t, tt.want, got)
179+ })
180+ }
181+}
182+
183+func TestDocumentWordMovementDoesNotAllocateWholeDocument(t *testing.T) {
184+ lines := make([]string, 8_192)
185+ lines[0] = "alpha/beta"
186+ for i := 1; i < len(lines); i++ {
187+ lines[i] = "unrelated"
188+ }
189+ doc := documentFromStrings(lines, "/")
190+
191+ tests := []struct {
192+ name string
193+ move func(Pos) (Pos, bool)
194+ in Pos
195+ want Pos
196+ }{
197+ {"next start", doc.NextWordStart, Pos{Row: 0, Col: 2}, Pos{Row: 0, Col: 6}},
198+ {"previous start", doc.PreviousWordStart, Pos{Row: 0, Col: 2}, Pos{Row: 0, Col: 0}},
199+ {"next end", doc.NextWordEnd, Pos{Row: 0, Col: 2}, Pos{Row: 0, Col: 4}},
200+ }
201+ for _, tt := range tests {
202+ t.Run(tt.name, func(t *testing.T) {
203+ allocs := testing.AllocsPerRun(100, func() {
204+ got, ok := tt.move(tt.in)
205+ if !ok || got != tt.want {
206+ t.Fatalf("move() = (%v, %t), want (%v, true)", got, ok, tt.want)
207+ }
208+ })
209+ require.Zero(t, allocs, "local word movement must not materialize the document")
210+ })
211+ }
212+}
213+
157214 func TestDocumentExtractRanges(t *testing.T) {
158215 wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: ' '}, {Rune: 'b'}}
159216 doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{documentCells("alpha "), documentCells("beta"), wide, {}}, 7, 4), "")
@@ -179,6 +236,34 @@ func TestDocumentExtractRanges(t *testing.T) {
179236 }
180237 }
181238
239+var benchmarkDocumentWordPos Pos
240+
241+func BenchmarkDocumentWordMovementLocal(b *testing.B) {
242+ lines := make([]string, 32_768)
243+ lines[0] = "alpha/beta"
244+ for i := 1; i < len(lines); i++ {
245+ lines[i] = "unrelated"
246+ }
247+ doc := documentFromStrings(lines, "/")
248+ moves := []func(Pos) (Pos, bool){
249+ doc.NextWordStart,
250+ doc.PreviousWordStart,
251+ doc.NextWordEnd,
252+ }
253+
254+ b.ReportAllocs()
255+ b.ResetTimer()
256+ for i := 0; i < b.N; i++ {
257+ for _, move := range moves {
258+ pos, ok := move(Pos{Row: 0, Col: 2})
259+ if !ok {
260+ b.Fatal("local word movement failed")
261+ }
262+ benchmarkDocumentWordPos = pos
263+ }
264+ }
265+}
266+
182267 func TestDocumentAccessorsExposeImmutableSnapshot(t *testing.T) {
183268 rows := [][]renderer.Cell{documentCells("one")}
184269 doc := NewDocument(NewSnapshotFromRows(rows, 9, 2), "_")
@@ -154,6 +154,63 @@ func TestDocumentNextWordEndCrossesEmptyRow(t *testing.T) {
154 require.Equal(t, Pos{Row: 2, Col: 3}, got)154 require.Equal(t, Pos{Row: 2, Col: 3}, got)
155 }155 }
156 156
157+func TestDocumentWordMovementPreservesWideSeparatorsEmptyRowsAndEndProgression(t *testing.T) {
158+ wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: '/'}, {Rune: 'b'}}
159+ doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{wide, {}, documentCells(" cd")}, 5, 3), "/")
160+
161+ tests := []struct {
162+ name string
163+ move func(Pos) (Pos, bool)
164+ in Pos
165+ want Pos
166+ }{
167+ {"next start skips wide word and separator", doc.NextWordStart, Pos{Row: 0, Col: 1}, Pos{Row: 0, Col: 4}},
168+ {"previous start crosses separator", doc.PreviousWordStart, Pos{Row: 0, Col: 4}, Pos{Row: 0, Col: 0}},
169+ {"end advances from wide word", doc.NextWordEnd, Pos{Row: 0, Col: 1}, Pos{Row: 0, Col: 4}},
170+ {"next start crosses empty row", doc.NextWordStart, Pos{Row: 0, Col: 4}, Pos{Row: 2, Col: 1}},
171+ {"previous start crosses empty row", doc.PreviousWordStart, Pos{Row: 2, Col: 1}, Pos{Row: 0, Col: 4}},
172+ {"end crosses empty row", doc.NextWordEnd, Pos{Row: 0, Col: 4}, Pos{Row: 2, Col: 2}},
173+ }
174+ for _, tt := range tests {
175+ t.Run(tt.name, func(t *testing.T) {
176+ got, ok := tt.move(tt.in)
177+ require.True(t, ok)
178+ require.Equal(t, tt.want, got)
179+ })
180+ }
181+}
182+
183+func TestDocumentWordMovementDoesNotAllocateWholeDocument(t *testing.T) {
184+ lines := make([]string, 8_192)
185+ lines[0] = "alpha/beta"
186+ for i := 1; i < len(lines); i++ {
187+ lines[i] = "unrelated"
188+ }
189+ doc := documentFromStrings(lines, "/")
190+
191+ tests := []struct {
192+ name string
193+ move func(Pos) (Pos, bool)
194+ in Pos
195+ want Pos
196+ }{
197+ {"next start", doc.NextWordStart, Pos{Row: 0, Col: 2}, Pos{Row: 0, Col: 6}},
198+ {"previous start", doc.PreviousWordStart, Pos{Row: 0, Col: 2}, Pos{Row: 0, Col: 0}},
199+ {"next end", doc.NextWordEnd, Pos{Row: 0, Col: 2}, Pos{Row: 0, Col: 4}},
200+ }
201+ for _, tt := range tests {
202+ t.Run(tt.name, func(t *testing.T) {
203+ allocs := testing.AllocsPerRun(100, func() {
204+ got, ok := tt.move(tt.in)
205+ if !ok || got != tt.want {
206+ t.Fatalf("move() = (%v, %t), want (%v, true)", got, ok, tt.want)
207+ }
208+ })
209+ require.Zero(t, allocs, "local word movement must not materialize the document")
210+ })
211+ }
212+}
213+
157 func TestDocumentExtractRanges(t *testing.T) {214 func TestDocumentExtractRanges(t *testing.T) {
158 wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: ' '}, {Rune: 'b'}}215 wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: ' '}, {Rune: 'b'}}
159 doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{documentCells("alpha "), documentCells("beta"), wide, {}}, 7, 4), "")216 doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{documentCells("alpha "), documentCells("beta"), wide, {}}, 7, 4), "")
@@ -179,6 +236,34 @@ func TestDocumentExtractRanges(t *testing.T) {
179 }236 }
180 }237 }
181 238
239+var benchmarkDocumentWordPos Pos
240+
241+func BenchmarkDocumentWordMovementLocal(b *testing.B) {
242+ lines := make([]string, 32_768)
243+ lines[0] = "alpha/beta"
244+ for i := 1; i < len(lines); i++ {
245+ lines[i] = "unrelated"
246+ }
247+ doc := documentFromStrings(lines, "/")
248+ moves := []func(Pos) (Pos, bool){
249+ doc.NextWordStart,
250+ doc.PreviousWordStart,
251+ doc.NextWordEnd,
252+ }
253+
254+ b.ReportAllocs()
255+ b.ResetTimer()
256+ for i := 0; i < b.N; i++ {
257+ for _, move := range moves {
258+ pos, ok := move(Pos{Row: 0, Col: 2})
259+ if !ok {
260+ b.Fatal("local word movement failed")
261+ }
262+ benchmarkDocumentWordPos = pos
263+ }
264+ }
265+}
266+
182 func TestDocumentAccessorsExposeImmutableSnapshot(t *testing.T) {267 func TestDocumentAccessorsExposeImmutableSnapshot(t *testing.T) {
183 rows := [][]renderer.Cell{documentCells("one")}268 rows := [][]renderer.Cell{documentCells("one")}
184 doc := NewDocument(NewSnapshotFromRows(rows, 9, 2), "_")269 doc := NewDocument(NewSnapshotFromRows(rows, 9, 2), "_")

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