detached be1f364

feat(docmodel): add section ranges

commit
be1f3648777a6f9792dd9f8665d0c1570a2462bd
author
brice · · 2026-08-14 04:47 UTC
committer
brice · · 2026-08-14 07:02 UTC
parent
5d1e8c2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +245 -0
M internal/domain/docmodel/docmodel.go +7 -0
@@ -59,6 +59,13 @@ type Cursor struct {
5959 BlockID BlockID
6060 }
6161
62+// BlockRange identifies a half-open span of blocks in document order. A zero
63+// EndExclusive cursor represents the end of the document.
64+type BlockRange struct {
65+ StartInclusive Cursor
66+ EndExclusive Cursor
67+}
68+
6269 // Document is an immutable, order-preserving collection of blocks.
6370 type Document struct {
6471 blocks []Block
@@ -59,6 +59,13 @@ type Cursor struct {
59 BlockID BlockID59 BlockID BlockID
60 }60 }
61 61
62+// BlockRange identifies a half-open span of blocks in document order. A zero
63+// EndExclusive cursor represents the end of the document.
64+type BlockRange struct {
65+ StartInclusive Cursor
66+ EndExclusive Cursor
67+}
68+
62 // Document is an immutable, order-preserving collection of blocks.69 // Document is an immutable, order-preserving collection of blocks.
63 type Document struct {70 type Document struct {
64 blocks []Block71 blocks []Block
A internal/domain/docmodel/section.go +41 -0
@@ -0,0 +1,41 @@
1+package docmodel
2+
3+// SectionRange returns the half-open range for the section containing from.
4+// The range starts at the containing heading and ends before the first
5+// subsequent heading whose level is less than or equal to the start heading's
6+// level. A zero EndExclusive cursor represents the end of the document.
7+func (d Document) SectionRange(from Cursor) (BlockRange, bool) {
8+ fromIndex, ok := d.indexByID[from.BlockID]
9+ if !ok {
10+ return BlockRange{}, false
11+ }
12+
13+ startIndex, ok := d.containingHeadingIndex(fromIndex)
14+ if !ok {
15+ return BlockRange{}, false
16+ }
17+
18+ start := d.blocks[startIndex]
19+ section := BlockRange{
20+ StartInclusive: Cursor{BlockID: start.ID},
21+ }
22+
23+ for i := startIndex + 1; i < len(d.blocks); i++ {
24+ block := d.blocks[i]
25+ if block.IsHeading() && block.HeadingLevel <= start.HeadingLevel {
26+ section.EndExclusive = Cursor{BlockID: block.ID}
27+ return section, true
28+ }
29+ }
30+
31+ return section, true
32+}
33+
34+func (d Document) containingHeadingIndex(fromIndex int) (int, bool) {
35+ for i := fromIndex; i >= 0; i-- {
36+ if d.blocks[i].IsHeading() {
37+ return i, true
38+ }
39+ }
40+ return 0, false
41+}
@@ -0,0 +1,41 @@
1+package docmodel
2+
3+// SectionRange returns the half-open range for the section containing from.
4+// The range starts at the containing heading and ends before the first
5+// subsequent heading whose level is less than or equal to the start heading's
6+// level. A zero EndExclusive cursor represents the end of the document.
7+func (d Document) SectionRange(from Cursor) (BlockRange, bool) {
8+ fromIndex, ok := d.indexByID[from.BlockID]
9+ if !ok {
10+ return BlockRange{}, false
11+ }
12+
13+ startIndex, ok := d.containingHeadingIndex(fromIndex)
14+ if !ok {
15+ return BlockRange{}, false
16+ }
17+
18+ start := d.blocks[startIndex]
19+ section := BlockRange{
20+ StartInclusive: Cursor{BlockID: start.ID},
21+ }
22+
23+ for i := startIndex + 1; i < len(d.blocks); i++ {
24+ block := d.blocks[i]
25+ if block.IsHeading() && block.HeadingLevel <= start.HeadingLevel {
26+ section.EndExclusive = Cursor{BlockID: block.ID}
27+ return section, true
28+ }
29+ }
30+
31+ return section, true
32+}
33+
34+func (d Document) containingHeadingIndex(fromIndex int) (int, bool) {
35+ for i := fromIndex; i >= 0; i-- {
36+ if d.blocks[i].IsHeading() {
37+ return i, true
38+ }
39+ }
40+ return 0, false
41+}
A internal/domain/docmodel/section_test.go +197 -0
@@ -0,0 +1,197 @@
1+package docmodel
2+
3+import (
4+ "reflect"
5+ "testing"
6+)
7+
8+func TestSectionRange(t *testing.T) {
9+ doc := sectionRangeDocument(t)
10+
11+ tests := []struct {
12+ name string
13+ from Cursor
14+ want BlockRange
15+ ok bool
16+ }{
17+ {
18+ name: "top level section includes descendant headings and stops before next top level heading",
19+ from: Cursor{BlockID: "h1"},
20+ want: BlockRange{
21+ StartInclusive: Cursor{BlockID: "h1"},
22+ EndExclusive: Cursor{BlockID: "h1b"},
23+ },
24+ ok: true,
25+ },
26+ {
27+ name: "non-heading anchor resolves to nearest preceding heading",
28+ from: Cursor{BlockID: "p-h2"},
29+ want: BlockRange{
30+ StartInclusive: Cursor{BlockID: "h2"},
31+ EndExclusive: Cursor{BlockID: "h2b"},
32+ },
33+ ok: true,
34+ },
35+ {
36+ name: "non-heading anchor resolves to innermost preceding heading",
37+ from: Cursor{BlockID: "p-h3"},
38+ want: BlockRange{
39+ StartInclusive: Cursor{BlockID: "h3"},
40+ EndExclusive: Cursor{BlockID: "h3b"},
41+ },
42+ ok: true,
43+ },
44+ {
45+ name: "nested section includes deeper descendant heading",
46+ from: Cursor{BlockID: "h2"},
47+ want: BlockRange{
48+ StartInclusive: Cursor{BlockID: "h2"},
49+ EndExclusive: Cursor{BlockID: "h2b"},
50+ },
51+ ok: true,
52+ },
53+ {
54+ name: "descendant heading section ends before next heading at same level",
55+ from: Cursor{BlockID: "h3"},
56+ want: BlockRange{
57+ StartInclusive: Cursor{BlockID: "h3"},
58+ EndExclusive: Cursor{BlockID: "h3b"},
59+ },
60+ ok: true,
61+ },
62+ {
63+ name: "skipped heading levels are valid descendants",
64+ from: Cursor{BlockID: "h2-skip"},
65+ want: BlockRange{
66+ StartInclusive: Cursor{BlockID: "h2-skip"},
67+ EndExclusive: Cursor{BlockID: "h2-sibling"},
68+ },
69+ ok: true,
70+ },
71+ {
72+ name: "adjacent same-level heading yields empty body range ending at adjacent heading",
73+ from: Cursor{BlockID: "h2-adjacent"},
74+ want: BlockRange{
75+ StartInclusive: Cursor{BlockID: "h2-adjacent"},
76+ EndExclusive: Cursor{BlockID: "h2-after-adjacent"},
77+ },
78+ ok: true,
79+ },
80+ {
81+ name: "lower-level heading ends nested section",
82+ from: Cursor{BlockID: "h2-after-adjacent"},
83+ want: BlockRange{
84+ StartInclusive: Cursor{BlockID: "h2-after-adjacent"},
85+ EndExclusive: Cursor{BlockID: "h1-final"},
86+ },
87+ ok: true,
88+ },
89+ {
90+ name: "EOF section uses zero end cursor",
91+ from: Cursor{BlockID: "h1-final"},
92+ want: BlockRange{
93+ StartInclusive: Cursor{BlockID: "h1-final"},
94+ },
95+ ok: true,
96+ },
97+ {
98+ name: "non-heading anchor in EOF section uses zero end cursor",
99+ from: Cursor{BlockID: "p-final"},
100+ want: BlockRange{
101+ StartInclusive: Cursor{BlockID: "h1-final"},
102+ },
103+ ok: true,
104+ },
105+ {
106+ name: "before first heading returns false",
107+ from: Cursor{BlockID: "intro"},
108+ },
109+ {
110+ name: "zero cursor returns false",
111+ from: Cursor{},
112+ },
113+ {
114+ name: "missing cursor returns false",
115+ from: Cursor{BlockID: "missing"},
116+ },
117+ }
118+
119+ for _, tt := range tests {
120+ t.Run(tt.name, func(t *testing.T) {
121+ before := doc.Blocks()
122+ got, ok := doc.SectionRange(tt.from)
123+ if ok != tt.ok || got != tt.want {
124+ t.Fatalf("SectionRange(%#v) = (%#v, %v), want (%#v, %v)", tt.from, got, ok, tt.want, tt.ok)
125+ }
126+ if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
127+ t.Fatalf("SectionRange mutated document: before %#v after %#v", before, after)
128+ }
129+ })
130+ }
131+}
132+
133+func TestSectionRangeNoSectionDocuments(t *testing.T) {
134+ tests := []struct {
135+ name string
136+ blocks []Block
137+ from Cursor
138+ }{
139+ {
140+ name: "empty document",
141+ from: Cursor{BlockID: "missing"},
142+ },
143+ {
144+ name: "headingless document",
145+ blocks: []Block{
146+ {ID: "p0", Kind: BlockKindParagraph},
147+ {ID: "code", Kind: BlockKindCode},
148+ {ID: "p1", Kind: BlockKindParagraph},
149+ },
150+ from: Cursor{BlockID: "code"},
151+ },
152+ }
153+
154+ for _, tt := range tests {
155+ t.Run(tt.name, func(t *testing.T) {
156+ doc := mustDocument(t, tt.blocks)
157+ before := doc.Blocks()
158+
159+ got, ok := doc.SectionRange(tt.from)
160+ if ok || got != (BlockRange{}) {
161+ t.Fatalf("SectionRange(%#v) = (%#v, %v), want zero range and false", tt.from, got, ok)
162+ }
163+ if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
164+ t.Fatalf("SectionRange mutated document: before %#v after %#v", before, after)
165+ }
166+ })
167+ }
168+}
169+
170+func sectionRangeDocument(t *testing.T) Document {
171+ t.Helper()
172+
173+ return mustDocument(t, []Block{
174+ {ID: "intro", Kind: BlockKindParagraph},
175+ {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1},
176+ {ID: "p-h1", Kind: BlockKindParagraph},
177+ {ID: "h2", Kind: BlockKindHeading, HeadingLevel: 2},
178+ {ID: "p-h2", Kind: BlockKindParagraph},
179+ {ID: "h3", Kind: BlockKindHeading, HeadingLevel: 3},
180+ {ID: "p-h3", Kind: BlockKindParagraph},
181+ {ID: "h3b", Kind: BlockKindHeading, HeadingLevel: 3},
182+ {ID: "p-h3b", Kind: BlockKindParagraph},
183+ {ID: "h2b", Kind: BlockKindHeading, HeadingLevel: 2},
184+ {ID: "p-h2b", Kind: BlockKindParagraph},
185+ {ID: "h1b", Kind: BlockKindHeading, HeadingLevel: 1},
186+ {ID: "h2-skip", Kind: BlockKindHeading, HeadingLevel: 2},
187+ {ID: "h5-skip", Kind: BlockKindHeading, HeadingLevel: 5},
188+ {ID: "p-skip", Kind: BlockKindParagraph},
189+ {ID: "h2-sibling", Kind: BlockKindHeading, HeadingLevel: 2},
190+ {ID: "h2-adjacent", Kind: BlockKindHeading, HeadingLevel: 2},
191+ {ID: "h2-after-adjacent", Kind: BlockKindHeading, HeadingLevel: 2},
192+ {ID: "p-after-adjacent", Kind: BlockKindParagraph},
193+ {ID: "h1-final", Kind: BlockKindHeading, HeadingLevel: 1},
194+ {ID: "p-final", Kind: BlockKindParagraph},
195+ {ID: "h2-final-child", Kind: BlockKindHeading, HeadingLevel: 2},
196+ })
197+}
@@ -0,0 +1,197 @@
1+package docmodel
2+
3+import (
4+ "reflect"
5+ "testing"
6+)
7+
8+func TestSectionRange(t *testing.T) {
9+ doc := sectionRangeDocument(t)
10+
11+ tests := []struct {
12+ name string
13+ from Cursor
14+ want BlockRange
15+ ok bool
16+ }{
17+ {
18+ name: "top level section includes descendant headings and stops before next top level heading",
19+ from: Cursor{BlockID: "h1"},
20+ want: BlockRange{
21+ StartInclusive: Cursor{BlockID: "h1"},
22+ EndExclusive: Cursor{BlockID: "h1b"},
23+ },
24+ ok: true,
25+ },
26+ {
27+ name: "non-heading anchor resolves to nearest preceding heading",
28+ from: Cursor{BlockID: "p-h2"},
29+ want: BlockRange{
30+ StartInclusive: Cursor{BlockID: "h2"},
31+ EndExclusive: Cursor{BlockID: "h2b"},
32+ },
33+ ok: true,
34+ },
35+ {
36+ name: "non-heading anchor resolves to innermost preceding heading",
37+ from: Cursor{BlockID: "p-h3"},
38+ want: BlockRange{
39+ StartInclusive: Cursor{BlockID: "h3"},
40+ EndExclusive: Cursor{BlockID: "h3b"},
41+ },
42+ ok: true,
43+ },
44+ {
45+ name: "nested section includes deeper descendant heading",
46+ from: Cursor{BlockID: "h2"},
47+ want: BlockRange{
48+ StartInclusive: Cursor{BlockID: "h2"},
49+ EndExclusive: Cursor{BlockID: "h2b"},
50+ },
51+ ok: true,
52+ },
53+ {
54+ name: "descendant heading section ends before next heading at same level",
55+ from: Cursor{BlockID: "h3"},
56+ want: BlockRange{
57+ StartInclusive: Cursor{BlockID: "h3"},
58+ EndExclusive: Cursor{BlockID: "h3b"},
59+ },
60+ ok: true,
61+ },
62+ {
63+ name: "skipped heading levels are valid descendants",
64+ from: Cursor{BlockID: "h2-skip"},
65+ want: BlockRange{
66+ StartInclusive: Cursor{BlockID: "h2-skip"},
67+ EndExclusive: Cursor{BlockID: "h2-sibling"},
68+ },
69+ ok: true,
70+ },
71+ {
72+ name: "adjacent same-level heading yields empty body range ending at adjacent heading",
73+ from: Cursor{BlockID: "h2-adjacent"},
74+ want: BlockRange{
75+ StartInclusive: Cursor{BlockID: "h2-adjacent"},
76+ EndExclusive: Cursor{BlockID: "h2-after-adjacent"},
77+ },
78+ ok: true,
79+ },
80+ {
81+ name: "lower-level heading ends nested section",
82+ from: Cursor{BlockID: "h2-after-adjacent"},
83+ want: BlockRange{
84+ StartInclusive: Cursor{BlockID: "h2-after-adjacent"},
85+ EndExclusive: Cursor{BlockID: "h1-final"},
86+ },
87+ ok: true,
88+ },
89+ {
90+ name: "EOF section uses zero end cursor",
91+ from: Cursor{BlockID: "h1-final"},
92+ want: BlockRange{
93+ StartInclusive: Cursor{BlockID: "h1-final"},
94+ },
95+ ok: true,
96+ },
97+ {
98+ name: "non-heading anchor in EOF section uses zero end cursor",
99+ from: Cursor{BlockID: "p-final"},
100+ want: BlockRange{
101+ StartInclusive: Cursor{BlockID: "h1-final"},
102+ },
103+ ok: true,
104+ },
105+ {
106+ name: "before first heading returns false",
107+ from: Cursor{BlockID: "intro"},
108+ },
109+ {
110+ name: "zero cursor returns false",
111+ from: Cursor{},
112+ },
113+ {
114+ name: "missing cursor returns false",
115+ from: Cursor{BlockID: "missing"},
116+ },
117+ }
118+
119+ for _, tt := range tests {
120+ t.Run(tt.name, func(t *testing.T) {
121+ before := doc.Blocks()
122+ got, ok := doc.SectionRange(tt.from)
123+ if ok != tt.ok || got != tt.want {
124+ t.Fatalf("SectionRange(%#v) = (%#v, %v), want (%#v, %v)", tt.from, got, ok, tt.want, tt.ok)
125+ }
126+ if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
127+ t.Fatalf("SectionRange mutated document: before %#v after %#v", before, after)
128+ }
129+ })
130+ }
131+}
132+
133+func TestSectionRangeNoSectionDocuments(t *testing.T) {
134+ tests := []struct {
135+ name string
136+ blocks []Block
137+ from Cursor
138+ }{
139+ {
140+ name: "empty document",
141+ from: Cursor{BlockID: "missing"},
142+ },
143+ {
144+ name: "headingless document",
145+ blocks: []Block{
146+ {ID: "p0", Kind: BlockKindParagraph},
147+ {ID: "code", Kind: BlockKindCode},
148+ {ID: "p1", Kind: BlockKindParagraph},
149+ },
150+ from: Cursor{BlockID: "code"},
151+ },
152+ }
153+
154+ for _, tt := range tests {
155+ t.Run(tt.name, func(t *testing.T) {
156+ doc := mustDocument(t, tt.blocks)
157+ before := doc.Blocks()
158+
159+ got, ok := doc.SectionRange(tt.from)
160+ if ok || got != (BlockRange{}) {
161+ t.Fatalf("SectionRange(%#v) = (%#v, %v), want zero range and false", tt.from, got, ok)
162+ }
163+ if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
164+ t.Fatalf("SectionRange mutated document: before %#v after %#v", before, after)
165+ }
166+ })
167+ }
168+}
169+
170+func sectionRangeDocument(t *testing.T) Document {
171+ t.Helper()
172+
173+ return mustDocument(t, []Block{
174+ {ID: "intro", Kind: BlockKindParagraph},
175+ {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1},
176+ {ID: "p-h1", Kind: BlockKindParagraph},
177+ {ID: "h2", Kind: BlockKindHeading, HeadingLevel: 2},
178+ {ID: "p-h2", Kind: BlockKindParagraph},
179+ {ID: "h3", Kind: BlockKindHeading, HeadingLevel: 3},
180+ {ID: "p-h3", Kind: BlockKindParagraph},
181+ {ID: "h3b", Kind: BlockKindHeading, HeadingLevel: 3},
182+ {ID: "p-h3b", Kind: BlockKindParagraph},
183+ {ID: "h2b", Kind: BlockKindHeading, HeadingLevel: 2},
184+ {ID: "p-h2b", Kind: BlockKindParagraph},
185+ {ID: "h1b", Kind: BlockKindHeading, HeadingLevel: 1},
186+ {ID: "h2-skip", Kind: BlockKindHeading, HeadingLevel: 2},
187+ {ID: "h5-skip", Kind: BlockKindHeading, HeadingLevel: 5},
188+ {ID: "p-skip", Kind: BlockKindParagraph},
189+ {ID: "h2-sibling", Kind: BlockKindHeading, HeadingLevel: 2},
190+ {ID: "h2-adjacent", Kind: BlockKindHeading, HeadingLevel: 2},
191+ {ID: "h2-after-adjacent", Kind: BlockKindHeading, HeadingLevel: 2},
192+ {ID: "p-after-adjacent", Kind: BlockKindParagraph},
193+ {ID: "h1-final", Kind: BlockKindHeading, HeadingLevel: 1},
194+ {ID: "p-final", Kind: BlockKindParagraph},
195+ {ID: "h2-final-child", Kind: BlockKindHeading, HeadingLevel: 2},
196+ })
197+}

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 8

Tags 42

↑↓ moveenter openesc close