Skip to content

Commit 5ec9fad

Browse files
committed
feat: click to copy working directory in TUI sidebar
Add click-to-copy for the working directory shown in the sidebar. Clicking the path copies it to the system clipboard and shows a notification. Works in both vertical and collapsed sidebar modes. - Add ClickWorkingDir result to sidebar HandleClickType - Add TargetSidebarWorkingDir mouse target in hit test - Add WorkingDirectory() getter to sidebar Model interface - Extract titleSectionLines() to share layout logic in collapsed view - Add tests for working dir click detection in both modes Assisted-By: docker-agent
1 parent d02c3f0 commit 5ec9fad

6 files changed

Lines changed: 139 additions & 15 deletions

File tree

pkg/tui/components/sidebar/collapsed_view.go

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,7 @@ type CollapsedViewModel struct {
2727
// LineCount returns the number of lines needed to render this layout.
2828
func (vm CollapsedViewModel) LineCount() int {
2929
lines := 1 // divider
30-
31-
switch {
32-
case vm.TitleAndIndicatorOnOneLine:
33-
lines++
34-
case vm.WorkingIndicator == "":
35-
// No working indicator but title wraps
36-
lines += linesNeeded(lipgloss.Width(vm.TitleWithStar), vm.ContentWidth)
37-
default:
38-
// Title and working indicator on separate lines, each may wrap
39-
lines += linesNeeded(lipgloss.Width(vm.TitleWithStar), vm.ContentWidth)
40-
lines += linesNeeded(lipgloss.Width(vm.WorkingIndicator), vm.ContentWidth)
41-
}
30+
lines += vm.titleSectionLines()
4231

4332
if vm.WdAndUsageOnOneLine {
4433
lines++
@@ -52,6 +41,20 @@ func (vm CollapsedViewModel) LineCount() int {
5241
return lines
5342
}
5443

44+
// titleSectionLines returns the number of rendered lines consumed by the
45+
// title (and optional working indicator) section.
46+
func (vm CollapsedViewModel) titleSectionLines() int {
47+
switch {
48+
case vm.TitleAndIndicatorOnOneLine:
49+
return 1
50+
case vm.WorkingIndicator == "":
51+
return linesNeeded(lipgloss.Width(vm.TitleWithStar), vm.ContentWidth)
52+
default:
53+
return linesNeeded(lipgloss.Width(vm.TitleWithStar), vm.ContentWidth) +
54+
linesNeeded(lipgloss.Width(vm.WorkingIndicator), vm.ContentWidth)
55+
}
56+
}
57+
5558
// RenderCollapsedView renders the collapsed sidebar from a CollapsedViewModel.
5659
// This is a pure function that takes data and returns a string.
5760
func RenderCollapsedView(vm CollapsedViewModel) string {

pkg/tui/components/sidebar/sidebar.go

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ type Model interface {
9292
SetTitleRegenerating(regenerating bool) tea.Cmd
9393
// IsScrollbarDragging returns true when the scrollbar thumb is being dragged.
9494
IsScrollbarDragging() bool
95+
// WorkingDirectory returns the working directory path displayed in the sidebar.
96+
WorkingDirectory() string
9597
// Cleanup cancels any in-flight async operations.
9698
Cleanup()
9799
}
@@ -351,13 +353,19 @@ func (m *model) IsScrollbarDragging() bool {
351353
return m.scrollview.IsDragging()
352354
}
353355

356+
// WorkingDirectory returns the working directory path displayed in the sidebar.
357+
func (m *model) WorkingDirectory() string {
358+
return m.workingDirectory
359+
}
360+
354361
// ClickResult indicates what was clicked in the sidebar
355362
type ClickResult int
356363

357364
const (
358365
ClickNone ClickResult = iota
359366
ClickStar
360-
ClickTitle // Click on the title area (use double-click to edit)
367+
ClickTitle // Click on the title area (use double-click to edit)
368+
ClickWorkingDir // Click on the working directory line
361369
)
362370

363371
// HandleClick checks if click is on the star or title and returns true if it was
@@ -367,7 +375,7 @@ func (m *model) HandleClick(x, y int) bool {
367375
return m.HandleClickType(x, y) != ClickNone
368376
}
369377

370-
// HandleClickType returns what was clicked (star, title, or nothing)
378+
// HandleClickType returns what was clicked (star, title, working dir, or nothing)
371379
func (m *model) HandleClickType(x, y int) ClickResult {
372380
// Account for left padding
373381
adjustedX := x - m.layoutCfg.PaddingLeft
@@ -390,6 +398,15 @@ func (m *model) HandleClickType(x, y int) ClickResult {
390398
return ClickTitle
391399
}
392400
}
401+
402+
// In collapsed mode, working dir line follows the title section.
403+
vm := m.computeCollapsedViewModel(m.contentWidth(false))
404+
wdStartY := vm.titleSectionLines()
405+
wdLines := linesNeeded(lipgloss.Width(vm.WorkingDir), vm.ContentWidth)
406+
if m.workingDirectory != "" && y >= wdStartY && y < wdStartY+wdLines {
407+
return ClickWorkingDir
408+
}
409+
393410
return ClickNone
394411
}
395412

@@ -409,6 +426,12 @@ func (m *model) HandleClickType(x, y int) ClickResult {
409426
return ClickTitle
410427
}
411428
}
429+
430+
// Working dir is at: verticalStarY + titleLines (title) + 1 (empty separator)
431+
if m.workingDirectory != "" && contentY == verticalStarY+titleLines+1 {
432+
return ClickWorkingDir
433+
}
434+
412435
return ClickNone
413436
}
414437

pkg/tui/components/sidebar/title_edit_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,3 +255,80 @@ func TestSidebar_HandleClickType_NoWrap(t *testing.T) {
255255
result = sb.HandleClickType(paddingLeft+1, verticalStarY)
256256
assert.Equal(t, ClickStar, result, "star should still be clickable")
257257
}
258+
259+
func TestSidebar_HandleClickType_WorkingDir_Vertical(t *testing.T) {
260+
t.Parallel()
261+
262+
sess := session.New()
263+
sessionState := service.NewSessionState(sess)
264+
sb := New(sessionState)
265+
266+
m := sb.(*model)
267+
m.sessionHasContent = true
268+
m.titleGenerated = true
269+
m.mode = ModeVertical
270+
m.width = 50
271+
m.sessionTitle = "Hi"
272+
m.workingDirectory = "~/projects/myapp"
273+
274+
paddingLeft := m.layoutCfg.PaddingLeft
275+
276+
// In vertical mode, working dir is at verticalStarY + titleLineCount + 1 (empty separator)
277+
titleLines := m.titleLineCount()
278+
wdY := verticalStarY + titleLines + 1
279+
280+
// Click on the working directory line
281+
result := sb.HandleClickType(paddingLeft+3, wdY)
282+
assert.Equal(t, ClickWorkingDir, result, "click on working dir line should return ClickWorkingDir")
283+
284+
// Click on the title line should still return ClickTitle
285+
result = sb.HandleClickType(paddingLeft+3, verticalStarY)
286+
assert.Equal(t, ClickTitle, result, "click on title should still return ClickTitle")
287+
288+
// Click on the empty separator line should return ClickNone
289+
result = sb.HandleClickType(paddingLeft+3, verticalStarY+titleLines)
290+
assert.Equal(t, ClickNone, result, "click on separator line should return ClickNone")
291+
}
292+
293+
func TestSidebar_HandleClickType_WorkingDir_Collapsed(t *testing.T) {
294+
t.Parallel()
295+
296+
sess := session.New()
297+
sessionState := service.NewSessionState(sess)
298+
sb := New(sessionState)
299+
300+
m := sb.(*model)
301+
m.sessionHasContent = true
302+
m.titleGenerated = true
303+
m.mode = ModeCollapsed
304+
m.width = 50
305+
m.sessionTitle = "Hi"
306+
m.workingDirectory = "~/projects/myapp"
307+
308+
paddingLeft := m.layoutCfg.PaddingLeft
309+
310+
// In collapsed mode, title occupies 1 line, then working dir
311+
titleLines := m.titleLineCount()
312+
assert.Equal(t, 1, titleLines, "title should be on single line")
313+
314+
// Click on the working directory line (right after title)
315+
result := sb.HandleClickType(paddingLeft+3, titleLines)
316+
assert.Equal(t, ClickWorkingDir, result, "click on working dir line should return ClickWorkingDir")
317+
318+
// Click on the title should still return ClickTitle
319+
result = sb.HandleClickType(paddingLeft+3, 0)
320+
assert.Equal(t, ClickTitle, result, "click on title should still return ClickTitle")
321+
}
322+
323+
func TestSidebar_WorkingDirectory(t *testing.T) {
324+
t.Parallel()
325+
326+
sess := session.New()
327+
sessionState := service.NewSessionState(sess)
328+
sb := New(sessionState)
329+
330+
m := sb.(*model)
331+
m.workingDirectory = "~/projects/myapp"
332+
333+
assert.Equal(t, "~/projects/myapp", sb.WorkingDirectory())
334+
}

pkg/tui/page/chat/chat.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -920,7 +920,7 @@ func (p *chatPage) SetSidebarSettings(settings SidebarSettings) {
920920
}
921921

922922
// handleSidebarClickType checks what was clicked in the sidebar area.
923-
// Returns the type of click (star, title, or none).
923+
// Returns the type of click (star, title, working dir, or none).
924924
func (p *chatPage) handleSidebarClickType(x, y int) sidebar.ClickResult {
925925
adjustedX := x - styles.AppPadding
926926
sl := p.computeSidebarLayout()

pkg/tui/page/chat/hittest.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const (
1616
TargetSidebarResizeHandle
1717
TargetSidebarStar
1818
TargetSidebarTitle
19+
TargetSidebarWorkingDir
1920
TargetSidebarContent
2021
TargetMessages
2122
)
@@ -124,6 +125,8 @@ func (h *HitTest) sidebarClickTarget(x, y int) MouseTarget {
124125
return TargetSidebarStar
125126
case sidebar.ClickTitle:
126127
return TargetSidebarTitle
128+
case sidebar.ClickWorkingDir:
129+
return TargetSidebarWorkingDir
127130
default:
128131
return TargetSidebarContent
129132
}

pkg/tui/page/chat/input_handlers.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"charm.land/bubbles/v2/key"
99
tea "charm.land/bubbletea/v2"
10+
"github.com/atotto/clipboard"
1011

1112
"github.com/docker/docker-agent/pkg/app"
1213
"github.com/docker/docker-agent/pkg/tui/components/messages"
@@ -89,6 +90,18 @@ func (p *chatPage) persistSessionTitle(newTitle string) tea.Cmd {
8990
}
9091
}
9192

93+
// copyWorkingDirToClipboard copies the working directory path to the system clipboard.
94+
func copyWorkingDirToClipboard(wd string) tea.Cmd {
95+
return tea.Sequence(
96+
func() tea.Msg {
97+
_ = clipboard.WriteAll(wd)
98+
return nil
99+
},
100+
tea.SetClipboard(wd),
101+
notification.SuccessCmd("Working directory copied to clipboard."),
102+
)
103+
}
104+
92105
// handleMouseClick handles mouse click events.
93106
func (p *chatPage) handleMouseClick(msg tea.MouseClickMsg) (layout.Model, tea.Cmd) {
94107
hit := NewHitTest(p)
@@ -130,6 +143,11 @@ func (p *chatPage) handleMouseClick(msg tea.MouseClickMsg) (layout.Model, tea.Cm
130143
return p, nil
131144
}
132145

146+
case TargetSidebarWorkingDir:
147+
if msg.Button == tea.MouseLeft {
148+
return p, copyWorkingDirToClipboard(p.sidebar.WorkingDirectory())
149+
}
150+
133151
case TargetMessages:
134152
if !p.messages.IsMouseOnScrollbar(msg.X, msg.Y) {
135153
cmd := p.routeMouseEvent(msg, msg.Y)

0 commit comments

Comments
 (0)