Skip to content

Commit 93dce45

Browse files
committed
feat: detect hunk keywords in annotations and populate EndLine
1 parent 2bda53c commit 93dce45

3 files changed

Lines changed: 142 additions & 9 deletions

File tree

docs/plans/20260406-hunk-keyword-expansion.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,13 @@ Add an `EndLine` field to `Annotation`. When creating an annotation, if the comm
4040
- Modify: `ui/annotate.go`
4141
- Modify: `ui/model_test.go`
4242

43-
- [ ] add `hunkEndLine(idx int) int` method that finds the last line of the hunk containing diffLines[idx]
44-
- [ ] in the annotation creation path, after building the Annotation, check if comment contains hunk keywords (case-insensitive "hunk" or "block" as whole words)
45-
- [ ] if keyword found and line is in a change hunk, set `EndLine` to hunk end line number via `hunkEndLine`
46-
- [ ] write test: annotation with "refactor this hunk" gets EndLine populated
47-
- [ ] write test: annotation with "this is fine" does NOT get EndLine
48-
- [ ] write test: annotation on context line (not in hunk) does NOT get EndLine even with keyword
49-
- [ ] run `make test && make lint`
43+
- [x] add `hunkEndLine(idx int) int` method that finds the last line of the hunk containing diffLines[idx]
44+
- [x] in the annotation creation path, after building the Annotation, check if comment contains hunk keywords (case-insensitive "hunk" or "block" as whole words)
45+
- [x] if keyword found and line is in a change hunk, set `EndLine` to hunk end line number via `hunkEndLine`
46+
- [x] write test: annotation with "refactor this hunk" gets EndLine populated
47+
- [x] write test: annotation with "this is fine" does NOT get EndLine
48+
- [x] write test: annotation on context line (not in hunk) does NOT get EndLine even with keyword
49+
- [x] run `make test && make lint`
5050

5151
### Task 3: Verify and document
5252

ui/annotate.go

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package ui
22

