diff --git a/docs/keybindings.ja.md b/docs/keybindings.ja.md index ccdd336..55dd1e2 100644 --- a/docs/keybindings.ja.md +++ b/docs/keybindings.ja.md @@ -22,6 +22,7 @@ claws で使用できるすべてのキーボードショートカットのリ | `:pulse` | ダッシュボードに移動します | | `:services` | サービスブラウザに移動します | | `/` | フィルターモード(あいまい検索) | +| `Ctrl+v` / ターミナルの貼り付け | クリップボードをフィルター/コマンド入力に貼り付け | | `A` | AIチャット(Bedrock) | | `Ctrl+E` | コンパクトヘッダーを切り替えます | | `?` | ヘルプを表示します | diff --git a/docs/keybindings.ko.md b/docs/keybindings.ko.md index c852124..fca51db 100644 --- a/docs/keybindings.ko.md +++ b/docs/keybindings.ko.md @@ -22,6 +22,7 @@ claws의 모든 키보드 단축키에 대한 전체 참조입니다. | `:pulse` | 대시보드로 이동 | | `:services` | 서비스 브라우저로 이동 | | `/` | 필터 모드 (퍼지 검색) | +| `Ctrl+v` / 터미널 붙여넣기 | 클립보드를 필터/명령 입력에 붙여넣기 | | `A` | AI 채팅 (Bedrock) | | `Ctrl+E` | 컴팩트 헤더 전환 | | `?` | 도움말 표시 | diff --git a/docs/keybindings.md b/docs/keybindings.md index d0f7ada..c96a90c 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -22,6 +22,7 @@ Complete reference for all keyboard shortcuts in claws. | `:pulse` | Go to dashboard | | `:services` | Go to service browser | | `/` | Filter mode (fuzzy search) | +| `Ctrl+v` / terminal paste | Paste clipboard into filter/command input | | `A` | AI Chat (Bedrock) | | `Ctrl+E` | Toggle compact header | | `?` | Show help | diff --git a/docs/keybindings.zh-CN.md b/docs/keybindings.zh-CN.md index 51d7af2..799934d 100644 --- a/docs/keybindings.zh-CN.md +++ b/docs/keybindings.zh-CN.md @@ -22,6 +22,7 @@ claws 所有键盘快捷键的完整参考。 | `:pulse` | 前往仪表盘 | | `:services` | 前往服务浏览器 | | `/` | 筛选模式(模糊搜索) | +| `Ctrl+v` / 终端粘贴 | 将剪贴板内容粘贴到筛选/命令输入框 | | `A` | AI 对话(Bedrock) | | `Ctrl+E` | 切换紧凑标题栏 | | `?` | 显示帮助 | diff --git a/internal/app/app.go b/internal/app/app.go index 9ee3400..c1f0452 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -288,11 +288,12 @@ func (a *App) handleCommandModeMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) { if !a.commandMode { return nil, nil, false } - keyMsg, ok := msg.(tea.KeyPressMsg) - if !ok { + switch msg.(type) { + case tea.KeyPressMsg, tea.PasteMsg: + default: return nil, nil, false } - cmd, nav := a.commandInput.Update(keyMsg) + cmd, nav := a.commandInput.Update(msg) if !a.commandInput.IsActive() { a.commandMode = false } diff --git a/internal/view/filter_paste_test.go b/internal/view/filter_paste_test.go new file mode 100644 index 0000000..41526be --- /dev/null +++ b/internal/view/filter_paste_test.go @@ -0,0 +1,151 @@ +package view + +import ( + "context" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/clawscli/claws/internal/dao" + "github.com/clawscli/claws/internal/registry" +) + +func TestResourceBrowserFilterPaste(t *testing.T) { + ctx := context.Background() + reg := registry.New() + + browser := NewResourceBrowser(ctx, reg, "ec2") + browser.SetSize(100, 50) + browser.renderer = &mockRenderer{detail: "test"} + browser.resources = []dao.Resource{ + &mockResource{id: "i-0123456789abcdef0", name: "web-server"}, + &mockResource{id: "i-0fedcba9876543210", name: "db-server"}, + } + browser.applyFilter() + browser.buildTable() + + // Paste while the filter is inactive should be ignored + browser.Update(tea.PasteMsg{Content: "i-0123456789abcdef0"}) + if browser.filterText != "" { + t.Fatalf("Expected paste to be ignored when filter is inactive, got filterText %q", browser.filterText) + } + + browser.filterActive = true + browser.filterInput.Focus() + + // Bracketed paste (Ctrl+Shift+V / tmux paste) arrives as tea.PasteMsg + browser.Update(tea.PasteMsg{Content: "i-0123456789abcdef0"}) + + if browser.filterText != "i-0123456789abcdef0" { + t.Errorf("filterText = %q, want %q", browser.filterText, "i-0123456789abcdef0") + } + if len(browser.filtered) != 1 || browser.filtered[0].GetID() != "i-0123456789abcdef0" { + t.Errorf("Expected filter to narrow to the pasted instance ID, got %d resources", len(browser.filtered)) + } + + // Ctrl+V must produce the textinput's clipboard-read command + _, cmd := browser.Update(tea.KeyPressMsg{Code: 'v', Mod: tea.ModCtrl}) + if cmd == nil { + t.Error("Expected ctrl+v to return the clipboard paste command") + } +} + +func TestServiceBrowserFilterPaste(t *testing.T) { + ctx := context.Background() + reg := registry.New() + reg.RegisterCustom("ec2", "instances", registry.Entry{}) + reg.RegisterCustom("s3", "buckets", registry.Entry{}) + + browser := NewServiceBrowser(ctx, reg) + browser.Update(browser.Init()()) + + browser.filterActive = true + browser.filterInput.Focus() + + browser.Update(tea.PasteMsg{Content: "ec2"}) + + if browser.filterText != "ec2" { + t.Errorf("filterText = %q, want %q", browser.filterText, "ec2") + } + if len(browser.flatItems) != 1 { + t.Errorf("Expected 1 service after paste, got %d", len(browser.flatItems)) + } +} + +func TestLogViewFilterPaste(t *testing.T) { + ctx := context.Background() + lv := NewLogView(ctx, "/aws/test") + lv.SetSize(80, 24) + lv.loading = false + lv.logs = []logEntry{ + {timestamp: time.Now(), message: "error in handler"}, + {timestamp: time.Now(), message: "request completed"}, + } + + lv.filterActive = true + lv.filterInput.Focus() + + lv.Update(tea.PasteMsg{Content: "error"}) + + if lv.filterText != "error" { + t.Errorf("filterText = %q, want %q", lv.filterText, "error") + } +} + +func TestTagSearchViewFilterPaste(t *testing.T) { + ctx := context.Background() + reg := registry.New() + + v := NewTagSearchView(ctx, reg, "") + v.SetSize(100, 50) + v.resources = []taggedARN{ + {RawARN: "arn:aws:ec2:us-east-1:123456789012:instance/i-0123456789abcdef0"}, + {RawARN: "arn:aws:s3:::my-bucket"}, + } + v.applyFilter() + v.buildTable() + + v.filterActive = true + v.filterInput.Focus() + + v.Update(tea.PasteMsg{Content: "i-0123456789abcdef0"}) + + if v.filterText != "i-0123456789abcdef0" { + t.Errorf("filterText = %q, want %q", v.filterText, "i-0123456789abcdef0") + } + if len(v.filtered) != 1 { + t.Errorf("Expected 1 resource after paste, got %d", len(v.filtered)) + } +} + +type selectorMockItem struct { + id string + label string +} + +func (s selectorMockItem) GetID() string { return s.id } +func (s selectorMockItem) GetLabel() string { return s.label } + +func TestMultiSelectorFilterPaste(t *testing.T) { + m := NewMultiSelector[selectorMockItem]("Select", nil) + m.SetItems([]selectorMockItem{ + {id: "i-1", label: "web-server"}, + {id: "i-2", label: "db-server"}, + }) + + m.filterActive = true + m.filterInput.Focus() + + _, result := m.HandleUpdate(tea.PasteMsg{Content: "db"}) + + if result != KeyHandled { + t.Errorf("Expected paste to be handled, got %v", result) + } + if m.filterText != "db" { + t.Errorf("filterText = %q, want %q", m.filterText, "db") + } + if len(m.filtered) != 1 || m.filtered[0].GetID() != "i-2" { + t.Errorf("Expected filter to narrow to the pasted text, got %d items", len(m.filtered)) + } +} diff --git a/internal/view/log_view.go b/internal/view/log_view.go index 3403dad..3cb8944 100644 --- a/internal/view/log_view.go +++ b/internal/view/log_view.go @@ -382,6 +382,11 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return v, nil } + // Route paste and other textinput-bound messages to the active filter. + if v.filterActive { + return v.updateFilterInput(msg) + } + if v.vp.Ready { var cmd tea.Cmd v.vp.Model, cmd = v.vp.Model.Update(msg) @@ -438,17 +443,25 @@ func (v *LogView) handleFilterInput(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } return v, nil default: - var cmd tea.Cmd - v.filterInput, cmd = v.filterInput.Update(msg) + return v.updateFilterInput(msg) + } +} - // Apply filter in real-time as user types - v.filterText = v.filterInput.Value() +// updateFilterInput forwards msg to the filter text input and re-applies the +// filter in real-time when the input value changed. Besides key presses this +// must receive tea.PasteMsg (bracketed paste) and the textinput's internal +// clipboard-read results, or pasting into the filter is silently dropped. +func (v *LogView) updateFilterInput(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + v.filterInput, cmd = v.filterInput.Update(msg) + if value := v.filterInput.Value(); value != v.filterText { + v.filterText = value if v.vp.Ready { v.updateViewportContent() } - return v, tea.Batch(cmd, tea.ClearScreen) } + return v, cmd } func (v *LogView) ViewString() string { diff --git a/internal/view/multi_selector.go b/internal/view/multi_selector.go index 5cf6ca2..c3b592b 100644 --- a/internal/view/multi_selector.go +++ b/internal/view/multi_selector.go @@ -229,6 +229,25 @@ func (m *MultiSelector[T]) HandleUpdate(msg tea.Msg) (tea.Cmd, SelectorKeyResult } } + // Route paste and other textinput-bound messages (tea.PasteMsg from + // bracketed paste, the textinput's internal clipboard-read results) to + // the active filter, or pasting into the filter is silently dropped. + if m.filterActive { + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(msg) + if value := m.filterInput.Value(); value != m.filterText { + m.filterText = value + m.applyFilter() + m.clampCursor() + m.updateViewport() + return cmd, KeyHandled + } + if cmd != nil { + return cmd, KeyHandled + } + return nil, KeyNotHandled + } + var cmd tea.Cmd m.vp.Model, cmd = m.vp.Model.Update(msg) if cmd != nil { diff --git a/internal/view/resource_browser.go b/internal/view/resource_browser.go index 1a72e80..a812a39 100644 --- a/internal/view/resource_browser.go +++ b/internal/view/resource_browser.go @@ -259,6 +259,12 @@ func (r *ResourceBrowser) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if model, cmd := r.handleMouseClickMsg(msg); cmd != nil { return model, cmd } + + default: + // Route paste and other textinput-bound messages to the active filter. + if r.filterActive { + return r.updateFilterInput(msg) + } } // Check if we should load more pages (infinite scroll) diff --git a/internal/view/resource_browser_input.go b/internal/view/resource_browser_input.go index 600c6e0..b4f7549 100644 --- a/internal/view/resource_browser_input.go +++ b/internal/view/resource_browser_input.go @@ -108,13 +108,23 @@ func (r *ResourceBrowser) handleFilterInput(msg tea.KeyPressMsg) (tea.Model, tea r.buildTable() return r, nil default: - var cmd tea.Cmd - r.filterInput, cmd = r.filterInput.Update(msg) - r.filterText = r.filterInput.Value() + return r.updateFilterInput(msg) + } +} + +// updateFilterInput forwards msg to the filter text input and re-applies the +// filter when the input value changed. Besides key presses this must receive +// tea.PasteMsg (bracketed paste) and the textinput's internal clipboard-read +// results, or pasting into the filter is silently dropped. +func (r *ResourceBrowser) updateFilterInput(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + r.filterInput, cmd = r.filterInput.Update(msg) + if value := r.filterInput.Value(); value != r.filterText { + r.filterText = value r.applyFilter() r.buildTable() - return r, cmd } + return r, cmd } func (r *ResourceBrowser) handleRefresh() (tea.Model, tea.Cmd) { diff --git a/internal/view/service_browser.go b/internal/view/service_browser.go index 4b8181b..6af4c0a 100644 --- a/internal/view/service_browser.go +++ b/internal/view/service_browser.go @@ -210,6 +210,12 @@ func (s *ServiceBrowser) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s.selectCurrentService() } } + + default: + // Route paste and other textinput-bound messages to the active filter. + if s.filterActive { + return s.updateFilterInput(msg) + } } return s, nil @@ -263,13 +269,24 @@ func (s *ServiceBrowser) handleFilterInput(msg tea.KeyPressMsg) (tea.Model, tea. return s, nil } + return s.updateFilterInput(msg) +} + +// updateFilterInput forwards msg to the filter text input and re-applies the +// filter when the input value changed. Besides key presses this must receive +// tea.PasteMsg (bracketed paste) and the textinput's internal clipboard-read +// results, or pasting into the filter is silently dropped. +func (s *ServiceBrowser) updateFilterInput(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd s.filterInput, cmd = s.filterInput.Update(msg) - s.filterText = s.filterInput.Value() - s.rebuildFlatItems() - s.cursor = 0 - s.updateViewport() - return s, tea.Batch(cmd, tea.ClearScreen) + if value := s.filterInput.Value(); value != s.filterText { + s.filterText = value + s.rebuildFlatItems() + s.cursor = 0 + s.updateViewport() + return s, tea.Batch(cmd, tea.ClearScreen) + } + return s, cmd } func (s *ServiceBrowser) handleNavigation(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { diff --git a/internal/view/tag_search_view.go b/internal/view/tag_search_view.go index 4a982da..680d6ba 100644 --- a/internal/view/tag_search_view.go +++ b/internal/view/tag_search_view.go @@ -305,6 +305,12 @@ func (v *TagSearchView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return v.handleFilterKey(msg) } return v.handleKeyPress(msg) + + default: + // Route paste and other textinput-bound messages to the active filter. + if v.filterActive { + return v.updateFilterInput(msg) + } } return v, nil @@ -377,13 +383,23 @@ func (v *TagSearchView) handleFilterKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd v.buildTable() return v, nil default: - var cmd tea.Cmd - v.filterInput, cmd = v.filterInput.Update(msg) - v.filterText = v.filterInput.Value() + return v.updateFilterInput(msg) + } +} + +// updateFilterInput forwards msg to the filter text input and re-applies the +// filter when the input value changed. Besides key presses this must receive +// tea.PasteMsg (bracketed paste) and the textinput's internal clipboard-read +// results, or pasting into the filter is silently dropped. +func (v *TagSearchView) updateFilterInput(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + v.filterInput, cmd = v.filterInput.Update(msg) + if value := v.filterInput.Value(); value != v.filterText { + v.filterText = value v.applyFilter() v.buildTable() - return v, cmd } + return v, cmd } func (v *TagSearchView) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {