feat: improve textbox editor behaviour

This commit is contained in:
2026-07-31 15:36:23 +02:00
parent b24669e604
commit e36b00f741
15 changed files with 504 additions and 154 deletions

View File

@@ -701,13 +701,16 @@ func TestDashboardEditorUpdatesTitleBodyAndBaseBranch(t *testing.T) {
send(tea.KeyMsg{Type: tea.KeyShiftTab})
send(tea.KeyMsg{Type: tea.KeyShiftTab})
send(tea.KeyMsg{Type: tea.KeyShiftTab})
send(runeKey("i"))
send(tea.KeyMsg{Type: tea.KeyHome})
for range len("main") {
send(tea.KeyMsg{Type: tea.KeyDelete})
}
send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("release")})
send(tea.KeyMsg{Type: tea.KeyEsc})
send(tea.KeyMsg{Type: tea.KeyShiftTab})
send(runeKey("i"))
send(tea.KeyMsg{Type: tea.KeyHome})
for range len("Old title") {
send(tea.KeyMsg{Type: tea.KeyDelete})
@@ -794,9 +797,95 @@ func TestDashboardDescriptionCanUseStandardEditingMode(t *testing.T) {
Permissions: ViewerPermissions{CanUpdatePR: true},
}
m.startPREdit()
editor := m.prEditEditors[prEditBodyField]
if editor.Modal || editor.Mode != textEditorInsert || editor.Cursor != len([]rune("body")) {
t.Fatalf("standard description editor = %#v", editor)
for field, editor := range m.prEditEditors {
if editor.Modal || editor.Mode != textEditorInsert ||
editor.Cursor != len([]rune(editor.Text)) {
t.Fatalf("standard editor field %d = %#v", field, editor)
}
}
}
func TestVimModeAppliesToEveryTextInput(t *testing.T) {
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
m.loading, m.width, m.height = false, 80, 24
m.details = PRDetails{
PullRequest: PullRequest{ID: "pr", Title: "Title"},
Body: "body", BaseRef: "main",
Permissions: ViewerPermissions{CanUpdatePR: true},
}
m.startPREdit()
for field, editor := range m.prEditEditors {
if !editor.Modal || editor.Mode != textEditorNormal {
t.Fatalf("Vim PR editor field %d = %#v", field, editor)
}
}
m.writeMode, m.replyDraft = writeReply, "reply"
m.resetInputEditor(&m.replyEditor, m.replyDraft)
updated, _ := m.updateWriteInput(runeKey("i"))
m = updated.(App)
updated, _ = m.updateWriteInput(runeKey("!"))
m = updated.(App)
updated, _ = m.updateWriteInput(tea.KeyMsg{Type: tea.KeyEsc})
m = updated.(App)
if m.replyDraft != "!reply" || m.replyEditor.Mode != textEditorNormal {
t.Fatalf("Vim reply = %q mode=%s", m.replyDraft, m.replyEditor.Mode)
}
m.writeMode, m.aiMode, m.aiInput = writeNone, aiDiscussion, "ask"
m.resetInputEditor(&m.aiInputEditor, m.aiInput)
updated, _, handled := m.updateAI(runeKey("A"))
m = updated.(App)
if !handled || m.aiInputEditor.Mode != textEditorInsert ||
m.aiInputEditor.Cursor != len([]rune("ask")) {
t.Fatalf("Vim AI append handled=%v editor=%#v", handled, m.aiInputEditor)
}
m.aiMode = aiNone
m.screen, m.loading = threadScreen, false
m.details.Threads = []ReviewThread{{ID: "thread", Path: "main.go"}}
updated, _ = m.Update(runeKey("/"))
m = updated.(App)
if !m.searching || m.searchEditor.Modal || m.searchEditor.Mode != textEditorInsert {
t.Fatalf("insert-only search editor = %#v searching=%v", m.searchEditor, m.searching)
}
updated, _ = m.Update(runeKey("main"))
m = updated.(App)
if m.searchQuery != "main" || m.searchEditor.Mode != textEditorInsert {
t.Fatalf("Vim search query=%q editor=%#v", m.searchQuery, m.searchEditor)
}
}
func TestVimModeUsesGlobalFooterBar(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, time.Second)
m.width, m.height = 80, 10
m.writeMode, m.replyDraft = writeReply, "reply"
m.resetInputEditor(&m.replyEditor, m.replyDraft)
footer := ansi.Strip(m.renderFooter("ctrl+s review • esc normal/cancel", m.width))
if !strings.Contains(footer, "NORMAL") || !strings.Contains(footer, "REPLY") {
t.Fatalf("Normal reply footer = %q", footer)
}
m.replyEditor.Mode = textEditorInsert
footer = ansi.Strip(m.renderFooter("ctrl+s review • esc normal/cancel", m.width))
if !strings.Contains(footer, "INSERT") || strings.Contains(footer, "NORMAL") {
t.Fatalf("Insert reply footer = %q", footer)
}
}
func TestDashboardVimModeBarStaysOnBottomRow(t *testing.T) {
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 80, 24
m.details = PRDetails{
PullRequest: PullRequest{ID: "pr", Title: "Title"},
Body: "short", BaseRef: "main",
Permissions: ViewerPermissions{CanUpdatePR: true},
}
m.startPREdit()
lines := strings.Split(ansi.Strip(m.viewDashboard()), "\n")
if len(lines) != m.height || !strings.Contains(lines[len(lines)-1], "NORMAL") ||
!strings.Contains(lines[len(lines)-1], "EDIT DESCRIPTION") {
t.Fatalf("dashboard mode bar is not on bottom row:\n%s", strings.Join(lines, "\n"))
}
}
@@ -1046,7 +1135,11 @@ func TestThreadInputsUseHardwareCursor(t *testing.T) {
}
defer file.Close()
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
settings := defaultAppSettings()
settings.EditorMode = "standard"
m := NewAppWithSettings(
&recordingPRService{}, "o", "r", false, 50, time.Second, settings,
)
m.cursorOutput = newTerminalCursorOutput(file)
m.screen, m.loading, m.width, m.height = threadScreen, false, 100, 24
m.details = PRDetails{
@@ -1058,6 +1151,7 @@ func TestThreadInputsUseHardwareCursor(t *testing.T) {
}
m.searching, m.searchQuery = true, "main"
m.resetInputEditor(&m.searchEditor, m.searchQuery)
_ = m.viewThreads()
m.cursorOutput.mu.Lock()
searchVisible, searchColumn := m.cursorOutput.visible, m.cursorOutput.column
@@ -1068,6 +1162,7 @@ func TestThreadInputsUseHardwareCursor(t *testing.T) {
m.searching = false
m.aiMode, m.writeThreadID, m.aiInput = aiDiscussion, "thread-1", "Question"
m.resetInputEditor(&m.aiInputEditor, m.aiInput)
m.focus = threadDetailPane
m.scroll = m.detailMaxScroll()
_ = m.viewThreads()
@@ -1140,6 +1235,8 @@ func TestReplyComposerConfirmsAndAddsReturnedComment(t *testing.T) {
if m.writeMode != writeReply {
t.Fatalf("reply key opened mode %d", m.writeMode)
}
updated, _ = m.Update(runeKey("i"))
m = updated.(App)
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("hello")})
m = updated.(App)
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
@@ -1183,6 +1280,13 @@ func TestReplyComposerRendersInlineWithCurrentThread(t *testing.T) {
t.Fatalf("inline reply view is missing %q:\n%s", wanted, plain)
}
}
inline := m.inlineReplyLines(80)
for _, line := range inline {
if strings.Contains(ansi.Strip(line.text), "newline") ||
strings.Contains(ansi.Strip(line.text), "review") {
t.Fatalf("Vim reply contains redundant inline key help: %#v", inline)
}
}
if m.focus != threadDetailPane {
t.Fatal("inline reply did not focus the thread detail pane")
}
@@ -1873,8 +1977,11 @@ func TestFuzzyFileSearchSupportsSpaceSeparatedTerms(t *testing.T) {
t.Fatalf("query matched despite a missing term")
}
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
settings := defaultAppSettings()
settings.EditorMode = "standard"
m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings)
m.screen, m.searching, m.searchQuery = threadScreen, true, "ng"
m.resetInputEditor(&m.searchEditor, m.searchQuery)
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeySpace})
if got := updated.(App).searchQuery; got != "ng " {
t.Fatalf("space key produced search query %q", got)
@@ -1882,8 +1989,11 @@ func TestFuzzyFileSearchSupportsSpaceSeparatedTerms(t *testing.T) {
}
func TestReplyAndAIDiscussionAcceptSpaceKeyWithoutRunes(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
settings := defaultAppSettings()
settings.EditorMode = "standard"
m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings)
m.writeMode, m.replyDraft = writeReply, "reply"
m.resetInputEditor(&m.replyEditor, m.replyDraft)
updated, _ := m.updateWriteInput(tea.KeyMsg{Type: tea.KeySpace})
m = updated.(App)
if m.replyDraft != "reply " {
@@ -1891,6 +2001,7 @@ func TestReplyAndAIDiscussionAcceptSpaceKeyWithoutRunes(t *testing.T) {
}
m.writeMode, m.aiMode, m.aiInput = writeNone, aiDiscussion, "question"
m.resetInputEditor(&m.aiInputEditor, m.aiInput)
updated, _, handled := m.updateAI(tea.KeyMsg{Type: tea.KeySpace})
m = updated.(App)
if !handled || m.aiInput != "question " {
@@ -1898,6 +2009,38 @@ func TestReplyAndAIDiscussionAcceptSpaceKeyWithoutRunes(t *testing.T) {
}
}
func TestReplyAndAIDiscussionArrowKeysMoveInputCursor(t *testing.T) {
settings := defaultAppSettings()
settings.EditorMode = "standard"
m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings)
m.width, m.height = 80, 24
m.writeMode, m.replyDraft = writeReply, "mistke"
m.resetInputEditor(&m.replyEditor, m.replyDraft)
for range 2 {
updated, _ := m.updateWriteInput(tea.KeyMsg{Type: tea.KeyLeft})
m = updated.(App)
}
updated, _ := m.updateWriteInput(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")})
m = updated.(App)
if m.replyDraft != "mistake" || m.replyEditor.Cursor != 5 {
t.Fatalf("reply after cursor edit = %q at %d", m.replyDraft, m.replyEditor.Cursor)
}
m.writeMode, m.aiMode, m.aiInput = writeNone, aiDiscussion, "abc\ndef"
m.resetInputEditor(&m.aiInputEditor, m.aiInput)
m.aiInputEditor.Cursor = 1
updated, _, handled := m.updateAI(tea.KeyMsg{Type: tea.KeyDown})
m = updated.(App)
if !handled || m.aiInputEditor.Cursor != 5 {
t.Fatalf("AI down movement handled=%v cursor=%d, want 5", handled, m.aiInputEditor.Cursor)
}
updated, _, handled = m.updateAI(tea.KeyMsg{Type: tea.KeyRight})
m = updated.(App)
if !handled || m.aiInputEditor.Cursor != 6 {
t.Fatalf("AI right movement handled=%v cursor=%d, want 6", handled, m.aiInputEditor.Cursor)
}
}
func TestFileSearchCanJumpOrCancel(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen = threadScreen