33
import (
44
"fmt"
5+
"regexp"
56

67
"github.com/charmbracelet/bubbles/textinput"
78
tea "github.com/charmbracelet/bubbletea"
@@ -11,6 +12,9 @@ import (
1112
"github.com/umputun/revdiff/diff"
1213
)
1314

15+
// hunkKeywordRe matches whole-word "hunk" or "block" (case-insensitive).
16+
var hunkKeywordRe = regexp.MustCompile(`(?i)\b(hunk|block)\b`)
17+
1418
// newAnnotationInput creates and focuses a text input for annotation editing.
1519
// prefixWidth accounts for the visible prefix characters (cursor col + emoji + label + margin).
1620
func (m *Model) newAnnotationInput(placeholder string, prefixWidth int) (textinput.Model, tea.Cmd) {
@@ -104,7 +108,13 @@ func (m *Model) saveAnnotation() {
104108
}
105109

106110
lineNum := m.diffLineNum(dl)
107-
m.store.Add(annotation.Annotation{File: m.currFile, Line: lineNum, Type: string(dl.ChangeType), Comment: text})
111+
a := annotation.Annotation{File: m.currFile, Line: lineNum, Type: string(dl.ChangeType), Comment: text}
112+
if hunkKeywordRe.MatchString(text) {
113+
if endLine := m.hunkEndLine(m.diffCursor); endLine > lineNum {
114+
a.EndLine = endLine
115+
}
116+
}
117+
m.store.Add(a)
108118
m.annotating = false
109119
m.tree.refreshFilter(m.annotatedFiles())
110120
m.viewport.SetContent(m.renderDiff())
@@ -221,6 +231,29 @@ func (m Model) diffLineNum(dl diff.DiffLine) int {
221231
return dl.NewNum
222232
}
223233

234+
// hunkEndLine returns the display line number of the last line in the change hunk
235+
// containing diffLines[idx]. returns 0 if idx is not inside a change hunk.
236+
func (m Model) hunkEndLine(idx int) int {
237+
if idx < 0 || idx >= len(m.diffLines) {
238+
return 0
239+
}
240+
dl := m.diffLines[idx]
241+
if dl.ChangeType != diff.ChangeAdd && dl.ChangeType != diff.ChangeRemove {
242+
return 0
243+
}
244+
245+
// walk forward from idx to find the last contiguous change line
246+
last := idx
247+
for i := idx + 1; i < len(m.diffLines); i++ {
248+
ct := m.diffLines[i].ChangeType
249+
if ct != diff.ChangeAdd && ct != diff.ChangeRemove {
250+
break
251+
}
252+
last = i
253+
}
254+
return m.diffLineNum(m.diffLines[last])
255+
}
256+
224257
// wrappedAnnotationLineCount returns the number of visual rows an annotation occupies.
225258
// annotations always wrap at the pane width regardless of wrapMode.
226259
func (m Model) wrappedAnnotationLineCount(key string) int {

ui/model_test.go

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -478,7 +478,6 @@ func TestModel_BlameFromConfig(t *testing.T) {
478478
})
479479
}
480480

481-
482481
func TestModel_StatusModeIcons(t *testing.T) {
483482
t.Run("all icons always present", func(t *testing.T) {
484483
m := testModel(nil, nil)
@@ -939,6 +938,107 @@ func TestModel_AnnotateEnterEmptyTextCancels(t *testing.T) {
939938
assert.Empty(t, model.store.Get("a.go"))
940939
}
941940

941+
func TestModel_AnnotateHunkKeywordSetsEndLine(t *testing.T) {
942+
lines := []diff.DiffLine{
943+
{OldNum: 1, NewNum: 1, Content: "ctx before", ChangeType: diff.ChangeContext},
944+
{OldNum: 2, Content: "old line", ChangeType: diff.ChangeRemove},
945+
{NewNum: 2, Content: "new line", ChangeType: diff.ChangeAdd},
946+
{NewNum: 3, Content: "added line", ChangeType: diff.ChangeAdd},
947+
{OldNum: 3, NewNum: 4, Content: "ctx after", ChangeType: diff.ChangeContext},
948+
}
949+
950+
t.Run("hunk keyword populates EndLine", func(t *testing.T) {
951+
m := testModel([]string{"a.go"}, nil)
952+
m.tree = newFileTree([]string{"a.go"})
953+
m.focus = paneDiff
954+
m.currFile = "a.go"
955+
m.diffLines = lines
956+
m.diffCursor = 2 // on "new line" (add, NewNum=2)
957+
m.startAnnotation()
958+
m.annotateInput.SetValue("refactor this hunk")
959+
m.saveAnnotation()
960+
anns := m.store.Get("a.go")
961+
require.Len(t, anns, 1)
962+
assert.Equal(t, 2, anns[0].Line)
963+
assert.Equal(t, 3, anns[0].EndLine, "EndLine should be last add line's NewNum")
964+
})
965+
966+
t.Run("block keyword populates EndLine", func(t *testing.T) {
967+
m := testModel([]string{"a.go"}, nil)
968+
m.tree = newFileTree([]string{"a.go"})
969+
m.focus = paneDiff
970+
m.currFile = "a.go"
971+
m.diffLines = lines
972+
m.diffCursor = 1 // on "old line" (remove, OldNum=2)
973+
m.startAnnotation()
974+
m.annotateInput.SetValue("review this BLOCK carefully")
975+
m.saveAnnotation()
976+
anns := m.store.Get("a.go")
977+
require.Len(t, anns, 1)
978+
assert.Equal(t, 2, anns[0].Line)
979+
assert.Equal(t, 3, anns[0].EndLine, "EndLine should be last add line's NewNum")
980+
})
981+
982+
t.Run("no keyword does not set EndLine", func(t *testing.T) {
983+
m := testModel([]string{"a.go"}, nil)
984+
m.tree = newFileTree([]string{"a.go"})
985+
m.focus = paneDiff
986+
m.currFile = "a.go"
987+
m.diffLines = lines
988+
m.diffCursor = 2
989+
m.startAnnotation()
990+
m.annotateInput.SetValue("this is fine")
991+
m.saveAnnotation()
992+
anns := m.store.Get("a.go")
993+
require.Len(t, anns, 1)
994+
assert.Equal(t, 0, anns[0].EndLine, "EndLine should be 0 when no hunk keyword")
995+
})
996+
997+
t.Run("context line with keyword does not set EndLine", func(t *testing.T) {
998+
m := testModel([]string{"a.go"}, nil)
999+
m.tree = newFileTree([]string{"a.go"})
1000+
m.focus = paneDiff
1001+
m.currFile = "a.go"
1002+
m.diffLines = lines
1003+
m.diffCursor = 0 // context line
1004+
m.startAnnotation()
1005+
m.annotateInput.SetValue("rewrite this hunk")
1006+
m.saveAnnotation()
1007+
anns := m.store.Get("a.go")
1008+
require.Len(t, anns, 1)
1009+
assert.Equal(t, 0, anns[0].EndLine, "EndLine should be 0 for context line even with keyword")
1010+
})
1011+
}
1012+
1013+
func TestModel_HunkEndLine(t *testing.T) {
1014+
lines := []diff.DiffLine{
1015+
{OldNum: 1, NewNum: 1, Content: "ctx", ChangeType: diff.ChangeContext},
1016+
{OldNum: 2, Content: "removed", ChangeType: diff.ChangeRemove},
1017+
{NewNum: 2, Content: "added1", ChangeType: diff.ChangeAdd},
1018+
{NewNum: 3, Content: "added2", ChangeType: diff.ChangeAdd},
1019+
{OldNum: 3, NewNum: 4, Content: "ctx", ChangeType: diff.ChangeContext},
1020+
}
1021+
m := testModel(nil, nil)
1022+
m.diffLines = lines
1023+
1024+
t.Run("returns last line of hunk from first change", func(t *testing.T) {
1025+
assert.Equal(t, 3, m.hunkEndLine(1), "hunk ending at add NewNum=3")
1026+
})
1027+
t.Run("returns last line from middle of hunk", func(t *testing.T) {
1028+
assert.Equal(t, 3, m.hunkEndLine(2))
1029+
})
1030+
t.Run("returns last line from last change line", func(t *testing.T) {
1031+
assert.Equal(t, 3, m.hunkEndLine(3))
1032+
})
1033+
t.Run("returns 0 for context line", func(t *testing.T) {
1034+
assert.Equal(t, 0, m.hunkEndLine(0))
1035+
})
1036+
t.Run("returns 0 for out of bounds", func(t *testing.T) {
1037+
assert.Equal(t, 0, m.hunkEndLine(-1))
1038+
assert.Equal(t, 0, m.hunkEndLine(99))
1039+
})
1040+
}
1041+
9421042
func TestModel_AnnotateEscCancels(t *testing.T) {
9431043
lines := []diff.DiffLine{
9441044
{NewNum: 1, Content: "line1", ChangeType: diff.ChangeContext},

0 commit comments

Comments
 (0)