mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-26 21:36:31 +02:00
tweak(tui): two-threshold paste collapse with line-vs-char shape
Before: any paste with >= 2 newlines (3+ lines) collapsed to
[pasted text #N +L lines]. That fired too eagerly on short
three-line snippets the user wants to see inline, and never
fired on a 2000-character single-line dump that bloats the
editor just as badly.
New rule matches the shape widely used in other TUIs:
collapse when
lines > 10 OR
chars > 1000
Both triggers produce distinct placeholder shapes so you can
see at a glance which dimension tripped it:
[pasted text #1 +12 lines] line trigger
[pasted text #1 1500 chars] char trigger
When both triggers hit (e.g. 12 lines of 400 chars each) the
line-count shape wins \u2014 "+12 lines" reads more informatively
than a raw character count for multi-line content.
Implementation:
- pasteCollapseLineThreshold = 10
- pasteCollapseCharThreshold = 1000
- pasteShouldCollapse checks countLines(s) > 10 || len(s) > 1000
- formatPastePlaceholder picks the shape; line trigger wins
on ties
- pastePlaceholderRE widened with a non-capturing alternation
(\+\d+ lines?|\d+ chars?) so expansion works for both
shapes; capture group 1 is still just the id
- the strings.Contains fast-path in SubmitValue still matches
both shapes because both start with the same prefix
Tests rewritten around the new thresholds:
- TestPasteCollapseLineTrigger 11 lines -> +11 lines marker
- TestPasteCollapseCharTrigger 1500 chars, 1 line -> N chars
- TestPasteCollapseLinePrecedence 12 lines of 400 chars each ->
line shape wins
- TestPasteCollapseFallthrough 1 line / 2 lines / 10 lines /
1000 chars / 5 lines under
caps all stay inline
- TestPasteCollapseSequentialIDs mixed-shape placeholders
coexist, both expand
- TestPasteCollapseClearResetsMap unchanged logic, updated body
to trigger the new threshold
This commit is contained in:
parent
755ca7ccdf
commit
c4150ce630
2 changed files with 124 additions and 38 deletions
|
|
@ -172,8 +172,7 @@ func (e *Editor) HandleKey(k Key) (submit bool) {
|
|||
e.pasteSeq++
|
||||
id := e.pasteSeq
|
||||
e.pastes[id] = k.Paste
|
||||
placeholder := fmt.Sprintf("[pasted text #%d +%d lines]", id, countLines(k.Paste))
|
||||
e.insert(placeholder)
|
||||
e.insert(formatPastePlaceholder(id, k.Paste))
|
||||
} else {
|
||||
// macOS Terminal / iTerm / Ghostty deliver drag-dropped
|
||||
// files as bracketed-paste text. Detect that pattern
|
||||
|
|
@ -958,13 +957,41 @@ func max(a, b int) int {
|
|||
return b
|
||||
}
|
||||
|
||||
// pasteCollapseLineThreshold and pasteCollapseCharThreshold govern
|
||||
// when a bracketed paste gets collapsed to a [pasted text #N …]
|
||||
// placeholder instead of being inserted inline. Either trigger
|
||||
// alone is enough — a 500-line log dump and a 1200-character
|
||||
// one-line log entry both bloat the editor in ways the user
|
||||
// doesn't want to scroll through while composing a prompt.
|
||||
const (
|
||||
pasteCollapseLineThreshold = 10
|
||||
pasteCollapseCharThreshold = 1000
|
||||
)
|
||||
|
||||
// pasteShouldCollapse reports whether a pasted chunk is big enough
|
||||
// to deserve a placeholder token instead of being inserted verbatim.
|
||||
// Two or more newlines (i.e. three-plus lines) is the cutoff: that
|
||||
// covers real multi-line code/log pastes while leaving single-line
|
||||
// file-drop paths and two-line copy-pastes of a sentence alone.
|
||||
// Collapse on > 10 lines OR > 1000 characters, whichever fires
|
||||
// first.
|
||||
func pasteShouldCollapse(s string) bool {
|
||||
return strings.Count(s, "\n") >= 2
|
||||
return countLines(s) > pasteCollapseLineThreshold || len(s) > pasteCollapseCharThreshold
|
||||
}
|
||||
|
||||
// formatPastePlaceholder builds the visible marker for a
|
||||
// collapsed paste. Two shapes:
|
||||
//
|
||||
// [pasted text #N +L lines] — used when the line count is the
|
||||
// trigger (multi-line dumps)
|
||||
// [pasted text #N C chars] — used when the character count is
|
||||
// the trigger (long single-line
|
||||
// or near-single-line pastes)
|
||||
//
|
||||
// Line-triggered takes precedence so a 12-line 4000-char paste
|
||||
// reads as "+12 lines", not "4000 chars".
|
||||
func formatPastePlaceholder(id int, body string) string {
|
||||
if countLines(body) > pasteCollapseLineThreshold {
|
||||
return fmt.Sprintf("[pasted text #%d +%d lines]", id, countLines(body))
|
||||
}
|
||||
return fmt.Sprintf("[pasted text #%d %d chars]", id, len(body))
|
||||
}
|
||||
|
||||
// countLines returns the number of visual lines in s. A trailing
|
||||
|
|
@ -982,10 +1009,16 @@ func countLines(s string) int {
|
|||
return n
|
||||
}
|
||||
|
||||
// pastePlaceholderRE matches the "[pasted text #N +L lines]" token
|
||||
// that collapsed pastes leave in the editor. Capture group 1 is
|
||||
// the numeric id used to look up the full body in e.pastes.
|
||||
var pastePlaceholderRE = regexp.MustCompile(`\[pasted text #(\d+) \+\d+ lines?\]`)
|
||||
// pastePlaceholderRE matches both placeholder shapes the editor
|
||||
// emits for a collapsed paste:
|
||||
//
|
||||
// [pasted text #N +L lines] — multi-line trigger
|
||||
// [pasted text #N C chars] — long-char trigger
|
||||
//
|
||||
// Capture group 1 is the numeric id used to look up the full
|
||||
// body in e.pastes; the rest of the token is free-form and gets
|
||||
// discarded during expansion.
|
||||
var pastePlaceholderRE = regexp.MustCompile(`\[pasted text #(\d+) (?:\+\d+ lines?|\d+ chars?)\]`)
|
||||
|
||||
// expandPastePlaceholders returns raw with every paste token
|
||||
// swapped for the body stored under its id in pastes. Tokens
|
||||
|
|
|
|||
|
|
@ -1,20 +1,32 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPasteCollapseInsertsPlaceholder verifies that a multi-line
|
||||
// paste gets collapsed to the "[paste #N +L lines]" placeholder
|
||||
// in the editor buffer, leaving the full body behind the scenes.
|
||||
func TestPasteCollapseInsertsPlaceholder(t *testing.T) {
|
||||
// makeLines returns a body with n lines of short text — useful for
|
||||
// exercising the line-count trigger without also tripping the
|
||||
// character-count one.
|
||||
func makeLines(n int) string {
|
||||
parts := make([]string, n)
|
||||
for i := range parts {
|
||||
parts[i] = fmt.Sprintf("line %d", i+1)
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
// TestPasteCollapseLineTrigger verifies that a paste with more than
|
||||
// ten lines gets collapsed to the "+L lines" placeholder shape.
|
||||
// The full body is preserved and expanded back by SubmitValue.
|
||||
func TestPasteCollapseLineTrigger(t *testing.T) {
|
||||
e := NewEditor("▌ ")
|
||||
body := "line1\nline2\nline3\nline4"
|
||||
body := makeLines(11)
|
||||
e.HandleKey(Key{Kind: KeyPaste, Paste: body})
|
||||
|
||||
got := e.Value()
|
||||
want := "[pasted text #1 +4 lines]"
|
||||
want := "[pasted text #1 +11 lines]"
|
||||
if got != want {
|
||||
t.Fatalf("editor Value: want %q, got %q", want, got)
|
||||
}
|
||||
|
|
@ -23,38 +35,79 @@ func TestPasteCollapseInsertsPlaceholder(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestPasteCollapseSingleLineFallthrough ensures short pastes are
|
||||
// NOT collapsed — single-line drag-drop file paths and short
|
||||
// two-line snippets should appear inline so the user can edit
|
||||
// them in place.
|
||||
func TestPasteCollapseSingleLineFallthrough(t *testing.T) {
|
||||
// TestPasteCollapseCharTrigger verifies that a paste with more than
|
||||
// 1000 characters but few enough lines collapses to the "C chars"
|
||||
// placeholder shape (long single-line / near-single-line dumps).
|
||||
func TestPasteCollapseCharTrigger(t *testing.T) {
|
||||
e := NewEditor("▌ ")
|
||||
e.HandleKey(Key{Kind: KeyPaste, Paste: "hello world"})
|
||||
if strings.Contains(e.Value(), "[pasted text #") {
|
||||
t.Errorf("single-line paste should not collapse, got %q", e.Value())
|
||||
}
|
||||
body := strings.Repeat("a", 1500) // 1 line, 1500 chars
|
||||
e.HandleKey(Key{Kind: KeyPaste, Paste: body})
|
||||
|
||||
e2 := NewEditor("▌ ")
|
||||
e2.HandleKey(Key{Kind: KeyPaste, Paste: "line1\nline2"})
|
||||
if strings.Contains(e2.Value(), "[pasted text #") {
|
||||
t.Errorf("two-line paste should not collapse, got %q", e2.Value())
|
||||
got := e.Value()
|
||||
want := "[pasted text #1 1500 chars]"
|
||||
if got != want {
|
||||
t.Fatalf("editor Value: want %q, got %q", want, got)
|
||||
}
|
||||
if e.SubmitValue() != body {
|
||||
t.Fatalf("SubmitValue didn't expand placeholder: got %q", e.SubmitValue())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPasteCollapseSequentialIDs makes sure two separate pastes get
|
||||
// distinct ids and both expand correctly in SubmitValue.
|
||||
// TestPasteCollapseLinePrecedence verifies that when a paste trips
|
||||
// both thresholds, the line-count marker wins. "12 lines, 4000
|
||||
// chars" should read as "+12 lines", not "4000 chars".
|
||||
func TestPasteCollapseLinePrecedence(t *testing.T) {
|
||||
e := NewEditor("▌ ")
|
||||
// 12 lines, each 400 chars = 12 lines, ~4800 chars.
|
||||
parts := make([]string, 12)
|
||||
for i := range parts {
|
||||
parts[i] = strings.Repeat("x", 400)
|
||||
}
|
||||
body := strings.Join(parts, "\n")
|
||||
e.HandleKey(Key{Kind: KeyPaste, Paste: body})
|
||||
|
||||
if !strings.HasPrefix(e.Value(), "[pasted text #1 +12 lines") {
|
||||
t.Errorf("want line-trigger placeholder, got %q", e.Value())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPasteCollapseFallthrough ensures short pastes (under both
|
||||
// thresholds) are NOT collapsed — 1-10 lines with < 1000 chars
|
||||
// should appear inline so the user can edit them in place.
|
||||
func TestPasteCollapseFallthrough(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"single line": "hello world",
|
||||
"two lines": "line1\nline2",
|
||||
"ten lines (at limit)": makeLines(10),
|
||||
"exactly 1000 chars": strings.Repeat("a", 1000),
|
||||
"multiline under caps": "aaa\nbbb\nccc\nddd\neee",
|
||||
}
|
||||
for name, body := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
e := NewEditor("▌ ")
|
||||
e.HandleKey(Key{Kind: KeyPaste, Paste: body})
|
||||
if strings.Contains(e.Value(), "[pasted text #") {
|
||||
t.Errorf("%s should NOT collapse, got %q", name, e.Value())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPasteCollapseSequentialIDs makes sure two separate large
|
||||
// pastes get distinct ids and both expand correctly in
|
||||
// SubmitValue, even when they use different placeholder shapes.
|
||||
func TestPasteCollapseSequentialIDs(t *testing.T) {
|
||||
e := NewEditor("▌ ")
|
||||
a := "aaa\nbbb\nccc"
|
||||
b := "xxx\nyyy\nzzz"
|
||||
a := makeLines(12) // line trigger
|
||||
b := strings.Repeat("y", 1500) // char trigger, single line
|
||||
e.HandleKey(Key{Kind: KeyPaste, Paste: a})
|
||||
e.Insert(" ")
|
||||
e.HandleKey(Key{Kind: KeyPaste, Paste: b})
|
||||
|
||||
visible := e.Value()
|
||||
if !strings.Contains(visible, "[pasted text #1 +3 lines]") ||
|
||||
!strings.Contains(visible, "[pasted text #2 +3 lines]") {
|
||||
t.Fatalf("expected two placeholders in %q", visible)
|
||||
if !strings.Contains(visible, "[pasted text #1 +12 lines]") ||
|
||||
!strings.Contains(visible, "[pasted text #2 1500 chars]") {
|
||||
t.Fatalf("expected mixed-shape placeholders in %q", visible)
|
||||
}
|
||||
|
||||
full := e.SubmitValue()
|
||||
|
|
@ -69,11 +122,11 @@ func TestPasteCollapseSequentialIDs(t *testing.T) {
|
|||
// the wrong body).
|
||||
func TestPasteCollapseClearResetsMap(t *testing.T) {
|
||||
e := NewEditor("▌ ")
|
||||
e.HandleKey(Key{Kind: KeyPaste, Paste: "a\nb\nc"})
|
||||
e.HandleKey(Key{Kind: KeyPaste, Paste: makeLines(11)})
|
||||
e.Clear()
|
||||
|
||||
// A fresh paste must start at id #1 again.
|
||||
e.HandleKey(Key{Kind: KeyPaste, Paste: "d\ne\nf"})
|
||||
e.HandleKey(Key{Kind: KeyPaste, Paste: makeLines(11)})
|
||||
if !strings.Contains(e.Value(), "[pasted text #1 ") {
|
||||
t.Errorf("Clear didn't reset pasteSeq: %q", e.Value())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue