feat: improve textbox editor behaviour
This commit is contained in:
16
README.md
16
README.md
@@ -201,12 +201,16 @@ focused pane by three items or rendered lines. Mouse reporting remains disabled
|
|||||||
by default so normal terminal text selection is unchanged; with mouse reporting
|
by default so normal terminal text selection is unchanged; with mouse reporting
|
||||||
enabled, terminals commonly require holding Shift while selecting text.
|
enabled, terminals commonly require holding Shift while selecting text.
|
||||||
|
|
||||||
The PR description editor defaults to Vim-style modal editing, including
|
Editable text fields default to Vim-style modal editing, including PR metadata,
|
||||||
Normal, Insert, and Visual modes, word/find motions, deletion, system clipboard
|
reply, and local-AI discussion fields. They support Normal, Insert, and Visual
|
||||||
yank/paste, and soft-wrap-aware movement. Set `editing.mode = "standard"` for a
|
modes, word/find motions, deletion, system clipboard yank/paste, and
|
||||||
non-modal editor. Target-branch, reviewer, and assignee completion use
|
soft-wrap-aware movement. The active mode and input are shown in a Neovim-style
|
||||||
`ctrl+n` and `ctrl+p`; reviewer and assignee fields accept comma-separated
|
footer bar. Set `editing.mode = "standard"` for non-modal inputs with arrow-key
|
||||||
GitHub usernames. Pending individual review requests and assignees are
|
cursor movement, including movement across wrapped lines. Search remains a
|
||||||
|
dedicated insert-only filter. Target-branch, reviewer, and assignee completion use
|
||||||
|
`ctrl+n` and `ctrl+p`; `enter` accepts the selected completion, while `tab`
|
||||||
|
moves to the next metadata field. Reviewer and assignee fields accept
|
||||||
|
comma-separated GitHub usernames. Pending individual review requests and assignees are
|
||||||
prefilled and marked in completion results. Reviewers who already submitted a
|
prefilled and marked in completion results. Reviewers who already submitted a
|
||||||
review, and requested teams, appear first as protected subdued tokens in the
|
review, and requested teams, appear first as protected subdued tokens in the
|
||||||
reviewer field. Their handles retain a darker version of their deterministic
|
reviewer field. Their handles retain a darker version of their deterministic
|
||||||
|
|||||||
34
ai_tui.go
34
ai_tui.go
@@ -70,6 +70,7 @@ func (m *App) startAIDiscussion(threadID string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
m.aiMode, m.aiInput, m.writeThreadID, m.err = aiDiscussion, "", threadID, nil
|
m.aiMode, m.aiInput, m.writeThreadID, m.err = aiDiscussion, "", threadID, nil
|
||||||
|
m.resetInputEditor(&m.aiInputEditor, "")
|
||||||
if thread := m.threadByID(threadID); thread != nil {
|
if thread := m.threadByID(threadID); thread != nil {
|
||||||
m.folded[threadID] = false
|
m.folded[threadID] = false
|
||||||
m.focus = threadDetailPane
|
m.focus = threadDetailPane
|
||||||
@@ -298,12 +299,20 @@ func (m App) updateAI(msg tea.Msg) (tea.Model, tea.Cmd, bool) {
|
|||||||
if m.aiMode != aiDiscussion {
|
if m.aiMode != aiDiscussion {
|
||||||
cancelled = cancelled || keyMatches(raw, m.keybindings.General.Back)
|
cancelled = cancelled || keyMatches(raw, m.keybindings.General.Back)
|
||||||
}
|
}
|
||||||
|
if cancelled && m.aiMode == aiDiscussion &&
|
||||||
|
m.aiInputEditor.Modal && m.aiInputEditor.Mode != textEditorNormal {
|
||||||
|
m.aiInputEditor.handleKeyAtWidth(key, true, m.threadInputWidth())
|
||||||
|
m.aiInput = m.aiInputEditor.Text
|
||||||
|
m.ensureThreadInputCursorVisible()
|
||||||
|
return m, nil, true
|
||||||
|
}
|
||||||
if cancelled {
|
if cancelled {
|
||||||
if m.aiCancel != nil {
|
if m.aiCancel != nil {
|
||||||
m.aiCancel()
|
m.aiCancel()
|
||||||
m.aiCancel = nil
|
m.aiCancel = nil
|
||||||
}
|
}
|
||||||
m.aiMode, m.aiInput, m.writeThreadID, m.aiEvents = aiNone, "", "", nil
|
m.aiMode, m.aiInput, m.writeThreadID, m.aiEvents = aiNone, "", "", nil
|
||||||
|
m.aiInputEditor = textEditor{}
|
||||||
return m, nil, true
|
return m, nil, true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,19 +360,12 @@ func (m App) updateAI(msg tea.Msg) (tea.Model, tea.Cmd, bool) {
|
|||||||
} else {
|
} else {
|
||||||
return m, m.beginAIPrepare(m.writeThreadID, strings.TrimSpace(m.aiInput)), true
|
return m, m.beginAIPrepare(m.writeThreadID, strings.TrimSpace(m.aiInput)), true
|
||||||
}
|
}
|
||||||
case keyMatches(raw, m.keybindings.Input.Newline):
|
|
||||||
m.aiInput += "\n"
|
|
||||||
case keyMatches(raw, m.keybindings.Input.DeleteBackward):
|
|
||||||
runes := []rune(m.aiInput)
|
|
||||||
if len(runes) > 0 {
|
|
||||||
m.aiInput = string(runes[:len(runes)-1])
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
if key.Type == tea.KeyRunes || key.Type == tea.KeySpace {
|
if m.aiInputEditor.handleKeyAtWidth(key, true, m.threadInputWidth()) {
|
||||||
m.aiInput += textInputKeyValue(key)
|
m.aiInput = m.aiInputEditor.Text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.scroll = m.detailMaxScroll()
|
m.ensureThreadInputCursorVisible()
|
||||||
case aiConfirm:
|
case aiConfirm:
|
||||||
switch {
|
switch {
|
||||||
case keyMatches(raw, m.keybindings.General.Confirm):
|
case keyMatches(raw, m.keybindings.General.Confirm):
|
||||||
@@ -461,16 +463,17 @@ func (m App) viewAI() string {
|
|||||||
lines = append(lines, titleStyle.Render("Local AI discussion"), "",
|
lines = append(lines, titleStyle.Render("Local AI discussion"), "",
|
||||||
dimStyle.Render("This message stays local; only the configured model receives it."), "")
|
dimStyle.Render("This message stays local; only the configured model receives it."), "")
|
||||||
lines = append(lines, renderTextInput(
|
lines = append(lines, renderTextInput(
|
||||||
m.aiInput, width-2, m.cursorOutput != nil,
|
m.aiInputEditor, width-2, m.cursorOutput != nil,
|
||||||
)...)
|
)...)
|
||||||
if m.err != nil {
|
if m.err != nil {
|
||||||
lines = append(lines, "", badStyle.Render(m.err.Error()))
|
lines = append(lines, "", badStyle.Render(m.err.Error()))
|
||||||
}
|
}
|
||||||
lines = append(lines, "", dimStyle.Render(fmt.Sprintf(
|
lines = append(lines, "", dimStyle.Render(fmt.Sprintf(
|
||||||
"%s newline • %s prepare • %s cancel",
|
"%s newline • %s prepare • %s %s",
|
||||||
primaryKeyLabel(m.keybindings.Input.Newline),
|
primaryKeyLabel(m.keybindings.Input.Newline),
|
||||||
primaryKeyLabel(m.keybindings.Input.Submit),
|
primaryKeyLabel(m.keybindings.Input.Submit),
|
||||||
primaryKeyLabel(m.keybindings.Input.Cancel),
|
primaryKeyLabel(m.keybindings.Input.Cancel),
|
||||||
|
m.inputCancelAction(),
|
||||||
)))
|
)))
|
||||||
case aiPreparing:
|
case aiPreparing:
|
||||||
lines = m.aiProgressLines(width)
|
lines = m.aiProgressLines(width)
|
||||||
@@ -666,7 +669,9 @@ func (m App) inlineAIDiscussionLines(width int) []detailLine {
|
|||||||
}
|
}
|
||||||
textWidth := max(1, width-5)
|
textWidth := max(1, width-5)
|
||||||
lineIndex := 0
|
lineIndex := 0
|
||||||
for _, part := range renderTextInput(m.aiInput, textWidth, m.cursorOutput != nil) {
|
for _, part := range renderTextInput(
|
||||||
|
m.aiInputEditor, textWidth, m.cursorOutput != nil,
|
||||||
|
) {
|
||||||
lines = append(lines, detailLine{
|
lines = append(lines, detailLine{
|
||||||
rail: rail, anchor: fmt.Sprintf("ai-discussion:body:%d", lineIndex), text: part,
|
rail: rail, anchor: fmt.Sprintf("ai-discussion:body:%d", lineIndex), text: part,
|
||||||
})
|
})
|
||||||
@@ -678,10 +683,11 @@ func (m App) inlineAIDiscussionLines(width int) []detailLine {
|
|||||||
lines = append(lines, detailLine{
|
lines = append(lines, detailLine{
|
||||||
rail: rail,
|
rail: rail,
|
||||||
text: dimStyle.Render(fmt.Sprintf(
|
text: dimStyle.Render(fmt.Sprintf(
|
||||||
"%s newline • %s prepare • %s cancel",
|
"%s newline • %s prepare • %s %s",
|
||||||
primaryKeyLabel(m.keybindings.Input.Newline),
|
primaryKeyLabel(m.keybindings.Input.Newline),
|
||||||
primaryKeyLabel(m.keybindings.Input.Submit),
|
primaryKeyLabel(m.keybindings.Input.Submit),
|
||||||
primaryKeyLabel(m.keybindings.Input.Cancel),
|
primaryKeyLabel(m.keybindings.Input.Cancel),
|
||||||
|
m.inputCancelAction(),
|
||||||
)),
|
)),
|
||||||
})
|
})
|
||||||
return lines
|
return lines
|
||||||
|
|||||||
@@ -178,13 +178,12 @@ func (m App) branchCompletionLines(width int) []string {
|
|||||||
return []string{dimStyle.Render(" no matching repository branches")}
|
return []string{dimStyle.Render(" no matching repository branches")}
|
||||||
}
|
}
|
||||||
lines := []string{dimStyle.Render(fmt.Sprintf(
|
lines := []string{dimStyle.Render(fmt.Sprintf(
|
||||||
" %s choose • %s complete • %s again advances",
|
" %s choose • %s complete",
|
||||||
primaryCombinedKeyLabel(
|
primaryCombinedKeyLabel(
|
||||||
m.keybindings.Input.PreviousCompletion,
|
m.keybindings.Input.PreviousCompletion,
|
||||||
m.keybindings.Input.NextCompletion,
|
m.keybindings.Input.NextCompletion,
|
||||||
),
|
),
|
||||||
primaryCombinedKeyLabel(m.keybindings.Input.NextField, m.keybindings.Input.Newline),
|
primaryKeyLabel(m.keybindings.Input.Newline),
|
||||||
primaryKeyLabel(m.keybindings.Input.NextField),
|
|
||||||
))}
|
))}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for index, suggestion := range suggestions {
|
for index, suggestion := range suggestions {
|
||||||
|
|||||||
@@ -68,19 +68,23 @@ func TestTargetBranchCompletionIsKeyboardFirst(t *testing.T) {
|
|||||||
m = updated.(App)
|
m = updated.(App)
|
||||||
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyTab})
|
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyTab})
|
||||||
m = updated.(App)
|
m = updated.(App)
|
||||||
|
if got := m.prEditEditors[prEditBaseField].Text; got != "release" {
|
||||||
|
t.Fatalf("tab unexpectedly completed selected branch: %q", got)
|
||||||
|
}
|
||||||
|
if m.prEditField != prEditReviewersField {
|
||||||
|
t.Fatalf("tab did not advance from target branch: field=%d", m.prEditField)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.prEditField = prEditBaseField
|
||||||
|
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
|
m = updated.(App)
|
||||||
if got := m.prEditEditors[prEditBaseField].Text; got != "release/2.0" &&
|
if got := m.prEditEditors[prEditBaseField].Text; got != "release/2.0" &&
|
||||||
got != "release/1.0" {
|
got != "release/1.0" {
|
||||||
t.Fatalf("tab did not complete selected branch: %q", got)
|
t.Fatalf("enter did not complete selected branch: %q", got)
|
||||||
}
|
}
|
||||||
if m.prEditField != prEditBaseField {
|
if m.prEditField != prEditBaseField {
|
||||||
t.Fatalf("completion moved away from target branch: field=%d", m.prEditField)
|
t.Fatalf("completion moved away from target branch: field=%d", m.prEditField)
|
||||||
}
|
}
|
||||||
|
|
||||||
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyTab})
|
|
||||||
m = updated.(App)
|
|
||||||
if m.prEditField != prEditReviewersField {
|
|
||||||
t.Fatalf("second tab did not advance: field=%d", m.prEditField)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTargetBranchSuggestionsRenderAndValidationRejectsUnknownBranch(t *testing.T) {
|
func TestTargetBranchSuggestionsRenderAndValidationRejectsUnknownBranch(t *testing.T) {
|
||||||
@@ -94,7 +98,7 @@ func TestTargetBranchSuggestionsRenderAndValidationRejectsUnknownBranch(t *testi
|
|||||||
m.prEditBranches = []RepositoryBranch{{Name: "main"}, {Name: "release/2.0"}}
|
m.prEditBranches = []RepositoryBranch{{Name: "main"}, {Name: "release/2.0"}}
|
||||||
|
|
||||||
view := ansi.Strip(strings.Join(m.prEditFieldLines("target branch", prEditBaseField, 80), "\n"))
|
view := ansi.Strip(strings.Join(m.prEditFieldLines("target branch", prEditBaseField, 80), "\n"))
|
||||||
if !strings.Contains(view, "release/2.0") || !strings.Contains(view, "tab / enter complete") {
|
if !strings.Contains(view, "release/2.0") || !strings.Contains(view, "enter complete") {
|
||||||
t.Fatalf("branch suggestions missing:\n%s", view)
|
t.Fatalf("branch suggestions missing:\n%s", view)
|
||||||
}
|
}
|
||||||
if err := m.validatePREdit(); err == nil || !strings.Contains(err.Error(), "not an available") {
|
if err := m.validatePREdit(); err == nil || !strings.Contains(err.Error(), "not an available") {
|
||||||
|
|||||||
4
cli.go
4
cli.go
@@ -210,7 +210,7 @@ _diple() {
|
|||||||
'--compact-reviews=[aggregate submitted reviews]:boolean:(true false)' \
|
'--compact-reviews=[aggregate submitted reviews]:boolean:(true false)' \
|
||||||
'--path-scroll=[scroll truncated paths]:boolean:(true false)' \
|
'--path-scroll=[scroll truncated paths]:boolean:(true false)' \
|
||||||
'--path-scroll-interval[path scrolling interval]:duration:' \
|
'--path-scroll-interval[path scrolling interval]:duration:' \
|
||||||
'--editor-mode[description editor mode]:mode:(vim standard)' \
|
'--editor-mode[text input editor mode]:mode:(vim standard)' \
|
||||||
'--config[TOML configuration file]:file:_files' \
|
'--config[TOML configuration file]:file:_files' \
|
||||||
'--cache=[enable local read cache]:boolean:(true false)' \
|
'--cache=[enable local read cache]:boolean:(true false)' \
|
||||||
'--cache-max-age[maximum offline cache age]:duration:' \
|
'--cache-max-age[maximum offline cache age]:duration:' \
|
||||||
@@ -243,7 +243,7 @@ complete -c diple -l fold-resolved -d 'Start resolved threads folded'
|
|||||||
complete -c diple -l compact-reviews -d 'Aggregate submitted reviews'
|
complete -c diple -l compact-reviews -d 'Aggregate submitted reviews'
|
||||||
complete -c diple -l path-scroll -d 'Scroll truncated paths'
|
complete -c diple -l path-scroll -d 'Scroll truncated paths'
|
||||||
complete -c diple -l path-scroll-interval -x -d 'Path scrolling interval'
|
complete -c diple -l path-scroll-interval -x -d 'Path scrolling interval'
|
||||||
complete -c diple -l editor-mode -x -a 'vim standard' -d 'Description editor mode'
|
complete -c diple -l editor-mode -x -a 'vim standard' -d 'Text input editor mode'
|
||||||
complete -c diple -l config -r -F -d 'TOML configuration file'
|
complete -c diple -l config -r -F -d 'TOML configuration file'
|
||||||
complete -c diple -l cache -d 'Enable local read cache'
|
complete -c diple -l cache -d 'Enable local read cache'
|
||||||
complete -c diple -l cache-max-age -x -d 'Maximum offline cache age'
|
complete -c diple -l cache-max-age -x -d 'Maximum offline cache age'
|
||||||
|
|||||||
@@ -559,7 +559,12 @@ func validateKeyBindingContexts(bindings KeyBindings) error {
|
|||||||
contextBinding{"previous_completion", input.PreviousCompletion},
|
contextBinding{"previous_completion", input.PreviousCompletion},
|
||||||
contextBinding{"next_completion", input.NextCompletion},
|
contextBinding{"next_completion", input.NextCompletion},
|
||||||
contextBinding{"delete_backward", input.DeleteBackward},
|
contextBinding{"delete_backward", input.DeleteBackward},
|
||||||
|
contextBinding{"delete_forward", input.DeleteForward},
|
||||||
contextBinding{"clear", input.Clear},
|
contextBinding{"clear", input.Clear},
|
||||||
|
contextBinding{"line_start", input.LineStart},
|
||||||
|
contextBinding{"line_end", input.LineEnd},
|
||||||
|
contextBinding{"left", nonTextBindings(navigation.Left)},
|
||||||
|
contextBinding{"right", nonTextBindings(navigation.Right)},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -569,6 +574,13 @@ func validateKeyBindingContexts(bindings KeyBindings) error {
|
|||||||
contextBinding{"submit", input.Submit},
|
contextBinding{"submit", input.Submit},
|
||||||
contextBinding{"newline", input.Newline},
|
contextBinding{"newline", input.Newline},
|
||||||
contextBinding{"delete_backward", input.DeleteBackward},
|
contextBinding{"delete_backward", input.DeleteBackward},
|
||||||
|
contextBinding{"delete_forward", input.DeleteForward},
|
||||||
|
contextBinding{"line_start", input.LineStart},
|
||||||
|
contextBinding{"line_end", input.LineEnd},
|
||||||
|
contextBinding{"left", nonTextBindings(navigation.Left)},
|
||||||
|
contextBinding{"down", nonTextBindings(navigation.Down)},
|
||||||
|
contextBinding{"up", nonTextBindings(navigation.Up)},
|
||||||
|
contextBinding{"right", nonTextBindings(navigation.Right)},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -658,6 +670,7 @@ func validateKeyBindingContexts(bindings KeyBindings) error {
|
|||||||
contextBinding{"selection_other_end", vim.SelectionOtherEnd},
|
contextBinding{"selection_other_end", vim.SelectionOtherEnd},
|
||||||
contextBinding{"yank", vim.Yank},
|
contextBinding{"yank", vim.Yank},
|
||||||
contextBinding{"delete", vim.Delete},
|
contextBinding{"delete", vim.Delete},
|
||||||
|
contextBinding{"substitute", vim.ReplaceCharacter},
|
||||||
contextBinding{"paste", vim.Paste},
|
contextBinding{"paste", vim.Paste},
|
||||||
contextBinding{"line_start", vim.LineStart},
|
contextBinding{"line_start", vim.LineStart},
|
||||||
contextBinding{"first_non_blank", vim.FirstNonBlank},
|
contextBinding{"first_non_blank", vim.FirstNonBlank},
|
||||||
@@ -706,6 +719,8 @@ func validateKeyBindingContexts(bindings KeyBindings) error {
|
|||||||
contextBinding{"delete_forward", input.DeleteForward},
|
contextBinding{"delete_forward", input.DeleteForward},
|
||||||
contextBinding{"line_start", input.LineStart},
|
contextBinding{"line_start", input.LineStart},
|
||||||
contextBinding{"line_end", input.LineEnd},
|
contextBinding{"line_end", input.LineEnd},
|
||||||
|
contextBinding{"left", nonTextBindings(navigation.Left)},
|
||||||
|
contextBinding{"right", nonTextBindings(navigation.Right)},
|
||||||
contextBinding{"up", nonTextBindings(navigation.Up)},
|
contextBinding{"up", nonTextBindings(navigation.Up)},
|
||||||
contextBinding{"down", nonTextBindings(navigation.Down)},
|
contextBinding{"down", nonTextBindings(navigation.Down)},
|
||||||
)
|
)
|
||||||
|
|||||||
2
main.go
2
main.go
@@ -52,7 +52,7 @@ func main() {
|
|||||||
cacheEnabled = flag.Bool("cache", defaults.Cache.Enabled, "enable the local read cache and offline fallback")
|
cacheEnabled = flag.Bool("cache", defaults.Cache.Enabled, "enable the local read cache and offline fallback")
|
||||||
cacheMaxAge = flag.Duration("cache-max-age", defaults.Cache.MaxAge.Duration, "oldest cache entry accepted for offline fallback")
|
cacheMaxAge = flag.Duration("cache-max-age", defaults.Cache.MaxAge.Duration, "oldest cache entry accepted for offline fallback")
|
||||||
cacheDir = flag.String("cache-dir", defaults.Cache.Directory, "local read-cache directory")
|
cacheDir = flag.String("cache-dir", defaults.Cache.Directory, "local read-cache directory")
|
||||||
editorMode = flag.String("editor-mode", defaults.Editing.Mode, "description editor mode")
|
editorMode = flag.String("editor-mode", defaults.Editing.Mode, "text input editor mode")
|
||||||
)
|
)
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
if flag.NArg() != 0 {
|
if flag.NArg() != 0 {
|
||||||
|
|||||||
28
pr_editor.go
28
pr_editor.go
@@ -29,17 +29,18 @@ func (m *App) startPREdit() tea.Cmd {
|
|||||||
}
|
}
|
||||||
m.writeMode = writePREdit
|
m.writeMode = writePREdit
|
||||||
m.prEditField = prEditBodyField
|
m.prEditField = prEditBodyField
|
||||||
m.prEditEditors[prEditTitleField] = newTextEditor(m.details.Title, false)
|
modal := m.editorMode == "vim"
|
||||||
m.prEditEditors[prEditBaseField] = newTextEditor(m.details.BaseRef, false)
|
m.prEditEditors[prEditTitleField] = newTextEditor(m.details.Title, modal)
|
||||||
|
m.prEditEditors[prEditBaseField] = newTextEditor(m.details.BaseRef, modal)
|
||||||
m.prEditEditors[prEditReviewersField] = newTextEditor(
|
m.prEditEditors[prEditReviewersField] = newTextEditor(
|
||||||
strings.Join(m.details.RequestedReviewers, ", "), false,
|
strings.Join(m.details.RequestedReviewers, ", "), modal,
|
||||||
)
|
)
|
||||||
m.prEditEditors[prEditAssigneesField] = newTextEditor(
|
m.prEditEditors[prEditAssigneesField] = newTextEditor(
|
||||||
strings.Join(m.details.Assignees, ", "), false,
|
strings.Join(m.details.Assignees, ", "), modal,
|
||||||
)
|
)
|
||||||
m.prEditEditors[prEditBodyField] = newTextEditor(
|
m.prEditEditors[prEditBodyField] = newTextEditor(
|
||||||
normalizeLineEndings(m.details.Body),
|
normalizeLineEndings(m.details.Body),
|
||||||
m.editorMode == "vim",
|
modal,
|
||||||
)
|
)
|
||||||
m.prEditEditors[prEditBodyField].highlightMarkdown = true
|
m.prEditEditors[prEditBodyField].highlightMarkdown = true
|
||||||
m.prEditOriginal = m.currentPRMetadata()
|
m.prEditOriginal = m.currentPRMetadata()
|
||||||
@@ -168,7 +169,9 @@ func (m App) updatePREditInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
if key.Type == tea.KeySpace && m.prEditField == prEditReviewersField {
|
if key.Type == tea.KeySpace && m.prEditField == prEditReviewersField &&
|
||||||
|
(!m.prEditEditors[m.prEditField].Modal ||
|
||||||
|
m.prEditEditors[m.prEditField].Mode == textEditorInsert) {
|
||||||
if m.startNextReviewer() {
|
if m.startNextReviewer() {
|
||||||
m.ensurePREditCursorVisible()
|
m.ensurePREditCursorVisible()
|
||||||
return m, m.queuePREditDraft()
|
return m, m.queuePREditDraft()
|
||||||
@@ -195,13 +198,7 @@ func (m App) updatePREditInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
case "tab":
|
case "tab":
|
||||||
completed := m.prEditField == prEditBaseField && m.completeBranchSuggestion()
|
m.movePREditField(1)
|
||||||
if isPREditPeopleField(m.prEditField) {
|
|
||||||
completed = m.completeUserSuggestion()
|
|
||||||
}
|
|
||||||
if !completed {
|
|
||||||
m.movePREditField(1)
|
|
||||||
}
|
|
||||||
case "shift+tab":
|
case "shift+tab":
|
||||||
m.movePREditField(-1)
|
m.movePREditField(-1)
|
||||||
case "ctrl+n":
|
case "ctrl+n":
|
||||||
@@ -548,7 +545,6 @@ func (m App) dashboardEditLayout() ([]string, int) {
|
|||||||
|
|
||||||
func (m App) prEditFieldLines(label string, field, width int) []string {
|
func (m App) prEditFieldLines(label string, field, width int) []string {
|
||||||
active := m.prEditField == field
|
active := m.prEditField == field
|
||||||
editor := m.prEditEditors[field]
|
|
||||||
if field == prEditReviewersField {
|
if field == prEditReviewersField {
|
||||||
label += " (pending requests editable)"
|
label += " (pending requests editable)"
|
||||||
}
|
}
|
||||||
@@ -556,10 +552,6 @@ func (m App) prEditFieldLines(label string, field, width int) []string {
|
|||||||
if active {
|
if active {
|
||||||
prefix = "▶ "
|
prefix = "▶ "
|
||||||
}
|
}
|
||||||
mode := editor.modeLabel()
|
|
||||||
if mode != "" {
|
|
||||||
label += " [" + mode + "]"
|
|
||||||
}
|
|
||||||
labelLine := dimStyle.Render(prefix + label)
|
labelLine := dimStyle.Render(prefix + label)
|
||||||
if active {
|
if active {
|
||||||
labelLine = titleStyle.Render(prefix + label)
|
labelLine = titleStyle.Render(prefix + label)
|
||||||
|
|||||||
@@ -306,6 +306,8 @@ func (e *textEditor) handleVisualKey(key tea.KeyMsg, multiline bool, wrapWidth i
|
|||||||
e.yankSelection(wrapWidth)
|
e.yankSelection(wrapWidth)
|
||||||
case keyMatches(k, e.keys.Vim.Delete):
|
case keyMatches(k, e.keys.Vim.Delete):
|
||||||
e.deleteSelection(wrapWidth)
|
e.deleteSelection(wrapWidth)
|
||||||
|
case keyMatches(k, e.keys.Vim.ReplaceCharacter):
|
||||||
|
e.substituteSelection(wrapWidth)
|
||||||
case keyMatches(k, e.keys.Vim.Paste):
|
case keyMatches(k, e.keys.Vim.Paste):
|
||||||
e.pasteClipboard(true, wrapWidth)
|
e.pasteClipboard(true, wrapWidth)
|
||||||
default:
|
default:
|
||||||
@@ -407,6 +409,20 @@ func (e *textEditor) deleteSelection(wrapWidth int) {
|
|||||||
e.stopVisual()
|
e.stopVisual()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *textEditor) substituteSelection(wrapWidth int) {
|
||||||
|
start, end, ok := e.selectionBounds(wrapWidth)
|
||||||
|
if !ok {
|
||||||
|
e.stopVisual()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runes := []rune(e.Text)
|
||||||
|
e.Text = string(append(runes[:start], runes[end:]...))
|
||||||
|
e.Cursor = start
|
||||||
|
e.Mode = textEditorInsert
|
||||||
|
e.visualLine = false
|
||||||
|
e.clearPending()
|
||||||
|
}
|
||||||
|
|
||||||
func (e *textEditor) pasteClipboard(replaceSelection bool, wrapWidth int) {
|
func (e *textEditor) pasteClipboard(replaceSelection bool, wrapWidth int) {
|
||||||
if e.clipboard == nil {
|
if e.clipboard == nil {
|
||||||
e.clipboard = systemTextClipboard{}
|
e.clipboard = systemTextClipboard{}
|
||||||
|
|||||||
@@ -397,6 +397,23 @@ func TestVimVisualModeDeletesAcrossSoftWrappedRows(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVimVisualSubstituteDeletesSelectionAndEntersInsertMode(t *testing.T) {
|
||||||
|
editor := newTextEditor("abcdef", true)
|
||||||
|
editor.Cursor = 1
|
||||||
|
editor.handleKey(runeKey("v"), false)
|
||||||
|
editor.handleKey(runeKey("l"), false)
|
||||||
|
editor.handleKey(runeKey("l"), false)
|
||||||
|
editor.handleKey(runeKey("s"), false)
|
||||||
|
|
||||||
|
if editor.Text != "aef" || editor.Cursor != 1 || editor.Mode != textEditorInsert {
|
||||||
|
t.Fatalf("visual substitute = %#v", editor)
|
||||||
|
}
|
||||||
|
editor.handleKey(runeKey("X"), false)
|
||||||
|
if editor.Text != "aXef" || editor.Cursor != 2 {
|
||||||
|
t.Fatalf("visual substitute insertion = %#v", editor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestVimVisualYankAndPasteUseSystemClipboardAbstraction(t *testing.T) {
|
func TestVimVisualYankAndPasteUseSystemClipboardAbstraction(t *testing.T) {
|
||||||
clipboard := &memoryTextClipboard{}
|
clipboard := &memoryTextClipboard{}
|
||||||
editor := newTextEditor("abcdef", true)
|
editor := newTextEditor("abcdef", true)
|
||||||
|
|||||||
312
tui.go
312
tui.go
@@ -148,12 +148,14 @@ type App struct {
|
|||||||
pendingZ bool
|
pendingZ bool
|
||||||
searching bool
|
searching bool
|
||||||
searchQuery string
|
searchQuery string
|
||||||
|
searchEditor textEditor
|
||||||
searchOrigin int
|
searchOrigin int
|
||||||
helpVisible bool
|
helpVisible bool
|
||||||
helpScroll int
|
helpScroll int
|
||||||
writeMode writeMode
|
writeMode writeMode
|
||||||
writeThreadID string
|
writeThreadID string
|
||||||
replyDraft string
|
replyDraft string
|
||||||
|
replyEditor textEditor
|
||||||
resolveTarget bool
|
resolveTarget bool
|
||||||
autoMergeTarget bool
|
autoMergeTarget bool
|
||||||
mergeMethod string
|
mergeMethod string
|
||||||
@@ -197,6 +199,7 @@ type App struct {
|
|||||||
aiMode aiMode
|
aiMode aiMode
|
||||||
aiMenuIndex int
|
aiMenuIndex int
|
||||||
aiInput string
|
aiInput string
|
||||||
|
aiInputEditor textEditor
|
||||||
aiPreview AIPreview
|
aiPreview AIPreview
|
||||||
aiCancel context.CancelFunc
|
aiCancel context.CancelFunc
|
||||||
aiStatus AIProviderStatus
|
aiStatus AIProviderStatus
|
||||||
@@ -298,6 +301,18 @@ func (m App) Init() tea.Cmd {
|
|||||||
return tea.Batch(commands...)
|
return tea.Batch(commands...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *App) resetInputEditor(editor *textEditor, text string) {
|
||||||
|
*editor = newTextEditor(text, m.editorMode == "vim")
|
||||||
|
editor.keys = m.keybindings
|
||||||
|
editor.hardwareCursor = m.cursorOutput != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *App) resetSearchEditor(text string) {
|
||||||
|
m.searchEditor = newTextEditor(text, false)
|
||||||
|
m.searchEditor.keys = m.keybindings
|
||||||
|
m.searchEditor.hardwareCursor = m.cursorOutput != nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m App) nextTick() tea.Cmd {
|
func (m App) nextTick() tea.Cmd {
|
||||||
return tea.Tick(m.adaptivePollInterval(time.Now()), func(t time.Time) tea.Msg { return tickMsg(t) })
|
return tea.Tick(m.adaptivePollInterval(time.Now()), func(t time.Time) tea.Msg { return tickMsg(t) })
|
||||||
}
|
}
|
||||||
@@ -416,6 +431,7 @@ func (m *App) startReply() {
|
|||||||
}
|
}
|
||||||
m.writeMode, m.writeThreadID, m.replyDraft, m.err = writeReply, thread.ID, "", nil
|
m.writeMode, m.writeThreadID, m.replyDraft, m.err = writeReply, thread.ID, "", nil
|
||||||
m.restoreReplyDraft(thread.ID)
|
m.restoreReplyDraft(thread.ID)
|
||||||
|
m.resetInputEditor(&m.replyEditor, m.replyDraft)
|
||||||
m.folded[thread.ID] = false
|
m.folded[thread.ID] = false
|
||||||
m.focusThreadDetail()
|
m.focusThreadDetail()
|
||||||
m.scroll = m.detailMaxScroll()
|
m.scroll = m.detailMaxScroll()
|
||||||
@@ -583,30 +599,28 @@ func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case writeReply:
|
case writeReply:
|
||||||
switch k {
|
switch k {
|
||||||
case "esc":
|
case "esc":
|
||||||
m.writeMode, m.replyDraft, m.writeThreadID = writeNone, "", ""
|
if m.replyEditor.Modal && m.replyEditor.Mode != textEditorNormal {
|
||||||
m.scroll = min(m.scroll, m.detailMaxScroll())
|
m.replyEditor.handleKeyAtWidth(key, true, m.threadInputWidth())
|
||||||
|
m.replyDraft = m.replyEditor.Text
|
||||||
|
} else {
|
||||||
|
m.writeMode, m.replyDraft, m.replyEditor, m.writeThreadID =
|
||||||
|
writeNone, "", textEditor{}, ""
|
||||||
|
m.scroll = min(m.scroll, m.detailMaxScroll())
|
||||||
|
}
|
||||||
case "ctrl+s":
|
case "ctrl+s":
|
||||||
if strings.TrimSpace(m.replyDraft) == "" {
|
if strings.TrimSpace(m.replyDraft) == "" {
|
||||||
m.err = errors.New("reply cannot be empty")
|
m.err = errors.New("reply cannot be empty")
|
||||||
} else {
|
} else {
|
||||||
m.writeMode, m.err = writeReplyConfirm, nil
|
m.writeMode, m.err = writeReplyConfirm, nil
|
||||||
}
|
}
|
||||||
case "enter":
|
|
||||||
m.replyDraft += "\n"
|
|
||||||
case "backspace":
|
|
||||||
runes := []rune(m.replyDraft)
|
|
||||||
if len(runes) > 0 {
|
|
||||||
m.replyDraft = string(runes[:len(runes)-1])
|
|
||||||
}
|
|
||||||
m.err = nil
|
|
||||||
default:
|
default:
|
||||||
if key.Type == tea.KeyRunes || key.Type == tea.KeySpace {
|
if m.replyEditor.handleKeyAtWidth(key, true, m.threadInputWidth()) {
|
||||||
m.replyDraft += textInputKeyValue(key)
|
m.replyDraft = m.replyEditor.Text
|
||||||
m.err = nil
|
m.err = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if m.writeMode == writeReply {
|
if m.writeMode == writeReply {
|
||||||
m.scroll = m.detailMaxScroll()
|
m.ensureThreadInputCursorVisible()
|
||||||
return m, m.queueReplyDraft()
|
return m, m.queueReplyDraft()
|
||||||
}
|
}
|
||||||
case writeReplyConfirm:
|
case writeReplyConfirm:
|
||||||
@@ -997,7 +1011,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if err := m.drafts.delete(draftKey); err != nil {
|
if err := m.drafts.delete(draftKey); err != nil {
|
||||||
m.recordHealth("draft persistence", healthWarning, err.Error())
|
m.recordHealth("draft persistence", healthWarning, err.Error())
|
||||||
}
|
}
|
||||||
m.replyDraft, m.writeThreadID = "", ""
|
m.replyDraft, m.replyEditor, m.writeThreadID = "", textEditor{}, ""
|
||||||
m.err = nil
|
m.err = nil
|
||||||
m.lastRefresh = time.Now()
|
m.lastRefresh = time.Now()
|
||||||
return m, m.difflet.setState(diffletSuccess)
|
return m, m.difflet.setState(diffletSuccess)
|
||||||
@@ -1130,29 +1144,34 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case "ctrl+c":
|
case "ctrl+c":
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
case "esc":
|
case "esc":
|
||||||
m.searching, m.searchQuery = false, ""
|
if m.searchEditor.Modal && m.searchEditor.Mode != textEditorNormal {
|
||||||
m.threadIndex = clamp(m.searchOrigin, 0, len(m.details.Threads)-1)
|
m.searchEditor.handleKey(key, false)
|
||||||
m.scroll = 0
|
m.searchQuery = m.searchEditor.Text
|
||||||
|
} else {
|
||||||
|
m.searching, m.searchQuery, m.searchEditor = false, "", textEditor{}
|
||||||
|
m.threadIndex = clamp(m.searchOrigin, 0, len(m.details.Threads)-1)
|
||||||
|
m.scroll = 0
|
||||||
|
}
|
||||||
case "enter":
|
case "enter":
|
||||||
m.searching = false
|
m.searching = false
|
||||||
case "up":
|
case "up":
|
||||||
m.moveSearch(-1)
|
m.moveSearch(-1)
|
||||||
case "down", "tab":
|
case "down", "tab":
|
||||||
m.moveSearch(1)
|
m.moveSearch(1)
|
||||||
case "backspace":
|
|
||||||
runes := []rune(m.searchQuery)
|
|
||||||
if len(runes) > 0 {
|
|
||||||
m.searchQuery = string(runes[:len(runes)-1])
|
|
||||||
m.selectBestSearchMatch()
|
|
||||||
}
|
|
||||||
case "ctrl+u":
|
case "ctrl+u":
|
||||||
m.searchQuery = ""
|
if !m.searchEditor.Modal || m.searchEditor.Mode == textEditorInsert {
|
||||||
m.threadIndex = clamp(m.searchOrigin, 0, len(m.details.Threads)-1)
|
m.resetSearchEditor("")
|
||||||
m.scroll = 0
|
m.searchQuery = ""
|
||||||
|
m.threadIndex = clamp(m.searchOrigin, 0, len(m.details.Threads)-1)
|
||||||
|
m.scroll = 0
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
if key.Type == tea.KeyRunes || key.Type == tea.KeySpace {
|
before := m.searchEditor.Text
|
||||||
m.searchQuery += textInputKeyValue(key)
|
if m.searchEditor.handleKey(key, false) {
|
||||||
m.selectBestSearchMatch()
|
m.searchQuery = m.searchEditor.Text
|
||||||
|
if m.searchQuery != before {
|
||||||
|
m.selectBestSearchMatch()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -1205,12 +1224,13 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
case "F":
|
case "F":
|
||||||
if m.screen == threadScreen {
|
if m.screen == threadScreen {
|
||||||
m.searchQuery = ""
|
m.searchQuery, m.searchEditor = "", textEditor{}
|
||||||
m.threadIndex = clamp(m.threadIndex, 0, len(m.details.Threads)-1)
|
m.threadIndex = clamp(m.threadIndex, 0, len(m.details.Threads)-1)
|
||||||
}
|
}
|
||||||
case "/":
|
case "/":
|
||||||
if m.screen == threadScreen {
|
if m.screen == threadScreen {
|
||||||
m.searching, m.searchQuery, m.searchOrigin = true, "", m.threadIndex
|
m.searching, m.searchQuery, m.searchOrigin = true, "", m.threadIndex
|
||||||
|
m.resetSearchEditor("")
|
||||||
m.focus, m.listHidden, m.scroll = threadListPane, false, 0
|
m.focus, m.listHidden, m.scroll = threadListPane, false, 0
|
||||||
}
|
}
|
||||||
case "r":
|
case "r":
|
||||||
@@ -2168,17 +2188,20 @@ func (m App) viewWritePopup() string {
|
|||||||
"",
|
"",
|
||||||
}
|
}
|
||||||
lines = append(lines, renderTextInput(
|
lines = append(lines, renderTextInput(
|
||||||
m.replyDraft, width-2, m.cursorOutput != nil,
|
m.replyEditor, width-2, m.cursorOutput != nil,
|
||||||
)...)
|
)...)
|
||||||
if m.err != nil {
|
if m.err != nil {
|
||||||
lines = append(lines, "", badStyle.Render(m.err.Error()))
|
lines = append(lines, "", badStyle.Render(m.err.Error()))
|
||||||
}
|
}
|
||||||
lines = append(lines, "", dimStyle.Render(fmt.Sprintf(
|
if m.editorMode != "vim" {
|
||||||
"%s newline • %s review • %s cancel",
|
lines = append(lines, "", dimStyle.Render(fmt.Sprintf(
|
||||||
primaryKeyLabel(m.keybindings.Input.Newline),
|
"%s newline • %s review • %s %s",
|
||||||
primaryKeyLabel(m.keybindings.Input.Submit),
|
primaryKeyLabel(m.keybindings.Input.Newline),
|
||||||
primaryKeyLabel(m.keybindings.Input.Cancel),
|
primaryKeyLabel(m.keybindings.Input.Submit),
|
||||||
)))
|
primaryKeyLabel(m.keybindings.Input.Cancel),
|
||||||
|
m.inputCancelAction(),
|
||||||
|
)))
|
||||||
|
}
|
||||||
case writeReplyConfirm:
|
case writeReplyConfirm:
|
||||||
lines = []string{titleStyle.Render("Submit this reply to " + location + "?"), ""}
|
lines = []string{titleStyle.Render("Submit this reply to " + location + "?"), ""}
|
||||||
lines = append(lines, renderCommentMarkdown(m.replyDraft, width-2)...)
|
lines = append(lines, renderCommentMarkdown(m.replyDraft, width-2)...)
|
||||||
@@ -2293,9 +2316,9 @@ func (m App) helpBindings() []helpBinding {
|
|||||||
{keyLabel(m.keybindings.Input.Cancel), "Return to Normal mode or cancel the editor"},
|
{keyLabel(m.keybindings.Input.Cancel), "Return to Normal mode or cancel the editor"},
|
||||||
{combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Move through the description by half a page"},
|
{combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Move through the description by half a page"},
|
||||||
{combinedKeyLabel(m.keybindings.Input.PreviousCompletion, m.keybindings.Input.NextCompletion), "Select the previous / next branch or user completion"},
|
{combinedKeyLabel(m.keybindings.Input.PreviousCompletion, m.keybindings.Input.NextCompletion), "Select the previous / next branch or user completion"},
|
||||||
{combinedKeyLabel(m.keybindings.Input.NextField, m.keybindings.Input.Newline), "Complete the selected branch or user"},
|
{keyLabel(m.keybindings.Input.Newline), "Complete the selected branch or user"},
|
||||||
}
|
}
|
||||||
if m.prEditEditors[prEditBodyField].Modal {
|
if m.editorMode == "vim" {
|
||||||
bindings = append(bindings,
|
bindings = append(bindings,
|
||||||
helpBinding{combinedKeyLabel(
|
helpBinding{combinedKeyLabel(
|
||||||
m.keybindings.Navigation.Left, m.keybindings.Navigation.Down,
|
m.keybindings.Navigation.Left, m.keybindings.Navigation.Down,
|
||||||
@@ -2312,7 +2335,7 @@ func (m App) helpBindings() []helpBinding {
|
|||||||
), "Move to the line start, first non-blank, or line end"},
|
), "Move to the line start, first non-blank, or line end"},
|
||||||
helpBinding{combinedKeyLabel(
|
helpBinding{combinedKeyLabel(
|
||||||
m.keybindings.Vim.GoPrefix, m.keybindings.Navigation.Last,
|
m.keybindings.Vim.GoPrefix, m.keybindings.Navigation.Last,
|
||||||
), "Move to the start / end of the description"},
|
), "Move to the start / end of the active field"},
|
||||||
helpBinding{combinedKeyLabel(
|
helpBinding{combinedKeyLabel(
|
||||||
m.keybindings.Vim.Insert, m.keybindings.Vim.Append,
|
m.keybindings.Vim.Insert, m.keybindings.Vim.Append,
|
||||||
m.keybindings.Vim.InsertLineStart, m.keybindings.Vim.AppendLineEnd,
|
m.keybindings.Vim.InsertLineStart, m.keybindings.Vim.AppendLineEnd,
|
||||||
@@ -2320,7 +2343,7 @@ func (m App) helpBindings() []helpBinding {
|
|||||||
), "Enter Insert mode"},
|
), "Enter Insert mode"},
|
||||||
helpBinding{combinedKeyLabel(
|
helpBinding{combinedKeyLabel(
|
||||||
m.keybindings.Vim.OpenBelow, m.keybindings.Vim.OpenAbove,
|
m.keybindings.Vim.OpenBelow, m.keybindings.Vim.OpenAbove,
|
||||||
), "Open a line below / above"},
|
), "Open a description line below / above"},
|
||||||
helpBinding{combinedKeyLabel(
|
helpBinding{combinedKeyLabel(
|
||||||
m.keybindings.Vim.Visual, m.keybindings.Vim.VisualLine,
|
m.keybindings.Vim.Visual, m.keybindings.Vim.VisualLine,
|
||||||
), "Start character-wise / line-wise Visual mode"},
|
), "Start character-wise / line-wise Visual mode"},
|
||||||
@@ -2343,7 +2366,7 @@ func (m App) helpBindings() []helpBinding {
|
|||||||
helpBinding{combinedKeyLabel(
|
helpBinding{combinedKeyLabel(
|
||||||
m.keybindings.Navigation.Left, m.keybindings.Navigation.Down,
|
m.keybindings.Navigation.Left, m.keybindings.Navigation.Down,
|
||||||
m.keybindings.Navigation.Up, m.keybindings.Navigation.Right,
|
m.keybindings.Navigation.Up, m.keybindings.Navigation.Right,
|
||||||
), "Move the description cursor"},
|
), "Move the active field cursor"},
|
||||||
helpBinding{combinedKeyLabel(
|
helpBinding{combinedKeyLabel(
|
||||||
m.keybindings.Input.LineStart, m.keybindings.Input.LineEnd,
|
m.keybindings.Input.LineStart, m.keybindings.Input.LineEnd,
|
||||||
), "Move to the start / end of the current visual line"},
|
), "Move to the start / end of the current visual line"},
|
||||||
@@ -2695,6 +2718,34 @@ func (m App) viewDashboardWithLines(lines []string) string {
|
|||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m App) activeInputEditor() (textEditor, string, bool) {
|
||||||
|
if m.helpVisible {
|
||||||
|
return textEditor{}, "", false
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case m.writeMode == writeReply:
|
||||||
|
return m.replyEditor, "REPLY", m.replyEditor.Modal
|
||||||
|
case m.aiMode == aiDiscussion:
|
||||||
|
return m.aiInputEditor, "AI DISCUSSION", m.aiInputEditor.Modal
|
||||||
|
case m.writeMode == writePREdit:
|
||||||
|
contexts := [...]string{
|
||||||
|
"EDIT TITLE", "EDIT TARGET BRANCH", "EDIT REVIEWERS",
|
||||||
|
"EDIT ASSIGNEES", "EDIT DESCRIPTION",
|
||||||
|
}
|
||||||
|
editor := m.prEditEditors[m.prEditField]
|
||||||
|
return editor, contexts[m.prEditField], editor.Modal
|
||||||
|
default:
|
||||||
|
return textEditor{}, "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m App) inputCancelAction() string {
|
||||||
|
if m.editorMode == "vim" {
|
||||||
|
return "normal/cancel"
|
||||||
|
}
|
||||||
|
return "cancel"
|
||||||
|
}
|
||||||
|
|
||||||
func (m App) dashboardDisplayLines() []string {
|
func (m App) dashboardDisplayLines() []string {
|
||||||
mascot := m.difflet.frameLines()
|
mascot := m.difflet.frameLines()
|
||||||
if len(mascot) == diffletHeight && !m.diffletHiddenForCurrentView() {
|
if len(mascot) == diffletHeight && !m.diffletHiddenForCurrentView() {
|
||||||
@@ -3488,18 +3539,27 @@ func (m App) viewThreads() string {
|
|||||||
primaryKeyLabel(m.keybindings.Input.Cancel),
|
primaryKeyLabel(m.keybindings.Input.Cancel),
|
||||||
)
|
)
|
||||||
} else if m.writeMode == writeReply {
|
} else if m.writeMode == writeReply {
|
||||||
help = fmt.Sprintf(
|
if m.editorMode == "vim" {
|
||||||
"reply inline • %s newline • %s review • %s cancel",
|
help = fmt.Sprintf(
|
||||||
primaryKeyLabel(m.keybindings.Input.Newline),
|
"%s review • %s normal/cancel",
|
||||||
primaryKeyLabel(m.keybindings.Input.Submit),
|
primaryKeyLabel(m.keybindings.Input.Submit),
|
||||||
primaryKeyLabel(m.keybindings.Input.Cancel),
|
primaryKeyLabel(m.keybindings.Input.Cancel),
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
help = fmt.Sprintf(
|
||||||
|
"reply inline • arrows move • %s newline • %s review • %s cancel",
|
||||||
|
primaryKeyLabel(m.keybindings.Input.Newline),
|
||||||
|
primaryKeyLabel(m.keybindings.Input.Submit),
|
||||||
|
primaryKeyLabel(m.keybindings.Input.Cancel),
|
||||||
|
)
|
||||||
|
}
|
||||||
} else if m.aiMode == aiDiscussion {
|
} else if m.aiMode == aiDiscussion {
|
||||||
help = fmt.Sprintf(
|
help = fmt.Sprintf(
|
||||||
"local AI discussion • %s newline • %s prepare • %s cancel",
|
"local AI discussion • arrows move • %s newline • %s prepare • %s %s",
|
||||||
primaryKeyLabel(m.keybindings.Input.Newline),
|
primaryKeyLabel(m.keybindings.Input.Newline),
|
||||||
primaryKeyLabel(m.keybindings.Input.Submit),
|
primaryKeyLabel(m.keybindings.Input.Submit),
|
||||||
primaryKeyLabel(m.keybindings.Input.Cancel),
|
primaryKeyLabel(m.keybindings.Input.Cancel),
|
||||||
|
m.inputCancelAction(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
m.positionThreadInputHardwareCursor()
|
m.positionThreadInputHardwareCursor()
|
||||||
@@ -3520,9 +3580,8 @@ func (m App) threadList(width, height int) string {
|
|||||||
lines := []string{titleStyle.Render(fmt.Sprintf("Threads (%d/%d)", len(matches), len(m.details.Threads)))}
|
lines := []string{titleStyle.Render(fmt.Sprintf("Threads (%d/%d)", len(matches), len(m.details.Threads)))}
|
||||||
if m.searching {
|
if m.searching {
|
||||||
queryWidth := max(1, innerWidth-len("Filter: ")-1)
|
queryWidth := max(1, innerWidth-len("Filter: ")-1)
|
||||||
query := ansi.Truncate(m.searchQuery, queryWidth, "…")
|
query := renderSingleLineInput(m.searchEditor, queryWidth, m.cursorOutput != nil)
|
||||||
lines = append(lines, titleStyle.Render("Filter: ")+query+
|
lines = append(lines, titleStyle.Render("Filter: ")+query)
|
||||||
inputCursorFallback(m.cursorOutput != nil))
|
|
||||||
}
|
}
|
||||||
if m.loading && len(m.details.Threads) == 0 {
|
if m.loading && len(m.details.Threads) == 0 {
|
||||||
lines = append(lines, "Loading…")
|
lines = append(lines, "Loading…")
|
||||||
@@ -3839,7 +3898,9 @@ func (m App) inlineReplyLines(width int) []detailLine {
|
|||||||
}
|
}
|
||||||
textWidth := max(1, width-5)
|
textWidth := max(1, width-5)
|
||||||
lineIndex := 0
|
lineIndex := 0
|
||||||
for _, part := range renderTextInput(m.replyDraft, textWidth, m.cursorOutput != nil) {
|
for _, part := range renderTextInput(
|
||||||
|
m.replyEditor, textWidth, m.cursorOutput != nil,
|
||||||
|
) {
|
||||||
lines = append(lines, detailLine{
|
lines = append(lines, detailLine{
|
||||||
rail: rail, anchor: fmt.Sprintf("reply:body:%d", lineIndex), text: part,
|
rail: rail, anchor: fmt.Sprintf("reply:body:%d", lineIndex), text: part,
|
||||||
})
|
})
|
||||||
@@ -3848,48 +3909,72 @@ func (m App) inlineReplyLines(width int) []detailLine {
|
|||||||
if m.err != nil {
|
if m.err != nil {
|
||||||
lines = append(lines, detailLine{rail: rail, text: badStyle.Render(m.err.Error())})
|
lines = append(lines, detailLine{rail: rail, text: badStyle.Render(m.err.Error())})
|
||||||
}
|
}
|
||||||
lines = append(lines, detailLine{
|
if m.editorMode != "vim" {
|
||||||
rail: rail,
|
lines = append(lines, detailLine{
|
||||||
text: dimStyle.Render(fmt.Sprintf(
|
rail: rail,
|
||||||
"%s newline • %s review • %s cancel",
|
text: dimStyle.Render(fmt.Sprintf(
|
||||||
primaryKeyLabel(m.keybindings.Input.Newline),
|
"%s newline • %s review • %s %s",
|
||||||
primaryKeyLabel(m.keybindings.Input.Submit),
|
primaryKeyLabel(m.keybindings.Input.Newline),
|
||||||
primaryKeyLabel(m.keybindings.Input.Cancel),
|
primaryKeyLabel(m.keybindings.Input.Submit),
|
||||||
)),
|
primaryKeyLabel(m.keybindings.Input.Cancel),
|
||||||
})
|
m.inputCancelAction(),
|
||||||
|
)),
|
||||||
|
})
|
||||||
|
}
|
||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
|
|
||||||
func inputCursorFallback(hardwareCursor bool) string {
|
func renderTextInput(editor textEditor, width int, hardwareCursor bool) []string {
|
||||||
if hardwareCursor {
|
editor.hardwareCursor = hardwareCursor
|
||||||
return ""
|
rendered := renderTextEditor(editor, width, true)
|
||||||
|
lines := make([]string, 0, len(rendered))
|
||||||
|
for _, line := range rendered {
|
||||||
|
lines = append(lines, line.text)
|
||||||
}
|
}
|
||||||
return "\x1b[4m \x1b[24m"
|
return lines
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderTextInput(value string, width int, hardwareCursor bool) []string {
|
func renderSingleLineInput(editor textEditor, width int, hardwareCursor bool) string {
|
||||||
const cursorSentinel = "\ue000"
|
editor.hardwareCursor = hardwareCursor
|
||||||
if hardwareCursor {
|
rendered := renderTextEditor(editor, width, true)
|
||||||
value += cursorSentinel
|
return rendered[editorCursorVisualLine(editor, width)].text
|
||||||
} else {
|
}
|
||||||
value += inputCursorFallback(false)
|
|
||||||
|
func (m App) threadInputWidth() int {
|
||||||
|
width, _ := m.detailPaneSize()
|
||||||
|
return max(1, width-5)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *App) ensureThreadInputCursorVisible() {
|
||||||
|
if m.writeMode != writeReply && m.aiMode != aiDiscussion {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
var lines []string
|
width, height := m.detailPaneSize()
|
||||||
for _, sourceLine := range strings.Split(value, "\n") {
|
editor, prefix := m.replyEditor, "reply:body:"
|
||||||
wrapped := ansi.Hardwrap(ansi.Wordwrap(sourceLine, width, ""), width, false)
|
if m.aiMode == aiDiscussion {
|
||||||
if hardwareCursor {
|
editor, prefix = m.aiInputEditor, "ai-discussion:body:"
|
||||||
wrapped = strings.TrimSuffix(wrapped, cursorSentinel)
|
}
|
||||||
|
visualLine := editorCursorVisualLine(editor, m.threadInputWidth())
|
||||||
|
wantedAnchor := fmt.Sprintf("%s%d", prefix, visualLine)
|
||||||
|
lines := m.renderedDetailLines(width)
|
||||||
|
cursorLine := -1
|
||||||
|
for index := range lines {
|
||||||
|
if lines[index].anchor == wantedAnchor {
|
||||||
|
cursorLine = index
|
||||||
|
break
|
||||||
}
|
}
|
||||||
lines = append(lines, strings.Split(wrapped, "\n")...)
|
|
||||||
}
|
}
|
||||||
return lines
|
if cursorLine < 0 {
|
||||||
}
|
return
|
||||||
|
|
||||||
func textInputKeyValue(key tea.KeyMsg) string {
|
|
||||||
if key.Type == tea.KeySpace {
|
|
||||||
return " "
|
|
||||||
}
|
}
|
||||||
return string(key.Runes)
|
viewportHeight := max(1, height-2)
|
||||||
|
switch {
|
||||||
|
case cursorLine < m.scroll:
|
||||||
|
m.scroll = cursorLine
|
||||||
|
case cursorLine >= m.scroll+viewportHeight:
|
||||||
|
m.scroll = cursorLine - viewportHeight + 1
|
||||||
|
}
|
||||||
|
m.scroll = clamp(m.scroll, 0, max(0, len(lines)-viewportHeight))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m App) positionThreadInputHardwareCursor() {
|
func (m App) positionThreadInputHardwareCursor() {
|
||||||
@@ -3906,10 +3991,13 @@ func (m App) positionThreadInputHardwareCursor() {
|
|||||||
paneWidth = m.threadListWidth()
|
paneWidth = m.threadListWidth()
|
||||||
}
|
}
|
||||||
queryWidth := max(1, max(1, paneWidth-2)-len("Filter: ")-1)
|
queryWidth := max(1, max(1, paneWidth-2)-len("Filter: ")-1)
|
||||||
query := ansi.Truncate(m.searchQuery, queryWidth, "…")
|
if m.searchEditor.Mode != textEditorInsert {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, column := editorCursorVisualPosition(m.searchEditor, queryWidth)
|
||||||
m.cursorOutput.SetCursor(
|
m.cursorOutput.SetCursor(
|
||||||
true,
|
true,
|
||||||
2+ansi.StringWidth("Filter: ")+ansi.StringWidth(query),
|
2+ansi.StringWidth("Filter: ")+column,
|
||||||
m.contentTop+topLines+3,
|
m.contentTop+topLines+3,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -3923,10 +4011,20 @@ func (m App) positionThreadInputHardwareCursor() {
|
|||||||
if m.aiMode == aiDiscussion {
|
if m.aiMode == aiDiscussion {
|
||||||
prefix = "ai-discussion:body:"
|
prefix = "ai-discussion:body:"
|
||||||
}
|
}
|
||||||
|
editor := m.replyEditor
|
||||||
|
if m.aiMode == aiDiscussion {
|
||||||
|
editor = m.aiInputEditor
|
||||||
|
}
|
||||||
|
if editor.Mode != textEditorInsert {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
visualLine, column := editorCursorVisualPosition(editor, m.threadInputWidth())
|
||||||
cursorLine := -1
|
cursorLine := -1
|
||||||
|
wantedAnchor := fmt.Sprintf("%s%d", prefix, visualLine)
|
||||||
for index := range lines {
|
for index := range lines {
|
||||||
if strings.HasPrefix(lines[index].anchor, prefix) {
|
if lines[index].anchor == wantedAnchor {
|
||||||
cursorLine = index
|
cursorLine = index
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if cursorLine < 0 {
|
if cursorLine < 0 {
|
||||||
@@ -3945,7 +4043,7 @@ func (m App) positionThreadInputHardwareCursor() {
|
|||||||
line := lines[cursorLine]
|
line := lines[cursorLine]
|
||||||
m.cursorOutput.SetCursor(
|
m.cursorOutput.SetCursor(
|
||||||
true,
|
true,
|
||||||
paneStart+2+ansi.StringWidth(line.rail)+ansi.StringWidth(line.fixed+line.text),
|
paneStart+2+ansi.StringWidth(line.rail+line.fixed)+column,
|
||||||
m.contentTop+topLines+2+screenLine,
|
m.contentTop+topLines+2+screenLine,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -4226,21 +4324,47 @@ func (m App) frame(lines []string, help string) string {
|
|||||||
if status == "" && !m.lastRefresh.IsZero() {
|
if status == "" && !m.lastRefresh.IsZero() {
|
||||||
status = dimStyle.Render("updated " + m.lastRefresh.Format("15:04:05"))
|
status = dimStyle.Render("updated " + m.lastRefresh.Format("15:04:05"))
|
||||||
}
|
}
|
||||||
footer := truncate(help, m.width)
|
footerWidth := m.width
|
||||||
if status != "" {
|
if status != "" {
|
||||||
footer = truncate(help, max(0, m.width-lipgloss.Width(status)-2)) + " " + status
|
footerWidth = max(0, m.width-lipgloss.Width(status)-2)
|
||||||
|
}
|
||||||
|
footer := m.renderFooter(help, footerWidth)
|
||||||
|
if status != "" {
|
||||||
|
footer += " " + status
|
||||||
}
|
}
|
||||||
bodyLines := strings.Split(body, "\n")
|
bodyLines := strings.Split(body, "\n")
|
||||||
if len(bodyLines) > max(0, m.height-1) {
|
bodyHeight := max(0, m.height-1)
|
||||||
bodyLines = bodyLines[:max(0, m.height-1)]
|
if len(bodyLines) > bodyHeight {
|
||||||
|
bodyLines = bodyLines[:bodyHeight]
|
||||||
}
|
}
|
||||||
for i := range bodyLines {
|
for i := range bodyLines {
|
||||||
bodyLines[i] = ansi.Truncate(bodyLines[i], m.width, "")
|
bodyLines[i] = ansi.Truncate(bodyLines[i], m.width, "")
|
||||||
}
|
}
|
||||||
bodyLines = append(bodyLines, ansi.Truncate(dimStyle.Render(footer), m.width, ""))
|
for len(bodyLines) < bodyHeight {
|
||||||
|
bodyLines = append(bodyLines, "")
|
||||||
|
}
|
||||||
|
bodyLines = append(bodyLines, ansi.Truncate(footer, m.width, ""))
|
||||||
return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(strings.Join(bodyLines, "\n"))
|
return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(strings.Join(bodyLines, "\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m App) renderFooter(help string, width int) string {
|
||||||
|
if width <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
editor, context, ok := m.activeInputEditor()
|
||||||
|
if !ok {
|
||||||
|
return dimStyle.Render(truncate(help, width))
|
||||||
|
}
|
||||||
|
mode := activeStyle.Render(" " + editor.modeLabel() + " ")
|
||||||
|
contextLabel := titleStyle.Render(context)
|
||||||
|
prefix := mode + " " + contextLabel
|
||||||
|
remaining := width - lipgloss.Width(prefix)
|
||||||
|
if remaining <= 2 {
|
||||||
|
return ansi.Truncate(prefix, width, "")
|
||||||
|
}
|
||||||
|
return prefix + " " + dimStyle.Render(truncate(help, remaining-2))
|
||||||
|
}
|
||||||
|
|
||||||
func (m App) restingDiffletState() diffletState {
|
func (m App) restingDiffletState() diffletState {
|
||||||
if m.err != nil {
|
if m.err != nil {
|
||||||
return diffletSad
|
return diffletSad
|
||||||
|
|||||||
155
tui_test.go
155
tui_test.go
@@ -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(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})
|
send(tea.KeyMsg{Type: tea.KeyHome})
|
||||||
for range len("main") {
|
for range len("main") {
|
||||||
send(tea.KeyMsg{Type: tea.KeyDelete})
|
send(tea.KeyMsg{Type: tea.KeyDelete})
|
||||||
}
|
}
|
||||||
send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("release")})
|
send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("release")})
|
||||||
|
send(tea.KeyMsg{Type: tea.KeyEsc})
|
||||||
|
|
||||||
send(tea.KeyMsg{Type: tea.KeyShiftTab})
|
send(tea.KeyMsg{Type: tea.KeyShiftTab})
|
||||||
|
send(runeKey("i"))
|
||||||
send(tea.KeyMsg{Type: tea.KeyHome})
|
send(tea.KeyMsg{Type: tea.KeyHome})
|
||||||
for range len("Old title") {
|
for range len("Old title") {
|
||||||
send(tea.KeyMsg{Type: tea.KeyDelete})
|
send(tea.KeyMsg{Type: tea.KeyDelete})
|
||||||
@@ -794,9 +797,95 @@ func TestDashboardDescriptionCanUseStandardEditingMode(t *testing.T) {
|
|||||||
Permissions: ViewerPermissions{CanUpdatePR: true},
|
Permissions: ViewerPermissions{CanUpdatePR: true},
|
||||||
}
|
}
|
||||||
m.startPREdit()
|
m.startPREdit()
|
||||||
editor := m.prEditEditors[prEditBodyField]
|
for field, editor := range m.prEditEditors {
|
||||||
if editor.Modal || editor.Mode != textEditorInsert || editor.Cursor != len([]rune("body")) {
|
if editor.Modal || editor.Mode != textEditorInsert ||
|
||||||
t.Fatalf("standard description editor = %#v", editor)
|
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()
|
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.cursorOutput = newTerminalCursorOutput(file)
|
||||||
m.screen, m.loading, m.width, m.height = threadScreen, false, 100, 24
|
m.screen, m.loading, m.width, m.height = threadScreen, false, 100, 24
|
||||||
m.details = PRDetails{
|
m.details = PRDetails{
|
||||||
@@ -1058,6 +1151,7 @@ func TestThreadInputsUseHardwareCursor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
m.searching, m.searchQuery = true, "main"
|
m.searching, m.searchQuery = true, "main"
|
||||||
|
m.resetInputEditor(&m.searchEditor, m.searchQuery)
|
||||||
_ = m.viewThreads()
|
_ = m.viewThreads()
|
||||||
m.cursorOutput.mu.Lock()
|
m.cursorOutput.mu.Lock()
|
||||||
searchVisible, searchColumn := m.cursorOutput.visible, m.cursorOutput.column
|
searchVisible, searchColumn := m.cursorOutput.visible, m.cursorOutput.column
|
||||||
@@ -1068,6 +1162,7 @@ func TestThreadInputsUseHardwareCursor(t *testing.T) {
|
|||||||
|
|
||||||
m.searching = false
|
m.searching = false
|
||||||
m.aiMode, m.writeThreadID, m.aiInput = aiDiscussion, "thread-1", "Question"
|
m.aiMode, m.writeThreadID, m.aiInput = aiDiscussion, "thread-1", "Question"
|
||||||
|
m.resetInputEditor(&m.aiInputEditor, m.aiInput)
|
||||||
m.focus = threadDetailPane
|
m.focus = threadDetailPane
|
||||||
m.scroll = m.detailMaxScroll()
|
m.scroll = m.detailMaxScroll()
|
||||||
_ = m.viewThreads()
|
_ = m.viewThreads()
|
||||||
@@ -1140,6 +1235,8 @@ func TestReplyComposerConfirmsAndAddsReturnedComment(t *testing.T) {
|
|||||||
if m.writeMode != writeReply {
|
if m.writeMode != writeReply {
|
||||||
t.Fatalf("reply key opened mode %d", m.writeMode)
|
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")})
|
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("hello")})
|
||||||
m = updated.(App)
|
m = updated.(App)
|
||||||
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
|
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)
|
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 {
|
if m.focus != threadDetailPane {
|
||||||
t.Fatal("inline reply did not focus the thread detail pane")
|
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")
|
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.screen, m.searching, m.searchQuery = threadScreen, true, "ng"
|
||||||
|
m.resetInputEditor(&m.searchEditor, m.searchQuery)
|
||||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeySpace})
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||||
if got := updated.(App).searchQuery; got != "ng " {
|
if got := updated.(App).searchQuery; got != "ng " {
|
||||||
t.Fatalf("space key produced search query %q", got)
|
t.Fatalf("space key produced search query %q", got)
|
||||||
@@ -1882,8 +1989,11 @@ func TestFuzzyFileSearchSupportsSpaceSeparatedTerms(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestReplyAndAIDiscussionAcceptSpaceKeyWithoutRunes(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.writeMode, m.replyDraft = writeReply, "reply"
|
||||||
|
m.resetInputEditor(&m.replyEditor, m.replyDraft)
|
||||||
updated, _ := m.updateWriteInput(tea.KeyMsg{Type: tea.KeySpace})
|
updated, _ := m.updateWriteInput(tea.KeyMsg{Type: tea.KeySpace})
|
||||||
m = updated.(App)
|
m = updated.(App)
|
||||||
if m.replyDraft != "reply " {
|
if m.replyDraft != "reply " {
|
||||||
@@ -1891,6 +2001,7 @@ func TestReplyAndAIDiscussionAcceptSpaceKeyWithoutRunes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
m.writeMode, m.aiMode, m.aiInput = writeNone, aiDiscussion, "question"
|
m.writeMode, m.aiMode, m.aiInput = writeNone, aiDiscussion, "question"
|
||||||
|
m.resetInputEditor(&m.aiInputEditor, m.aiInput)
|
||||||
updated, _, handled := m.updateAI(tea.KeyMsg{Type: tea.KeySpace})
|
updated, _, handled := m.updateAI(tea.KeyMsg{Type: tea.KeySpace})
|
||||||
m = updated.(App)
|
m = updated.(App)
|
||||||
if !handled || m.aiInput != "question " {
|
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) {
|
func TestFileSearchCanJumpOrCancel(t *testing.T) {
|
||||||
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
|
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
|
||||||
m.screen = threadScreen
|
m.screen = threadScreen
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ func (m App) userCompletionLines(width int) []string {
|
|||||||
m.keybindings.Input.PreviousCompletion,
|
m.keybindings.Input.PreviousCompletion,
|
||||||
m.keybindings.Input.NextCompletion,
|
m.keybindings.Input.NextCompletion,
|
||||||
),
|
),
|
||||||
primaryCombinedKeyLabel(m.keybindings.Input.NextField, m.keybindings.Input.Newline),
|
primaryKeyLabel(m.keybindings.Input.Newline),
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
current := m.prEditOriginal.Assignees
|
current := m.prEditOriginal.Assignees
|
||||||
|
|||||||
@@ -50,6 +50,36 @@ func TestReviewerCompletionSupportsMultipleEligibleUsers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReviewerCompletionUsesEnterAndTabLeavesField(t *testing.T) {
|
||||||
|
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
|
||||||
|
m.writeMode = writePREdit
|
||||||
|
m.prEditField = prEditReviewersField
|
||||||
|
m.details = PRDetails{PullRequest: PullRequest{Author: "author"}}
|
||||||
|
m.prEditUsers = []RepositoryUser{{Login: "alice", CanReview: true}}
|
||||||
|
m.prEditEditors[prEditReviewersField] = newTextEditor("ali", false)
|
||||||
|
|
||||||
|
updated, _ := m.updatePREditInput(tea.KeyMsg{Type: tea.KeyTab})
|
||||||
|
m = updated.(App)
|
||||||
|
if m.prEditField != prEditAssigneesField ||
|
||||||
|
m.prEditEditors[prEditReviewersField].Text != "ali" {
|
||||||
|
t.Fatalf(
|
||||||
|
"tab field=%d reviewers=%q",
|
||||||
|
m.prEditField, m.prEditEditors[prEditReviewersField].Text,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.prEditField = prEditReviewersField
|
||||||
|
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
|
m = updated.(App)
|
||||||
|
if m.prEditField != prEditReviewersField ||
|
||||||
|
m.prEditEditors[prEditReviewersField].Text != "alice" {
|
||||||
|
t.Fatalf(
|
||||||
|
"enter field=%d reviewers=%q",
|
||||||
|
m.prEditField, m.prEditEditors[prEditReviewersField].Text,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReviewerInputCommitsMultipleEligibleUsersWithSpace(t *testing.T) {
|
func TestReviewerInputCommitsMultipleEligibleUsersWithSpace(t *testing.T) {
|
||||||
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
|
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
|
||||||
m.writeMode = writePREdit
|
m.writeMode = writePREdit
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
const dipleVersion = "0.2.0"
|
const dipleVersion = "0.3.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user