detached 7d43626

feat(docmodel): add paragraph motion kind

commit
7d436262384fa44b8854caeb357d450028966245
author
brice · · 2026-08-14 04:39 UTC
committer
brice · · 2026-08-14 07:01 UTC
parent
830f50b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +177 -0
M internal/domain/docmodel/docmodel.go +2 -0
@@ -15,6 +15,8 @@ type BlockKind string
1515 const (
1616 // BlockKindHeading marks blocks that participate in heading motions.
1717 BlockKindHeading BlockKind = "heading"
18+ // BlockKindParagraph marks a paragraph block for Vim Mode paragraph motions.
19+ BlockKindParagraph BlockKind = "paragraph"
1820 // BlockKindCode marks a structural code block for Vim Mode block-kind motions.
1921 BlockKindCode BlockKind = "code"
2022 // BlockKindTable marks a structural table block for Vim Mode block-kind motions.
@@ -15,6 +15,8 @@ type BlockKind string
15 const (15 const (
16 // BlockKindHeading marks blocks that participate in heading motions.16 // BlockKindHeading marks blocks that participate in heading motions.
17 BlockKindHeading BlockKind = "heading"17 BlockKindHeading BlockKind = "heading"
18+ // BlockKindParagraph marks a paragraph block for Vim Mode paragraph motions.
19+ BlockKindParagraph BlockKind = "paragraph"
18 // BlockKindCode marks a structural code block for Vim Mode block-kind motions.20 // BlockKindCode marks a structural code block for Vim Mode block-kind motions.
19 BlockKindCode BlockKind = "code"21 BlockKindCode BlockKind = "code"
20 // BlockKindTable marks a structural table block for Vim Mode block-kind motions.22 // BlockKindTable marks a structural table block for Vim Mode block-kind motions.
A internal/domain/docmodel/paragraph_motions_test.go +175 -0
@@ -0,0 +1,175 @@
1+package docmodel
2+
3+import (
4+ "reflect"
5+ "testing"
6+)
7+
8+func TestParagraphNextBlockOfKindProvidesVimCloseBraceSemantics(t *testing.T) {
9+ doc := paragraphMotionDocument(t)
10+
11+ tests := []struct {
12+ name string
13+ from Cursor
14+ count int
15+ want Cursor
16+ ok bool
17+ }{
18+ {
19+ name: "forward paragraph motion skips non paragraphs",
20+ from: Cursor{BlockID: "h1"},
21+ want: Cursor{BlockID: "p1"},
22+ ok: true,
23+ },
24+ {
25+ name: "count below one means one",
26+ from: Cursor{BlockID: "h1"},
27+ count: -1,
28+ want: Cursor{BlockID: "p1"},
29+ ok: true,
30+ },
31+ {
32+ name: "count zero means one",
33+ from: Cursor{BlockID: "code1"},
34+ count: 0,
35+ want: Cursor{BlockID: "p2"},
36+ ok: true,
37+ },
38+ {
39+ name: "strict forward paragraph motion does not match current paragraph",
40+ from: Cursor{BlockID: "p1"},
41+ count: 1,
42+ want: Cursor{BlockID: "p2"},
43+ ok: true,
44+ },
45+ {
46+ name: "count selects nth next paragraph",
47+ from: Cursor{BlockID: "p0"},
48+ count: 2,
49+ want: Cursor{BlockID: "p2"},
50+ ok: true,
51+ },
52+ {
53+ name: "no later paragraph returns false",
54+ from: Cursor{BlockID: "p3"},
55+ },
56+ {
57+ name: "count past later paragraphs returns false",
58+ from: Cursor{BlockID: "p1"},
59+ count: 3,
60+ },
61+ {
62+ name: "zero cursor returns false",
63+ from: Cursor{},
64+ },
65+ {
66+ name: "missing cursor returns false",
67+ from: Cursor{BlockID: "missing"},
68+ },
69+ }
70+
71+ for _, tt := range tests {
72+ t.Run(tt.name, func(t *testing.T) {
73+ before := doc.Blocks()
74+ got, ok := doc.NextBlockOfKind(tt.from, BlockKindParagraph, tt.count)
75+ if ok != tt.ok || got != tt.want {
76+ t.Fatalf("NextBlockOfKind(%#v, BlockKindParagraph, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, got, ok, tt.want, tt.ok)
77+ }
78+ if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
79+ t.Fatalf("NextBlockOfKind mutated document: before %#v after %#v", before, after)
80+ }
81+ })
82+ }
83+}
84+
85+func TestParagraphPreviousBlockOfKindProvidesVimOpenBraceSemantics(t *testing.T) {
86+ doc := paragraphMotionDocument(t)
87+
88+ tests := []struct {
89+ name string
90+ from Cursor
91+ count int
92+ want Cursor
93+ ok bool
94+ }{
95+ {
96+ name: "backward paragraph motion skips non paragraphs",
97+ from: Cursor{BlockID: "table1"},
98+ want: Cursor{BlockID: "p3"},
99+ ok: true,
100+ },
101+ {
102+ name: "count below one means one",
103+ from: Cursor{BlockID: "table1"},
104+ count: -1,
105+ want: Cursor{BlockID: "p3"},
106+ ok: true,
107+ },
108+ {
109+ name: "count zero means one",
110+ from: Cursor{BlockID: "code1"},
111+ count: 0,
112+ want: Cursor{BlockID: "p1"},
113+ ok: true,
114+ },
115+ {
116+ name: "strict backward paragraph motion does not match current paragraph",
117+ from: Cursor{BlockID: "p3"},
118+ count: 1,
119+ want: Cursor{BlockID: "p2"},
120+ ok: true,
121+ },
122+ {
123+ name: "count selects nth previous paragraph",
124+ from: Cursor{BlockID: "p3"},
125+ count: 2,
126+ want: Cursor{BlockID: "p1"},
127+ ok: true,
128+ },
129+ {
130+ name: "no earlier paragraph returns false",
131+ from: Cursor{BlockID: "p0"},
132+ },
133+ {
134+ name: "count past earlier paragraphs returns false",
135+ from: Cursor{BlockID: "p2"},
136+ count: 3,
137+ },
138+ {
139+ name: "zero cursor returns false",
140+ from: Cursor{},
141+ },
142+ {
143+ name: "missing cursor returns false",
144+ from: Cursor{BlockID: "missing"},
145+ },
146+ }
147+
148+ for _, tt := range tests {
149+ t.Run(tt.name, func(t *testing.T) {
150+ before := doc.Blocks()
151+ got, ok := doc.PreviousBlockOfKind(tt.from, BlockKindParagraph, tt.count)
152+ if ok != tt.ok || got != tt.want {
153+ t.Fatalf("PreviousBlockOfKind(%#v, BlockKindParagraph, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, got, ok, tt.want, tt.ok)
154+ }
155+ if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
156+ t.Fatalf("PreviousBlockOfKind mutated document: before %#v after %#v", before, after)
157+ }
158+ })
159+ }
160+}
161+
162+func paragraphMotionDocument(t *testing.T) Document {
163+ t.Helper()
164+
165+ return mustDocument(t, []Block{
166+ {ID: "p0", Kind: BlockKindParagraph},
167+ {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1},
168+ {ID: "p1", Kind: BlockKindParagraph},
169+ {ID: "code1", Kind: BlockKindCode},
170+ {ID: "p2", Kind: BlockKindParagraph},
171+ {ID: "image1", Kind: BlockKindImage},
172+ {ID: "p3", Kind: BlockKindParagraph},
173+ {ID: "table1", Kind: BlockKindTable},
174+ })
175+}
@@ -0,0 +1,175 @@
1+package docmodel
2+
3+import (
4+ "reflect"
5+ "testing"
6+)
7+
8+func TestParagraphNextBlockOfKindProvidesVimCloseBraceSemantics(t *testing.T) {
9+ doc := paragraphMotionDocument(t)
10+
11+ tests := []struct {
12+ name string
13+ from Cursor
14+ count int
15+ want Cursor
16+ ok bool
17+ }{
18+ {
19+ name: "forward paragraph motion skips non paragraphs",
20+ from: Cursor{BlockID: "h1"},
21+ want: Cursor{BlockID: "p1"},
22+ ok: true,
23+ },
24+ {
25+ name: "count below one means one",
26+ from: Cursor{BlockID: "h1"},
27+ count: -1,
28+ want: Cursor{BlockID: "p1"},
29+ ok: true,
30+ },
31+ {
32+ name: "count zero means one",
33+ from: Cursor{BlockID: "code1"},
34+ count: 0,
35+ want: Cursor{BlockID: "p2"},
36+ ok: true,
37+ },
38+ {
39+ name: "strict forward paragraph motion does not match current paragraph",
40+ from: Cursor{BlockID: "p1"},
41+ count: 1,
42+ want: Cursor{BlockID: "p2"},
43+ ok: true,
44+ },
45+ {
46+ name: "count selects nth next paragraph",
47+ from: Cursor{BlockID: "p0"},
48+ count: 2,
49+ want: Cursor{BlockID: "p2"},
50+ ok: true,
51+ },
52+ {
53+ name: "no later paragraph returns false",
54+ from: Cursor{BlockID: "p3"},
55+ },
56+ {
57+ name: "count past later paragraphs returns false",
58+ from: Cursor{BlockID: "p1"},
59+ count: 3,
60+ },
61+ {
62+ name: "zero cursor returns false",
63+ from: Cursor{},
64+ },
65+ {
66+ name: "missing cursor returns false",
67+ from: Cursor{BlockID: "missing"},
68+ },
69+ }
70+
71+ for _, tt := range tests {
72+ t.Run(tt.name, func(t *testing.T) {
73+ before := doc.Blocks()
74+ got, ok := doc.NextBlockOfKind(tt.from, BlockKindParagraph, tt.count)
75+ if ok != tt.ok || got != tt.want {
76+ t.Fatalf("NextBlockOfKind(%#v, BlockKindParagraph, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, got, ok, tt.want, tt.ok)
77+ }
78+ if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
79+ t.Fatalf("NextBlockOfKind mutated document: before %#v after %#v", before, after)
80+ }
81+ })
82+ }
83+}
84+
85+func TestParagraphPreviousBlockOfKindProvidesVimOpenBraceSemantics(t *testing.T) {
86+ doc := paragraphMotionDocument(t)
87+
88+ tests := []struct {
89+ name string
90+ from Cursor
91+ count int
92+ want Cursor
93+ ok bool
94+ }{
95+ {
96+ name: "backward paragraph motion skips non paragraphs",
97+ from: Cursor{BlockID: "table1"},
98+ want: Cursor{BlockID: "p3"},
99+ ok: true,
100+ },
101+ {
102+ name: "count below one means one",
103+ from: Cursor{BlockID: "table1"},
104+ count: -1,
105+ want: Cursor{BlockID: "p3"},
106+ ok: true,
107+ },
108+ {
109+ name: "count zero means one",
110+ from: Cursor{BlockID: "code1"},
111+ count: 0,
112+ want: Cursor{BlockID: "p1"},
113+ ok: true,
114+ },
115+ {
116+ name: "strict backward paragraph motion does not match current paragraph",
117+ from: Cursor{BlockID: "p3"},
118+ count: 1,
119+ want: Cursor{BlockID: "p2"},
120+ ok: true,
121+ },
122+ {
123+ name: "count selects nth previous paragraph",
124+ from: Cursor{BlockID: "p3"},
125+ count: 2,
126+ want: Cursor{BlockID: "p1"},
127+ ok: true,
128+ },
129+ {
130+ name: "no earlier paragraph returns false",
131+ from: Cursor{BlockID: "p0"},
132+ },
133+ {
134+ name: "count past earlier paragraphs returns false",
135+ from: Cursor{BlockID: "p2"},
136+ count: 3,
137+ },
138+ {
139+ name: "zero cursor returns false",
140+ from: Cursor{},
141+ },
142+ {
143+ name: "missing cursor returns false",
144+ from: Cursor{BlockID: "missing"},
145+ },
146+ }
147+
148+ for _, tt := range tests {
149+ t.Run(tt.name, func(t *testing.T) {
150+ before := doc.Blocks()
151+ got, ok := doc.PreviousBlockOfKind(tt.from, BlockKindParagraph, tt.count)
152+ if ok != tt.ok || got != tt.want {
153+ t.Fatalf("PreviousBlockOfKind(%#v, BlockKindParagraph, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, got, ok, tt.want, tt.ok)
154+ }
155+ if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
156+ t.Fatalf("PreviousBlockOfKind mutated document: before %#v after %#v", before, after)
157+ }
158+ })
159+ }
160+}
161+
162+func paragraphMotionDocument(t *testing.T) Document {
163+ t.Helper()
164+
165+ return mustDocument(t, []Block{
166+ {ID: "p0", Kind: BlockKindParagraph},
167+ {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1},
168+ {ID: "p1", Kind: BlockKindParagraph},
169+ {ID: "code1", Kind: BlockKindCode},
170+ {ID: "p2", Kind: BlockKindParagraph},
171+ {ID: "image1", Kind: BlockKindImage},
172+ {ID: "p3", Kind: BlockKindParagraph},
173+ {ID: "table1", Kind: BlockKindTable},
174+ })
175+}

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