mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 18:00:54 -10:00
* fix(tui): strip all ANSI escapes from copied transcript text renderAssistantMarkdownText embeds bold markers for prose and ANSI color sequences from chroma for fenced code blocks. stripMarkdownRenderControls only stripped the bold markers, leaking ANSI codes into clipboard text. Switch to ansi.Strip() to remove all escape sequences. * refactor(tui): extract rendering dispatch and add detailed bool to transcriptBodyItems Extract rowRenderFn and transcriptRowDispatchFn types and refactor rendering methods into *Fn variants parameterized by the render function. Add detailed bool parameter to transcriptBodyItems to prepare for the detailed transcript viewport. All callers pass false so there is no behavior change yet. Extract transcriptRowBodyHeightCacheKeyOpts from transcriptRowBodyHeightCacheKey so the body cap can be configured per caller. * feat(tui): scrollable detailed transcript viewport with sidebar suppression Enable keyboard scrolling (PgUp/PgDown/Up/Down) in detailed transcript mode by routing through scrollChat instead of swallowing the keys. Rewrite detailedTranscriptView to use the transcriptBodyItems pipeline so the body renders through the same viewport/scroll engine as the live view. Implement detailedTranscriptFooter with copy-status and jump-to-bottom hint integration. Suppress the sidebar in detailed mode so the full chat width is available for reading uncapped tool output. * test(tui): detailed transcript scroll with PgUp/Down and Up/Down arrows TestPgUpDownScrollsDetailedTranscript verifies that PgUp scrolls toward older content and PgDown returns to the bottom in detailed mode. TestUpDownArrowsScrollDetailedTranscript verifies that arrow keys scroll one line at a time and bottom is clamped at offset 0. * test(tui): ANSI strip verification for markdown rendering controls and selectable text TestStripMarkdownRenderControlsStripsAllANSI verifies bold markers, color sequences, and combined sequences are stripped. TestSelectedTranscriptTextStripsANSIFromHighlightedCode verifies that chroma-highlighted code blocks produce clean plain text in the selectable layer. * fix for coderabit, and better fewer code. * fix(tui): address PR review feedback dead code, hover clear, and rebindable key label * format * addressing the review issue 1. the ctrl + b in detailed mode, added the guard to not be able to do it in detailed mode so it affect its state in the normal mode. 2. added a test coverage for keyDown after `keDown then keyUp` so it covers that. * fix(tui): close file drill-in when entering detailed transcript toggleDetailedTranscript now calls exitFileView before returning, so an active file drill-in is automatically closed when the user enters detailed transcript mode. * test(tui): verify file drill-in closes on detailed toggle Adds TestDetailedTranscriptClosesFileView and TestDetailedTranscriptStaysClosedOnSecondToggle to guard against the file-view bypass bug. * fix(tui): block composer hit-test while detailed transcript is active composerMouseSelectionBlocked and mouseOverComposer now check m.transcriptDetailed so mouse clicks and wheel events in the composer area reach the transcript body instead of being silently consumed. * test(tui): verify composer mouse is ignored in detailed mode Adds TestComposerMouseSelectionBlockedInDetailedMode to guard against composer hit-test swallowing mouse events during detailed transcript viewing.
766 lines
22 KiB
Go
766 lines
22 KiB
Go
package tui
|
|
|
|
import (
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
"charm.land/lipgloss/v2"
|
|
)
|
|
|
|
type composerState struct {
|
|
text string
|
|
cursor int
|
|
}
|
|
|
|
type composerSelectionState struct {
|
|
active bool
|
|
anchor int
|
|
cursor int
|
|
}
|
|
|
|
type composerPastePreview struct {
|
|
active bool
|
|
start int
|
|
end int
|
|
label string
|
|
}
|
|
|
|
func insertComposerText(state composerState, text string) composerState {
|
|
state = normalizeComposerState(state)
|
|
if text == "" {
|
|
return state
|
|
}
|
|
runes := []rune(state.text)
|
|
insert := []rune(text)
|
|
out := make([]rune, 0, len(runes)+len(insert))
|
|
out = append(out, runes[:state.cursor]...)
|
|
out = append(out, insert...)
|
|
out = append(out, runes[state.cursor:]...)
|
|
return composerState{text: string(out), cursor: state.cursor + len(insert)}
|
|
}
|
|
|
|
func deleteComposerWordBefore(state composerState) composerState {
|
|
state = normalizeComposerState(state)
|
|
if state.cursor == 0 {
|
|
return state
|
|
}
|
|
runes := []rune(state.text)
|
|
start := state.cursor
|
|
for start > 0 && unicode.IsSpace(runes[start-1]) {
|
|
start--
|
|
}
|
|
for start > 0 && !unicode.IsSpace(runes[start-1]) {
|
|
start--
|
|
}
|
|
return deleteComposerRange(state, start, state.cursor)
|
|
}
|
|
|
|
func deleteComposerWordAfter(state composerState) composerState {
|
|
state = normalizeComposerState(state)
|
|
runes := []rune(state.text)
|
|
if state.cursor >= len(runes) {
|
|
return state
|
|
}
|
|
end := state.cursor
|
|
for end < len(runes) && unicode.IsSpace(runes[end]) {
|
|
end++
|
|
}
|
|
for end < len(runes) && !unicode.IsSpace(runes[end]) {
|
|
end++
|
|
}
|
|
for end < len(runes) && runes[end] != '\n' && unicode.IsSpace(runes[end]) {
|
|
end++
|
|
}
|
|
return deleteComposerRange(state, state.cursor, end)
|
|
}
|
|
|
|
func moveComposerWordBefore(state composerState) composerState {
|
|
state = normalizeComposerState(state)
|
|
runes := []rune(state.text)
|
|
pos := state.cursor
|
|
for pos > 0 && unicode.IsSpace(runes[pos-1]) {
|
|
pos--
|
|
}
|
|
for pos > 0 && !unicode.IsSpace(runes[pos-1]) {
|
|
pos--
|
|
}
|
|
state.cursor = pos
|
|
return state
|
|
}
|
|
|
|
func moveComposerWordAfter(state composerState) composerState {
|
|
state = normalizeComposerState(state)
|
|
runes := []rune(state.text)
|
|
pos := state.cursor
|
|
for pos < len(runes) && unicode.IsSpace(runes[pos]) {
|
|
pos++
|
|
}
|
|
for pos < len(runes) && !unicode.IsSpace(runes[pos]) {
|
|
pos++
|
|
}
|
|
state.cursor = pos
|
|
return state
|
|
}
|
|
|
|
func sanitizeComposerPaste(text string) string {
|
|
var out strings.Builder
|
|
runes := []rune(text)
|
|
for i := 0; i < len(runes); i++ {
|
|
switch r := runes[i]; r {
|
|
case '\r':
|
|
out.WriteRune('\n')
|
|
if i+1 < len(runes) && runes[i+1] == '\n' {
|
|
i++
|
|
}
|
|
case '\n':
|
|
out.WriteRune('\n')
|
|
case '\t':
|
|
out.WriteString(" ")
|
|
default:
|
|
if !unicode.IsControl(r) {
|
|
out.WriteRune(r)
|
|
}
|
|
}
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
func sanitizeComposerInput(text string) string {
|
|
return sanitizeComposerPaste(strings.ReplaceAll(text, "\n", ""))
|
|
}
|
|
|
|
func (m model) composerValue() string {
|
|
if m.composerActive {
|
|
return m.composer.text
|
|
}
|
|
return m.input.Value()
|
|
}
|
|
|
|
func (m model) currentComposerState() composerState {
|
|
if m.composerActive {
|
|
return normalizeComposerState(m.composer)
|
|
}
|
|
return composerState{text: m.input.Value(), cursor: m.input.Position()}
|
|
}
|
|
|
|
func (m *model) setComposerState(state composerState) {
|
|
m.composer = normalizeComposerState(state)
|
|
m.composerActive = true
|
|
m.syncInputFromComposer()
|
|
}
|
|
|
|
func (m *model) clearComposer() {
|
|
m.composer = composerState{}
|
|
m.composerActive = false
|
|
m.composerPastePreviews = nil
|
|
m.composerSelection = composerSelectionState{}
|
|
m.input.SetValue("")
|
|
}
|
|
|
|
func (m *model) resetComposerFromInput() {
|
|
m.composer = composerState{}
|
|
m.composerActive = false
|
|
m.composerPastePreviews = nil
|
|
m.composerSelection = composerSelectionState{}
|
|
}
|
|
|
|
func (m *model) syncInputFromComposer() {
|
|
display := strings.ReplaceAll(m.composer.text, "\n", " ")
|
|
m.input.SetValue(display)
|
|
m.input.SetCursor(composerDisplayCursor(m.composer))
|
|
}
|
|
|
|
func composerDisplayCursor(state composerState) int {
|
|
state = normalizeComposerState(state)
|
|
count := 0
|
|
for range []rune(state.text)[:state.cursor] {
|
|
count++
|
|
}
|
|
return count
|
|
}
|
|
|
|
func (selection composerSelectionState) rangeFor(state composerState) (int, int, bool) {
|
|
if !selection.active {
|
|
return 0, 0, false
|
|
}
|
|
state = normalizeComposerState(state)
|
|
start := clamp(selection.anchor, 0, len([]rune(state.text)))
|
|
end := clamp(selection.cursor, 0, len([]rune(state.text)))
|
|
if start > end {
|
|
start, end = end, start
|
|
}
|
|
return start, end, start != end
|
|
}
|
|
|
|
func (m model) selectedComposerText() string {
|
|
state := m.currentComposerState()
|
|
start, end, ok := m.composerSelection.rangeFor(state)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return string([]rune(state.text)[start:end])
|
|
}
|
|
|
|
func (m model) handleComposerSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd, bool) {
|
|
switch {
|
|
case mouseLeftPress(msg):
|
|
pos, ok := m.composerPositionAtMouse(msg)
|
|
if !ok {
|
|
if m.composerSelection.active {
|
|
m.composerSelection = composerSelectionState{}
|
|
return m, nil, true
|
|
}
|
|
return m, nil, false
|
|
}
|
|
state := m.currentComposerState()
|
|
state.cursor = pos
|
|
m.setComposerState(state)
|
|
m.copyStatus = ""
|
|
m.composerSelection = composerSelectionState{active: true, anchor: pos, cursor: pos}
|
|
return m, nil, true
|
|
case mouseMotion(msg):
|
|
if !m.composerSelection.active {
|
|
return m, nil, false
|
|
}
|
|
if pos, ok := m.composerPositionAtMouse(msg); ok {
|
|
state := m.currentComposerState()
|
|
state.cursor = pos
|
|
m.setComposerState(state)
|
|
m.composerSelection.active = true
|
|
m.composerSelection.cursor = pos
|
|
}
|
|
return m, nil, true
|
|
case mouseRelease(msg):
|
|
if !m.composerSelection.active {
|
|
return m, nil, false
|
|
}
|
|
if pos, ok := m.composerPositionAtMouse(msg); ok {
|
|
state := m.currentComposerState()
|
|
state.cursor = pos
|
|
m.setComposerState(state)
|
|
m.composerSelection.active = true
|
|
m.composerSelection.cursor = pos
|
|
}
|
|
text := m.selectedComposerText()
|
|
m.composerSelection = composerSelectionState{}
|
|
if strings.TrimSpace(text) == "" {
|
|
return m, nil, true
|
|
}
|
|
return m, copyTranscriptSelectionCmd(text), true
|
|
default:
|
|
return m, nil, false
|
|
}
|
|
}
|
|
|
|
func (m model) composerPositionAtMouse(msg tea.MouseMsg) (int, bool) {
|
|
if !m.altScreen || m.height <= 0 || m.composerMouseSelectionBlocked() {
|
|
return 0, false
|
|
}
|
|
width := m.chatColumnWidth()
|
|
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
|
|
localX, localY, ok := frame.composerRect.local(mouseX(msg), mouseY(msg))
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
if width < 8 {
|
|
return m.composerPositionAtVisualCell(localX, localY, width)
|
|
}
|
|
contentY := localY - 1
|
|
if renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments) != "" {
|
|
contentY--
|
|
}
|
|
if contentY < 0 {
|
|
return 0, false
|
|
}
|
|
return m.composerPositionAtVisualCell(localX-2, contentY, maxInt(1, width-4))
|
|
}
|
|
|
|
func (m model) composerMouseSelectionBlocked() bool {
|
|
return m.transcriptDetailed || m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil ||
|
|
m.mcpManager != nil || m.picker != nil || m.suggestionsActive()
|
|
}
|
|
|
|
func (m model) composerPositionAtVisualCell(x int, y int, width int) (int, bool) {
|
|
input := m.input
|
|
state := m.currentComposerState()
|
|
previews := validComposerPastePreviews(state, m.composerPastePreviews)
|
|
displayState := composerDisplayStateForPastePreviews(state, previews)
|
|
segments, _ := composerVisibleVisualLines(input, displayState, width)
|
|
if len(segments) == 0 {
|
|
return 0, y == 0
|
|
}
|
|
if y < 0 || y >= len(segments) {
|
|
return 0, false
|
|
}
|
|
segment := segments[y]
|
|
prefixWidth := lipgloss.Width(composerVisualLinePrefix(input, segment.first))
|
|
column := maxInt(0, x-prefixWidth)
|
|
displayCursor := composerCursorForVisualColumn(displayState, segment, column)
|
|
return clamp(composerOriginalCursorForPastePreviews(displayCursor, previews), 0, len([]rune(state.text))), true
|
|
}
|
|
|
|
func (m model) applyComposerKey(msg tea.KeyMsg) (model, bool) {
|
|
state := m.currentComposerState()
|
|
switch {
|
|
case keyIs(msg, tea.KeyEnter) && (keyAlt(msg) || keyShift(msg)):
|
|
m = m.insertComposerTextWithPastePreview(state, "\n", "")
|
|
case keyCtrl(msg, 'j'):
|
|
m = m.insertComposerTextWithPastePreview(state, "\n", "")
|
|
case keyAlt(msg) && keyText(msg) == "d":
|
|
end := deleteComposerWordAfter(state).cursor
|
|
nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, state.cursor, end)
|
|
m.setComposerState(nextState)
|
|
m.composerPastePreviews = nextPreviews
|
|
case keyAlt(msg) && keyIs(msg, tea.KeyLeft):
|
|
m.setComposerState(moveComposerWordBefore(state))
|
|
case keyAlt(msg) && keyIs(msg, tea.KeyRight):
|
|
m.setComposerState(moveComposerWordAfter(state))
|
|
case keyCtrlArrow(msg, tea.KeyLeft):
|
|
m.setComposerState(moveComposerWordBefore(state))
|
|
case keyCtrlArrow(msg, tea.KeyRight):
|
|
m.setComposerState(moveComposerWordAfter(state))
|
|
case keyAlt(msg) && keyText(msg) == "b":
|
|
m.setComposerState(moveComposerWordBefore(state))
|
|
case keyAlt(msg) && keyText(msg) == "f":
|
|
m.setComposerState(moveComposerWordAfter(state))
|
|
case keyIs(msg, tea.KeySpace):
|
|
m = m.insertComposerTextWithPastePreview(state, " ", "")
|
|
case keyPrintable(msg):
|
|
m = m.applyComposerText(state, keyText(msg), false)
|
|
case keyIs(msg, tea.KeyLeft) || keyCtrl(msg, 'b'):
|
|
if nextState, ok := moveComposerPastePreviewBoundary(state, m.composerPastePreviews, -1); ok {
|
|
m.setComposerState(nextState)
|
|
break
|
|
}
|
|
state.cursor--
|
|
m.setComposerState(state)
|
|
case keyIs(msg, tea.KeyRight) || keyCtrl(msg, 'f'):
|
|
if nextState, ok := moveComposerPastePreviewBoundary(state, m.composerPastePreviews, 1); ok {
|
|
m.setComposerState(nextState)
|
|
break
|
|
}
|
|
state.cursor++
|
|
m.setComposerState(state)
|
|
case keyIs(msg, tea.KeyHome) || keyCtrl(msg, 'a'):
|
|
state.cursor = composerLineStart(state)
|
|
m.setComposerState(state)
|
|
case keyIs(msg, tea.KeyEnd) || keyCtrl(msg, 'e'):
|
|
state.cursor = composerLineEnd(state)
|
|
m.setComposerState(state)
|
|
case keyCtrl(msg, 'u'):
|
|
nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, composerLineStart(state), state.cursor)
|
|
m.setComposerState(nextState)
|
|
m.composerPastePreviews = nextPreviews
|
|
case keyCtrl(msg, 'k'):
|
|
nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, state.cursor, composerLineEnd(state))
|
|
m.setComposerState(nextState)
|
|
m.composerPastePreviews = nextPreviews
|
|
case keyCtrl(msg, 'w') || (keyAlt(msg) && keyBackspace(msg)):
|
|
start := deleteComposerWordBefore(state).cursor
|
|
nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, start, state.cursor)
|
|
m.setComposerState(nextState)
|
|
m.composerPastePreviews = nextPreviews
|
|
case keyAlt(msg) && keyIs(msg, tea.KeyDelete):
|
|
end := deleteComposerWordAfter(state).cursor
|
|
nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, state.cursor, end)
|
|
m.setComposerState(nextState)
|
|
m.composerPastePreviews = nextPreviews
|
|
case keyBackspace(msg):
|
|
if nextState, nextPreviews, ok := deleteComposerPastePreviewBefore(state, m.composerPastePreviews); ok && !m.suggestionsActive() {
|
|
m.setComposerState(nextState)
|
|
m.composerPastePreviews = nextPreviews
|
|
} else if start, end, ok := completedFileMentionRangeBefore(state); ok && !m.suggestionsActive() {
|
|
nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, start, end)
|
|
m.setComposerState(nextState)
|
|
m.composerPastePreviews = nextPreviews
|
|
} else {
|
|
nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, state.cursor-1, state.cursor)
|
|
m.setComposerState(nextState)
|
|
m.composerPastePreviews = nextPreviews
|
|
}
|
|
case keyIs(msg, tea.KeyDelete) || keyCtrl(msg, 'd'):
|
|
if nextState, nextPreviews, ok := deleteComposerPastePreviewAfter(state, m.composerPastePreviews); ok {
|
|
m.setComposerState(nextState)
|
|
m.composerPastePreviews = nextPreviews
|
|
} else {
|
|
nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, state.cursor, state.cursor+1)
|
|
m.setComposerState(nextState)
|
|
m.composerPastePreviews = nextPreviews
|
|
}
|
|
default:
|
|
return m, false
|
|
}
|
|
|
|
if strings.Contains(m.composer.text, "\n") {
|
|
m.clearSuggestions()
|
|
} else {
|
|
m.recomputeSuggestions()
|
|
}
|
|
return m, true
|
|
}
|
|
|
|
func (m model) applyComposerText(state composerState, text string, paste bool) model {
|
|
previewLabel := ""
|
|
if paste {
|
|
text = sanitizeComposerPaste(text)
|
|
previewLabel, _ = composerPastePreviewLabel(text, m.composerPastePreviewWrapWidth())
|
|
} else {
|
|
text = sanitizeComposerInput(text)
|
|
}
|
|
if shouldInsertCommandArgumentSpace(state, text) {
|
|
text = " " + text
|
|
if previewLabel != "" {
|
|
previewLabel = " " + previewLabel
|
|
}
|
|
}
|
|
return m.insertComposerTextWithPastePreview(state, text, previewLabel)
|
|
}
|
|
|
|
func (m model) insertComposerTextWithPastePreview(state composerState, text string, previewLabel string) model {
|
|
state = normalizeComposerState(state)
|
|
insertStart := state.cursor
|
|
insertedRunes := len([]rune(text))
|
|
nextPreviews := composerPastePreviewsAfterInsert(m.composerPastePreviews, insertStart, insertedRunes)
|
|
m.setComposerState(insertComposerText(state, text))
|
|
if previewLabel != "" && insertedRunes > 0 {
|
|
previewLabel = composerPastePreviewLabelWithIndex(previewLabel, len(nextPreviews)+1)
|
|
nextPreviews = append(nextPreviews, composerPastePreview{
|
|
active: true,
|
|
start: insertStart,
|
|
end: insertStart + insertedRunes,
|
|
label: previewLabel,
|
|
})
|
|
sortComposerPastePreviews(nextPreviews)
|
|
m.composerPastePreviews = nextPreviews
|
|
return m
|
|
}
|
|
m.composerPastePreviews = nextPreviews
|
|
return m
|
|
}
|
|
|
|
func (m model) replaceComposerRangeWithPastePreviews(state composerState, start int, end int, replacement string) model {
|
|
nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, start, end)
|
|
m.setComposerState(nextState)
|
|
m.composerPastePreviews = nextPreviews
|
|
if replacement == "" {
|
|
return m
|
|
}
|
|
return m.insertComposerTextWithPastePreview(m.currentComposerState(), replacement, "")
|
|
}
|
|
|
|
func (m model) composerPastePreviewWrapWidth() int {
|
|
width := chatWidth(m.width)
|
|
if width < 8 {
|
|
width = defaultStartupWidth
|
|
}
|
|
return maxInt(1, width-4-lipgloss.Width(m.input.Prompt))
|
|
}
|
|
|
|
func composerPastePreviewLabel(text string, wrapWidth int) (string, bool) {
|
|
text = strings.TrimSpace(text)
|
|
if text == "" {
|
|
return "", false
|
|
}
|
|
runeCount := len([]rune(text))
|
|
lineCount := composerPastePreviewVisualLineCount(text, wrapWidth)
|
|
if lineCount < 3 && runeCount < 220 {
|
|
return "", false
|
|
}
|
|
snippet := text
|
|
if newline := strings.IndexRune(snippet, '\n'); newline >= 0 {
|
|
snippet = snippet[:newline]
|
|
}
|
|
snippet = strings.Join(strings.Fields(snippet), " ")
|
|
if snippet == "" {
|
|
snippet = "pasted text"
|
|
}
|
|
snippet = truncateComposerPasteSnippet(snippet, 48)
|
|
label := "lines"
|
|
if lineCount == 1 {
|
|
label = "line"
|
|
}
|
|
return "[" + snippet + " · " + strconv.Itoa(lineCount) + " " + label + "]", true
|
|
}
|
|
|
|
func composerPastePreviewLabelWithIndex(label string, pasteNumber int) string {
|
|
if pasteNumber <= 1 || !strings.HasSuffix(label, "]") {
|
|
return label
|
|
}
|
|
return strings.TrimSuffix(label, "]") + ", paste " + strconv.Itoa(pasteNumber) + "]"
|
|
}
|
|
|
|
func composerPastePreviewVisualLineCount(text string, wrapWidth int) int {
|
|
wrapWidth = maxInt(1, wrapWidth)
|
|
total := 0
|
|
for _, line := range strings.Split(text, "\n") {
|
|
width := lipgloss.Width(line)
|
|
total += maxInt(1, (width+wrapWidth-1)/wrapWidth)
|
|
}
|
|
return total
|
|
}
|
|
|
|
func truncateComposerPasteSnippet(text string, limit int) string {
|
|
runes := []rune(text)
|
|
if limit <= 3 || len(runes) <= limit {
|
|
return text
|
|
}
|
|
return string(runes[:limit-3]) + "..."
|
|
}
|
|
|
|
func composerPastePreviewsAfterInsert(previews []composerPastePreview, pos int, length int) []composerPastePreview {
|
|
if length <= 0 || len(previews) == 0 {
|
|
return previews
|
|
}
|
|
next := make([]composerPastePreview, 0, len(previews))
|
|
for _, preview := range previews {
|
|
if !preview.active {
|
|
continue
|
|
}
|
|
switch {
|
|
case pos <= preview.start:
|
|
preview.start += length
|
|
preview.end += length
|
|
case pos < preview.end:
|
|
continue
|
|
}
|
|
next = append(next, preview)
|
|
}
|
|
sortComposerPastePreviews(next)
|
|
return next
|
|
}
|
|
|
|
func moveComposerPastePreviewBoundary(state composerState, previews []composerPastePreview, direction int) (composerState, bool) {
|
|
state = normalizeComposerState(state)
|
|
for _, preview := range validComposerPastePreviews(state, previews) {
|
|
switch {
|
|
case direction < 0 && state.cursor > preview.start && state.cursor <= preview.end:
|
|
state.cursor = preview.start
|
|
return state, true
|
|
case direction > 0 && state.cursor >= preview.start && state.cursor < preview.end:
|
|
state.cursor = preview.end
|
|
return state, true
|
|
}
|
|
}
|
|
return state, false
|
|
}
|
|
|
|
func deleteComposerPastePreviewBefore(state composerState, previews []composerPastePreview) (composerState, []composerPastePreview, bool) {
|
|
state = normalizeComposerState(state)
|
|
valid := validComposerPastePreviews(state, previews)
|
|
for index, preview := range valid {
|
|
if state.cursor != preview.end {
|
|
continue
|
|
}
|
|
return deleteComposerRange(state, preview.start, preview.end), composerPastePreviewsAfterDelete(valid, index), true
|
|
}
|
|
return state, previews, false
|
|
}
|
|
|
|
func deleteComposerPastePreviewAfter(state composerState, previews []composerPastePreview) (composerState, []composerPastePreview, bool) {
|
|
state = normalizeComposerState(state)
|
|
valid := validComposerPastePreviews(state, previews)
|
|
for index, preview := range valid {
|
|
if state.cursor != preview.start {
|
|
continue
|
|
}
|
|
return deleteComposerRange(state, preview.start, preview.end), composerPastePreviewsAfterDelete(valid, index), true
|
|
}
|
|
return state, previews, false
|
|
}
|
|
|
|
func deleteComposerRangeWithPastePreviews(state composerState, previews []composerPastePreview, start int, end int) (composerState, []composerPastePreview) {
|
|
state = normalizeComposerState(state)
|
|
runeCount := len([]rune(state.text))
|
|
start = clamp(start, 0, runeCount)
|
|
end = clamp(end, 0, runeCount)
|
|
if end < start {
|
|
start, end = end, start
|
|
}
|
|
if start == end {
|
|
return state, validComposerPastePreviews(state, previews)
|
|
}
|
|
|
|
valid := validComposerPastePreviews(state, previews)
|
|
deleteStart, deleteEnd := start, end
|
|
for {
|
|
expanded := false
|
|
for _, preview := range valid {
|
|
if !composerRangesOverlap(deleteStart, deleteEnd, preview.start, preview.end) {
|
|
continue
|
|
}
|
|
if preview.start < deleteStart {
|
|
deleteStart = preview.start
|
|
expanded = true
|
|
}
|
|
if preview.end > deleteEnd {
|
|
deleteEnd = preview.end
|
|
expanded = true
|
|
}
|
|
}
|
|
if !expanded {
|
|
break
|
|
}
|
|
}
|
|
|
|
nextState := deleteComposerRange(state, deleteStart, deleteEnd)
|
|
delta := deleteEnd - deleteStart
|
|
nextPreviews := make([]composerPastePreview, 0, len(valid))
|
|
for _, preview := range valid {
|
|
switch {
|
|
case composerRangesOverlap(deleteStart, deleteEnd, preview.start, preview.end):
|
|
continue
|
|
case preview.start >= deleteEnd:
|
|
preview.start -= delta
|
|
preview.end -= delta
|
|
}
|
|
nextPreviews = append(nextPreviews, preview)
|
|
}
|
|
sortComposerPastePreviews(nextPreviews)
|
|
return nextState, nextPreviews
|
|
}
|
|
|
|
func composerRangesOverlap(leftStart int, leftEnd int, rightStart int, rightEnd int) bool {
|
|
return leftStart < rightEnd && rightStart < leftEnd
|
|
}
|
|
|
|
func validComposerPastePreviews(state composerState, previews []composerPastePreview) []composerPastePreview {
|
|
state = normalizeComposerState(state)
|
|
if len(previews) == 0 {
|
|
return nil
|
|
}
|
|
runeCount := len([]rune(state.text))
|
|
valid := make([]composerPastePreview, 0, len(previews))
|
|
for _, preview := range previews {
|
|
if !preview.active || preview.label == "" || preview.start < 0 || preview.start >= preview.end || preview.end > runeCount {
|
|
continue
|
|
}
|
|
valid = append(valid, preview)
|
|
}
|
|
sortComposerPastePreviews(valid)
|
|
out := valid[:0]
|
|
lastEnd := -1
|
|
for _, preview := range valid {
|
|
if preview.start < lastEnd {
|
|
continue
|
|
}
|
|
out = append(out, preview)
|
|
lastEnd = preview.end
|
|
}
|
|
return out
|
|
}
|
|
|
|
func sortComposerPastePreviews(previews []composerPastePreview) {
|
|
sort.SliceStable(previews, func(i, j int) bool {
|
|
if previews[i].start == previews[j].start {
|
|
return previews[i].end < previews[j].end
|
|
}
|
|
return previews[i].start < previews[j].start
|
|
})
|
|
}
|
|
|
|
func composerPastePreviewsAfterDelete(previews []composerPastePreview, deleteIndex int) []composerPastePreview {
|
|
if deleteIndex < 0 || deleteIndex >= len(previews) {
|
|
return previews
|
|
}
|
|
deleted := previews[deleteIndex]
|
|
delta := deleted.end - deleted.start
|
|
next := make([]composerPastePreview, 0, len(previews)-1)
|
|
for index, preview := range previews {
|
|
if index == deleteIndex {
|
|
continue
|
|
}
|
|
if preview.start >= deleted.end {
|
|
preview.start -= delta
|
|
preview.end -= delta
|
|
}
|
|
next = append(next, preview)
|
|
}
|
|
sortComposerPastePreviews(next)
|
|
return next
|
|
}
|
|
|
|
func shouldInsertCommandArgumentSpace(state composerState, text string) bool {
|
|
if text == "" {
|
|
return false
|
|
}
|
|
first, _ := utf8.DecodeRuneInString(text)
|
|
if unicode.IsSpace(first) {
|
|
return false
|
|
}
|
|
state = normalizeComposerState(state)
|
|
if state.cursor != len([]rune(state.text)) {
|
|
return false
|
|
}
|
|
if strings.TrimRightFunc(state.text, unicode.IsSpace) != state.text {
|
|
return false
|
|
}
|
|
return commandArgumentHintForInput(state.text) != ""
|
|
}
|
|
|
|
func completedFileMentionRangeBefore(state composerState) (int, int, bool) {
|
|
state = normalizeComposerState(state)
|
|
runes := []rune(state.text)
|
|
if state.cursor <= 0 || state.cursor > len(runes) || !isPathQueryBoundary(runes[state.cursor-1]) {
|
|
return 0, 0, false
|
|
}
|
|
tokenEnd := state.cursor
|
|
for tokenEnd > 0 && isPathQueryBoundary(runes[tokenEnd-1]) {
|
|
tokenEnd--
|
|
}
|
|
tokenStart := tokenEnd
|
|
for tokenStart > 0 && !isPathQueryBoundary(runes[tokenStart-1]) {
|
|
tokenStart--
|
|
}
|
|
if tokenStart >= tokenEnd || runes[tokenStart] != '@' || tokenEnd-tokenStart <= 1 {
|
|
return 0, 0, false
|
|
}
|
|
return tokenStart, state.cursor, true
|
|
}
|
|
|
|
func deleteComposerRange(state composerState, start int, end int) composerState {
|
|
state = normalizeComposerState(state)
|
|
runes := []rune(state.text)
|
|
start = clamp(start, 0, len(runes))
|
|
end = clamp(end, 0, len(runes))
|
|
if end < start {
|
|
start, end = end, start
|
|
}
|
|
if start == end {
|
|
return state
|
|
}
|
|
out := make([]rune, 0, len(runes)-(end-start))
|
|
out = append(out, runes[:start]...)
|
|
out = append(out, runes[end:]...)
|
|
return composerState{text: string(out), cursor: start}
|
|
}
|
|
|
|
func normalizeComposerState(state composerState) composerState {
|
|
runes := []rune(state.text)
|
|
state.cursor = clamp(state.cursor, 0, len(runes))
|
|
return state
|
|
}
|
|
|
|
func composerLineStart(state composerState) int {
|
|
state = normalizeComposerState(state)
|
|
runes := []rune(state.text)
|
|
pos := state.cursor
|
|
for pos > 0 && runes[pos-1] != '\n' {
|
|
pos--
|
|
}
|
|
return pos
|
|
}
|
|
|
|
func composerLineEnd(state composerState) int {
|
|
state = normalizeComposerState(state)
|
|
runes := []rune(state.text)
|
|
pos := state.cursor
|
|
for pos < len(runes) && runes[pos] != '\n' {
|
|
pos++
|
|
}
|
|
return pos
|
|
}
|