Skip to content

Commit dfe444e

Browse files
committed
nook: jump to next/previous diagnostic with F8 / Alt+F8
The only ways to reach a diagnostic were the Alt+P workspace panel or scrolling to the gutter marker by hand — there was no cursor-level navigation between problems in the active buffer. F8 moves the cursor to the next diagnostic, Alt+F8 to the previous one, both wrapping around the ends. Positions are sorted by (row, col) and deduplicated so several diagnostics on one spot count once. The landing cursor is pushed to the jump list first, so Alt+- returns to where you were, and the status bar reports the ordinal. bubbletea has no portable shift+f8 constant, so Alt is the backward fallback, consistent with the debug F-keys' Alt+F5 / Alt+F11.
1 parent 14afc1e commit dfe444e

4 files changed

Lines changed: 209 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
88

99
### Added
1010

11+
- nook can now jump between diagnostics in the active buffer with F8 (next)
12+
and Alt+F8 (previous), mirroring Zed's f8 / shift+f8. Both directions wrap
13+
around the ends of the buffer, the landing position is recorded in the jump
14+
list so Alt+- returns to where you were, and the status bar reports the
15+
ordinal (`diagnostic 2/3 — L5:2`). Previously the only way to reach a
16+
problem was the Alt+P workspace panel or scrolling to the gutter marker by
17+
hand; there was no cursor-level navigation between them.
1118
- nook's status bar now shows a breadcrumb of the enclosing-symbol path at
1219
the cursor — `Server > Handle` when the caret sits inside the `Handle`
1320
method of `Server`, matching Zed and VSCode. The trail reads a warm

