Skip to content

Commit 95abba7

Browse files
committed
Commit
1 parent be1f58a commit 95abba7

5 files changed

Lines changed: 83 additions & 44 deletions

File tree

internal/tui/components/content_panel.go

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
package components
22

33
import (
4-
"os/exec"
5-
"runtime"
64
"strings"
75
"time"
86

7+
"github.com/atotto/clipboard"
98
"github.com/charmbracelet/bubbles/viewport"
109
tea "github.com/charmbracelet/bubbletea"
1110
"github.com/charmbracelet/lipgloss"
@@ -579,6 +578,15 @@ func (cp *ContentPanel) GetViewportWidth() int {
579578
return cp.viewport.Width
580579
}
581580

581+
// GetRawContent returns the raw content without ANSI codes
582+
func (cp *ContentPanel) GetRawContent() string {
583+
var lines []string
584+
for _, line := range cp.contentLines {
585+
lines = append(lines, stripAnsi(line))
586+
}
587+
return strings.Join(lines, "\n")
588+
}
589+
582590
// UpdateContentLines updates content lines without clearing selection
583591
// Use this when content changes but you want to preserve any active selection
584592
func (cp *ContentPanel) UpdateContentLines(lines []string) {
@@ -614,30 +622,21 @@ func stripAnsi(s string) string {
614622
}
615623

616624
// copyToClipboard copies text to system clipboard
617-
func (cp *ContentPanel) copyToClipboard(text string) tea.Cmd {
625+
func (cp *ContentPanel) copyToClipboard(text string) {
618626
if text == "" {
619-
return nil
620-
}
621-
622-
var cmd *exec.Cmd
623-
624-
switch runtime.GOOS {
625-
case "darwin":
626-
cmd = exec.Command("pbcopy")
627-
case "linux":
628-
if _, err := exec.LookPath("xclip"); err == nil {
629-
cmd = exec.Command("xclip", "-selection", "clipboard")
630-
} else if _, err := exec.LookPath("xsel"); err == nil {
631-
cmd = exec.Command("xsel", "--clipboard", "--input")
632-
} else {
633-
return nil
634-
}
635-
default:
636-
return nil
627+
return
637628
}
638629

639-
cmd.Stdin = strings.NewReader(text)
640-
_ = cmd.Run()
630+
_ = clipboard.WriteAll(text)
641631
cp.showCopied = true
642-
return nil
632+
}
633+
634+
// CopyText copies the given text to clipboard and shows the "[copied]" indicator.
635+
// Returns a tea.Cmd to reset the indicator after a timeout.
636+
func (cp *ContentPanel) CopyText(text string) tea.Cmd {
637+
cp.copyToClipboard(text)
638+
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
639+
cp.showCopied = false
640+
return struct{}{}
641+
})
643642
}

internal/tui/components/details_panel.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,9 @@ func (dp *DetailsPanel) SetSize(width, height int) {
7171
func (dp *DetailsPanel) View() string {
7272
return dp.ContentPanel.View(dp.width, dp.height)
7373
}
74+
75+
// CopyAllContent copies all panel content to the clipboard
76+
func (dp *DetailsPanel) CopyAllContent() tea.Cmd {
77+
text := dp.ContentPanel.GetRawContent()
78+
return dp.ContentPanel.CopyText(text)
79+
}

internal/tui/components/log_panel.go

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ type LogPanelComponent struct {
1515
testLogs map[string][]string
1616
currentTestID string
1717
logMutex sync.RWMutex
18+
19+
// Caching to avoid expensive rebuilds on every View() call
20+
lastWrapWidth int
21+
contentNeedsSync bool
22+
pendingGotoBottom bool
1823
}
1924

2025
// NewLogPanelComponent creates a new log panel
@@ -39,7 +44,22 @@ func (lp *LogPanelComponent) View(width, height int) string {
3944
lp.logMutex.Lock()
4045
defer lp.logMutex.Unlock()
4146

42-
lp.rebuildContent(false)
47+
// Only rebuild content if something actually changed
48+
currentWrapWidth := lp.ContentPanel.GetViewportWidth() - 2
49+
if currentWrapWidth <= 0 {
50+
currentWrapWidth = 70
51+
}
52+
53+
// Check if we need to rebuild: width changed or content was marked dirty
54+
widthChanged := lp.lastWrapWidth != currentWrapWidth
55+
if lp.contentNeedsSync || widthChanged {
56+
// Go to bottom if new content was added, but not if just width changed
57+
gotoBottom := lp.pendingGotoBottom && !widthChanged
58+
lp.rebuildContent(gotoBottom)
59+
lp.lastWrapWidth = currentWrapWidth
60+
lp.contentNeedsSync = false
61+
lp.pendingGotoBottom = false
62+
}
4363

4464
return lp.ContentPanel.View(width, height)
4565
}
@@ -56,7 +76,8 @@ func (lp *LogPanelComponent) AddServiceLog(line string) {
5676
}
5777

5878
if lp.currentTestID == "" {
59-
lp.rebuildContent(true)
79+
lp.contentNeedsSync = true
80+
lp.pendingGotoBottom = true
6081
}
6182
}
6283

@@ -76,7 +97,8 @@ func (lp *LogPanelComponent) AddTestLog(testID, line string) {
7697
}
7798

7899
if lp.currentTestID == testID {
79-
lp.rebuildContent(true)
100+
lp.contentNeedsSync = true
101+
lp.pendingGotoBottom = true
80102
}
81103
}
82104

@@ -106,7 +128,8 @@ func (lp *LogPanelComponent) SetCurrentTest(testID string) {
106128

107129
lp.currentTestID = testID
108130
lp.updateTitle()
109-
lp.rebuildContent(true)
131+
lp.contentNeedsSync = true
132+
lp.pendingGotoBottom = true
110133
}
111134

