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- commit
- c4150ce63030df2b8e1c43dbe44987bbec23425a
- author
- patriceckhart · · 2026-04-21 15:13 UTC
- parent
- 755ca7c
- signature
- unsigned
- refs
- tag: v0.0.79
2 files · +123 -37
M internal/tui/editor.go +43 -10
| @@ -172,8 +172,7 @@ func (e *Editor) HandleKey(k Key) (submit bool) { | |||
| 172 | 172 | e.pasteSeq++ | |
| 173 | 173 | id := e.pasteSeq | |
| 174 | 174 | e.pastes[id] = k.Paste | |
| 175 | - | placeholder := fmt.Sprintf("[pasted text #%d +%d lines]", id, countLines(k.Paste)) | |
| 176 | - | e.insert(placeholder) | |
| 175 | + | e.insert(formatPastePlaceholder(id, k.Paste)) | |
| 177 | 176 | } else { | |
| 178 | 177 | // macOS Terminal / iTerm / Ghostty deliver drag-dropped | |
| 179 | 178 | // files as bracketed-paste text. Detect that pattern | |
| @@ -958,13 +957,41 @@ func max(a, b int) int { | |||
| 958 | 957 | return b | |
| 959 | 958 | } | |
| 960 | 959 | ||
| 960 | + | // pasteCollapseLineThreshold and pasteCollapseCharThreshold govern | |
| 961 | + | // when a bracketed paste gets collapsed to a [pasted text #N …] | |
| 962 | + | // placeholder instead of being inserted inline. Either trigger | |
| 963 | + | // alone is enough — a 500-line log dump and a 1200-character | |
| 964 | + | // one-line log entry both bloat the editor in ways the user | |
| 965 | + | // doesn't want to scroll through while composing a prompt. | |
| 966 | + | const ( | |
| 967 | + | pasteCollapseLineThreshold = 10 | |
| 968 | + | pasteCollapseCharThreshold = 1000 | |
| 969 | + | ) | |
| 970 | + | ||
| 961 | 971 | // pasteShouldCollapse reports whether a pasted chunk is big enough | |
| 962 | 972 | // to deserve a placeholder token instead of being inserted verbatim. | |
| 963 | - | // Two or more newlines (i.e. three-plus lines) is the cutoff: that | |
| 964 | - | // covers real multi-line code/log pastes while leaving single-line | |
| 965 | - | // file-drop paths and two-line copy-pastes of a sentence alone. | |
| 973 | + | // Collapse on > 10 lines OR > 1000 characters, whichever fires | |
| 974 | + | // first. | |
| 966 | 975 | func pasteShouldCollapse(s string) bool { | |
| 967 | - | return strings.Count(s, "\n") >= 2 | |
| 976 | + | return countLines(s) > pasteCollapseLineThreshold || len(s) > pasteCollapseCharThreshold | |
| 977 | + | } | |
| 978 | + | ||
| 979 | + | // formatPastePlaceholder builds the visible marker for a | |
| 980 | + | // collapsed paste. Two shapes: | |
| 981 | + | // | |
| 982 | + | // [pasted text #N +L lines] — used when the line count is the | |
| 983 | + | // trigger (multi-line dumps) | |
| 984 | + | // [pasted text #N C chars] — used when the character count is | |
| 985 | + | // the trigger (long single-line | |
| 986 | + | // or near-single-line pastes) | |
| 987 | + | // | |
| 988 | + | // Line-triggered takes precedence so a 12-line 4000-char paste | |
| 989 | + | // reads as "+12 lines", not "4000 chars". | |
| 990 | + | func formatPastePlaceholder(id int, body string) string { | |
| 991 | + | if countLines(body) > pasteCollapseLineThreshold { | |
| 992 | + | return fmt.Sprintf("[pasted text #%d +%d lines]", id, countLines(body)) | |
| 993 | + | } | |
| 994 | + | return fmt.Sprintf("[pasted text #%d %d chars]", id, len(body)) | |
| 968 | 995 | } | |
| 969 | 996 | ||
| 970 | 997 | // countLines returns the number of visual lines in s. A trailing | |
| @@ -982,10 +1009,16 @@ func countLines(s string) int { | |||
| 982 | 1009 | return n | |
| 983 | 1010 | } | |
| 984 | 1011 | ||
| 985 | - | // pastePlaceholderRE matches the "[pasted text #N +L lines]" token | |
| 986 | - | // that collapsed pastes leave in the editor. Capture group 1 is | |
| 987 | - | // the numeric id used to look up the full body in e.pastes. | |
| 988 | - | var pastePlaceholderRE = regexp.MustCompile(`\[pasted text #(\d+) \+\d+ lines?\]`) | |
| 1012 | + | // pastePlaceholderRE matches both placeholder shapes the editor | |
| 1013 | + | // emits for a collapsed paste: | |
| 1014 | + | // | |
| 1015 | + | // [pasted text #N +L lines] — multi-line trigger | |
| 1016 | + | // [pasted text #N C chars] — long-char trigger | |
| 1017 | + | // | |
| 1018 | + | // Capture group 1 is the numeric id used to look up the full | |
| 1019 | + | // body in e.pastes; the rest of the token is free-form and gets | |
| 1020 | + | // discarded during expansion. | |
| 1021 | + | var pastePlaceholderRE = regexp.MustCompile(`\[pasted text #(\d+) (?:\+\d+ lines?|\d+ chars?)\]`) | |
| 989 | 1022 | ||
| 990 | 1023 | // expandPastePlaceholders returns raw with every paste token | |
| 991 | 1024 | // swapped for the body stored under its id in pastes. Tokens | |
| @@ -172,8 +172,7 @@ func (e *Editor) HandleKey(k Key) (submit bool) { | |||||
| 172 | e.pasteSeq++ | 172 | e.pasteSeq++ | ||
| 173 | id := e.pasteSeq | 173 | id := e.pasteSeq | ||
| 174 | e.pastes[id] = k.Paste | 174 | e.pastes[id] = k.Paste | ||
| 175 | - | placeholder := fmt.Sprintf("[pasted text #%d +%d lines]", id, countLines(k.Paste)) | 175 | + | e.insert(formatPastePlaceholder(id, k.Paste)) |
| 176 | - | e.insert(placeholder) | |||
| 177 | } else { | 176 | } else { | ||
| 178 | // macOS Terminal / iTerm / Ghostty deliver drag-dropped | 177 | // macOS Terminal / iTerm / Ghostty deliver drag-dropped | ||
| 179 | // files as bracketed-paste text. Detect that pattern | 178 | // files as bracketed-paste text. Detect that pattern | ||
| @@ -958,13 +957,41 @@ func max(a, b int) int { | |||||
| 958 | return b | 957 | return b | ||
| 959 | } | 958 | } | ||
| 960 | 959 | ||||
| 960 | + | // pasteCollapseLineThreshold and pasteCollapseCharThreshold govern | |||
| 961 | + | // when a bracketed paste gets collapsed to a [pasted text #N …] | |||
| 962 | + | // placeholder instead of being inserted inline. Either trigger | |||
| 963 | + | // alone is enough — a 500-line log dump and a 1200-character | |||
| 964 | + | // one-line log entry both bloat the editor in ways the user | |||
| 965 | + | // doesn't want to scroll through while composing a prompt. | |||
| 966 | + | const ( | |||
| 967 | + | pasteCollapseLineThreshold = 10 | |||
| 968 | + | pasteCollapseCharThreshold = 1000 | |||
| 969 | + | ) | |||
| 970 | + | ||||
| 961 | // pasteShouldCollapse reports whether a pasted chunk is big enough | 971 | // pasteShouldCollapse reports whether a pasted chunk is big enough | ||
| 962 | // to deserve a placeholder token instead of being inserted verbatim. | 972 | // to deserve a placeholder token instead of being inserted verbatim. | ||
| 963 | - | // Two or more newlines (i.e. three-plus lines) is the cutoff: that | 973 | + | // Collapse on > 10 lines OR > 1000 characters, whichever fires |
| 964 | - | // covers real multi-line code/log pastes while leaving single-line | 974 | + | // first. |
| 965 | - | // file-drop paths and two-line copy-pastes of a sentence alone. | |||
| 966 | func pasteShouldCollapse(s string) bool { | 975 | func pasteShouldCollapse(s string) bool { | ||
| 967 | - | return strings.Count(s, "\n") >= 2 | 976 | + | return countLines(s) > pasteCollapseLineThreshold || len(s) > pasteCollapseCharThreshold |
| 977 | + | } | |||
| 978 | + | ||||
| 979 | + | // formatPastePlaceholder builds the visible marker for a | |||
| 980 | + | // collapsed paste. Two shapes: | |||
| 981 | + | // | |||
| 982 | + | // [pasted text #N +L lines] — used when the line count is the | |||
| 983 | + | // trigger (multi-line dumps) | |||
| 984 | + | // [pasted text #N C chars] — used when the character count is | |||
| 985 | + | // the trigger (long single-line | |||
| 986 | + | // or near-single-line pastes) | |||
| 987 | + | // | |||
| 988 | + | // Line-triggered takes precedence so a 12-line 4000-char paste | |||
| 989 | + | // reads as "+12 lines", not "4000 chars". | |||
| 990 | + | func formatPastePlaceholder(id int, body string) string { | |||
| 991 | + | if countLines(body) > pasteCollapseLineThreshold { | |||
| 992 | + | return fmt.Sprintf("[pasted text #%d +%d lines]", id, countLines(body)) | |||
| 993 | + | } | |||
| 994 | + | return fmt.Sprintf("[pasted text #%d %d chars]", id, len(body)) | |||
| 968 | } | 995 | } | ||
| 969 | 996 | ||||
| 970 | // countLines returns the number of visual lines in s. A trailing | 997 | // countLines returns the number of visual lines in s. A trailing | ||
| @@ -982,10 +1009,16 @@ func countLines(s string) int { | |||||
| 982 | return n | 1009 | return n | ||
| 983 | } | 1010 | } | ||
| 984 | 1011 | ||||
| 985 | - | // pastePlaceholderRE matches the "[pasted text #N +L lines]" token | 1012 | + | // pastePlaceholderRE matches both placeholder shapes the editor |
| 986 | - | // that collapsed pastes leave in the editor. Capture group 1 is | 1013 | + | // emits for a collapsed paste: |
| 987 | - | // the numeric id used to look up the full body in e.pastes. | 1014 | + | // |
| 988 | - | var pastePlaceholderRE = regexp.MustCompile(`\[pasted text #(\d+) \+\d+ lines?\]`) | 1015 | + | // [pasted text #N +L lines] — multi-line trigger |
| 1016 | + | // [pasted text #N C chars] — long-char trigger | |||
| 1017 | + | // | |||
| 1018 | + | // Capture group 1 is the numeric id used to look up the full | |||
| 1019 | + | // body in e.pastes; the rest of the token is free-form and gets | |||
| 1020 | + | // discarded during expansion. | |||
| 1021 | + | var pastePlaceholderRE = regexp.MustCompile(`\[pasted text #(\d+) (?:\+\d+ lines?|\d+ chars?)\]`) | |||
| 989 | 1022 | ||||
| 990 | // expandPastePlaceholders returns raw with every paste token | 1023 | // expandPastePlaceholders returns raw with every paste token | ||
| 991 | // swapped for the body stored under its id in pastes. Tokens | 1024 | // swapped for the body stored under its id in pastes. Tokens | ||
M internal/tui/paste_collapse_test.go +80 -27
| @@ -1,20 +1,50 @@ | |||
| 1 | 1 | package tui | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 4 | 5 | "strings" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ) | |
| 7 | 8 | ||
| 8 | - | // TestPasteCollapseInsertsPlaceholder verifies that a multi-line | |
| 9 | - | // paste gets collapsed to the "[paste #N +L lines]" placeholder | |
| 10 | - | // in the editor buffer, leaving the full body behind the scenes. | |
| 11 | - | func TestPasteCollapseInsertsPlaceholder(t *testing.T) { | |
| 9 | + | // makeLines returns a body with n lines of short text — useful for | |
| 10 | + | // exercising the line-count trigger without also tripping the | |
| 11 | + | // character-count one. | |
| 12 | + | func makeLines(n int) string { | |
| 13 | + | parts := make([]string, n) | |
| 14 | + | for i := range parts { | |
| 15 | + | parts[i] = fmt.Sprintf("line %d", i+1) | |
| 16 | + | } | |
| 17 | + | return strings.Join(parts, "\n") | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // TestPasteCollapseLineTrigger verifies that a paste with more than | |
| 21 | + | // ten lines gets collapsed to the "+L lines" placeholder shape. | |
| 22 | + | // The full body is preserved and expanded back by SubmitValue. | |
| 23 | + | func TestPasteCollapseLineTrigger(t *testing.T) { | |
| 24 | + | e := NewEditor("▌ ") | |
| 25 | + | body := makeLines(11) | |
| 26 | + | e.HandleKey(Key{Kind: KeyPaste, Paste: body}) | |
| 27 | + | ||
| 28 | + | got := e.Value() | |
| 29 | + | want := "[pasted text #1 +11 lines]" | |
| 30 | + | if got != want { | |
| 31 | + | t.Fatalf("editor Value: want %q, got %q", want, got) | |
| 32 | + | } | |
| 33 | + | if e.SubmitValue() != body { | |
| 34 | + | t.Fatalf("SubmitValue didn't expand placeholder: got %q", e.SubmitValue()) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // TestPasteCollapseCharTrigger verifies that a paste with more than | |
| 39 | + | // 1000 characters but few enough lines collapses to the "C chars" | |
| 40 | + | // placeholder shape (long single-line / near-single-line dumps). | |
| 41 | + | func TestPasteCollapseCharTrigger(t *testing.T) { | |
| 12 | 42 | e := NewEditor("▌ ") | |
| 13 | - | body := "line1\nline2\nline3\nline4" | |
| 43 | + | body := strings.Repeat("a", 1500) // 1 line, 1500 chars | |
| 14 | 44 | e.HandleKey(Key{Kind: KeyPaste, Paste: body}) | |
| 15 | 45 | ||
| 16 | 46 | got := e.Value() | |
| 17 | - | want := "[pasted text #1 +4 lines]" | |
| 47 | + | want := "[pasted text #1 1500 chars]" | |
| 18 | 48 | if got != want { | |
| 19 | 49 | t.Fatalf("editor Value: want %q, got %q", want, got) | |
| 20 | 50 | } | |
| @@ -23,38 +53,61 @@ func TestPasteCollapseInsertsPlaceholder(t *testing.T) { | |||
| 23 | 53 | } | |
| 24 | 54 | } | |
| 25 | 55 | ||
| 26 | - | // TestPasteCollapseSingleLineFallthrough ensures short pastes are | |
| 27 | - | // NOT collapsed — single-line drag-drop file paths and short | |
| 28 | - | // two-line snippets should appear inline so the user can edit | |
| 29 | - | // them in place. | |
| 30 | - | func TestPasteCollapseSingleLineFallthrough(t *testing.T) { | |
| 56 | + | // TestPasteCollapseLinePrecedence verifies that when a paste trips | |
| 57 | + | // both thresholds, the line-count marker wins. "12 lines, 4000 | |
| 58 | + | // chars" should read as "+12 lines", not "4000 chars". | |
| 59 | + | func TestPasteCollapseLinePrecedence(t *testing.T) { | |
| 31 | 60 | e := NewEditor("▌ ") | |
| 32 | - | e.HandleKey(Key{Kind: KeyPaste, Paste: "hello world"}) | |
| 33 | - | if strings.Contains(e.Value(), "[pasted text #") { | |
| 34 | - | t.Errorf("single-line paste should not collapse, got %q", e.Value()) | |
| 61 | + | // 12 lines, each 400 chars = 12 lines, ~4800 chars. | |
| 62 | + | parts := make([]string, 12) | |
| 63 | + | for i := range parts { | |
| 64 | + | parts[i] = strings.Repeat("x", 400) | |
| 35 | 65 | } | |
| 66 | + | body := strings.Join(parts, "\n") | |
| 67 | + | e.HandleKey(Key{Kind: KeyPaste, Paste: body}) | |
| 36 | 68 | ||
| 37 | - | e2 := NewEditor("▌ ") | |
| 38 | - | e2.HandleKey(Key{Kind: KeyPaste, Paste: "line1\nline2"}) | |
| 39 | - | if strings.Contains(e2.Value(), "[pasted text #") { | |
| 40 | - | t.Errorf("two-line paste should not collapse, got %q", e2.Value()) | |
| 69 | + | if !strings.HasPrefix(e.Value(), "[pasted text #1 +12 lines") { | |
| 70 | + | t.Errorf("want line-trigger placeholder, got %q", e.Value()) | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // TestPasteCollapseFallthrough ensures short pastes (under both | |
| 75 | + | // thresholds) are NOT collapsed — 1-10 lines with < 1000 chars | |
| 76 | + | // should appear inline so the user can edit them in place. | |
| 77 | + | func TestPasteCollapseFallthrough(t *testing.T) { | |
| 78 | + | cases := map[string]string{ | |
| 79 | + | "single line": "hello world", | |
| 80 | + | "two lines": "line1\nline2", | |
| 81 | + | "ten lines (at limit)": makeLines(10), | |
| 82 | + | "exactly 1000 chars": strings.Repeat("a", 1000), | |
| 83 | + | "multiline under caps": "aaa\nbbb\nccc\nddd\neee", | |
| 84 | + | } | |
| 85 | + | for name, body := range cases { | |
| 86 | + | t.Run(name, func(t *testing.T) { | |
| 87 | + | e := NewEditor("▌ ") | |
| 88 | + | e.HandleKey(Key{Kind: KeyPaste, Paste: body}) | |
| 89 | + | if strings.Contains(e.Value(), "[pasted text #") { | |
| 90 | + | t.Errorf("%s should NOT collapse, got %q", name, e.Value()) | |
| 91 | + | } | |
| 92 | + | }) | |
| 41 | 93 | } | |
| 42 | 94 | } | |
| 43 | 95 | ||
| 44 | - | // TestPasteCollapseSequentialIDs makes sure two separate pastes get | |
| 45 | - | // distinct ids and both expand correctly in SubmitValue. | |
| 96 | + | // TestPasteCollapseSequentialIDs makes sure two separate large | |
| 97 | + | // pastes get distinct ids and both expand correctly in | |
| 98 | + | // SubmitValue, even when they use different placeholder shapes. | |
| 46 | 99 | func TestPasteCollapseSequentialIDs(t *testing.T) { | |
| 47 | 100 | e := NewEditor("▌ ") | |
| 48 | - | a := "aaa\nbbb\nccc" | |
| 49 | - | b := "xxx\nyyy\nzzz" | |
| 101 | + | a := makeLines(12) // line trigger | |
| 102 | + | b := strings.Repeat("y", 1500) // char trigger, single line | |
| 50 | 103 | e.HandleKey(Key{Kind: KeyPaste, Paste: a}) | |
| 51 | 104 | e.Insert(" ") | |
| 52 | 105 | e.HandleKey(Key{Kind: KeyPaste, Paste: b}) | |
| 53 | 106 | ||
| 54 | 107 | visible := e.Value() | |
| 55 | - | if !strings.Contains(visible, "[pasted text #1 +3 lines]") || | |
| 56 | - | !strings.Contains(visible, "[pasted text #2 +3 lines]") { | |
| 57 | - | t.Fatalf("expected two placeholders in %q", visible) | |
| 108 | + | if !strings.Contains(visible, "[pasted text #1 +12 lines]") || | |
| 109 | + | !strings.Contains(visible, "[pasted text #2 1500 chars]") { | |
| 110 | + | t.Fatalf("expected mixed-shape placeholders in %q", visible) | |
| 58 | 111 | } | |
| 59 | 112 | ||
| 60 | 113 | full := e.SubmitValue() | |
| @@ -69,11 +122,11 @@ func TestPasteCollapseSequentialIDs(t *testing.T) { | |||
| 69 | 122 | // the wrong body). | |
| 70 | 123 | func TestPasteCollapseClearResetsMap(t *testing.T) { | |
| 71 | 124 | e := NewEditor("▌ ") | |
| 72 | - | e.HandleKey(Key{Kind: KeyPaste, Paste: "a\nb\nc"}) | |
| 125 | + | e.HandleKey(Key{Kind: KeyPaste, Paste: makeLines(11)}) | |
| 73 | 126 | e.Clear() | |
| 74 | 127 | ||
| 75 | 128 | // A fresh paste must start at id #1 again. | |
| 76 | - | e.HandleKey(Key{Kind: KeyPaste, Paste: "d\ne\nf"}) | |
| 129 | + | e.HandleKey(Key{Kind: KeyPaste, Paste: makeLines(11)}) | |
| 77 | 130 | if !strings.Contains(e.Value(), "[pasted text #1 ") { | |
| 78 | 131 | t.Errorf("Clear didn't reset pasteSeq: %q", e.Value()) | |
| 79 | 132 | } | |
| @@ -1,20 +1,50 @@ | |||||
| 1 | package tui | 1 | package tui | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "fmt" | |||
| 4 | "strings" | 5 | "strings" | ||
| 5 | "testing" | 6 | "testing" | ||
| 6 | ) | 7 | ) | ||
| 7 | 8 | ||||
| 8 | - | // TestPasteCollapseInsertsPlaceholder verifies that a multi-line | 9 | + | // makeLines returns a body with n lines of short text — useful for |
| 9 | - | // paste gets collapsed to the "[paste #N +L lines]" placeholder | 10 | + | // exercising the line-count trigger without also tripping the |
| 10 | - | // in the editor buffer, leaving the full body behind the scenes. | 11 | + | // character-count one. |
| 11 | - | func TestPasteCollapseInsertsPlaceholder(t *testing.T) { | 12 | + | func makeLines(n int) string { |
| 13 | + | parts := make([]string, n) | |||
| 14 | + | for i := range parts { | |||
| 15 | + | parts[i] = fmt.Sprintf("line %d", i+1) | |||
| 16 | + | } | |||
| 17 | + | return strings.Join(parts, "\n") | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // TestPasteCollapseLineTrigger verifies that a paste with more than | |||
| 21 | + | // ten lines gets collapsed to the "+L lines" placeholder shape. | |||
| 22 | + | // The full body is preserved and expanded back by SubmitValue. | |||
| 23 | + | func TestPasteCollapseLineTrigger(t *testing.T) { | |||
| 24 | + | e := NewEditor("▌ ") | |||
| 25 | + | body := makeLines(11) | |||
| 26 | + | e.HandleKey(Key{Kind: KeyPaste, Paste: body}) | |||
| 27 | + | ||||
| 28 | + | got := e.Value() | |||
| 29 | + | want := "[pasted text #1 +11 lines]" | |||
| 30 | + | if got != want { | |||
| 31 | + | t.Fatalf("editor Value: want %q, got %q", want, got) | |||
| 32 | + | } | |||
| 33 | + | if e.SubmitValue() != body { | |||
| 34 | + | t.Fatalf("SubmitValue didn't expand placeholder: got %q", e.SubmitValue()) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // TestPasteCollapseCharTrigger verifies that a paste with more than | |||
| 39 | + | // 1000 characters but few enough lines collapses to the "C chars" | |||
| 40 | + | // placeholder shape (long single-line / near-single-line dumps). | |||
| 41 | + | func TestPasteCollapseCharTrigger(t *testing.T) { | |||
| 12 | e := NewEditor("▌ ") | 42 | e := NewEditor("▌ ") | ||
| 13 | - | body := "line1\nline2\nline3\nline4" | 43 | + | body := strings.Repeat("a", 1500) // 1 line, 1500 chars |
| 14 | e.HandleKey(Key{Kind: KeyPaste, Paste: body}) | 44 | e.HandleKey(Key{Kind: KeyPaste, Paste: body}) | ||
| 15 | 45 | ||||
| 16 | got := e.Value() | 46 | got := e.Value() | ||
| 17 | - | want := "[pasted text #1 +4 lines]" | 47 | + | want := "[pasted text #1 1500 chars]" |
| 18 | if got != want { | 48 | if got != want { | ||
| 19 | t.Fatalf("editor Value: want %q, got %q", want, got) | 49 | t.Fatalf("editor Value: want %q, got %q", want, got) | ||
| 20 | } | 50 | } | ||
| @@ -23,38 +53,61 @@ func TestPasteCollapseInsertsPlaceholder(t *testing.T) { | |||||
| 23 | } | 53 | } | ||
| 24 | } | 54 | } | ||
| 25 | 55 | ||||
| 26 | - | // TestPasteCollapseSingleLineFallthrough ensures short pastes are | 56 | + | // TestPasteCollapseLinePrecedence verifies that when a paste trips |
| 27 | - | // NOT collapsed — single-line drag-drop file paths and short | 57 | + | // both thresholds, the line-count marker wins. "12 lines, 4000 |
| 28 | - | // two-line snippets should appear inline so the user can edit | 58 | + | // chars" should read as "+12 lines", not "4000 chars". |
| 29 | - | // them in place. | 59 | + | func TestPasteCollapseLinePrecedence(t *testing.T) { |
| 30 | - | func TestPasteCollapseSingleLineFallthrough(t *testing.T) { | |||
| 31 | e := NewEditor("▌ ") | 60 | e := NewEditor("▌ ") | ||
| 32 | - | e.HandleKey(Key{Kind: KeyPaste, Paste: "hello world"}) | 61 | + | // 12 lines, each 400 chars = 12 lines, ~4800 chars. |
| 33 | - | if strings.Contains(e.Value(), "[pasted text #") { | 62 | + | parts := make([]string, 12) |
| 34 | - | t.Errorf("single-line paste should not collapse, got %q", e.Value()) | 63 | + | for i := range parts { |
| 64 | + | parts[i] = strings.Repeat("x", 400) | |||
| 35 | } | 65 | } | ||
| 66 | + | body := strings.Join(parts, "\n") | |||
| 67 | + | e.HandleKey(Key{Kind: KeyPaste, Paste: body}) | |||
| 36 | 68 | ||||
| 37 | - | e2 := NewEditor("▌ ") | 69 | + | if !strings.HasPrefix(e.Value(), "[pasted text #1 +12 lines") { |
| 38 | - | e2.HandleKey(Key{Kind: KeyPaste, Paste: "line1\nline2"}) | 70 | + | t.Errorf("want line-trigger placeholder, got %q", e.Value()) |
| 39 | - | if strings.Contains(e2.Value(), "[pasted text #") { | 71 | + | } |
| 40 | - | t.Errorf("two-line paste should not collapse, got %q", e2.Value()) | 72 | + | } |
| 73 | + | ||||
| 74 | + | // TestPasteCollapseFallthrough ensures short pastes (under both | |||
| 75 | + | // thresholds) are NOT collapsed — 1-10 lines with < 1000 chars | |||
| 76 | + | // should appear inline so the user can edit them in place. | |||
| 77 | + | func TestPasteCollapseFallthrough(t *testing.T) { | |||
| 78 | + | cases := map[string]string{ | |||
| 79 | + | "single line": "hello world", | |||
| 80 | + | "two lines": "line1\nline2", | |||
| 81 | + | "ten lines (at limit)": makeLines(10), | |||
| 82 | + | "exactly 1000 chars": strings.Repeat("a", 1000), | |||
| 83 | + | "multiline under caps": "aaa\nbbb\nccc\nddd\neee", | |||
| 84 | + | } | |||
| 85 | + | for name, body := range cases { | |||
| 86 | + | t.Run(name, func(t *testing.T) { | |||
| 87 | + | e := NewEditor("▌ ") | |||
| 88 | + | e.HandleKey(Key{Kind: KeyPaste, Paste: body}) | |||
| 89 | + | if strings.Contains(e.Value(), "[pasted text #") { | |||
| 90 | + | t.Errorf("%s should NOT collapse, got %q", name, e.Value()) | |||
| 91 | + | } | |||
| 92 | + | }) | |||
| 41 | } | 93 | } | ||
| 42 | } | 94 | } | ||
| 43 | 95 | ||||
| 44 | - | // TestPasteCollapseSequentialIDs makes sure two separate pastes get | 96 | + | // TestPasteCollapseSequentialIDs makes sure two separate large |
| 45 | - | // distinct ids and both expand correctly in SubmitValue. | 97 | + | // pastes get distinct ids and both expand correctly in |
| 98 | + | // SubmitValue, even when they use different placeholder shapes. | |||
| 46 | func TestPasteCollapseSequentialIDs(t *testing.T) { | 99 | func TestPasteCollapseSequentialIDs(t *testing.T) { | ||
| 47 | e := NewEditor("▌ ") | 100 | e := NewEditor("▌ ") | ||
| 48 | - | a := "aaa\nbbb\nccc" | 101 | + | a := makeLines(12) // line trigger |
| 49 | - | b := "xxx\nyyy\nzzz" | 102 | + | b := strings.Repeat("y", 1500) // char trigger, single line |
| 50 | e.HandleKey(Key{Kind: KeyPaste, Paste: a}) | 103 | e.HandleKey(Key{Kind: KeyPaste, Paste: a}) | ||
| 51 | e.Insert(" ") | 104 | e.Insert(" ") | ||
| 52 | e.HandleKey(Key{Kind: KeyPaste, Paste: b}) | 105 | e.HandleKey(Key{Kind: KeyPaste, Paste: b}) | ||
| 53 | 106 | ||||
| 54 | visible := e.Value() | 107 | visible := e.Value() | ||
| 55 | - | if !strings.Contains(visible, "[pasted text #1 +3 lines]") || | 108 | + | if !strings.Contains(visible, "[pasted text #1 +12 lines]") || |
| 56 | - | !strings.Contains(visible, "[pasted text #2 +3 lines]") { | 109 | + | !strings.Contains(visible, "[pasted text #2 1500 chars]") { |
| 57 | - | t.Fatalf("expected two placeholders in %q", visible) | 110 | + | t.Fatalf("expected mixed-shape placeholders in %q", visible) |
| 58 | } | 111 | } | ||
| 59 | 112 | ||||
| 60 | full := e.SubmitValue() | 113 | full := e.SubmitValue() | ||
| @@ -69,11 +122,11 @@ func TestPasteCollapseSequentialIDs(t *testing.T) { | |||||
| 69 | // the wrong body). | 122 | // the wrong body). | ||
| 70 | func TestPasteCollapseClearResetsMap(t *testing.T) { | 123 | func TestPasteCollapseClearResetsMap(t *testing.T) { | ||
| 71 | e := NewEditor("▌ ") | 124 | e := NewEditor("▌ ") | ||
| 72 | - | e.HandleKey(Key{Kind: KeyPaste, Paste: "a\nb\nc"}) | 125 | + | e.HandleKey(Key{Kind: KeyPaste, Paste: makeLines(11)}) |
| 73 | e.Clear() | 126 | e.Clear() | ||
| 74 | 127 | ||||
| 75 | // A fresh paste must start at id #1 again. | 128 | // A fresh paste must start at id #1 again. | ||
| 76 | - | e.HandleKey(Key{Kind: KeyPaste, Paste: "d\ne\nf"}) | 129 | + | e.HandleKey(Key{Kind: KeyPaste, Paste: makeLines(11)}) |
| 77 | if !strings.Contains(e.Value(), "[pasted text #1 ") { | 130 | if !strings.Contains(e.Value(), "[pasted text #1 ") { | ||
| 78 | t.Errorf("Clear didn't reset pasteSeq: %q", e.Value()) | 131 | t.Errorf("Clear didn't reset pasteSeq: %q", e.Value()) | ||
| 79 | } | 132 | } | ||