cmd/nook/internal/help/help.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ func Default() []Section {
118118
}},
119119
{Name: "Problems", Bindings: []Binding{
120120
{"alt+p", "Workspace-wide diagnostics panel"},
121+
{"f8 / alt+f8", "Jump to next / previous diagnostic in the buffer (wraps)"},
121122
{"↑ ↓", "Navigate rows"},
122123
{"pgup / pgdn", "Page through entries"},
123124
{"home / end", "Jump to first / last row"},

cmd/nook/main.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2292,6 +2292,13 @@ func (m model) routeKey(km tea.KeyMsg) (tea.Model, tea.Cmd) {
22922292
// identifier under the cursor, not whatever character the
22932293
// user happened to be over.
22942294
return m.triggerRename()
2295+
case tea.KeyF8:
2296+
// F8 jumps to the next diagnostic in the active buffer, Alt+F8 to
2297+
// the previous one — mirroring Zed's f8 / shift+f8. bubbletea has no
2298+
// portable shift+f8 constant (the same gap that forces Alt+F5 for
2299+
// debug stop), so Alt is the backward fallback. Both directions wrap
2300+
// around the ends of the buffer.
2301+
return m.jumpDiagnostic(!km.Alt)
22952302
case tea.KeyF5:
22962303
// F5 launches a debug session when none is live, or resumes
22972304
// the paused thread when one is. Alt+F5 (handled further
@@ -5483,6 +5490,83 @@ func (m model) diagCounts() (errs, warns int) {
54835490
return
54845491
}
54855492

5493+
// diagnosticPositions returns the active buffer's diagnostics as (row, col)
5494+
// pairs sorted ascending by row then col, with duplicates at the same position
5495+
// collapsed. Both coordinates are 0-based, matching the editor cursor. Returns
5496+
// nil when no buffer is active or the buffer carries no diagnostics.
5497+
func (m model) diagnosticPositions() []diagPos {
5498+
p := m.bufs.Active()
5499+
if p == nil || p.Path() == "" {
5500+
return nil
5501+
}
5502+
items := m.diagnostics[p.Path()]
5503+
if len(items) == 0 {
5504+
return nil
5505+
}
5506+
pos := make([]diagPos, 0, len(items))
5507+
for _, d := range items {
5508+
pos = append(pos, diagPos{Row: int(d.Range.Start.Line), Col: int(d.Range.Start.Character)})
5509+
}
5510+
sort.Slice(pos, func(i, j int) bool {
5511+
if pos[i].Row != pos[j].Row {
5512+
return pos[i].Row < pos[j].Row
5513+
}
5514+
return pos[i].Col < pos[j].Col
5515+
})
5516+
out := pos[:1]
5517+
for _, q := range pos[1:] {
5518+
if last := out[len(out)-1]; q == last {
5519+
continue
5520+
}
5521+
out = append(out, q)
5522+
}
5523+
return out
5524+
}
5525+
5526+
type diagPos struct{ Row, Col int }
5527+
5528+
// jumpDiagnostic moves the cursor to the next (forward) or previous diagnostic
5529+
// in the active buffer, wrapping around the ends. The cursor position is
5530+
// recorded in the jump list first so Alt+- returns to it. A no-op with a status
5531+
// hint when the buffer has no diagnostics.
5532+
func (m model) jumpDiagnostic(forward bool) (tea.Model, tea.Cmd) {
5533+
pos := m.diagnosticPositions()
5534+
if len(pos) == 0 {
5535+
m.status = "no diagnostics in buffer"
5536+
return m, nil
5537+
}
5538+
p := m.bufs.Active()
5539+
curRow, curCol := p.CursorRow(), p.CursorCol()
5540+
target := -1
5541+
if forward {
5542+
for i, q := range pos {
5543+
if q.Row > curRow || (q.Row == curRow && q.Col > curCol) {
5544+
target = i
5545+
break
5546+
}
5547+
}
5548+
if target == -1 {
5549+
target = 0 // past the last diagnostic: wrap to the first
5550+
}
5551+
} else {
5552+
for i := len(pos) - 1; i >= 0; i-- {
5553+
if q := pos[i]; q.Row < curRow || (q.Row == curRow && q.Col < curCol) {
5554+
target = i
5555+
break
5556+
}
5557+
}
5558+
if target == -1 {
5559+
target = len(pos) - 1 // before the first diagnostic: wrap to the last
5560+
}
5561+
}
5562+
t := pos[target]
5563+
m.pushNavCurrent()
5564+
*p = p.JumpTo(t.Row+1, t.Col+1).Focus()
5565+
m = m.resize()
5566+
m.status = fmt.Sprintf("diagnostic %d/%d — L%d:%d", target+1, len(pos), t.Row+1, t.Col+1)
5567+
return m, tea.Batch(m.refreshGutterCmd(), m.refreshInlayHintsCmd(), m.refreshBlameCmd())
5568+
}
5569+
54865570
func renderDiff(t theme.Theme, title, body string, w, h int) string {
54875571
header := lipgloss.NewStyle().Foreground(t.TextMuted).Bold(true).Render("diff: " + title)
54885572
addStyle := lipgloss.NewStyle().Foreground(t.Success)

cmd/nook/main_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,123 @@ func TestDiagCounts(t *testing.T) {
690690
}
691691
}
692692

693+
// diagJumpModel builds a hermetic model with a single buffer open and three
694+
// diagnostics seeded on content lines (rows 2, 4, 6, cols within the line
695+
// length so JumpTo does not clamp), returned unsorted to prove the jump path
696+
// sorts them. The cursor starts at row 0, col 0.
697+
func diagJumpModel(t *testing.T) (model, string) {
698+
t.Helper()
699+
root := t.TempDir()
700+
path := filepath.Join(root, "a.go")
701+
body := "package main\n\nfunc a() {}\n\nfunc b() {}\n\nfunc c() {}\n"
702+
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
703+
t.Fatal(err)
704+
}
705+
m := newModel(root)
706+
m.width, m.height = 100, 30
707+
m = m.resize()
708+
m.bufs.OpenOrSwitch(path)
709+
m.diagnostics[path] = []protocol.Diagnostic{
710+
{Range: protocol.Range{Start: protocol.Position{Line: 6, Character: 2}}, Severity: protocol.DiagnosticSeverityWarning, Message: "c"},
711+
{Range: protocol.Range{Start: protocol.Position{Line: 2, Character: 0}}, Severity: protocol.DiagnosticSeverityError, Message: "a"},
712+
{Range: protocol.Range{Start: protocol.Position{Line: 4, Character: 5}}, Severity: protocol.DiagnosticSeverityWarning, Message: "b"},
713+
}
714+
return m, path
715+
}
716+
717+
// TestJumpDiagnosticForwardWraps walks F8 through all three diagnostics in
718+
// sorted order and confirms the fourth press wraps to the first.
719+
func TestJumpDiagnosticForwardWraps(t *testing.T) {
720+
m, _ := diagJumpModel(t)
721+
want := []struct{ row, col int }{{2, 0}, {4, 5}, {6, 2}, {2, 0}}
722+
for i, w := range want {
723+
updated, _ := m.jumpDiagnostic(true)
724+
m = updated.(model)
725+
p := m.bufs.Active()
726+
if p.CursorRow() != w.row || p.CursorCol() != w.col {
727+
t.Fatalf("forward step %d: cursor at (%d,%d), want (%d,%d)", i, p.CursorRow(), p.CursorCol(), w.row, w.col)
728+
}
729+
}
730+
}
731+
732+
// TestJumpDiagnosticBackwardWraps confirms Alt+F8 from the top of the buffer
733+
// wraps to the last diagnostic, then walks upward in order.
734+
func TestJumpDiagnosticBackwardWraps(t *testing.T) {
735+
m, _ := diagJumpModel(t)
736+
want := []struct{ row, col int }{{6, 2}, {4, 5}, {2, 0}, {6, 2}}
737+
for i, w := range want {
738+
updated, _ := m.jumpDiagnostic(false)
739+
m = updated.(model)
740+
p := m.bufs.Active()
741+
if p.CursorRow() != w.row || p.CursorCol() != w.col {
742+
t.Fatalf("backward step %d: cursor at (%d,%d), want (%d,%d)", i, p.CursorRow(), p.CursorCol(), w.row, w.col)
743+
}
744+
}
745+
}
746+
747+
// TestJumpDiagnosticF8KeyWiring proves the F8 / Alt+F8 keys are routed into
748+
// jumpDiagnostic through the real Update path, not just the helper.
749+
func TestJumpDiagnosticF8KeyWiring(t *testing.T) {
750+
m, _ := diagJumpModel(t)
751+
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyF8})
752+
m = updated.(model)
753+
if p := m.bufs.Active(); p.CursorRow() != 2 {
754+
t.Fatalf("F8 should move cursor to first diagnostic row 2, got %d", p.CursorRow())
755+
}
756+
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyF8, Alt: true})
757+
m = updated.(model)
758+
if p := m.bufs.Active(); p.CursorRow() != 6 {
759+
t.Fatalf("Alt+F8 from row 2 should wrap back to last diagnostic row 6, got %d", p.CursorRow())
760+
}
761+
}
762+
763+
// TestJumpDiagnosticNoDiagnostics confirms the jump is a no-op with a status
764+
// hint when the buffer carries no diagnostics — the mutation-proof: strip the
765+
// seeded map and the cursor must not move.
766+
func TestJumpDiagnosticNoDiagnostics(t *testing.T) {
767+
m, path := diagJumpModel(t)
768+
delete(m.diagnostics, path)
769+
if got := m.diagnosticPositions(); got != nil {
770+
t.Fatalf("diagnosticPositions should be nil with no diagnostics, got %v", got)
771+
}
772+
before := m.bufs.Active().CursorRow()
773+
updated, _ := m.jumpDiagnostic(true)
774+
m = updated.(model)
775+
if p := m.bufs.Active(); p.CursorRow() != before {
776+
t.Fatalf("cursor moved from %d to %d despite no diagnostics", before, p.CursorRow())
777+
}
778+
if m.status != "no diagnostics in buffer" {
779+
t.Fatalf("status = %q, want no-diagnostics hint", m.status)
780+
}
781+
}
782+
783+
// TestDiagnosticPositionsSortedDeduped proves the position list is sorted and
784+
// collapses duplicate diagnostics sharing one (row,col).
785+
func TestDiagnosticPositionsSortedDeduped(t *testing.T) {
786+
root := t.TempDir()
787+
path := filepath.Join(root, "a.go")
788+
if err := os.WriteFile(path, []byte("package main\n\nfunc a() {}\n"), 0o644); err != nil {
789+
t.Fatal(err)
790+
}
791+
m := newModel(root)
792+
m.bufs.OpenOrSwitch(path)
793+
m.diagnostics[path] = []protocol.Diagnostic{
794+
{Range: protocol.Range{Start: protocol.Position{Line: 2, Character: 5}}, Message: "dup2"},
795+
{Range: protocol.Range{Start: protocol.Position{Line: 0, Character: 0}}, Message: "first"},
796+
{Range: protocol.Range{Start: protocol.Position{Line: 2, Character: 5}}, Message: "dup1"},
797+
}
798+
got := m.diagnosticPositions()
799+
want := []diagPos{{0, 0}, {2, 5}}
800+
if len(got) != len(want) {
801+
t.Fatalf("positions len=%d want %d (%v)", len(got), len(want), got)
802+
}
803+
for i := range want {
804+
if got[i] != want[i] {
805+
t.Fatalf("position %d = %v, want %v", i, got[i], want[i])
806+
}
807+
}
808+
}
809+
693810
// TestTabFlowMultipleBuffers exercises the full v0.4.0 tab story end-to-end:
694811
// open three buffers, walk forward/back with Alt+]/Alt+[, close with Ctrl+W,
695812
// and verify the welcome card returns when the last buffer closes.

0 commit comments

Comments
 (0)