Skip to content

Commit 7b44330

Browse files
authored
Merge pull request #250 from NLipatov/fix/tui-ctrl-v-paste-windows
fix(tui): Ctrl+V paste broken on Windows
2 parents bcaea01 + f052e6d commit 7b44330

File tree

9 files changed

+400
-14
lines changed

9 files changed

+400
-14
lines changed

src/presentation/ui/tui/internal/bubble_tea/configurator_session.go

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ type configuratorLogTickMsg struct {
2929
seq uint64
3030
}
3131

32+
type pasteSettledMsg struct {
33+
seq uint64
34+
}
35+
3236
const (
3337
configuratorTabMain = iota
3438
configuratorTabSettings
@@ -123,6 +127,8 @@ type configuratorSessionModel struct {
123127
addNameInput textinput.Model
124128
addJSONInput textarea.Model
125129
addName string
130+
lastInputAt time.Time
131+
pasteSeq uint64
126132

127133
invalidErr error
128134
invalidConfig string
@@ -228,6 +234,11 @@ func (m configuratorSessionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
228234
}
229235
m.refreshLogs()
230236
return m, configuratorLogUpdateCmd(m.logsFeed(), m.logWaitStop, m.logTickSeq)
237+
case pasteSettledMsg:
238+
if m.screen == configuratorScreenClientAddJSON && msg.seq == m.pasteSeq {
239+
m.tryFormatJSON()
240+
}
241+
return m, nil
231242
case tea.KeyMsg:
232243
switch msg.String() {
233244
case "ctrl+c":
@@ -270,6 +281,19 @@ func (m configuratorSessionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
270281
}
271282
}
272283

284+
// Forward non-key messages (e.g. clipboard paste results, cursor blink ticks)
285+
// to the active input component so they are not silently dropped.
286+
switch m.screen {
287+
case configuratorScreenClientAddName:
288+
var cmd tea.Cmd
289+
m.addNameInput, cmd = m.addNameInput.Update(msg)
290+
return m, cmd
291+
case configuratorScreenClientAddJSON:
292+
var cmd tea.Cmd
293+
m.addJSONInput, cmd = m.addJSONInput.Update(msg)
294+
return m, cmd
295+
}
296+
273297
return m, nil
274298
}
275299

@@ -344,7 +368,7 @@ func (m configuratorSessionModel) View() string {
344368
m.tabsLine(styles),
345369
"Paste configuration",
346370
body,
347-
"Enter confirm | Tab switch tabs | Esc back | ctrl+c exit",
371+
"Enter confirm | Esc back | ctrl+c exit",
348372
m.preferences,
349373
styles,
350374
)
@@ -540,6 +564,7 @@ func (m configuratorSessionModel) updateClientAddNameScreen(msg tea.KeyMsg) (tea
540564
m.notice = ""
541565
m.cursor = 0
542566
m.screen = configuratorScreenClientAddJSON
567+
m.lastInputAt = time.Time{}
543568
m.initJSONInput()
544569
m.adjustInputsToViewport()
545570
return m, textarea.Blink
@@ -550,14 +575,28 @@ func (m configuratorSessionModel) updateClientAddNameScreen(msg tea.KeyMsg) (tea
550575
return m, cmd
551576
}
552577

578+
const pasteDebounce = 300 * time.Millisecond
579+
553580
func (m configuratorSessionModel) updateClientAddJSONScreen(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
554-
switch msg.String() {
555-
case "esc":
581+
if msg.String() == "esc" {
556582
m.notice = ""
557583
m.screen = configuratorScreenClientAddName
558584
m.adjustInputsToViewport()
559585
return m, nil
560-
case "enter":
586+
}
587+
588+
if msg.String() == "enter" {
589+
// Debounce: if Enter arrives within pasteDebounce of the last
590+
// non-Enter keystroke, it is almost certainly a newline from a
591+
// character-by-character terminal paste — insert it as a newline
592+
// instead of submitting.
593+
if !m.lastInputAt.IsZero() && time.Since(m.lastInputAt) < pasteDebounce {
594+
m.lastInputAt = time.Now()
595+
var cmd tea.Cmd
596+
m.addJSONInput, cmd = m.addJSONInput.Update(msg)
597+
return m, cmd
598+
}
599+
561600
configuration, parseErr := parseClientConfigurationJSON(m.addJSONInput.Value())
562601
if parseErr != nil {
563602
m.invalidErr = parseErr
@@ -585,9 +624,17 @@ func (m configuratorSessionModel) updateClientAddJSONScreen(msg tea.KeyMsg) (tea
585624
return m, nil
586625
}
587626

627+
// Track non-Enter input timing for debounce.
628+
m.lastInputAt = time.Now()
629+
m.pasteSeq++
630+
seq := m.pasteSeq
631+
632+
// Forward to textarea (paste characters, cursor movement, etc.)
588633
var cmd tea.Cmd
589634
m.addJSONInput, cmd = m.addJSONInput.Update(msg)
590-
return m, cmd
635+
return m, tea.Batch(cmd, tea.Tick(pasteDebounce, func(time.Time) tea.Msg {
636+
return pasteSettledMsg{seq: seq}
637+
}))
591638
}
592639

593640
func (m configuratorSessionModel) updateClientInvalidScreen(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
@@ -927,13 +974,32 @@ func (m *configuratorSessionModel) initNameInput() {
927974
m.addNameInput = ti
928975
}
929976

977+
func (m *configuratorSessionModel) tryFormatJSON() {
978+
raw := m.addJSONInput.Value()
979+
if strings.TrimSpace(raw) == "" {
980+
return
981+
}
982+
var obj json.RawMessage
983+
if err := json.Unmarshal([]byte(raw), &obj); err != nil {
984+
return
985+
}
986+
pretty, err := json.MarshalIndent(obj, "", " ")
987+
if err != nil {
988+
return
989+
}
990+
if string(pretty) != raw {
991+
m.addJSONInput.SetValue(string(pretty))
992+
}
993+
}
994+
930995
func (m *configuratorSessionModel) initJSONInput() {
931996
ta := textarea.New()
932997
ta.Prompt = "> "
933998
ta.Placeholder = "Paste it here"
934999
ta.SetWidth(80)
9351000
ta.SetHeight(10)
9361001
ta.ShowLineNumbers = true
1002+
ta.FocusedStyle.CursorLine = ta.FocusedStyle.Text
9371003
ta.SetValue("")
9381004
ta.Focus()
9391005
m.addJSONInput = ta

src/presentation/ui/tui/internal/bubble_tea/configurator_session_test.go

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"errors"
55
"strings"
66
"testing"
7+
"time"
78

89
"tungo/domain/mode"
910
clientConfiguration "tungo/infrastructure/PAL/configuration/client"
@@ -3032,3 +3033,215 @@ func TestUpdateClientSelectScreen_SelectorError_Exits(t *testing.T) {
30323033
t.Fatal("expected quit cmd")
30333034
}
30343035
}
3036+
3037+
// --- Non-key message forwarding to active input (fixes Ctrl+V paste on Windows) ---
3038+
3039+
func TestUpdate_NonKeyMsg_ForwardedToInput_AddName(t *testing.T) {
3040+
m := newTestSessionModel(t)
3041+
m.screen = configuratorScreenClientAddName
3042+
3043+
// Any non-key, non-window-size message should be forwarded to the textinput,
3044+
// not silently dropped. This is required for clipboard paste results and cursor blinks.
3045+
type customMsg struct{}
3046+
result, _ := m.Update(customMsg{})
3047+
s := result.(configuratorSessionModel)
3048+
if s.screen != configuratorScreenClientAddName {
3049+
t.Fatalf("expected to stay on add name screen, got %v", s.screen)
3050+
}
3051+
}
3052+
3053+
func TestUpdate_NonKeyMsg_ForwardedToInput_AddJSON(t *testing.T) {
3054+
m := newTestSessionModel(t)
3055+
m.screen = configuratorScreenClientAddJSON
3056+
3057+
// Any non-key, non-window-size message should be forwarded to the textarea,
3058+
// not silently dropped. This is required for clipboard paste results and cursor blinks.
3059+
type customMsg struct{}
3060+
result, _ := m.Update(customMsg{})
3061+
s := result.(configuratorSessionModel)
3062+
if s.screen != configuratorScreenClientAddJSON {
3063+
t.Fatalf("expected to stay on add JSON screen, got %v", s.screen)
3064+
}
3065+
}
3066+
3067+
func TestUpdate_JSONScreen_EnterDebouncedDuringPaste(t *testing.T) {
3068+
m := newTestSessionModel(t)
3069+
m.screen = configuratorScreenClientAddJSON
3070+
// Simulate recent non-Enter input (as if paste just happened).
3071+
m.lastInputAt = time.Now()
3072+
3073+
// Enter within debounce window should be forwarded to textarea as newline,
3074+
// not treated as submit.
3075+
result, _ := m.updateClientAddJSONScreen(keyNamed(tea.KeyEnter))
3076+
s := result.(configuratorSessionModel)
3077+
if s.screen != configuratorScreenClientAddJSON {
3078+
t.Fatal("expected Enter to be debounced during paste")
3079+
}
3080+
// lastInputAt should be refreshed so the debounce window extends.
3081+
if s.lastInputAt.IsZero() {
3082+
t.Fatal("expected lastInputAt to be refreshed during debounce")
3083+
}
3084+
}
3085+
3086+
func TestUpdate_JSONScreen_EnterAcceptedAfterDebounce(t *testing.T) {
3087+
m := newTestSessionModel(t)
3088+
m.screen = configuratorScreenClientAddJSON
3089+
m.addJSONInput.SetValue("not valid json")
3090+
// No recent input — lastInputAt is zero, Enter should be accepted.
3091+
3092+
result, _ := m.updateClientAddJSONScreen(keyNamed(tea.KeyEnter))
3093+
s := result.(configuratorSessionModel)
3094+
if s.screen != configuratorScreenClientInvalid {
3095+
t.Fatalf("expected Enter to be accepted (goes to invalid screen for bad JSON), got %v", s.screen)
3096+
}
3097+
}
3098+
3099+
func TestUpdate_JSONScreen_NonEnterKeySetsLastInputAt(t *testing.T) {
3100+
m := newTestSessionModel(t)
3101+
m.screen = configuratorScreenClientAddJSON
3102+
3103+
if !m.lastInputAt.IsZero() {
3104+
t.Fatal("expected lastInputAt to be zero initially")
3105+
}
3106+
result, _ := m.updateClientAddJSONScreen(keyRunes('x'))
3107+
s := result.(configuratorSessionModel)
3108+
if s.lastInputAt.IsZero() {
3109+
t.Fatal("expected lastInputAt to be set after key input")
3110+
}
3111+
}
3112+
3113+
func TestUpdate_PasteSettledMsg_FormatsJSON(t *testing.T) {
3114+
m := newTestSessionModel(t)
3115+
m.screen = configuratorScreenClientAddJSON
3116+
m.pasteSeq = 5
3117+
m.addJSONInput.SetValue(`{"a":1,"b":2}`)
3118+
3119+
result, _ := m.Update(pasteSettledMsg{seq: 5})
3120+
s := result.(configuratorSessionModel)
3121+
got := s.addJSONInput.Value()
3122+
if !strings.Contains(got, "\n") {
3123+
t.Fatalf("expected formatted JSON with newlines, got %q", got)
3124+
}
3125+
}
3126+
3127+
func TestUpdate_PasteSettledMsg_StaleSeqIgnored(t *testing.T) {
3128+
m := newTestSessionModel(t)
3129+
m.screen = configuratorScreenClientAddJSON
3130+
m.pasteSeq = 5
3131+
m.addJSONInput.SetValue(`{"a":1}`)
3132+
3133+
result, _ := m.Update(pasteSettledMsg{seq: 3}) // stale
3134+
s := result.(configuratorSessionModel)
3135+
got := s.addJSONInput.Value()
3136+
if strings.Contains(got, "\n") {
3137+
t.Fatalf("stale seq should not reformat, got %q", got)
3138+
}
3139+
}
3140+
3141+
func TestTryFormatJSON_EmptyInput(t *testing.T) {
3142+
m := newTestSessionModel(t)
3143+
m.screen = configuratorScreenClientAddJSON
3144+
m.pasteSeq = 1
3145+
m.addJSONInput.SetValue("")
3146+
3147+
// Should not panic or change anything.
3148+
result, _ := m.Update(pasteSettledMsg{seq: 1})
3149+
s := result.(configuratorSessionModel)
3150+
if s.addJSONInput.Value() != "" {
3151+
t.Fatalf("expected empty value unchanged, got %q", s.addJSONInput.Value())
3152+
}
3153+
}
3154+
3155+
func TestTryFormatJSON_InvalidJSON(t *testing.T) {
3156+
m := newTestSessionModel(t)
3157+
m.screen = configuratorScreenClientAddJSON
3158+
m.pasteSeq = 1
3159+
m.addJSONInput.SetValue("not json at all")
3160+
3161+
result, _ := m.Update(pasteSettledMsg{seq: 1})
3162+
s := result.(configuratorSessionModel)
3163+
if s.addJSONInput.Value() != "not json at all" {
3164+
t.Fatalf("expected invalid JSON unchanged, got %q", s.addJSONInput.Value())
3165+
}
3166+
}
3167+
3168+
func TestTryFormatJSON_AlreadyFormatted(t *testing.T) {
3169+
m := newTestSessionModel(t)
3170+
m.screen = configuratorScreenClientAddJSON
3171+
m.pasteSeq = 1
3172+
formatted := "{\n \"a\": 1\n}"
3173+
m.addJSONInput.SetValue(formatted)
3174+
3175+
result, _ := m.Update(pasteSettledMsg{seq: 1})
3176+
s := result.(configuratorSessionModel)
3177+
if s.addJSONInput.Value() != formatted {
3178+
t.Fatalf("expected already-formatted JSON unchanged, got %q", s.addJSONInput.Value())
3179+
}
3180+
}
3181+
3182+
func TestView_ClientAddJSONScreen_MultilineContent(t *testing.T) {
3183+
m := newTestSessionModel(t)
3184+
m.screen = configuratorScreenClientAddJSON
3185+
m.width = 80
3186+
m.height = 30
3187+
m.addJSONInput.SetValue("{\n \"key\": \"value\"\n}")
3188+
3189+
view := m.View()
3190+
if !strings.Contains(view, "Lines: 3") {
3191+
t.Fatalf("expected 'Lines: 3' in view for multiline content, got: %s", view)
3192+
}
3193+
}
3194+
3195+
func TestUpdateClientSelectScreen_EmptyMenuOptions(t *testing.T) {
3196+
m := newTestSessionModel(t)
3197+
m.screen = configuratorScreenClientSelect
3198+
m.clientMenuOptions = nil
3199+
3200+
result, _ := m.updateClientSelectScreen(keyNamed(tea.KeyEnter))
3201+
s := result.(configuratorSessionModel)
3202+
if s.screen != configuratorScreenClientSelect {
3203+
t.Fatalf("expected to stay on client select with empty options, got %v", s.screen)
3204+
}
3205+
}
3206+
3207+
func TestUpdateServerSelectScreen_DefaultFallthrough(t *testing.T) {
3208+
m := newTestSessionModel(t)
3209+
m.screen = configuratorScreenServerSelect
3210+
// Set options to something that doesn't match any known case.
3211+
m.serverMenuOptions = []string{"unknown option"}
3212+
m.cursor = 0
3213+
3214+
result, _ := m.updateServerSelectScreen(keyNamed(tea.KeyEnter))
3215+
s := result.(configuratorSessionModel)
3216+
// Should fall through to default return m, nil.
3217+
if s.screen != configuratorScreenServerSelect {
3218+
t.Fatalf("expected to stay on server select for unknown option, got %v", s.screen)
3219+
}
3220+
}
3221+
3222+
func TestUpdateServerManageScreen_EmptyPeersOnEnter(t *testing.T) {
3223+
m := newTestSessionModel(t)
3224+
m.screen = configuratorScreenServerManage
3225+
m.serverManagePeers = nil
3226+
3227+
result, _ := m.updateServerManageScreen(keyNamed(tea.KeyEnter))
3228+
s := result.(configuratorSessionModel)
3229+
if s.screen != configuratorScreenServerManage {
3230+
t.Fatalf("expected to stay on manage screen with empty peers, got %v", s.screen)
3231+
}
3232+
}
3233+
3234+
func TestUpdate_NonKeyMsg_DroppedOnOtherScreens(t *testing.T) {
3235+
m := newTestSessionModel(t)
3236+
m.screen = configuratorScreenMode
3237+
3238+
type customMsg struct{}
3239+
result, cmd := m.Update(customMsg{})
3240+
s := result.(configuratorSessionModel)
3241+
if s.screen != configuratorScreenMode {
3242+
t.Fatalf("expected to stay on mode screen, got %v", s.screen)
3243+
}
3244+
if cmd != nil {
3245+
t.Fatal("expected nil cmd for dropped message")
3246+
}
3247+
}

0 commit comments

Comments
 (0)