112135
// SetOffset sets the panel's position on screen (for mouse coordinate translation)
@@ -170,3 +193,9 @@ func (lp *LogPanelComponent) updateTitle() {
170193
lp.ContentPanel.SetTitle(title)
171194
}
172195
}
196+
197+
// CopyAllLogs copies all currently visible logs to the clipboard
198+
func (lp *LogPanelComponent) CopyAllLogs() tea.Cmd {
199+
text := lp.GetRawLogs()
200+
return lp.ContentPanel.CopyText(text)
201+
}

internal/tui/list.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ func (m *listModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
146146
case "enter", "d", "D":
147147
m.sizeWarning.Dismiss()
148148
return m, nil
149-
case "q", "ctrl+c", "esc":
149+
case "q", "ctrl+c":
150150
return m, tea.Quit
151151
}
152152
return m, nil
@@ -155,7 +155,7 @@ func (m *listModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
155155
switch m.state {
156156
case listView:
157157
switch msg.String() {
158-
case "q", "ctrl+c", "esc":
158+
case "q", "ctrl+c":
159159
return m, tea.Quit
160160
case "enter":
161161
if m.cursor >= 0 &&
@@ -254,11 +254,13 @@ func (m *listModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
254254
}
255255
}
256256
return m, nil
257+
case "y":
258+
return m, m.detailsPanel.CopyAllContent()
257259
}
258260
case testExecutionView:
259261
if m.testExecutor != nil && m.testExecutor.state == stateCompleted {
260262
switch msg.String() {
261-
case "q", "ctrl+c", "esc", "enter", " ":
263+
case "q", "ctrl+c", "enter", " ":
262264
// Clean up and return to list
263265
m.testExecutor.cleanup()
264266
log.SetTUILogger(nil)
@@ -478,7 +480,7 @@ func (m *listModel) View() string {
478480
availableWidthForHelp = max(availableWidthForHelp, 20)
479481

480482
footer := utils.TruncateWithEllipsis(
481-
testCount+"• j/k: select • u/d: scroll • g/G: top/bottom • J/K/U/D: scroll details • ←/→: expand • enter: run • q: quit",
483+
testCount+"• j/k: select • u/d: scroll • g/G: top/bottom • J/K/U/D: scroll details • ←/→: expand • y: copy • enter: run • q: quit",
482484
availableWidthForHelp,
483485
)
484486

internal/tui/test_executor.go

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,7 @@ func (m *testExecutorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
375375
case "enter", "d", "D":
376376
m.sizeWarning.Dismiss()
377377
return m, nil
378-
case "q", "ctrl+c", "esc":
378+
case "q", "ctrl+c":
379379
m.cleanup()
380380
return m, tea.Quit
381381
}
@@ -399,7 +399,6 @@ func (m *testExecutorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
399399
} else if selectedTest := m.testTable.GetSelectedTest(); selectedTest != nil {
400400
m.logPanel.SetCurrentTest(selectedTest.TraceID)
401401
}
402-
return m, nil
403402
case tea.MouseButtonWheelDown:
404403
// Scroll viewport only, clamp cursor to visible bounds
405404
m.testTable.ScrollDown(3)
@@ -409,17 +408,18 @@ func (m *testExecutorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
409408
} else if selectedTest := m.testTable.GetSelectedTest(); selectedTest != nil {
410409
m.logPanel.SetCurrentTest(selectedTest.TraceID)
411410
}
412-
return m, nil
413-
}
414-
} else {
415-
// - X: leftWidth + border(1) + padding(1) = leftWidth + 2
416-
// - Y: headerHeight + title(1) + empty line(1) = headerHeight + 2
417-
m.logPanel.SetOffset(leftWidth+2, headerHeight+2)
418-
if cmd := m.logPanel.Update(msg); cmd != nil {
419-
return m, cmd
420411
}
412+
// Always return for left-side mouse events to prevent fall-through
421413
return m, nil
422414
}
415+
// Right panel - handle mouse events there
416+
// - X: leftWidth + border(1) + padding(1) = leftWidth + 2
417+
// - Y: headerHeight + title(1) + empty line(1) = headerHeight + 2
418+
m.logPanel.SetOffset(leftWidth+2, headerHeight+2)
419+
if cmd := m.logPanel.Update(msg); cmd != nil {
420+
return m, cmd
421+
}
422+
return m, nil
423423

424424
case testsLoadedMsg:
425425
// Inject tests into model and start execution
@@ -726,7 +726,10 @@ func (m *testExecutorModel) handleTableNavigation(msg tea.KeyMsg) (tea.Model, te
726726
m.updateLogPanelFromSelection()
727727
return m, nil
728728

729-
case "q", "ctrl+c", "esc":
729+
case "y":
730+
return m, m.logPanel.CopyAllLogs()
731+
732+
case "q", "ctrl+c":
730733
m.cleanup()
731734
return m, tea.Quit
732735
}
@@ -745,7 +748,7 @@ func (m *testExecutorModel) updateLogPanelFromSelection() {
745748

746749
func (m *testExecutorModel) getFooterText() string {
747750
testCount := fmt.Sprintf("%d TESTS ", len(m.tests))
748-
return testCount + "• j/k: select • u/d: scroll • g/G: top/bottom • J/K/U/D: scroll logs • q: quit"
751+
return testCount + "• j/k: select • u/d: scroll • g/G: top/bottom • J/K/U/D: scroll logs • y: copy logs • q: quit"
749752
}
750753

751754
func (m *testExecutorModel) View() string {

0 commit comments

Comments
 (0)