detached 94a9ba9

feat(ui): add pane and workspace view components

- Add PaneView for single pane container with active state
- Add WorkspaceView for rendering workspace pane trees
- Integrate with TreeRenderer for layout building
- Support active pane visual indication via CSS classes
commit
94a9ba9a4367008a6e24c86ac5a2bc567912b0f0
author
brice · · 2025-12-09 19:37 UTC
committer
brice · · 2025-12-09 18:43 UTC
parent
faafca5
signature
unsigned
4 files · +1630 -0
A internal/ui/component/pane_view.go +203 -0
@@ -0,0 +1,203 @@
1+// Package component provides UI components for the browser.
2+package component
3+
4+import (
5+ "sync"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/ui/layout"
9+)
10+
11+const (
12+ // CSS class applied to active pane's border overlay
13+ activePaneClass = "active-pane"
14+)
15+
16+// PaneView is a container for a single WebView with active state indication.
17+// It uses an overlay to display a border around the active pane.
18+type PaneView struct {
19+ factory layout.WidgetFactory
20+ overlay layout.OverlayWidget
21+ webViewWidget layout.Widget // The actual WebView widget
22+ borderBox layout.BoxWidget // Border overlay for active indication
23+ paneID entity.PaneID
24+ isActive bool
25+
26+ onFocusIn func(paneID entity.PaneID)
27+ onFocusOut func(paneID entity.PaneID)
28+
29+ mu sync.RWMutex
30+}
31+
32+// NewPaneView creates a new pane view container for a WebView widget.
33+func NewPaneView(factory layout.WidgetFactory, paneID entity.PaneID, webViewWidget layout.Widget) *PaneView {
34+ overlay := factory.NewOverlay()
35+ overlay.SetHexpand(true)
36+ overlay.SetVexpand(true)
37+
38+ // Set the WebView as the main child
39+ if webViewWidget != nil {
40+ overlay.SetChild(webViewWidget)
41+ }
42+
43+ // Create border overlay box (used for active pane indication)
44+ // This is an empty box that gets styled via CSS to show a border
45+ borderBox := factory.NewBox(layout.OrientationVertical, 0)
46+ borderBox.SetCanFocus(false)
47+ borderBox.AddCssClass("pane-border")
48+ // Position border to cover entire pane
49+ borderBox.SetHexpand(true)
50+ borderBox.SetVexpand(true)
51+ overlay.AddOverlay(borderBox)
52+
53+ // Don't let border intercept pointer events
54+ overlay.SetClipOverlay(borderBox, false)
55+ overlay.SetMeasureOverlay(borderBox, false)
56+
57+ return &PaneView{
58+ factory: factory,
59+ overlay: overlay,
60+ webViewWidget: webViewWidget,
61+ borderBox: borderBox,
62+ paneID: paneID,
63+ isActive: false,
64+ }
65+}
66+
67+// SetActive updates the active state of the pane.
68+// Active panes display a visual border indicator.
69+func (pv *PaneView) SetActive(active bool) {
70+ pv.mu.Lock()
71+ defer pv.mu.Unlock()
72+
73+ if pv.isActive == active {
74+ return
75+ }
76+
77+ pv.isActive = active
78+
79+ if active {
80+ pv.borderBox.AddCssClass(activePaneClass)
81+ } else {
82+ pv.borderBox.RemoveCssClass(activePaneClass)
83+ }
84+}
85+
86+// IsActive returns whether this pane is currently active.
87+func (pv *PaneView) IsActive() bool {
88+ pv.mu.RLock()
89+ defer pv.mu.RUnlock()
90+
91+ return pv.isActive
92+}
93+
94+// PaneID returns the ID of the pane this view represents.
95+func (pv *PaneView) PaneID() entity.PaneID {
96+ return pv.paneID
97+}
98+
99+// WebViewWidget returns the underlying WebView widget.
100+func (pv *PaneView) WebViewWidget() layout.Widget {
101+ pv.mu.RLock()
102+ defer pv.mu.RUnlock()
103+
104+ return pv.webViewWidget
105+}
106+
107+// SetWebViewWidget replaces the WebView widget.
108+func (pv *PaneView) SetWebViewWidget(widget layout.Widget) {
109+ pv.mu.Lock()
110+ defer pv.mu.Unlock()
111+
112+ // Remove old widget
113+ if pv.webViewWidget != nil {
114+ pv.overlay.SetChild(nil)
115+ }
116+
117+ pv.webViewWidget = widget
118+
119+ if widget != nil {
120+ pv.overlay.SetChild(widget)
121+ }
122+}
123+
124+// GrabFocus attempts to focus the WebView.
125+// Returns true if focus was successfully grabbed.
126+func (pv *PaneView) GrabFocus() bool {
127+ pv.mu.RLock()
128+ wv := pv.webViewWidget
129+ pv.mu.RUnlock()
130+
131+ if wv == nil {
132+ return false
133+ }
134+ return wv.GrabFocus()
135+}
136+
137+// HasFocus returns whether the WebView currently has focus.
138+func (pv *PaneView) HasFocus() bool {
139+ pv.mu.RLock()
140+ wv := pv.webViewWidget
141+ pv.mu.RUnlock()
142+
143+ if wv == nil {
144+ return false
145+ }
146+ return wv.HasFocus()
147+}
148+
149+// SetOnFocusIn sets the callback for when the pane gains focus.
150+func (pv *PaneView) SetOnFocusIn(fn func(paneID entity.PaneID)) {
151+ pv.mu.Lock()
152+ defer pv.mu.Unlock()
153+
154+ pv.onFocusIn = fn
155+}
156+
157+// SetOnFocusOut sets the callback for when the pane loses focus.
158+func (pv *PaneView) SetOnFocusOut(fn func(paneID entity.PaneID)) {
159+ pv.mu.Lock()
160+ defer pv.mu.Unlock()
161+
162+ pv.onFocusOut = fn
163+}
164+
165+// Widget returns the underlying overlay widget for embedding in containers.
166+func (pv *PaneView) Widget() layout.Widget {
167+ return pv.overlay
168+}
169+
170+// Overlay returns the underlying overlay widget for direct access.
171+func (pv *PaneView) Overlay() layout.OverlayWidget {
172+ return pv.overlay
173+}
174+
175+// Show makes the pane visible.
176+func (pv *PaneView) Show() {
177+ pv.overlay.Show()
178+}
179+
180+// Hide makes the pane invisible.
181+func (pv *PaneView) Hide() {
182+ pv.overlay.Hide()
183+}
184+
185+// SetVisible sets the visibility of the pane.
186+func (pv *PaneView) SetVisible(visible bool) {
187+ pv.overlay.SetVisible(visible)
188+}
189+
190+// IsVisible returns whether the pane is visible.
191+func (pv *PaneView) IsVisible() bool {
192+ return pv.overlay.IsVisible()
193+}
194+
195+// AddCssClass adds a CSS class to the overlay.
196+func (pv *PaneView) AddCssClass(class string) {
197+ pv.overlay.AddCssClass(class)
198+}
199+
200+// RemoveCssClass removes a CSS class from the overlay.
201+func (pv *PaneView) RemoveCssClass(class string) {
202+ pv.overlay.RemoveCssClass(class)
203+}
@@ -0,0 +1,203 @@
1+// Package component provides UI components for the browser.
2+package component
3+
4+import (
5+ "sync"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/ui/layout"
9+)
10+
11+const (
12+ // CSS class applied to active pane's border overlay
13+ activePaneClass = "active-pane"
14+)
15+
16+// PaneView is a container for a single WebView with active state indication.
17+// It uses an overlay to display a border around the active pane.
18+type PaneView struct {
19+ factory layout.WidgetFactory
20+ overlay layout.OverlayWidget
21+ webViewWidget layout.Widget // The actual WebView widget
22+ borderBox layout.BoxWidget // Border overlay for active indication
23+ paneID entity.PaneID
24+ isActive bool
25+
26+ onFocusIn func(paneID entity.PaneID)
27+ onFocusOut func(paneID entity.PaneID)
28+
29+ mu sync.RWMutex
30+}
31+
32+// NewPaneView creates a new pane view container for a WebView widget.
33+func NewPaneView(factory layout.WidgetFactory, paneID entity.PaneID, webViewWidget layout.Widget) *PaneView {
34+ overlay := factory.NewOverlay()
35+ overlay.SetHexpand(true)
36+ overlay.SetVexpand(true)
37+
38+ // Set the WebView as the main child
39+ if webViewWidget != nil {
40+ overlay.SetChild(webViewWidget)
41+ }
42+
43+ // Create border overlay box (used for active pane indication)
44+ // This is an empty box that gets styled via CSS to show a border
45+ borderBox := factory.NewBox(layout.OrientationVertical, 0)
46+ borderBox.SetCanFocus(false)
47+ borderBox.AddCssClass("pane-border")
48+ // Position border to cover entire pane
49+ borderBox.SetHexpand(true)
50+ borderBox.SetVexpand(true)
51+ overlay.AddOverlay(borderBox)
52+
53+ // Don't let border intercept pointer events
54+ overlay.SetClipOverlay(borderBox, false)
55+ overlay.SetMeasureOverlay(borderBox, false)
56+
57+ return &PaneView{
58+ factory: factory,
59+ overlay: overlay,
60+ webViewWidget: webViewWidget,
61+ borderBox: borderBox,
62+ paneID: paneID,
63+ isActive: false,
64+ }
65+}
66+
67+// SetActive updates the active state of the pane.
68+// Active panes display a visual border indicator.
69+func (pv *PaneView) SetActive(active bool) {
70+ pv.mu.Lock()
71+ defer pv.mu.Unlock()
72+
73+ if pv.isActive == active {
74+ return
75+ }
76+
77+ pv.isActive = active
78+
79+ if active {
80+ pv.borderBox.AddCssClass(activePaneClass)
81+ } else {
82+ pv.borderBox.RemoveCssClass(activePaneClass)
83+ }
84+}
85+
86+// IsActive returns whether this pane is currently active.
87+func (pv *PaneView) IsActive() bool {
88+ pv.mu.RLock()
89+ defer pv.mu.RUnlock()
90+
91+ return pv.isActive
92+}
93+
94+// PaneID returns the ID of the pane this view represents.
95+func (pv *PaneView) PaneID() entity.PaneID {
96+ return pv.paneID
97+}
98+
99+// WebViewWidget returns the underlying WebView widget.
100+func (pv *PaneView) WebViewWidget() layout.Widget {
101+ pv.mu.RLock()
102+ defer pv.mu.RUnlock()
103+
104+ return pv.webViewWidget
105+}
106+
107+// SetWebViewWidget replaces the WebView widget.
108+func (pv *PaneView) SetWebViewWidget(widget layout.Widget) {
109+ pv.mu.Lock()
110+ defer pv.mu.Unlock()
111+
112+ // Remove old widget
113+ if pv.webViewWidget != nil {
114+ pv.overlay.SetChild(nil)
115+ }
116+
117+ pv.webViewWidget = widget
118+
119+ if widget != nil {
120+ pv.overlay.SetChild(widget)
121+ }
122+}
123+
124+// GrabFocus attempts to focus the WebView.
125+// Returns true if focus was successfully grabbed.
126+func (pv *PaneView) GrabFocus() bool {
127+ pv.mu.RLock()
128+ wv := pv.webViewWidget
129+ pv.mu.RUnlock()
130+
131+ if wv == nil {
132+ return false
133+ }
134+ return wv.GrabFocus()
135+}
136+
137+// HasFocus returns whether the WebView currently has focus.
138+func (pv *PaneView) HasFocus() bool {
139+ pv.mu.RLock()
140+ wv := pv.webViewWidget
141+ pv.mu.RUnlock()
142+
143+ if wv == nil {
144+ return false
145+ }
146+ return wv.HasFocus()
147+}
148+
149+// SetOnFocusIn sets the callback for when the pane gains focus.
150+func (pv *PaneView) SetOnFocusIn(fn func(paneID entity.PaneID)) {
151+ pv.mu.Lock()
152+ defer pv.mu.Unlock()
153+
154+ pv.onFocusIn = fn
155+}
156+
157+// SetOnFocusOut sets the callback for when the pane loses focus.
158+func (pv *PaneView) SetOnFocusOut(fn func(paneID entity.PaneID)) {
159+ pv.mu.Lock()
160+ defer pv.mu.Unlock()
161+
162+ pv.onFocusOut = fn
163+}
164+
165+// Widget returns the underlying overlay widget for embedding in containers.
166+func (pv *PaneView) Widget() layout.Widget {
167+ return pv.overlay
168+}
169+
170+// Overlay returns the underlying overlay widget for direct access.
171+func (pv *PaneView) Overlay() layout.OverlayWidget {
172+ return pv.overlay
173+}
174+
175+// Show makes the pane visible.
176+func (pv *PaneView) Show() {
177+ pv.overlay.Show()
178+}
179+
180+// Hide makes the pane invisible.
181+func (pv *PaneView) Hide() {
182+ pv.overlay.Hide()
183+}
184+
185+// SetVisible sets the visibility of the pane.
186+func (pv *PaneView) SetVisible(visible bool) {
187+ pv.overlay.SetVisible(visible)
188+}
189+
190+// IsVisible returns whether the pane is visible.
191+func (pv *PaneView) IsVisible() bool {
192+ return pv.overlay.IsVisible()
193+}
194+
195+// AddCssClass adds a CSS class to the overlay.
196+func (pv *PaneView) AddCssClass(class string) {
197+ pv.overlay.AddCssClass(class)
198+}
199+
200+// RemoveCssClass removes a CSS class from the overlay.
201+func (pv *PaneView) RemoveCssClass(class string) {
202+ pv.overlay.RemoveCssClass(class)
203+}
A internal/ui/component/pane_view_test.go +538 -0
@@ -0,0 +1,538 @@
1+package component_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/ui/component"
11+ "github.com/bnema/dumber/internal/ui/layout"
12+ "github.com/bnema/dumber/internal/ui/layout/mocks"
13+)
14+
15+func TestNewPaneView_CreatesOverlay(t *testing.T) {
16+ // Arrange
17+ mockFactory := mocks.NewMockWidgetFactory(t)
18+ mockOverlay := mocks.NewMockOverlayWidget(t)
19+ mockBorderBox := mocks.NewMockBoxWidget(t)
20+ mockWebView := mocks.NewMockWidget(t)
21+
22+ paneID := entity.PaneID("pane-1")
23+
24+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
25+ mockOverlay.EXPECT().SetHexpand(true).Once()
26+ mockOverlay.EXPECT().SetVexpand(true).Once()
27+ mockOverlay.EXPECT().SetChild(mockWebView).Once()
28+
29+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
30+ mockBorderBox.EXPECT().SetCanFocus(false).Once()
31+ mockBorderBox.EXPECT().AddCssClass("pane-border").Once()
32+ mockBorderBox.EXPECT().SetHexpand(true).Once()
33+ mockBorderBox.EXPECT().SetVexpand(true).Once()
34+ mockOverlay.EXPECT().AddOverlay(mockBorderBox).Once()
35+ mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once()
36+ mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
37+
38+ // Act
39+ pv := component.NewPaneView(mockFactory, paneID, mockWebView)
40+
41+ // Assert
42+ require.NotNil(t, pv)
43+ assert.Equal(t, paneID, pv.PaneID())
44+ assert.Equal(t, mockWebView, pv.WebViewWidget())
45+}
46+
47+func TestNewPaneView_NilWebView(t *testing.T) {
48+ // Arrange
49+ mockFactory := mocks.NewMockWidgetFactory(t)
50+ mockOverlay := mocks.NewMockOverlayWidget(t)
51+ mockBorderBox := mocks.NewMockBoxWidget(t)
52+
53+ paneID := entity.PaneID("pane-1")
54+
55+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
56+ mockOverlay.EXPECT().SetHexpand(true).Once()
57+ mockOverlay.EXPECT().SetVexpand(true).Once()
58+ // SetChild should NOT be called when webview is nil
59+
60+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
61+ mockBorderBox.EXPECT().SetCanFocus(false).Once()
62+ mockBorderBox.EXPECT().AddCssClass("pane-border").Once()
63+ mockBorderBox.EXPECT().SetHexpand(true).Once()
64+ mockBorderBox.EXPECT().SetVexpand(true).Once()
65+ mockOverlay.EXPECT().AddOverlay(mockBorderBox).Once()
66+ mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once()
67+ mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
68+
69+ // Act
70+ pv := component.NewPaneView(mockFactory, paneID, nil)
71+
72+ // Assert
73+ require.NotNil(t, pv)
74+ assert.Nil(t, pv.WebViewWidget())
75+}
76+
77+func TestSetActive_True_AddsCSSClass(t *testing.T) {
78+ // Arrange
79+ mockFactory := mocks.NewMockWidgetFactory(t)
80+ mockOverlay := mocks.NewMockOverlayWidget(t)
81+ mockBorderBox := mocks.NewMockBoxWidget(t)
82+ mockWebView := mocks.NewMockWidget(t)
83+
84+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
85+
86+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
87+
88+ // Expect CSS class to be added
89+ mockBorderBox.EXPECT().AddCssClass("active-pane").Once()
90+
91+ // Act
92+ pv.SetActive(true)
93+
94+ // Assert
95+ assert.True(t, pv.IsActive())
96+}
97+
98+func TestSetActive_False_RemovesCSSClass(t *testing.T) {
99+ // Arrange
100+ mockFactory := mocks.NewMockWidgetFactory(t)
101+ mockOverlay := mocks.NewMockOverlayWidget(t)
102+ mockBorderBox := mocks.NewMockBoxWidget(t)
103+ mockWebView := mocks.NewMockWidget(t)
104+
105+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
106+
107+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
108+
109+ // First activate
110+ mockBorderBox.EXPECT().AddCssClass("active-pane").Once()
111+ pv.SetActive(true)
112+
113+ // Then deactivate
114+ mockBorderBox.EXPECT().RemoveCssClass("active-pane").Once()
115+
116+ // Act
117+ pv.SetActive(false)
118+
119+ // Assert
120+ assert.False(t, pv.IsActive())
121+}
122+
123+func TestSetActive_NoChangeWhenSameState(t *testing.T) {
124+ // Arrange
125+ mockFactory := mocks.NewMockWidgetFactory(t)
126+ mockOverlay := mocks.NewMockOverlayWidget(t)
127+ mockBorderBox := mocks.NewMockBoxWidget(t)
128+ mockWebView := mocks.NewMockWidget(t)
129+
130+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
131+
132+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
133+
134+ // Act - setting false when already false should not call RemoveCssClass
135+ pv.SetActive(false)
136+
137+ // Assert - no mock expectations for CSS changes
138+ assert.False(t, pv.IsActive())
139+}
140+
141+func TestPaneID_ReturnsPaneID(t *testing.T) {
142+ // Arrange
143+ mockFactory := mocks.NewMockWidgetFactory(t)
144+ mockOverlay := mocks.NewMockOverlayWidget(t)
145+ mockBorderBox := mocks.NewMockBoxWidget(t)
146+ mockWebView := mocks.NewMockWidget(t)
147+
148+ paneID := entity.PaneID("test-pane-123")
149+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
150+
151+ pv := component.NewPaneView(mockFactory, paneID, mockWebView)
152+
153+ // Act
154+ result := pv.PaneID()
155+
156+ // Assert
157+ assert.Equal(t, paneID, result)
158+}
159+
160+func TestWebViewWidget_ReturnsWebView(t *testing.T) {
161+ // Arrange
162+ mockFactory := mocks.NewMockWidgetFactory(t)
163+ mockOverlay := mocks.NewMockOverlayWidget(t)
164+ mockBorderBox := mocks.NewMockBoxWidget(t)
165+ mockWebView := mocks.NewMockWidget(t)
166+
167+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
168+
169+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
170+
171+ // Act
172+ result := pv.WebViewWidget()
173+
174+ // Assert
175+ assert.Equal(t, mockWebView, result)
176+}
177+
178+func TestSetWebViewWidget_ReplacesWidget(t *testing.T) {
179+ // Arrange
180+ mockFactory := mocks.NewMockWidgetFactory(t)
181+ mockOverlay := mocks.NewMockOverlayWidget(t)
182+ mockBorderBox := mocks.NewMockBoxWidget(t)
183+ mockOldWebView := mocks.NewMockWidget(t)
184+ mockNewWebView := mocks.NewMockWidget(t)
185+
186+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockOldWebView)
187+
188+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockOldWebView)
189+
190+ // Expect removal of old widget and addition of new
191+ mockOverlay.EXPECT().SetChild(nil).Once()
192+ mockOverlay.EXPECT().SetChild(mockNewWebView).Once()
193+
194+ // Act
195+ pv.SetWebViewWidget(mockNewWebView)
196+
197+ // Assert
198+ assert.Equal(t, mockNewWebView, pv.WebViewWidget())
199+}
200+
201+func TestSetWebViewWidget_FromNil(t *testing.T) {
202+ // Arrange
203+ mockFactory := mocks.NewMockWidgetFactory(t)
204+ mockOverlay := mocks.NewMockOverlayWidget(t)
205+ mockBorderBox := mocks.NewMockBoxWidget(t)
206+ mockNewWebView := mocks.NewMockWidget(t)
207+
208+ // Setup without webview
209+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
210+ mockOverlay.EXPECT().SetHexpand(true).Once()
211+ mockOverlay.EXPECT().SetVexpand(true).Once()
212+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
213+ mockBorderBox.EXPECT().SetCanFocus(false).Once()
214+ mockBorderBox.EXPECT().AddCssClass("pane-border").Once()
215+ mockBorderBox.EXPECT().SetHexpand(true).Once()
216+ mockBorderBox.EXPECT().SetVexpand(true).Once()
217+ mockOverlay.EXPECT().AddOverlay(mockBorderBox).Once()
218+ mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once()
219+ mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
220+
221+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), nil)
222+
223+ // Expect only setting new child (no removal since old was nil)
224+ mockOverlay.EXPECT().SetChild(mockNewWebView).Once()
225+
226+ // Act
227+ pv.SetWebViewWidget(mockNewWebView)
228+
229+ // Assert
230+ assert.Equal(t, mockNewWebView, pv.WebViewWidget())
231+}
232+
233+func TestGrabFocus_DelegatesToWebView(t *testing.T) {
234+ // Arrange
235+ mockFactory := mocks.NewMockWidgetFactory(t)
236+ mockOverlay := mocks.NewMockOverlayWidget(t)
237+ mockBorderBox := mocks.NewMockBoxWidget(t)
238+ mockWebView := mocks.NewMockWidget(t)
239+
240+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
241+
242+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
243+
244+ mockWebView.EXPECT().GrabFocus().Return(true).Once()
245+
246+ // Act
247+ result := pv.GrabFocus()
248+
249+ // Assert
250+ assert.True(t, result)
251+}
252+
253+func TestGrabFocus_NilWebView_ReturnsFalse(t *testing.T) {
254+ // Arrange
255+ mockFactory := mocks.NewMockWidgetFactory(t)
256+ mockOverlay := mocks.NewMockOverlayWidget(t)
257+ mockBorderBox := mocks.NewMockBoxWidget(t)
258+
259+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
260+ mockOverlay.EXPECT().SetHexpand(true).Once()
261+ mockOverlay.EXPECT().SetVexpand(true).Once()
262+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
263+ mockBorderBox.EXPECT().SetCanFocus(false).Once()
264+ mockBorderBox.EXPECT().AddCssClass("pane-border").Once()
265+ mockBorderBox.EXPECT().SetHexpand(true).Once()
266+ mockBorderBox.EXPECT().SetVexpand(true).Once()
267+ mockOverlay.EXPECT().AddOverlay(mockBorderBox).Once()
268+ mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once()
269+ mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
270+
271+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), nil)
272+
273+ // Act
274+ result := pv.GrabFocus()
275+
276+ // Assert
277+ assert.False(t, result)
278+}
279+
280+func TestHasFocus_DelegatesToWebView(t *testing.T) {
281+ // Arrange
282+ mockFactory := mocks.NewMockWidgetFactory(t)
283+ mockOverlay := mocks.NewMockOverlayWidget(t)
284+ mockBorderBox := mocks.NewMockBoxWidget(t)
285+ mockWebView := mocks.NewMockWidget(t)
286+
287+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
288+
289+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
290+
291+ mockWebView.EXPECT().HasFocus().Return(true).Once()
292+
293+ // Act
294+ result := pv.HasFocus()
295+
296+ // Assert
297+ assert.True(t, result)
298+}
299+
300+func TestHasFocus_NilWebView_ReturnsFalse(t *testing.T) {
301+ // Arrange
302+ mockFactory := mocks.NewMockWidgetFactory(t)
303+ mockOverlay := mocks.NewMockOverlayWidget(t)
304+ mockBorderBox := mocks.NewMockBoxWidget(t)
305+
306+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
307+ mockOverlay.EXPECT().SetHexpand(true).Once()
308+ mockOverlay.EXPECT().SetVexpand(true).Once()
309+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
310+ mockBorderBox.EXPECT().SetCanFocus(false).Once()
311+ mockBorderBox.EXPECT().AddCssClass("pane-border").Once()
312+ mockBorderBox.EXPECT().SetHexpand(true).Once()
313+ mockBorderBox.EXPECT().SetVexpand(true).Once()
314+ mockOverlay.EXPECT().AddOverlay(mockBorderBox).Once()
315+ mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once()
316+ mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
317+
318+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), nil)
319+
320+ // Act
321+ result := pv.HasFocus()
322+
323+ // Assert
324+ assert.False(t, result)
325+}
326+
327+func TestWidget_ReturnsOverlay(t *testing.T) {
328+ // Arrange
329+ mockFactory := mocks.NewMockWidgetFactory(t)
330+ mockOverlay := mocks.NewMockOverlayWidget(t)
331+ mockBorderBox := mocks.NewMockBoxWidget(t)
332+ mockWebView := mocks.NewMockWidget(t)
333+
334+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
335+
336+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
337+
338+ // Act
339+ result := pv.Widget()
340+
341+ // Assert
342+ assert.Equal(t, mockOverlay, result)
343+}
344+
345+func TestOverlay_ReturnsOverlayWidget(t *testing.T) {
346+ // Arrange
347+ mockFactory := mocks.NewMockWidgetFactory(t)
348+ mockOverlay := mocks.NewMockOverlayWidget(t)
349+ mockBorderBox := mocks.NewMockBoxWidget(t)
350+ mockWebView := mocks.NewMockWidget(t)
351+
352+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
353+
354+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
355+
356+ // Act
357+ result := pv.Overlay()
358+
359+ // Assert
360+ assert.Equal(t, mockOverlay, result)
361+}
362+
363+func TestShow_DelegatesToOverlay(t *testing.T) {
364+ // Arrange
365+ mockFactory := mocks.NewMockWidgetFactory(t)
366+ mockOverlay := mocks.NewMockOverlayWidget(t)
367+ mockBorderBox := mocks.NewMockBoxWidget(t)
368+ mockWebView := mocks.NewMockWidget(t)
369+
370+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
371+
372+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
373+
374+ mockOverlay.EXPECT().Show().Once()
375+
376+ // Act
377+ pv.Show()
378+
379+ // Assert - verified by mock expectations
380+}
381+
382+func TestHide_DelegatesToOverlay(t *testing.T) {
383+ // Arrange
384+ mockFactory := mocks.NewMockWidgetFactory(t)
385+ mockOverlay := mocks.NewMockOverlayWidget(t)
386+ mockBorderBox := mocks.NewMockBoxWidget(t)
387+ mockWebView := mocks.NewMockWidget(t)
388+
389+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
390+
391+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
392+
393+ mockOverlay.EXPECT().Hide().Once()
394+
395+ // Act
396+ pv.Hide()
397+
398+ // Assert - verified by mock expectations
399+}
400+
401+func TestSetVisible_DelegatesToOverlay(t *testing.T) {
402+ // Arrange
403+ mockFactory := mocks.NewMockWidgetFactory(t)
404+ mockOverlay := mocks.NewMockOverlayWidget(t)
405+ mockBorderBox := mocks.NewMockBoxWidget(t)
406+ mockWebView := mocks.NewMockWidget(t)
407+
408+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
409+
410+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
411+
412+ mockOverlay.EXPECT().SetVisible(true).Once()
413+
414+ // Act
415+ pv.SetVisible(true)
416+
417+ // Assert - verified by mock expectations
418+}
419+
420+func TestIsVisible_DelegatesToOverlay(t *testing.T) {
421+ // Arrange
422+ mockFactory := mocks.NewMockWidgetFactory(t)
423+ mockOverlay := mocks.NewMockOverlayWidget(t)
424+ mockBorderBox := mocks.NewMockBoxWidget(t)
425+ mockWebView := mocks.NewMockWidget(t)
426+
427+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
428+
429+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
430+
431+ mockOverlay.EXPECT().IsVisible().Return(true).Once()
432+
433+ // Act
434+ result := pv.IsVisible()
435+
436+ // Assert
437+ assert.True(t, result)
438+}
439+
440+func TestAddCssClass_DelegatesToOverlay(t *testing.T) {
441+ // Arrange
442+ mockFactory := mocks.NewMockWidgetFactory(t)
443+ mockOverlay := mocks.NewMockOverlayWidget(t)
444+ mockBorderBox := mocks.NewMockBoxWidget(t)
445+ mockWebView := mocks.NewMockWidget(t)
446+
447+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
448+
449+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
450+
451+ mockOverlay.EXPECT().AddCssClass("custom-class").Once()
452+
453+ // Act
454+ pv.AddCssClass("custom-class")
455+
456+ // Assert - verified by mock expectations
457+}
458+
459+func TestRemoveCssClass_DelegatesToOverlay(t *testing.T) {
460+ // Arrange
461+ mockFactory := mocks.NewMockWidgetFactory(t)
462+ mockOverlay := mocks.NewMockOverlayWidget(t)
463+ mockBorderBox := mocks.NewMockBoxWidget(t)
464+ mockWebView := mocks.NewMockWidget(t)
465+
466+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
467+
468+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
469+
470+ mockOverlay.EXPECT().RemoveCssClass("custom-class").Once()
471+
472+ // Act
473+ pv.RemoveCssClass("custom-class")
474+
475+ // Assert - verified by mock expectations
476+}
477+
478+func TestSetOnFocusIn_SetsCallback(t *testing.T) {
479+ // Arrange
480+ mockFactory := mocks.NewMockWidgetFactory(t)
481+ mockOverlay := mocks.NewMockOverlayWidget(t)
482+ mockBorderBox := mocks.NewMockBoxWidget(t)
483+ mockWebView := mocks.NewMockWidget(t)
484+
485+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
486+
487+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
488+
489+ callback := func(paneID entity.PaneID) {}
490+
491+ // Act
492+ pv.SetOnFocusIn(callback)
493+
494+ // Assert - callback is set (we can't directly verify without triggering focus)
495+ assert.NotNil(t, pv)
496+}
497+
498+func TestSetOnFocusOut_SetsCallback(t *testing.T) {
499+ // Arrange
500+ mockFactory := mocks.NewMockWidgetFactory(t)
501+ mockOverlay := mocks.NewMockOverlayWidget(t)
502+ mockBorderBox := mocks.NewMockBoxWidget(t)
503+ mockWebView := mocks.NewMockWidget(t)
504+
505+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
506+
507+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
508+
509+ callback := func(paneID entity.PaneID) {}
510+
511+ // Act
512+ pv.SetOnFocusOut(callback)
513+
514+ // Assert - callback is set (we can't directly verify without triggering focus)
515+ assert.NotNil(t, pv)
516+}
517+
518+// Helper function to setup common mock expectations for PaneView creation
519+func setupPaneViewMocks(
520+ mockFactory *mocks.MockWidgetFactory,
521+ mockOverlay *mocks.MockOverlayWidget,
522+ mockBorderBox *mocks.MockBoxWidget,
523+ mockWebView *mocks.MockWidget,
524+) {
525+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
526+ mockOverlay.EXPECT().SetHexpand(true).Once()
527+ mockOverlay.EXPECT().SetVexpand(true).Once()
528+ mockOverlay.EXPECT().SetChild(mockWebView).Once()
529+
530+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
531+ mockBorderBox.EXPECT().SetCanFocus(false).Once()
532+ mockBorderBox.EXPECT().AddCssClass("pane-border").Once()
533+ mockBorderBox.EXPECT().SetHexpand(true).Once()
534+ mockBorderBox.EXPECT().SetVexpand(true).Once()
535+ mockOverlay.EXPECT().AddOverlay(mockBorderBox).Once()
536+ mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once()
537+ mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
538+}
@@ -0,0 +1,538 @@
1+package component_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/ui/component"
11+ "github.com/bnema/dumber/internal/ui/layout"
12+ "github.com/bnema/dumber/internal/ui/layout/mocks"
13+)
14+
15+func TestNewPaneView_CreatesOverlay(t *testing.T) {
16+ // Arrange
17+ mockFactory := mocks.NewMockWidgetFactory(t)
18+ mockOverlay := mocks.NewMockOverlayWidget(t)
19+ mockBorderBox := mocks.NewMockBoxWidget(t)
20+ mockWebView := mocks.NewMockWidget(t)
21+
22+ paneID := entity.PaneID("pane-1")
23+
24+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
25+ mockOverlay.EXPECT().SetHexpand(true).Once()
26+ mockOverlay.EXPECT().SetVexpand(true).Once()
27+ mockOverlay.EXPECT().SetChild(mockWebView).Once()
28+
29+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
30+ mockBorderBox.EXPECT().SetCanFocus(false).Once()
31+ mockBorderBox.EXPECT().AddCssClass("pane-border").Once()
32+ mockBorderBox.EXPECT().SetHexpand(true).Once()
33+ mockBorderBox.EXPECT().SetVexpand(true).Once()
34+ mockOverlay.EXPECT().AddOverlay(mockBorderBox).Once()
35+ mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once()
36+ mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
37+
38+ // Act
39+ pv := component.NewPaneView(mockFactory, paneID, mockWebView)
40+
41+ // Assert
42+ require.NotNil(t, pv)
43+ assert.Equal(t, paneID, pv.PaneID())
44+ assert.Equal(t, mockWebView, pv.WebViewWidget())
45+}
46+
47+func TestNewPaneView_NilWebView(t *testing.T) {
48+ // Arrange
49+ mockFactory := mocks.NewMockWidgetFactory(t)
50+ mockOverlay := mocks.NewMockOverlayWidget(t)
51+ mockBorderBox := mocks.NewMockBoxWidget(t)
52+
53+ paneID := entity.PaneID("pane-1")
54+
55+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
56+ mockOverlay.EXPECT().SetHexpand(true).Once()
57+ mockOverlay.EXPECT().SetVexpand(true).Once()
58+ // SetChild should NOT be called when webview is nil
59+
60+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
61+ mockBorderBox.EXPECT().SetCanFocus(false).Once()
62+ mockBorderBox.EXPECT().AddCssClass("pane-border").Once()
63+ mockBorderBox.EXPECT().SetHexpand(true).Once()
64+ mockBorderBox.EXPECT().SetVexpand(true).Once()
65+ mockOverlay.EXPECT().AddOverlay(mockBorderBox).Once()
66+ mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once()
67+ mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
68+
69+ // Act
70+ pv := component.NewPaneView(mockFactory, paneID, nil)
71+
72+ // Assert
73+ require.NotNil(t, pv)
74+ assert.Nil(t, pv.WebViewWidget())
75+}
76+
77+func TestSetActive_True_AddsCSSClass(t *testing.T) {
78+ // Arrange
79+ mockFactory := mocks.NewMockWidgetFactory(t)
80+ mockOverlay := mocks.NewMockOverlayWidget(t)
81+ mockBorderBox := mocks.NewMockBoxWidget(t)
82+ mockWebView := mocks.NewMockWidget(t)
83+
84+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
85+
86+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
87+
88+ // Expect CSS class to be added
89+ mockBorderBox.EXPECT().AddCssClass("active-pane").Once()
90+
91+ // Act
92+ pv.SetActive(true)
93+
94+ // Assert
95+ assert.True(t, pv.IsActive())
96+}
97+
98+func TestSetActive_False_RemovesCSSClass(t *testing.T) {
99+ // Arrange
100+ mockFactory := mocks.NewMockWidgetFactory(t)
101+ mockOverlay := mocks.NewMockOverlayWidget(t)
102+ mockBorderBox := mocks.NewMockBoxWidget(t)
103+ mockWebView := mocks.NewMockWidget(t)
104+
105+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
106+
107+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
108+
109+ // First activate
110+ mockBorderBox.EXPECT().AddCssClass("active-pane").Once()
111+ pv.SetActive(true)
112+
113+ // Then deactivate
114+ mockBorderBox.EXPECT().RemoveCssClass("active-pane").Once()
115+
116+ // Act
117+ pv.SetActive(false)
118+
119+ // Assert
120+ assert.False(t, pv.IsActive())
121+}
122+
123+func TestSetActive_NoChangeWhenSameState(t *testing.T) {
124+ // Arrange
125+ mockFactory := mocks.NewMockWidgetFactory(t)
126+ mockOverlay := mocks.NewMockOverlayWidget(t)
127+ mockBorderBox := mocks.NewMockBoxWidget(t)
128+ mockWebView := mocks.NewMockWidget(t)
129+
130+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
131+
132+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
133+
134+ // Act - setting false when already false should not call RemoveCssClass
135+ pv.SetActive(false)
136+
137+ // Assert - no mock expectations for CSS changes
138+ assert.False(t, pv.IsActive())
139+}
140+
141+func TestPaneID_ReturnsPaneID(t *testing.T) {
142+ // Arrange
143+ mockFactory := mocks.NewMockWidgetFactory(t)
144+ mockOverlay := mocks.NewMockOverlayWidget(t)
145+ mockBorderBox := mocks.NewMockBoxWidget(t)
146+ mockWebView := mocks.NewMockWidget(t)
147+
148+ paneID := entity.PaneID("test-pane-123")
149+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
150+
151+ pv := component.NewPaneView(mockFactory, paneID, mockWebView)
152+
153+ // Act
154+ result := pv.PaneID()
155+
156+ // Assert
157+ assert.Equal(t, paneID, result)
158+}
159+
160+func TestWebViewWidget_ReturnsWebView(t *testing.T) {
161+ // Arrange
162+ mockFactory := mocks.NewMockWidgetFactory(t)
163+ mockOverlay := mocks.NewMockOverlayWidget(t)
164+ mockBorderBox := mocks.NewMockBoxWidget(t)
165+ mockWebView := mocks.NewMockWidget(t)
166+
167+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
168+
169+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
170+
171+ // Act
172+ result := pv.WebViewWidget()
173+
174+ // Assert
175+ assert.Equal(t, mockWebView, result)
176+}
177+
178+func TestSetWebViewWidget_ReplacesWidget(t *testing.T) {
179+ // Arrange
180+ mockFactory := mocks.NewMockWidgetFactory(t)
181+ mockOverlay := mocks.NewMockOverlayWidget(t)
182+ mockBorderBox := mocks.NewMockBoxWidget(t)
183+ mockOldWebView := mocks.NewMockWidget(t)
184+ mockNewWebView := mocks.NewMockWidget(t)
185+
186+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockOldWebView)
187+
188+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockOldWebView)
189+
190+ // Expect removal of old widget and addition of new
191+ mockOverlay.EXPECT().SetChild(nil).Once()
192+ mockOverlay.EXPECT().SetChild(mockNewWebView).Once()
193+
194+ // Act
195+ pv.SetWebViewWidget(mockNewWebView)
196+
197+ // Assert
198+ assert.Equal(t, mockNewWebView, pv.WebViewWidget())
199+}
200+
201+func TestSetWebViewWidget_FromNil(t *testing.T) {
202+ // Arrange
203+ mockFactory := mocks.NewMockWidgetFactory(t)
204+ mockOverlay := mocks.NewMockOverlayWidget(t)
205+ mockBorderBox := mocks.NewMockBoxWidget(t)
206+ mockNewWebView := mocks.NewMockWidget(t)
207+
208+ // Setup without webview
209+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
210+ mockOverlay.EXPECT().SetHexpand(true).Once()
211+ mockOverlay.EXPECT().SetVexpand(true).Once()
212+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
213+ mockBorderBox.EXPECT().SetCanFocus(false).Once()
214+ mockBorderBox.EXPECT().AddCssClass("pane-border").Once()
215+ mockBorderBox.EXPECT().SetHexpand(true).Once()
216+ mockBorderBox.EXPECT().SetVexpand(true).Once()
217+ mockOverlay.EXPECT().AddOverlay(mockBorderBox).Once()
218+ mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once()
219+ mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
220+
221+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), nil)
222+
223+ // Expect only setting new child (no removal since old was nil)
224+ mockOverlay.EXPECT().SetChild(mockNewWebView).Once()
225+
226+ // Act
227+ pv.SetWebViewWidget(mockNewWebView)
228+
229+ // Assert
230+ assert.Equal(t, mockNewWebView, pv.WebViewWidget())
231+}
232+
233+func TestGrabFocus_DelegatesToWebView(t *testing.T) {
234+ // Arrange
235+ mockFactory := mocks.NewMockWidgetFactory(t)
236+ mockOverlay := mocks.NewMockOverlayWidget(t)
237+ mockBorderBox := mocks.NewMockBoxWidget(t)
238+ mockWebView := mocks.NewMockWidget(t)
239+
240+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
241+
242+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
243+
244+ mockWebView.EXPECT().GrabFocus().Return(true).Once()
245+
246+ // Act
247+ result := pv.GrabFocus()
248+
249+ // Assert
250+ assert.True(t, result)
251+}
252+
253+func TestGrabFocus_NilWebView_ReturnsFalse(t *testing.T) {
254+ // Arrange
255+ mockFactory := mocks.NewMockWidgetFactory(t)
256+ mockOverlay := mocks.NewMockOverlayWidget(t)
257+ mockBorderBox := mocks.NewMockBoxWidget(t)
258+
259+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
260+ mockOverlay.EXPECT().SetHexpand(true).Once()
261+ mockOverlay.EXPECT().SetVexpand(true).Once()
262+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
263+ mockBorderBox.EXPECT().SetCanFocus(false).Once()
264+ mockBorderBox.EXPECT().AddCssClass("pane-border").Once()
265+ mockBorderBox.EXPECT().SetHexpand(true).Once()
266+ mockBorderBox.EXPECT().SetVexpand(true).Once()
267+ mockOverlay.EXPECT().AddOverlay(mockBorderBox).Once()
268+ mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once()
269+ mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
270+
271+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), nil)
272+
273+ // Act
274+ result := pv.GrabFocus()
275+
276+ // Assert
277+ assert.False(t, result)
278+}
279+
280+func TestHasFocus_DelegatesToWebView(t *testing.T) {
281+ // Arrange
282+ mockFactory := mocks.NewMockWidgetFactory(t)
283+ mockOverlay := mocks.NewMockOverlayWidget(t)
284+ mockBorderBox := mocks.NewMockBoxWidget(t)
285+ mockWebView := mocks.NewMockWidget(t)
286+
287+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
288+
289+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
290+
291+ mockWebView.EXPECT().HasFocus().Return(true).Once()
292+
293+ // Act
294+ result := pv.HasFocus()
295+
296+ // Assert
297+ assert.True(t, result)
298+}
299+
300+func TestHasFocus_NilWebView_ReturnsFalse(t *testing.T) {
301+ // Arrange
302+ mockFactory := mocks.NewMockWidgetFactory(t)
303+ mockOverlay := mocks.NewMockOverlayWidget(t)
304+ mockBorderBox := mocks.NewMockBoxWidget(t)
305+
306+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
307+ mockOverlay.EXPECT().SetHexpand(true).Once()
308+ mockOverlay.EXPECT().SetVexpand(true).Once()
309+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
310+ mockBorderBox.EXPECT().SetCanFocus(false).Once()
311+ mockBorderBox.EXPECT().AddCssClass("pane-border").Once()
312+ mockBorderBox.EXPECT().SetHexpand(true).Once()
313+ mockBorderBox.EXPECT().SetVexpand(true).Once()
314+ mockOverlay.EXPECT().AddOverlay(mockBorderBox).Once()
315+ mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once()
316+ mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
317+
318+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), nil)
319+
320+ // Act
321+ result := pv.HasFocus()
322+
323+ // Assert
324+ assert.False(t, result)
325+}
326+
327+func TestWidget_ReturnsOverlay(t *testing.T) {
328+ // Arrange
329+ mockFactory := mocks.NewMockWidgetFactory(t)
330+ mockOverlay := mocks.NewMockOverlayWidget(t)
331+ mockBorderBox := mocks.NewMockBoxWidget(t)
332+ mockWebView := mocks.NewMockWidget(t)
333+
334+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
335+
336+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
337+
338+ // Act
339+ result := pv.Widget()
340+
341+ // Assert
342+ assert.Equal(t, mockOverlay, result)
343+}
344+
345+func TestOverlay_ReturnsOverlayWidget(t *testing.T) {
346+ // Arrange
347+ mockFactory := mocks.NewMockWidgetFactory(t)
348+ mockOverlay := mocks.NewMockOverlayWidget(t)
349+ mockBorderBox := mocks.NewMockBoxWidget(t)
350+ mockWebView := mocks.NewMockWidget(t)
351+
352+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
353+
354+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
355+
356+ // Act
357+ result := pv.Overlay()
358+
359+ // Assert
360+ assert.Equal(t, mockOverlay, result)
361+}
362+
363+func TestShow_DelegatesToOverlay(t *testing.T) {
364+ // Arrange
365+ mockFactory := mocks.NewMockWidgetFactory(t)
366+ mockOverlay := mocks.NewMockOverlayWidget(t)
367+ mockBorderBox := mocks.NewMockBoxWidget(t)
368+ mockWebView := mocks.NewMockWidget(t)
369+
370+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
371+
372+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
373+
374+ mockOverlay.EXPECT().Show().Once()
375+
376+ // Act
377+ pv.Show()
378+
379+ // Assert - verified by mock expectations
380+}
381+
382+func TestHide_DelegatesToOverlay(t *testing.T) {
383+ // Arrange
384+ mockFactory := mocks.NewMockWidgetFactory(t)
385+ mockOverlay := mocks.NewMockOverlayWidget(t)
386+ mockBorderBox := mocks.NewMockBoxWidget(t)
387+ mockWebView := mocks.NewMockWidget(t)
388+
389+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
390+
391+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
392+
393+ mockOverlay.EXPECT().Hide().Once()
394+
395+ // Act
396+ pv.Hide()
397+
398+ // Assert - verified by mock expectations
399+}
400+
401+func TestSetVisible_DelegatesToOverlay(t *testing.T) {
402+ // Arrange
403+ mockFactory := mocks.NewMockWidgetFactory(t)
404+ mockOverlay := mocks.NewMockOverlayWidget(t)
405+ mockBorderBox := mocks.NewMockBoxWidget(t)
406+ mockWebView := mocks.NewMockWidget(t)
407+
408+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
409+
410+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
411+
412+ mockOverlay.EXPECT().SetVisible(true).Once()
413+
414+ // Act
415+ pv.SetVisible(true)
416+
417+ // Assert - verified by mock expectations
418+}
419+
420+func TestIsVisible_DelegatesToOverlay(t *testing.T) {
421+ // Arrange
422+ mockFactory := mocks.NewMockWidgetFactory(t)
423+ mockOverlay := mocks.NewMockOverlayWidget(t)
424+ mockBorderBox := mocks.NewMockBoxWidget(t)
425+ mockWebView := mocks.NewMockWidget(t)
426+
427+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
428+
429+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
430+
431+ mockOverlay.EXPECT().IsVisible().Return(true).Once()
432+
433+ // Act
434+ result := pv.IsVisible()
435+
436+ // Assert
437+ assert.True(t, result)
438+}
439+
440+func TestAddCssClass_DelegatesToOverlay(t *testing.T) {
441+ // Arrange
442+ mockFactory := mocks.NewMockWidgetFactory(t)
443+ mockOverlay := mocks.NewMockOverlayWidget(t)
444+ mockBorderBox := mocks.NewMockBoxWidget(t)
445+ mockWebView := mocks.NewMockWidget(t)
446+
447+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
448+
449+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
450+
451+ mockOverlay.EXPECT().AddCssClass("custom-class").Once()
452+
453+ // Act
454+ pv.AddCssClass("custom-class")
455+
456+ // Assert - verified by mock expectations
457+}
458+
459+func TestRemoveCssClass_DelegatesToOverlay(t *testing.T) {
460+ // Arrange
461+ mockFactory := mocks.NewMockWidgetFactory(t)
462+ mockOverlay := mocks.NewMockOverlayWidget(t)
463+ mockBorderBox := mocks.NewMockBoxWidget(t)
464+ mockWebView := mocks.NewMockWidget(t)
465+
466+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
467+
468+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
469+
470+ mockOverlay.EXPECT().RemoveCssClass("custom-class").Once()
471+
472+ // Act
473+ pv.RemoveCssClass("custom-class")
474+
475+ // Assert - verified by mock expectations
476+}
477+
478+func TestSetOnFocusIn_SetsCallback(t *testing.T) {
479+ // Arrange
480+ mockFactory := mocks.NewMockWidgetFactory(t)
481+ mockOverlay := mocks.NewMockOverlayWidget(t)
482+ mockBorderBox := mocks.NewMockBoxWidget(t)
483+ mockWebView := mocks.NewMockWidget(t)
484+
485+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
486+
487+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
488+
489+ callback := func(paneID entity.PaneID) {}
490+
491+ // Act
492+ pv.SetOnFocusIn(callback)
493+
494+ // Assert - callback is set (we can't directly verify without triggering focus)
495+ assert.NotNil(t, pv)
496+}
497+
498+func TestSetOnFocusOut_SetsCallback(t *testing.T) {
499+ // Arrange
500+ mockFactory := mocks.NewMockWidgetFactory(t)
501+ mockOverlay := mocks.NewMockOverlayWidget(t)
502+ mockBorderBox := mocks.NewMockBoxWidget(t)
503+ mockWebView := mocks.NewMockWidget(t)
504+
505+ setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
506+
507+ pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
508+
509+ callback := func(paneID entity.PaneID) {}
510+
511+ // Act
512+ pv.SetOnFocusOut(callback)
513+
514+ // Assert - callback is set (we can't directly verify without triggering focus)
515+ assert.NotNil(t, pv)
516+}
517+
518+// Helper function to setup common mock expectations for PaneView creation
519+func setupPaneViewMocks(
520+ mockFactory *mocks.MockWidgetFactory,
521+ mockOverlay *mocks.MockOverlayWidget,
522+ mockBorderBox *mocks.MockBoxWidget,
523+ mockWebView *mocks.MockWidget,
524+) {
525+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
526+ mockOverlay.EXPECT().SetHexpand(true).Once()
527+ mockOverlay.EXPECT().SetVexpand(true).Once()
528+ mockOverlay.EXPECT().SetChild(mockWebView).Once()
529+
530+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
531+ mockBorderBox.EXPECT().SetCanFocus(false).Once()
532+ mockBorderBox.EXPECT().AddCssClass("pane-border").Once()
533+ mockBorderBox.EXPECT().SetHexpand(true).Once()
534+ mockBorderBox.EXPECT().SetVexpand(true).Once()
535+ mockOverlay.EXPECT().AddOverlay(mockBorderBox).Once()
536+ mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once()
537+ mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
538+}
A internal/ui/component/workspace_view.go +258 -0
@@ -0,0 +1,258 @@
1+// Package component provides UI components for the browser.
2+package component
3+
4+import (
5+ "errors"
6+ "sync"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/ui/layout"
10+)
11+
12+// ErrNilWorkspace is returned when attempting to set a nil workspace.
13+var ErrNilWorkspace = errors.New("workspace is nil")
14+
15+// ErrPaneNotFound is returned when a pane ID cannot be found.
16+var ErrPaneNotFound = errors.New("pane not found")
17+
18+// WorkspaceView is the top-level container that renders a workspace's pane tree.
19+// It manages the widget tree and handles active pane state.
20+type WorkspaceView struct {
21+ factory layout.WidgetFactory
22+ treeRenderer *layout.TreeRenderer
23+ container layout.BoxWidget
24+
25+ workspace *entity.Workspace
26+ paneViews map[entity.PaneID]*PaneView
27+ activePaneID entity.PaneID
28+
29+ onPaneFocused func(paneID entity.PaneID)
30+
31+ mu sync.RWMutex
32+}
33+
34+// paneViewFactoryAdapter adapts WorkspaceView to the PaneViewFactory interface
35+// so TreeRenderer can create PaneViews.
36+type paneViewFactoryAdapter struct {
37+ wv *WorkspaceView
38+}
39+
40+func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Widget {
41+ if node == nil || node.Pane == nil {
42+ return nil
43+ }
44+
45+ // Create PaneView without WebView widget for now
46+ // WebView will be attached later by the application layer
47+ pv := NewPaneView(a.wv.factory, node.Pane.ID, nil)
48+
49+ // Store in map for later lookup
50+ // Note: Caller (SetWorkspace) already holds the lock, so we access directly
51+ a.wv.paneViews[node.Pane.ID] = pv
52+
53+ // Set up focus callback
54+ pv.SetOnFocusIn(func(paneID entity.PaneID) {
55+ a.wv.mu.RLock()
56+ callback := a.wv.onPaneFocused
57+ a.wv.mu.RUnlock()
58+
59+ if callback != nil {
60+ callback(paneID)
61+ }
62+ })
63+
64+ return pv.Widget()
65+}
66+
67+// NewWorkspaceView creates a new workspace view.
68+func NewWorkspaceView(factory layout.WidgetFactory) *WorkspaceView {
69+ container := factory.NewBox(layout.OrientationVertical, 0)
70+ container.SetHexpand(true)
71+ container.SetVexpand(true)
72+
73+ wv := &WorkspaceView{
74+ factory: factory,
75+ container: container,
76+ paneViews: make(map[entity.PaneID]*PaneView),
77+ }
78+
79+ // Create tree renderer with our adapter as the pane view factory
80+ wv.treeRenderer = layout.NewTreeRenderer(factory, &paneViewFactoryAdapter{wv: wv})
81+
82+ return wv
83+}
84+
85+// SetWorkspace sets the workspace to render and builds the widget tree.
86+func (wv *WorkspaceView) SetWorkspace(ws *entity.Workspace) error {
87+ if ws == nil {
88+ return ErrNilWorkspace
89+ }
90+
91+ wv.mu.Lock()
92+ defer wv.mu.Unlock()
93+
94+ // Clear previous state
95+ wv.workspace = ws
96+ wv.paneViews = make(map[entity.PaneID]*PaneView)
97+
98+ // Remove old content from container
99+ // (In a real GTK app, we'd need to iterate and remove children)
100+
101+ // Build new tree
102+ if ws.Root != nil {
103+ widget, err := wv.treeRenderer.Build(ws.Root)
104+ if err != nil {
105+ return err
106+ }
107+
108+ if widget != nil {
109+ wv.container.Append(widget)
110+ }
111+ }
112+
113+ // Set initial active pane
114+ if ws.ActivePaneID != "" {
115+ wv.setActivePaneIDInternal(ws.ActivePaneID)
116+ }
117+
118+ return nil
119+}
120+
121+// SetActivePaneID updates which pane is visually marked as active.
122+func (wv *WorkspaceView) SetActivePaneID(paneID entity.PaneID) error {
123+ wv.mu.Lock()
124+ defer wv.mu.Unlock()
125+
126+ return wv.setActivePaneIDInternal(paneID)
127+}
128+
129+// setActivePaneIDInternal updates active pane without locking.
130+func (wv *WorkspaceView) setActivePaneIDInternal(paneID entity.PaneID) error {
131+ // Deactivate current active pane
132+ if wv.activePaneID != "" {
133+ if oldPV, ok := wv.paneViews[wv.activePaneID]; ok {
134+ oldPV.SetActive(false)
135+ }
136+ }
137+
138+ // Activate new pane
139+ newPV, ok := wv.paneViews[paneID]
140+ if !ok {
141+ return ErrPaneNotFound
142+ }
143+
144+ newPV.SetActive(true)
145+ wv.activePaneID = paneID
146+
147+ return nil
148+}
149+
150+// GetActivePaneID returns the ID of the currently active pane.
151+func (wv *WorkspaceView) GetActivePaneID() entity.PaneID {
152+ wv.mu.RLock()
153+ defer wv.mu.RUnlock()
154+
155+ return wv.activePaneID
156+}
157+
158+// GetPaneView returns the PaneView for a given pane ID.
159+// Returns nil if not found.
160+func (wv *WorkspaceView) GetPaneView(paneID entity.PaneID) *PaneView {
161+ wv.mu.RLock()
162+ defer wv.mu.RUnlock()
163+
164+ return wv.paneViews[paneID]
165+}
166+
167+// GetPaneIDs returns all pane IDs in this workspace view.
168+func (wv *WorkspaceView) GetPaneIDs() []entity.PaneID {
169+ wv.mu.RLock()
170+ defer wv.mu.RUnlock()
171+
172+ ids := make([]entity.PaneID, 0, len(wv.paneViews))
173+ for id := range wv.paneViews {
174+ ids = append(ids, id)
175+ }
176+ return ids
177+}
178+
179+// PaneCount returns the number of panes in the workspace view.
180+func (wv *WorkspaceView) PaneCount() int {
181+ wv.mu.RLock()
182+ defer wv.mu.RUnlock()
183+
184+ return len(wv.paneViews)
185+}
186+
187+// SetOnPaneFocused sets the callback for when a pane receives focus.
188+func (wv *WorkspaceView) SetOnPaneFocused(fn func(paneID entity.PaneID)) {
189+ wv.mu.Lock()
190+ defer wv.mu.Unlock()
191+
192+ wv.onPaneFocused = fn
193+}
194+
195+// Rebuild rebuilds the widget tree from the current workspace.
196+// Use this after structural changes like splits or closes.
197+func (wv *WorkspaceView) Rebuild() error {
198+ wv.mu.RLock()
199+ ws := wv.workspace
200+ wv.mu.RUnlock()
201+
202+ if ws == nil {
203+ return ErrNilWorkspace
204+ }
205+
206+ return wv.SetWorkspace(ws)
207+}
208+
209+// FocusPane attempts to give focus to a specific pane.
210+// Returns true if focus was successfully grabbed.
211+func (wv *WorkspaceView) FocusPane(paneID entity.PaneID) bool {
212+ wv.mu.RLock()
213+ pv, ok := wv.paneViews[paneID]
214+ wv.mu.RUnlock()
215+
216+ if !ok || pv == nil {
217+ return false
218+ }
219+
220+ return pv.GrabFocus()
221+}
222+
223+// SetWebViewWidget attaches a WebView widget to a specific pane.
224+func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Widget) error {
225+ wv.mu.RLock()
226+ pv, ok := wv.paneViews[paneID]
227+ wv.mu.RUnlock()
228+
229+ if !ok {
230+ return ErrPaneNotFound
231+ }
232+
233+ pv.SetWebViewWidget(widget)
234+ return nil
235+}
236+
237+// Widget returns the underlying container widget for embedding in the UI.
238+func (wv *WorkspaceView) Widget() layout.Widget {
239+ return wv.container
240+}
241+
242+// Container returns the underlying BoxWidget for direct access.
243+func (wv *WorkspaceView) Container() layout.BoxWidget {
244+ return wv.container
245+}
246+
247+// TreeRenderer returns the underlying tree renderer.
248+func (wv *WorkspaceView) TreeRenderer() *layout.TreeRenderer {
249+ return wv.treeRenderer
250+}
251+
252+// Workspace returns the current workspace.
253+func (wv *WorkspaceView) Workspace() *entity.Workspace {
254+ wv.mu.RLock()
255+ defer wv.mu.RUnlock()
256+
257+ return wv.workspace
258+}
@@ -0,0 +1,258 @@
1+// Package component provides UI components for the browser.
2+package component
3+
4+import (
5+ "errors"
6+ "sync"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/ui/layout"
10+)
11+
12+// ErrNilWorkspace is returned when attempting to set a nil workspace.
13+var ErrNilWorkspace = errors.New("workspace is nil")
14+
15+// ErrPaneNotFound is returned when a pane ID cannot be found.
16+var ErrPaneNotFound = errors.New("pane not found")
17+
18+// WorkspaceView is the top-level container that renders a workspace's pane tree.
19+// It manages the widget tree and handles active pane state.
20+type WorkspaceView struct {
21+ factory layout.WidgetFactory
22+ treeRenderer *layout.TreeRenderer
23+ container layout.BoxWidget
24+
25+ workspace *entity.Workspace
26+ paneViews map[entity.PaneID]*PaneView
27+ activePaneID entity.PaneID
28+
29+ onPaneFocused func(paneID entity.PaneID)
30+
31+ mu sync.RWMutex
32+}
33+
34+// paneViewFactoryAdapter adapts WorkspaceView to the PaneViewFactory interface
35+// so TreeRenderer can create PaneViews.
36+type paneViewFactoryAdapter struct {
37+ wv *WorkspaceView
38+}
39+
40+func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Widget {
41+ if node == nil || node.Pane == nil {
42+ return nil
43+ }
44+
45+ // Create PaneView without WebView widget for now
46+ // WebView will be attached later by the application layer
47+ pv := NewPaneView(a.wv.factory, node.Pane.ID, nil)
48+
49+ // Store in map for later lookup
50+ // Note: Caller (SetWorkspace) already holds the lock, so we access directly
51+ a.wv.paneViews[node.Pane.ID] = pv
52+
53+ // Set up focus callback
54+ pv.SetOnFocusIn(func(paneID entity.PaneID) {
55+ a.wv.mu.RLock()
56+ callback := a.wv.onPaneFocused
57+ a.wv.mu.RUnlock()
58+
59+ if callback != nil {
60+ callback(paneID)
61+ }
62+ })
63+
64+ return pv.Widget()
65+}
66+
67+// NewWorkspaceView creates a new workspace view.
68+func NewWorkspaceView(factory layout.WidgetFactory) *WorkspaceView {
69+ container := factory.NewBox(layout.OrientationVertical, 0)
70+ container.SetHexpand(true)
71+ container.SetVexpand(true)
72+
73+ wv := &WorkspaceView{
74+ factory: factory,
75+ container: container,
76+ paneViews: make(map[entity.PaneID]*PaneView),
77+ }
78+
79+ // Create tree renderer with our adapter as the pane view factory
80+ wv.treeRenderer = layout.NewTreeRenderer(factory, &paneViewFactoryAdapter{wv: wv})
81+
82+ return wv
83+}
84+
85+// SetWorkspace sets the workspace to render and builds the widget tree.
86+func (wv *WorkspaceView) SetWorkspace(ws *entity.Workspace) error {
87+ if ws == nil {
88+ return ErrNilWorkspace
89+ }
90+
91+ wv.mu.Lock()
92+ defer wv.mu.Unlock()
93+
94+ // Clear previous state
95+ wv.workspace = ws
96+ wv.paneViews = make(map[entity.PaneID]*PaneView)
97+
98+ // Remove old content from container
99+ // (In a real GTK app, we'd need to iterate and remove children)
100+
101+ // Build new tree
102+ if ws.Root != nil {
103+ widget, err := wv.treeRenderer.Build(ws.Root)
104+ if err != nil {
105+ return err
106+ }
107+
108+ if widget != nil {
109+ wv.container.Append(widget)
110+ }
111+ }
112+
113+ // Set initial active pane
114+ if ws.ActivePaneID != "" {
115+ wv.setActivePaneIDInternal(ws.ActivePaneID)
116+ }
117+
118+ return nil
119+}
120+
121+// SetActivePaneID updates which pane is visually marked as active.
122+func (wv *WorkspaceView) SetActivePaneID(paneID entity.PaneID) error {
123+ wv.mu.Lock()
124+ defer wv.mu.Unlock()
125+
126+ return wv.setActivePaneIDInternal(paneID)
127+}
128+
129+// setActivePaneIDInternal updates active pane without locking.
130+func (wv *WorkspaceView) setActivePaneIDInternal(paneID entity.PaneID) error {
131+ // Deactivate current active pane
132+ if wv.activePaneID != "" {
133+ if oldPV, ok := wv.paneViews[wv.activePaneID]; ok {
134+ oldPV.SetActive(false)
135+ }
136+ }
137+
138+ // Activate new pane
139+ newPV, ok := wv.paneViews[paneID]
140+ if !ok {
141+ return ErrPaneNotFound
142+ }
143+
144+ newPV.SetActive(true)
145+ wv.activePaneID = paneID
146+
147+ return nil
148+}
149+
150+// GetActivePaneID returns the ID of the currently active pane.
151+func (wv *WorkspaceView) GetActivePaneID() entity.PaneID {
152+ wv.mu.RLock()
153+ defer wv.mu.RUnlock()
154+
155+ return wv.activePaneID
156+}
157+
158+// GetPaneView returns the PaneView for a given pane ID.
159+// Returns nil if not found.
160+func (wv *WorkspaceView) GetPaneView(paneID entity.PaneID) *PaneView {
161+ wv.mu.RLock()
162+ defer wv.mu.RUnlock()
163+
164+ return wv.paneViews[paneID]
165+}
166+
167+// GetPaneIDs returns all pane IDs in this workspace view.
168+func (wv *WorkspaceView) GetPaneIDs() []entity.PaneID {
169+ wv.mu.RLock()
170+ defer wv.mu.RUnlock()
171+
172+ ids := make([]entity.PaneID, 0, len(wv.paneViews))
173+ for id := range wv.paneViews {
174+ ids = append(ids, id)
175+ }
176+ return ids
177+}
178+
179+// PaneCount returns the number of panes in the workspace view.
180+func (wv *WorkspaceView) PaneCount() int {
181+ wv.mu.RLock()
182+ defer wv.mu.RUnlock()
183+
184+ return len(wv.paneViews)
185+}
186+
187+// SetOnPaneFocused sets the callback for when a pane receives focus.
188+func (wv *WorkspaceView) SetOnPaneFocused(fn func(paneID entity.PaneID)) {
189+ wv.mu.Lock()
190+ defer wv.mu.Unlock()
191+
192+ wv.onPaneFocused = fn
193+}
194+
195+// Rebuild rebuilds the widget tree from the current workspace.
196+// Use this after structural changes like splits or closes.
197+func (wv *WorkspaceView) Rebuild() error {
198+ wv.mu.RLock()
199+ ws := wv.workspace
200+ wv.mu.RUnlock()
201+
202+ if ws == nil {
203+ return ErrNilWorkspace
204+ }
205+
206+ return wv.SetWorkspace(ws)
207+}
208+
209+// FocusPane attempts to give focus to a specific pane.
210+// Returns true if focus was successfully grabbed.
211+func (wv *WorkspaceView) FocusPane(paneID entity.PaneID) bool {
212+ wv.mu.RLock()
213+ pv, ok := wv.paneViews[paneID]
214+ wv.mu.RUnlock()
215+
216+ if !ok || pv == nil {
217+ return false
218+ }
219+
220+ return pv.GrabFocus()
221+}
222+
223+// SetWebViewWidget attaches a WebView widget to a specific pane.
224+func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Widget) error {
225+ wv.mu.RLock()
226+ pv, ok := wv.paneViews[paneID]
227+ wv.mu.RUnlock()
228+
229+ if !ok {
230+ return ErrPaneNotFound
231+ }
232+
233+ pv.SetWebViewWidget(widget)
234+ return nil
235+}
236+
237+// Widget returns the underlying container widget for embedding in the UI.
238+func (wv *WorkspaceView) Widget() layout.Widget {
239+ return wv.container
240+}
241+
242+// Container returns the underlying BoxWidget for direct access.
243+func (wv *WorkspaceView) Container() layout.BoxWidget {
244+ return wv.container
245+}
246+
247+// TreeRenderer returns the underlying tree renderer.
248+func (wv *WorkspaceView) TreeRenderer() *layout.TreeRenderer {
249+ return wv.treeRenderer
250+}
251+
252+// Workspace returns the current workspace.
253+func (wv *WorkspaceView) Workspace() *entity.Workspace {
254+ wv.mu.RLock()
255+ defer wv.mu.RUnlock()
256+
257+ return wv.workspace
258+}
A internal/ui/component/workspace_view_test.go +631 -0
@@ -0,0 +1,631 @@
1+package component_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/mock"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/ui/component"
12+ "github.com/bnema/dumber/internal/ui/layout"
13+ "github.com/bnema/dumber/internal/ui/layout/mocks"
14+)
15+
16+func TestNewWorkspaceView_CreatesEmptyContainer(t *testing.T) {
17+ // Arrange
18+ mockFactory := mocks.NewMockWidgetFactory(t)
19+ mockBox := mocks.NewMockBoxWidget(t)
20+
21+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
22+ mockBox.EXPECT().SetHexpand(true).Once()
23+ mockBox.EXPECT().SetVexpand(true).Once()
24+
25+ // Act
26+ wv := component.NewWorkspaceView(mockFactory)
27+
28+ // Assert
29+ require.NotNil(t, wv)
30+ assert.Equal(t, 0, wv.PaneCount())
31+ assert.Equal(t, mockBox, wv.Container())
32+}
33+
34+func TestSetWorkspace_NilWorkspace_ReturnsError(t *testing.T) {
35+ // Arrange
36+ mockFactory := mocks.NewMockWidgetFactory(t)
37+ mockBox := mocks.NewMockBoxWidget(t)
38+
39+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
40+ mockBox.EXPECT().SetHexpand(true).Once()
41+ mockBox.EXPECT().SetVexpand(true).Once()
42+
43+ wv := component.NewWorkspaceView(mockFactory)
44+
45+ // Act
46+ err := wv.SetWorkspace(nil)
47+
48+ // Assert
49+ assert.ErrorIs(t, err, component.ErrNilWorkspace)
50+}
51+
52+func TestSetWorkspace_SinglePane_CreatesPaneView(t *testing.T) {
53+ // Arrange
54+ mockFactory := mocks.NewMockWidgetFactory(t)
55+ mockBox := mocks.NewMockBoxWidget(t)
56+
57+ // Container creation
58+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
59+ mockBox.EXPECT().SetHexpand(true).Once()
60+ mockBox.EXPECT().SetVexpand(true).Once()
61+
62+ wv := component.NewWorkspaceView(mockFactory)
63+
64+ // PaneView creation (via paneViewFactoryAdapter)
65+ mockOverlay := mocks.NewMockOverlayWidget(t)
66+ mockBorderBox := mocks.NewMockBoxWidget(t)
67+
68+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
69+ mockOverlay.EXPECT().SetHexpand(true).Once()
70+ mockOverlay.EXPECT().SetVexpand(true).Once()
71+ // No SetChild since WebView is nil
72+
73+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
74+ mockBorderBox.EXPECT().SetCanFocus(false).Once()
75+ mockBorderBox.EXPECT().AddCssClass("pane-border").Once()
76+ mockBorderBox.EXPECT().SetHexpand(true).Once()
77+ mockBorderBox.EXPECT().SetVexpand(true).Once()
78+ mockOverlay.EXPECT().AddOverlay(mockBorderBox).Once()
79+ mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once()
80+ mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
81+
82+ // Widget appended to container
83+ mockBox.EXPECT().Append(mockOverlay).Once()
84+
85+ // Active state set
86+ mockBorderBox.EXPECT().AddCssClass("active-pane").Once()
87+
88+ pane := entity.NewPane(entity.PaneID("pane-1"))
89+ node := &entity.PaneNode{ID: "node-1", Pane: pane}
90+
91+ ws := &entity.Workspace{
92+ ID: "ws-1",
93+ Root: node,
94+ ActivePaneID: pane.ID,
95+ }
96+
97+ // Act
98+ err := wv.SetWorkspace(ws)
99+
100+ // Assert
101+ require.NoError(t, err)
102+ assert.Equal(t, 1, wv.PaneCount())
103+ assert.Equal(t, pane.ID, wv.GetActivePaneID())
104+ assert.NotNil(t, wv.GetPaneView(pane.ID))
105+}
106+
107+func TestSetActivePaneID_UpdatesStyling(t *testing.T) {
108+ // Arrange
109+ mockFactory := mocks.NewMockWidgetFactory(t)
110+ mockBox := mocks.NewMockBoxWidget(t)
111+
112+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
113+ mockBox.EXPECT().SetHexpand(true).Once()
114+ mockBox.EXPECT().SetVexpand(true).Once()
115+
116+ wv := component.NewWorkspaceView(mockFactory)
117+
118+ // Setup two panes
119+ pane1 := entity.NewPane(entity.PaneID("pane-1"))
120+ pane2 := entity.NewPane(entity.PaneID("pane-2"))
121+ node1 := &entity.PaneNode{ID: "node-1", Pane: pane1}
122+ node2 := &entity.PaneNode{ID: "node-2", Pane: pane2}
123+
124+ // Create mock widgets for split
125+ mockPaned := mocks.NewMockPanedWidget(t)
126+ mockOverlay1 := mocks.NewMockOverlayWidget(t)
127+ mockOverlay2 := mocks.NewMockOverlayWidget(t)
128+ mockBorderBox1 := mocks.NewMockBoxWidget(t)
129+ mockBorderBox2 := mocks.NewMockBoxWidget(t)
130+
131+ // First pane view creation
132+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay1).Once()
133+ mockOverlay1.EXPECT().SetHexpand(true).Once()
134+ mockOverlay1.EXPECT().SetVexpand(true).Once()
135+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox1).Once()
136+ mockBorderBox1.EXPECT().SetCanFocus(false).Once()
137+ mockBorderBox1.EXPECT().AddCssClass("pane-border").Once()
138+ mockBorderBox1.EXPECT().SetHexpand(true).Once()
139+ mockBorderBox1.EXPECT().SetVexpand(true).Once()
140+ mockOverlay1.EXPECT().AddOverlay(mockBorderBox1).Once()
141+ mockOverlay1.EXPECT().SetClipOverlay(mockBorderBox1, false).Once()
142+ mockOverlay1.EXPECT().SetMeasureOverlay(mockBorderBox1, false).Once()
143+
144+ // Second pane view creation
145+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay2).Once()
146+ mockOverlay2.EXPECT().SetHexpand(true).Once()
147+ mockOverlay2.EXPECT().SetVexpand(true).Once()
148+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox2).Once()
149+ mockBorderBox2.EXPECT().SetCanFocus(false).Once()
150+ mockBorderBox2.EXPECT().AddCssClass("pane-border").Once()
151+ mockBorderBox2.EXPECT().SetHexpand(true).Once()
152+ mockBorderBox2.EXPECT().SetVexpand(true).Once()
153+ mockOverlay2.EXPECT().AddOverlay(mockBorderBox2).Once()
154+ mockOverlay2.EXPECT().SetClipOverlay(mockBorderBox2, false).Once()
155+ mockOverlay2.EXPECT().SetMeasureOverlay(mockBorderBox2, false).Once()
156+
157+ // Split view creation
158+ mockFactory.EXPECT().NewPaned(layout.OrientationHorizontal).Return(mockPaned).Once()
159+ mockPaned.EXPECT().SetResizeStartChild(true).Once()
160+ mockPaned.EXPECT().SetResizeEndChild(true).Once()
161+ mockPaned.EXPECT().SetShrinkStartChild(false).Once()
162+ mockPaned.EXPECT().SetShrinkEndChild(false).Once()
163+ mockPaned.EXPECT().SetStartChild(mockOverlay1).Once()
164+ mockPaned.EXPECT().SetEndChild(mockOverlay2).Once()
165+ mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
166+
167+ mockBox.EXPECT().Append(mockPaned).Once()
168+
169+ // Initial active pane is pane-1
170+ mockBorderBox1.EXPECT().AddCssClass("active-pane").Once()
171+
172+ splitNode := &entity.PaneNode{
173+ ID: "split",
174+ SplitDir: entity.SplitHorizontal,
175+ SplitRatio: 0.5,
176+ Children: []*entity.PaneNode{node1, node2},
177+ }
178+
179+ ws := &entity.Workspace{
180+ ID: "ws-1",
181+ Root: splitNode,
182+ ActivePaneID: pane1.ID,
183+ }
184+
185+ err := wv.SetWorkspace(ws)
186+ require.NoError(t, err)
187+
188+ // Now change active pane
189+ mockBorderBox1.EXPECT().RemoveCssClass("active-pane").Once()
190+ mockBorderBox2.EXPECT().AddCssClass("active-pane").Once()
191+
192+ // Act
193+ err = wv.SetActivePaneID(pane2.ID)
194+
195+ // Assert
196+ require.NoError(t, err)
197+ assert.Equal(t, pane2.ID, wv.GetActivePaneID())
198+}
199+
200+func TestSetActivePaneID_InvalidID_ReturnsError(t *testing.T) {
201+ // Arrange
202+ mockFactory := mocks.NewMockWidgetFactory(t)
203+ mockBox := mocks.NewMockBoxWidget(t)
204+
205+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
206+ mockBox.EXPECT().SetHexpand(true).Once()
207+ mockBox.EXPECT().SetVexpand(true).Once()
208+
209+ wv := component.NewWorkspaceView(mockFactory)
210+
211+ // Act
212+ err := wv.SetActivePaneID(entity.PaneID("non-existent"))
213+
214+ // Assert
215+ assert.ErrorIs(t, err, component.ErrPaneNotFound)
216+}
217+
218+func TestGetPaneView_Existing(t *testing.T) {
219+ // Arrange
220+ mockFactory := mocks.NewMockWidgetFactory(t)
221+ mockBox := mocks.NewMockBoxWidget(t)
222+ mockOverlay := mocks.NewMockOverlayWidget(t)
223+ mockBorderBox := mocks.NewMockBoxWidget(t)
224+
225+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
226+ mockBox.EXPECT().SetHexpand(true).Once()
227+ mockBox.EXPECT().SetVexpand(true).Once()
228+
229+ wv := component.NewWorkspaceView(mockFactory)
230+
231+ // PaneView creation
232+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
233+ mockOverlay.EXPECT().SetHexpand(true).Once()
234+ mockOverlay.EXPECT().SetVexpand(true).Once()
235+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
236+ mockBorderBox.EXPECT().SetCanFocus(false).Once()
237+ mockBorderBox.EXPECT().AddCssClass("pane-border").Once()
238+ mockBorderBox.EXPECT().SetHexpand(true).Once()
239+ mockBorderBox.EXPECT().SetVexpand(true).Once()
240+ mockOverlay.EXPECT().AddOverlay(mockBorderBox).Once()
241+ mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once()
242+ mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
243+ mockBox.EXPECT().Append(mockOverlay).Once()
244+ mockBorderBox.EXPECT().AddCssClass("active-pane").Once()
245+
246+ pane := entity.NewPane(entity.PaneID("pane-1"))
247+ node := &entity.PaneNode{ID: "node-1", Pane: pane}
248+ ws := &entity.Workspace{
249+ ID: "ws-1",
250+ Root: node,
251+ ActivePaneID: pane.ID,
252+ }
253+
254+ _ = wv.SetWorkspace(ws)
255+
256+ // Act
257+ result := wv.GetPaneView(pane.ID)
258+
259+ // Assert
260+ assert.NotNil(t, result)
261+}
262+
263+func TestGetPaneView_NonExistent(t *testing.T) {
264+ // Arrange
265+ mockFactory := mocks.NewMockWidgetFactory(t)
266+ mockBox := mocks.NewMockBoxWidget(t)
267+
268+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
269+ mockBox.EXPECT().SetHexpand(true).Once()
270+ mockBox.EXPECT().SetVexpand(true).Once()
271+
272+ wv := component.NewWorkspaceView(mockFactory)
273+
274+ // Act
275+ result := wv.GetPaneView(entity.PaneID("non-existent"))
276+
277+ // Assert
278+ assert.Nil(t, result)
279+}
280+
281+func TestGetPaneIDs_ReturnsAllIDs(t *testing.T) {
282+ // Arrange
283+ mockFactory := mocks.NewMockWidgetFactory(t)
284+ mockBox := mocks.NewMockBoxWidget(t)
285+ mockPaned := mocks.NewMockPanedWidget(t)
286+ mockOverlay1 := mocks.NewMockOverlayWidget(t)
287+ mockOverlay2 := mocks.NewMockOverlayWidget(t)
288+ mockBorderBox1 := mocks.NewMockBoxWidget(t)
289+ mockBorderBox2 := mocks.NewMockBoxWidget(t)
290+
291+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
292+ mockBox.EXPECT().SetHexpand(true).Once()
293+ mockBox.EXPECT().SetVexpand(true).Once()
294+
295+ wv := component.NewWorkspaceView(mockFactory)
296+
297+ // First pane view
298+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay1).Once()
299+ mockOverlay1.EXPECT().SetHexpand(true).Once()
300+ mockOverlay1.EXPECT().SetVexpand(true).Once()
301+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox1).Once()
302+ mockBorderBox1.EXPECT().SetCanFocus(false).Once()
303+ mockBorderBox1.EXPECT().AddCssClass("pane-border").Once()
304+ mockBorderBox1.EXPECT().SetHexpand(true).Once()
305+ mockBorderBox1.EXPECT().SetVexpand(true).Once()
306+ mockOverlay1.EXPECT().AddOverlay(mockBorderBox1).Once()
307+ mockOverlay1.EXPECT().SetClipOverlay(mockBorderBox1, false).Once()
308+ mockOverlay1.EXPECT().SetMeasureOverlay(mockBorderBox1, false).Once()
309+
310+ // Second pane view
311+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay2).Once()
312+ mockOverlay2.EXPECT().SetHexpand(true).Once()
313+ mockOverlay2.EXPECT().SetVexpand(true).Once()
314+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox2).Once()
315+ mockBorderBox2.EXPECT().SetCanFocus(false).Once()
316+ mockBorderBox2.EXPECT().AddCssClass("pane-border").Once()
317+ mockBorderBox2.EXPECT().SetHexpand(true).Once()
318+ mockBorderBox2.EXPECT().SetVexpand(true).Once()
319+ mockOverlay2.EXPECT().AddOverlay(mockBorderBox2).Once()
320+ mockOverlay2.EXPECT().SetClipOverlay(mockBorderBox2, false).Once()
321+ mockOverlay2.EXPECT().SetMeasureOverlay(mockBorderBox2, false).Once()
322+
323+ // Split view
324+ mockFactory.EXPECT().NewPaned(layout.OrientationHorizontal).Return(mockPaned).Once()
325+ mockPaned.EXPECT().SetResizeStartChild(true).Once()
326+ mockPaned.EXPECT().SetResizeEndChild(true).Once()
327+ mockPaned.EXPECT().SetShrinkStartChild(false).Once()
328+ mockPaned.EXPECT().SetShrinkEndChild(false).Once()
329+ mockPaned.EXPECT().SetStartChild(mockOverlay1).Once()
330+ mockPaned.EXPECT().SetEndChild(mockOverlay2).Once()
331+ mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
332+
333+ mockBox.EXPECT().Append(mockPaned).Once()
334+ mockBorderBox1.EXPECT().AddCssClass("active-pane").Once()
335+
336+ pane1 := entity.NewPane(entity.PaneID("pane-1"))
337+ pane2 := entity.NewPane(entity.PaneID("pane-2"))
338+ node1 := &entity.PaneNode{ID: "node-1", Pane: pane1}
339+ node2 := &entity.PaneNode{ID: "node-2", Pane: pane2}
340+
341+ splitNode := &entity.PaneNode{
342+ ID: "split",
343+ SplitDir: entity.SplitHorizontal,
344+ SplitRatio: 0.5,
345+ Children: []*entity.PaneNode{node1, node2},
346+ }
347+
348+ ws := &entity.Workspace{
349+ ID: "ws-1",
350+ Root: splitNode,
351+ ActivePaneID: pane1.ID,
352+ }
353+
354+ _ = wv.SetWorkspace(ws)
355+
356+ // Act
357+ ids := wv.GetPaneIDs()
358+
359+ // Assert
360+ assert.Len(t, ids, 2)
361+ assert.Contains(t, ids, pane1.ID)
362+ assert.Contains(t, ids, pane2.ID)
363+}
364+
365+func TestWidget_ReturnsContainer(t *testing.T) {
366+ // Arrange
367+ mockFactory := mocks.NewMockWidgetFactory(t)
368+ mockBox := mocks.NewMockBoxWidget(t)
369+
370+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
371+ mockBox.EXPECT().SetHexpand(true).Once()
372+ mockBox.EXPECT().SetVexpand(true).Once()
373+
374+ wv := component.NewWorkspaceView(mockFactory)
375+
376+ // Act
377+ widget := wv.Widget()
378+
379+ // Assert
380+ assert.Equal(t, mockBox, widget)
381+}
382+
383+func TestSetOnPaneFocused_SetsCallback(t *testing.T) {
384+ // Arrange
385+ mockFactory := mocks.NewMockWidgetFactory(t)
386+ mockBox := mocks.NewMockBoxWidget(t)
387+
388+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
389+ mockBox.EXPECT().SetHexpand(true).Once()
390+ mockBox.EXPECT().SetVexpand(true).Once()
391+
392+ wv := component.NewWorkspaceView(mockFactory)
393+
394+ callback := func(paneID entity.PaneID) {}
395+
396+ // Act
397+ wv.SetOnPaneFocused(callback)
398+
399+ // Assert - callback is set (verified by no panic)
400+ assert.NotNil(t, wv)
401+}
402+
403+func TestSetWebViewWidget_AttachesToPane(t *testing.T) {
404+ // Arrange
405+ mockFactory := mocks.NewMockWidgetFactory(t)
406+ mockBox := mocks.NewMockBoxWidget(t)
407+ mockOverlay := mocks.NewMockOverlayWidget(t)
408+ mockBorderBox := mocks.NewMockBoxWidget(t)
409+ mockWebView := mocks.NewMockWidget(t)
410+
411+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
412+ mockBox.EXPECT().SetHexpand(true).Once()
413+ mockBox.EXPECT().SetVexpand(true).Once()
414+
415+ wv := component.NewWorkspaceView(mockFactory)
416+
417+ // PaneView creation
418+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
419+ mockOverlay.EXPECT().SetHexpand(true).Once()
420+ mockOverlay.EXPECT().SetVexpand(true).Once()
421+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
422+ mockBorderBox.EXPECT().SetCanFocus(false).Once()
423+ mockBorderBox.EXPECT().AddCssClass("pane-border").Once()
424+ mockBorderBox.EXPECT().SetHexpand(true).Once()
425+ mockBorderBox.EXPECT().SetVexpand(true).Once()
426+ mockOverlay.EXPECT().AddOverlay(mockBorderBox).Once()
427+ mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once()
428+ mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
429+ mockBox.EXPECT().Append(mockOverlay).Once()
430+ mockBorderBox.EXPECT().AddCssClass("active-pane").Once()
431+
432+ pane := entity.NewPane(entity.PaneID("pane-1"))
433+ node := &entity.PaneNode{ID: "node-1", Pane: pane}
434+ ws := &entity.Workspace{
435+ ID: "ws-1",
436+ Root: node,
437+ ActivePaneID: pane.ID,
438+ }
439+
440+ _ = wv.SetWorkspace(ws)
441+
442+ // Expect WebView to be set
443+ mockOverlay.EXPECT().SetChild(mockWebView).Once()
444+
445+ // Act
446+ err := wv.SetWebViewWidget(pane.ID, mockWebView)
447+
448+ // Assert
449+ require.NoError(t, err)
450+}
451+
452+func TestSetWebViewWidget_NonExistentPane_ReturnsError(t *testing.T) {
453+ // Arrange
454+ mockFactory := mocks.NewMockWidgetFactory(t)
455+ mockBox := mocks.NewMockBoxWidget(t)
456+ mockWebView := mocks.NewMockWidget(t)
457+
458+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
459+ mockBox.EXPECT().SetHexpand(true).Once()
460+ mockBox.EXPECT().SetVexpand(true).Once()
461+
462+ wv := component.NewWorkspaceView(mockFactory)
463+
464+ // Act
465+ err := wv.SetWebViewWidget(entity.PaneID("non-existent"), mockWebView)
466+
467+ // Assert
468+ assert.ErrorIs(t, err, component.ErrPaneNotFound)
469+}
470+
471+func TestFocusPane_DelegatesToPaneView(t *testing.T) {
472+ // Arrange
473+ mockFactory := mocks.NewMockWidgetFactory(t)
474+ mockBox := mocks.NewMockBoxWidget(t)
475+ mockOverlay := mocks.NewMockOverlayWidget(t)
476+ mockBorderBox := mocks.NewMockBoxWidget(t)
477+
478+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
479+ mockBox.EXPECT().SetHexpand(true).Once()
480+ mockBox.EXPECT().SetVexpand(true).Once()
481+
482+ wv := component.NewWorkspaceView(mockFactory)
483+
484+ // PaneView creation
485+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
486+ mockOverlay.EXPECT().SetHexpand(true).Once()
487+ mockOverlay.EXPECT().SetVexpand(true).Once()
488+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
489+ mockBorderBox.EXPECT().SetCanFocus(false).Once()
490+ mockBorderBox.EXPECT().AddCssClass("pane-border").Once()
491+ mockBorderBox.EXPECT().SetHexpand(true).Once()
492+ mockBorderBox.EXPECT().SetVexpand(true).Once()
493+ mockOverlay.EXPECT().AddOverlay(mockBorderBox).Once()
494+ mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once()
495+ mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
496+ mockBox.EXPECT().Append(mockOverlay).Once()
497+ mockBorderBox.EXPECT().AddCssClass("active-pane").Once()
498+
499+ pane := entity.NewPane(entity.PaneID("pane-1"))
500+ node := &entity.PaneNode{ID: "node-1", Pane: pane}
501+ ws := &entity.Workspace{
502+ ID: "ws-1",
503+ Root: node,
504+ ActivePaneID: pane.ID,
505+ }
506+
507+ _ = wv.SetWorkspace(ws)
508+
509+ // Act - FocusPane with nil WebView returns false
510+ result := wv.FocusPane(pane.ID)
511+
512+ // Assert
513+ assert.False(t, result) // No WebView attached, so GrabFocus fails
514+}
515+
516+func TestFocusPane_NonExistentPane_ReturnsFalse(t *testing.T) {
517+ // Arrange
518+ mockFactory := mocks.NewMockWidgetFactory(t)
519+ mockBox := mocks.NewMockBoxWidget(t)
520+
521+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
522+ mockBox.EXPECT().SetHexpand(true).Once()
523+ mockBox.EXPECT().SetVexpand(true).Once()
524+
525+ wv := component.NewWorkspaceView(mockFactory)
526+
527+ // Act
528+ result := wv.FocusPane(entity.PaneID("non-existent"))
529+
530+ // Assert
531+ assert.False(t, result)
532+}
533+
534+func TestRebuild_NilWorkspace_ReturnsError(t *testing.T) {
535+ // Arrange
536+ mockFactory := mocks.NewMockWidgetFactory(t)
537+ mockBox := mocks.NewMockBoxWidget(t)
538+
539+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
540+ mockBox.EXPECT().SetHexpand(true).Once()
541+ mockBox.EXPECT().SetVexpand(true).Once()
542+
543+ wv := component.NewWorkspaceView(mockFactory)
544+
545+ // Act
546+ err := wv.Rebuild()
547+
548+ // Assert
549+ assert.ErrorIs(t, err, component.ErrNilWorkspace)
550+}
551+
552+func TestTreeRenderer_ReturnsRenderer(t *testing.T) {
553+ // Arrange
554+ mockFactory := mocks.NewMockWidgetFactory(t)
555+ mockBox := mocks.NewMockBoxWidget(t)
556+
557+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
558+ mockBox.EXPECT().SetHexpand(true).Once()
559+ mockBox.EXPECT().SetVexpand(true).Once()
560+
561+ wv := component.NewWorkspaceView(mockFactory)
562+
563+ // Act
564+ renderer := wv.TreeRenderer()
565+
566+ // Assert
567+ assert.NotNil(t, renderer)
568+}
569+
570+func TestWorkspace_ReturnsCurrentWorkspace(t *testing.T) {
571+ // Arrange
572+ mockFactory := mocks.NewMockWidgetFactory(t)
573+ mockBox := mocks.NewMockBoxWidget(t)
574+ mockOverlay := mocks.NewMockOverlayWidget(t)
575+ mockBorderBox := mocks.NewMockBoxWidget(t)
576+
577+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
578+ mockBox.EXPECT().SetHexpand(true).Once()
579+ mockBox.EXPECT().SetVexpand(true).Once()
580+
581+ wv := component.NewWorkspaceView(mockFactory)
582+
583+ // PaneView creation
584+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
585+ mockOverlay.EXPECT().SetHexpand(true).Once()
586+ mockOverlay.EXPECT().SetVexpand(true).Once()
587+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
588+ mockBorderBox.EXPECT().SetCanFocus(false).Once()
589+ mockBorderBox.EXPECT().AddCssClass("pane-border").Once()
590+ mockBorderBox.EXPECT().SetHexpand(true).Once()
591+ mockBorderBox.EXPECT().SetVexpand(true).Once()
592+ mockOverlay.EXPECT().AddOverlay(mockBorderBox).Once()
593+ mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once()
594+ mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
595+ mockBox.EXPECT().Append(mockOverlay).Once()
596+ mockBorderBox.EXPECT().AddCssClass("active-pane").Once()
597+
598+ pane := entity.NewPane(entity.PaneID("pane-1"))
599+ node := &entity.PaneNode{ID: "node-1", Pane: pane}
600+ ws := &entity.Workspace{
601+ ID: "ws-1",
602+ Root: node,
603+ ActivePaneID: pane.ID,
604+ }
605+
606+ _ = wv.SetWorkspace(ws)
607+
608+ // Act
609+ result := wv.Workspace()
610+
611+ // Assert
612+ assert.Equal(t, ws, result)
613+}
614+
615+func TestWorkspace_NilBeforeSet(t *testing.T) {
616+ // Arrange
617+ mockFactory := mocks.NewMockWidgetFactory(t)
618+ mockBox := mocks.NewMockBoxWidget(t)
619+
620+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
621+ mockBox.EXPECT().SetHexpand(true).Once()
622+ mockBox.EXPECT().SetVexpand(true).Once()
623+
624+ wv := component.NewWorkspaceView(mockFactory)
625+
626+ // Act
627+ result := wv.Workspace()
628+
629+ // Assert
630+ assert.Nil(t, result)
631+}
@@ -0,0 +1,631 @@
1+package component_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/mock"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/ui/component"
12+ "github.com/bnema/dumber/internal/ui/layout"
13+ "github.com/bnema/dumber/internal/ui/layout/mocks"
14+)
15+
16+func TestNewWorkspaceView_CreatesEmptyContainer(t *testing.T) {
17+ // Arrange
18+ mockFactory := mocks.NewMockWidgetFactory(t)
19+ mockBox := mocks.NewMockBoxWidget(t)
20+
21+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
22+ mockBox.EXPECT().SetHexpand(true).Once()
23+ mockBox.EXPECT().SetVexpand(true).Once()
24+
25+ // Act
26+ wv := component.NewWorkspaceView(mockFactory)
27+
28+ // Assert
29+ require.NotNil(t, wv)
30+ assert.Equal(t, 0, wv.PaneCount())
31+ assert.Equal(t, mockBox, wv.Container())
32+}
33+
34+func TestSetWorkspace_NilWorkspace_ReturnsError(t *testing.T) {
35+ // Arrange
36+ mockFactory := mocks.NewMockWidgetFactory(t)
37+ mockBox := mocks.NewMockBoxWidget(t)
38+
39+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
40+ mockBox.EXPECT().SetHexpand(true).Once()
41+ mockBox.EXPECT().SetVexpand(true).Once()
42+
43+ wv := component.NewWorkspaceView(mockFactory)
44+
45+ // Act
46+ err := wv.SetWorkspace(nil)
47+
48+ // Assert
49+ assert.ErrorIs(t, err, component.ErrNilWorkspace)
50+}
51+
52+func TestSetWorkspace_SinglePane_CreatesPaneView(t *testing.T) {
53+ // Arrange
54+ mockFactory := mocks.NewMockWidgetFactory(t)
55+ mockBox := mocks.NewMockBoxWidget(t)
56+
57+ // Container creation
58+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
59+ mockBox.EXPECT().SetHexpand(true).Once()
60+ mockBox.EXPECT().SetVexpand(true).Once()
61+
62+ wv := component.NewWorkspaceView(mockFactory)
63+
64+ // PaneView creation (via paneViewFactoryAdapter)
65+ mockOverlay := mocks.NewMockOverlayWidget(t)
66+ mockBorderBox := mocks.NewMockBoxWidget(t)
67+
68+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
69+ mockOverlay.EXPECT().SetHexpand(true).Once()
70+ mockOverlay.EXPECT().SetVexpand(true).Once()
71+ // No SetChild since WebView is nil
72+
73+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
74+ mockBorderBox.EXPECT().SetCanFocus(false).Once()
75+ mockBorderBox.EXPECT().AddCssClass("pane-border").Once()
76+ mockBorderBox.EXPECT().SetHexpand(true).Once()
77+ mockBorderBox.EXPECT().SetVexpand(true).Once()
78+ mockOverlay.EXPECT().AddOverlay(mockBorderBox).Once()
79+ mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once()
80+ mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
81+
82+ // Widget appended to container
83+ mockBox.EXPECT().Append(mockOverlay).Once()
84+
85+ // Active state set
86+ mockBorderBox.EXPECT().AddCssClass("active-pane").Once()
87+
88+ pane := entity.NewPane(entity.PaneID("pane-1"))
89+ node := &entity.PaneNode{ID: "node-1", Pane: pane}
90+
91+ ws := &entity.Workspace{
92+ ID: "ws-1",
93+ Root: node,
94+ ActivePaneID: pane.ID,
95+ }
96+
97+ // Act
98+ err := wv.SetWorkspace(ws)
99+
100+ // Assert
101+ require.NoError(t, err)
102+ assert.Equal(t, 1, wv.PaneCount())
103+ assert.Equal(t, pane.ID, wv.GetActivePaneID())
104+ assert.NotNil(t, wv.GetPaneView(pane.ID))
105+}
106+
107+func TestSetActivePaneID_UpdatesStyling(t *testing.T) {
108+ // Arrange
109+ mockFactory := mocks.NewMockWidgetFactory(t)
110+ mockBox := mocks.NewMockBoxWidget(t)
111+
112+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
113+ mockBox.EXPECT().SetHexpand(true).Once()
114+ mockBox.EXPECT().SetVexpand(true).Once()
115+
116+ wv := component.NewWorkspaceView(mockFactory)
117+
118+ // Setup two panes
119+ pane1 := entity.NewPane(entity.PaneID("pane-1"))
120+ pane2 := entity.NewPane(entity.PaneID("pane-2"))
121+ node1 := &entity.PaneNode{ID: "node-1", Pane: pane1}
122+ node2 := &entity.PaneNode{ID: "node-2", Pane: pane2}
123+
124+ // Create mock widgets for split
125+ mockPaned := mocks.NewMockPanedWidget(t)
126+ mockOverlay1 := mocks.NewMockOverlayWidget(t)
127+ mockOverlay2 := mocks.NewMockOverlayWidget(t)
128+ mockBorderBox1 := mocks.NewMockBoxWidget(t)
129+ mockBorderBox2 := mocks.NewMockBoxWidget(t)
130+
131+ // First pane view creation
132+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay1).Once()
133+ mockOverlay1.EXPECT().SetHexpand(true).Once()
134+ mockOverlay1.EXPECT().SetVexpand(true).Once()
135+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox1).Once()
136+ mockBorderBox1.EXPECT().SetCanFocus(false).Once()
137+ mockBorderBox1.EXPECT().AddCssClass("pane-border").Once()
138+ mockBorderBox1.EXPECT().SetHexpand(true).Once()
139+ mockBorderBox1.EXPECT().SetVexpand(true).Once()
140+ mockOverlay1.EXPECT().AddOverlay(mockBorderBox1).Once()
141+ mockOverlay1.EXPECT().SetClipOverlay(mockBorderBox1, false).Once()
142+ mockOverlay1.EXPECT().SetMeasureOverlay(mockBorderBox1, false).Once()
143+
144+ // Second pane view creation
145+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay2).Once()
146+ mockOverlay2.EXPECT().SetHexpand(true).Once()
147+ mockOverlay2.EXPECT().SetVexpand(true).Once()
148+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox2).Once()
149+ mockBorderBox2.EXPECT().SetCanFocus(false).Once()
150+ mockBorderBox2.EXPECT().AddCssClass("pane-border").Once()
151+ mockBorderBox2.EXPECT().SetHexpand(true).Once()
152+ mockBorderBox2.EXPECT().SetVexpand(true).Once()
153+ mockOverlay2.EXPECT().AddOverlay(mockBorderBox2).Once()
154+ mockOverlay2.EXPECT().SetClipOverlay(mockBorderBox2, false).Once()
155+ mockOverlay2.EXPECT().SetMeasureOverlay(mockBorderBox2, false).Once()
156+
157+ // Split view creation
158+ mockFactory.EXPECT().NewPaned(layout.OrientationHorizontal).Return(mockPaned).Once()
159+ mockPaned.EXPECT().SetResizeStartChild(true).Once()
160+ mockPaned.EXPECT().SetResizeEndChild(true).Once()
161+ mockPaned.EXPECT().SetShrinkStartChild(false).Once()
162+ mockPaned.EXPECT().SetShrinkEndChild(false).Once()
163+ mockPaned.EXPECT().SetStartChild(mockOverlay1).Once()
164+ mockPaned.EXPECT().SetEndChild(mockOverlay2).Once()
165+ mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
166+
167+ mockBox.EXPECT().Append(mockPaned).Once()
168+
169+ // Initial active pane is pane-1
170+ mockBorderBox1.EXPECT().AddCssClass("active-pane").Once()
171+
172+ splitNode := &entity.PaneNode{
173+ ID: "split",
174+ SplitDir: entity.SplitHorizontal,
175+ SplitRatio: 0.5,
176+ Children: []*entity.PaneNode{node1, node2},
177+ }
178+
179+ ws := &entity.Workspace{
180+ ID: "ws-1",
181+ Root: splitNode,
182+ ActivePaneID: pane1.ID,
183+ }
184+
185+ err := wv.SetWorkspace(ws)
186+ require.NoError(t, err)
187+
188+ // Now change active pane
189+ mockBorderBox1.EXPECT().RemoveCssClass("active-pane").Once()
190+ mockBorderBox2.EXPECT().AddCssClass("active-pane").Once()
191+
192+ // Act
193+ err = wv.SetActivePaneID(pane2.ID)
194+
195+ // Assert
196+ require.NoError(t, err)
197+ assert.Equal(t, pane2.ID, wv.GetActivePaneID())
198+}
199+
200+func TestSetActivePaneID_InvalidID_ReturnsError(t *testing.T) {
201+ // Arrange
202+ mockFactory := mocks.NewMockWidgetFactory(t)
203+ mockBox := mocks.NewMockBoxWidget(t)
204+
205+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
206+ mockBox.EXPECT().SetHexpand(true).Once()
207+ mockBox.EXPECT().SetVexpand(true).Once()
208+
209+ wv := component.NewWorkspaceView(mockFactory)
210+
211+ // Act
212+ err := wv.SetActivePaneID(entity.PaneID("non-existent"))
213+
214+ // Assert
215+ assert.ErrorIs(t, err, component.ErrPaneNotFound)
216+}
217+
218+func TestGetPaneView_Existing(t *testing.T) {
219+ // Arrange
220+ mockFactory := mocks.NewMockWidgetFactory(t)
221+ mockBox := mocks.NewMockBoxWidget(t)
222+ mockOverlay := mocks.NewMockOverlayWidget(t)
223+ mockBorderBox := mocks.NewMockBoxWidget(t)
224+
225+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
226+ mockBox.EXPECT().SetHexpand(true).Once()
227+ mockBox.EXPECT().SetVexpand(true).Once()
228+
229+ wv := component.NewWorkspaceView(mockFactory)
230+
231+ // PaneView creation
232+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
233+ mockOverlay.EXPECT().SetHexpand(true).Once()
234+ mockOverlay.EXPECT().SetVexpand(true).Once()
235+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
236+ mockBorderBox.EXPECT().SetCanFocus(false).Once()
237+ mockBorderBox.EXPECT().AddCssClass("pane-border").Once()
238+ mockBorderBox.EXPECT().SetHexpand(true).Once()
239+ mockBorderBox.EXPECT().SetVexpand(true).Once()
240+ mockOverlay.EXPECT().AddOverlay(mockBorderBox).Once()
241+ mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once()
242+ mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
243+ mockBox.EXPECT().Append(mockOverlay).Once()
244+ mockBorderBox.EXPECT().AddCssClass("active-pane").Once()
245+
246+ pane := entity.NewPane(entity.PaneID("pane-1"))
247+ node := &entity.PaneNode{ID: "node-1", Pane: pane}
248+ ws := &entity.Workspace{
249+ ID: "ws-1",
250+ Root: node,
251+ ActivePaneID: pane.ID,
252+ }
253+
254+ _ = wv.SetWorkspace(ws)
255+
256+ // Act
257+ result := wv.GetPaneView(pane.ID)
258+
259+ // Assert
260+ assert.NotNil(t, result)
261+}
262+
263+func TestGetPaneView_NonExistent(t *testing.T) {
264+ // Arrange
265+ mockFactory := mocks.NewMockWidgetFactory(t)
266+ mockBox := mocks.NewMockBoxWidget(t)
267+
268+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
269+ mockBox.EXPECT().SetHexpand(true).Once()
270+ mockBox.EXPECT().SetVexpand(true).Once()
271+
272+ wv := component.NewWorkspaceView(mockFactory)
273+
274+ // Act
275+ result := wv.GetPaneView(entity.PaneID("non-existent"))
276+
277+ // Assert
278+ assert.Nil(t, result)
279+}
280+
281+func TestGetPaneIDs_ReturnsAllIDs(t *testing.T) {
282+ // Arrange
283+ mockFactory := mocks.NewMockWidgetFactory(t)
284+ mockBox := mocks.NewMockBoxWidget(t)
285+ mockPaned := mocks.NewMockPanedWidget(t)
286+ mockOverlay1 := mocks.NewMockOverlayWidget(t)
287+ mockOverlay2 := mocks.NewMockOverlayWidget(t)
288+ mockBorderBox1 := mocks.NewMockBoxWidget(t)
289+ mockBorderBox2 := mocks.NewMockBoxWidget(t)
290+
291+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
292+ mockBox.EXPECT().SetHexpand(true).Once()
293+ mockBox.EXPECT().SetVexpand(true).Once()
294+
295+ wv := component.NewWorkspaceView(mockFactory)
296+
297+ // First pane view
298+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay1).Once()
299+ mockOverlay1.EXPECT().SetHexpand(true).Once()
300+ mockOverlay1.EXPECT().SetVexpand(true).Once()
301+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox1).Once()
302+ mockBorderBox1.EXPECT().SetCanFocus(false).Once()
303+ mockBorderBox1.EXPECT().AddCssClass("pane-border").Once()
304+ mockBorderBox1.EXPECT().SetHexpand(true).Once()
305+ mockBorderBox1.EXPECT().SetVexpand(true).Once()
306+ mockOverlay1.EXPECT().AddOverlay(mockBorderBox1).Once()
307+ mockOverlay1.EXPECT().SetClipOverlay(mockBorderBox1, false).Once()
308+ mockOverlay1.EXPECT().SetMeasureOverlay(mockBorderBox1, false).Once()
309+
310+ // Second pane view
311+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay2).Once()
312+ mockOverlay2.EXPECT().SetHexpand(true).Once()
313+ mockOverlay2.EXPECT().SetVexpand(true).Once()
314+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox2).Once()
315+ mockBorderBox2.EXPECT().SetCanFocus(false).Once()
316+ mockBorderBox2.EXPECT().AddCssClass("pane-border").Once()
317+ mockBorderBox2.EXPECT().SetHexpand(true).Once()
318+ mockBorderBox2.EXPECT().SetVexpand(true).Once()
319+ mockOverlay2.EXPECT().AddOverlay(mockBorderBox2).Once()
320+ mockOverlay2.EXPECT().SetClipOverlay(mockBorderBox2, false).Once()
321+ mockOverlay2.EXPECT().SetMeasureOverlay(mockBorderBox2, false).Once()
322+
323+ // Split view
324+ mockFactory.EXPECT().NewPaned(layout.OrientationHorizontal).Return(mockPaned).Once()
325+ mockPaned.EXPECT().SetResizeStartChild(true).Once()
326+ mockPaned.EXPECT().SetResizeEndChild(true).Once()
327+ mockPaned.EXPECT().SetShrinkStartChild(false).Once()
328+ mockPaned.EXPECT().SetShrinkEndChild(false).Once()
329+ mockPaned.EXPECT().SetStartChild(mockOverlay1).Once()
330+ mockPaned.EXPECT().SetEndChild(mockOverlay2).Once()
331+ mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
332+
333+ mockBox.EXPECT().Append(mockPaned).Once()
334+ mockBorderBox1.EXPECT().AddCssClass("active-pane").Once()
335+
336+ pane1 := entity.NewPane(entity.PaneID("pane-1"))
337+ pane2 := entity.NewPane(entity.PaneID("pane-2"))
338+ node1 := &entity.PaneNode{ID: "node-1", Pane: pane1}
339+ node2 := &entity.PaneNode{ID: "node-2", Pane: pane2}
340+
341+ splitNode := &entity.PaneNode{
342+ ID: "split",
343+ SplitDir: entity.SplitHorizontal,
344+ SplitRatio: 0.5,
345+ Children: []*entity.PaneNode{node1, node2},
346+ }
347+
348+ ws := &entity.Workspace{
349+ ID: "ws-1",
350+ Root: splitNode,
351+ ActivePaneID: pane1.ID,
352+ }
353+
354+ _ = wv.SetWorkspace(ws)
355+
356+ // Act
357+ ids := wv.GetPaneIDs()
358+
359+ // Assert
360+ assert.Len(t, ids, 2)
361+ assert.Contains(t, ids, pane1.ID)
362+ assert.Contains(t, ids, pane2.ID)
363+}
364+
365+func TestWidget_ReturnsContainer(t *testing.T) {
366+ // Arrange
367+ mockFactory := mocks.NewMockWidgetFactory(t)
368+ mockBox := mocks.NewMockBoxWidget(t)
369+
370+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
371+ mockBox.EXPECT().SetHexpand(true).Once()
372+ mockBox.EXPECT().SetVexpand(true).Once()
373+
374+ wv := component.NewWorkspaceView(mockFactory)
375+
376+ // Act
377+ widget := wv.Widget()
378+
379+ // Assert
380+ assert.Equal(t, mockBox, widget)
381+}
382+
383+func TestSetOnPaneFocused_SetsCallback(t *testing.T) {
384+ // Arrange
385+ mockFactory := mocks.NewMockWidgetFactory(t)
386+ mockBox := mocks.NewMockBoxWidget(t)
387+
388+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
389+ mockBox.EXPECT().SetHexpand(true).Once()
390+ mockBox.EXPECT().SetVexpand(true).Once()
391+
392+ wv := component.NewWorkspaceView(mockFactory)
393+
394+ callback := func(paneID entity.PaneID) {}
395+
396+ // Act
397+ wv.SetOnPaneFocused(callback)
398+
399+ // Assert - callback is set (verified by no panic)
400+ assert.NotNil(t, wv)
401+}
402+
403+func TestSetWebViewWidget_AttachesToPane(t *testing.T) {
404+ // Arrange
405+ mockFactory := mocks.NewMockWidgetFactory(t)
406+ mockBox := mocks.NewMockBoxWidget(t)
407+ mockOverlay := mocks.NewMockOverlayWidget(t)
408+ mockBorderBox := mocks.NewMockBoxWidget(t)
409+ mockWebView := mocks.NewMockWidget(t)
410+
411+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
412+ mockBox.EXPECT().SetHexpand(true).Once()
413+ mockBox.EXPECT().SetVexpand(true).Once()
414+
415+ wv := component.NewWorkspaceView(mockFactory)
416+
417+ // PaneView creation
418+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
419+ mockOverlay.EXPECT().SetHexpand(true).Once()
420+ mockOverlay.EXPECT().SetVexpand(true).Once()
421+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
422+ mockBorderBox.EXPECT().SetCanFocus(false).Once()
423+ mockBorderBox.EXPECT().AddCssClass("pane-border").Once()
424+ mockBorderBox.EXPECT().SetHexpand(true).Once()
425+ mockBorderBox.EXPECT().SetVexpand(true).Once()
426+ mockOverlay.EXPECT().AddOverlay(mockBorderBox).Once()
427+ mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once()
428+ mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
429+ mockBox.EXPECT().Append(mockOverlay).Once()
430+ mockBorderBox.EXPECT().AddCssClass("active-pane").Once()
431+
432+ pane := entity.NewPane(entity.PaneID("pane-1"))
433+ node := &entity.PaneNode{ID: "node-1", Pane: pane}
434+ ws := &entity.Workspace{
435+ ID: "ws-1",
436+ Root: node,
437+ ActivePaneID: pane.ID,
438+ }
439+
440+ _ = wv.SetWorkspace(ws)
441+
442+ // Expect WebView to be set
443+ mockOverlay.EXPECT().SetChild(mockWebView).Once()
444+
445+ // Act
446+ err := wv.SetWebViewWidget(pane.ID, mockWebView)
447+
448+ // Assert
449+ require.NoError(t, err)
450+}
451+
452+func TestSetWebViewWidget_NonExistentPane_ReturnsError(t *testing.T) {
453+ // Arrange
454+ mockFactory := mocks.NewMockWidgetFactory(t)
455+ mockBox := mocks.NewMockBoxWidget(t)
456+ mockWebView := mocks.NewMockWidget(t)
457+
458+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
459+ mockBox.EXPECT().SetHexpand(true).Once()
460+ mockBox.EXPECT().SetVexpand(true).Once()
461+
462+ wv := component.NewWorkspaceView(mockFactory)
463+
464+ // Act
465+ err := wv.SetWebViewWidget(entity.PaneID("non-existent"), mockWebView)
466+
467+ // Assert
468+ assert.ErrorIs(t, err, component.ErrPaneNotFound)
469+}
470+
471+func TestFocusPane_DelegatesToPaneView(t *testing.T) {
472+ // Arrange
473+ mockFactory := mocks.NewMockWidgetFactory(t)
474+ mockBox := mocks.NewMockBoxWidget(t)
475+ mockOverlay := mocks.NewMockOverlayWidget(t)
476+ mockBorderBox := mocks.NewMockBoxWidget(t)
477+
478+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
479+ mockBox.EXPECT().SetHexpand(true).Once()
480+ mockBox.EXPECT().SetVexpand(true).Once()
481+
482+ wv := component.NewWorkspaceView(mockFactory)
483+
484+ // PaneView creation
485+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
486+ mockOverlay.EXPECT().SetHexpand(true).Once()
487+ mockOverlay.EXPECT().SetVexpand(true).Once()
488+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
489+ mockBorderBox.EXPECT().SetCanFocus(false).Once()
490+ mockBorderBox.EXPECT().AddCssClass("pane-border").Once()
491+ mockBorderBox.EXPECT().SetHexpand(true).Once()
492+ mockBorderBox.EXPECT().SetVexpand(true).Once()
493+ mockOverlay.EXPECT().AddOverlay(mockBorderBox).Once()
494+ mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once()
495+ mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
496+ mockBox.EXPECT().Append(mockOverlay).Once()
497+ mockBorderBox.EXPECT().AddCssClass("active-pane").Once()
498+
499+ pane := entity.NewPane(entity.PaneID("pane-1"))
500+ node := &entity.PaneNode{ID: "node-1", Pane: pane}
501+ ws := &entity.Workspace{
502+ ID: "ws-1",
503+ Root: node,
504+ ActivePaneID: pane.ID,
505+ }
506+
507+ _ = wv.SetWorkspace(ws)
508+
509+ // Act - FocusPane with nil WebView returns false
510+ result := wv.FocusPane(pane.ID)
511+
512+ // Assert
513+ assert.False(t, result) // No WebView attached, so GrabFocus fails
514+}
515+
516+func TestFocusPane_NonExistentPane_ReturnsFalse(t *testing.T) {
517+ // Arrange
518+ mockFactory := mocks.NewMockWidgetFactory(t)
519+ mockBox := mocks.NewMockBoxWidget(t)
520+
521+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
522+ mockBox.EXPECT().SetHexpand(true).Once()
523+ mockBox.EXPECT().SetVexpand(true).Once()
524+
525+ wv := component.NewWorkspaceView(mockFactory)
526+
527+ // Act
528+ result := wv.FocusPane(entity.PaneID("non-existent"))
529+
530+ // Assert
531+ assert.False(t, result)
532+}
533+
534+func TestRebuild_NilWorkspace_ReturnsError(t *testing.T) {
535+ // Arrange
536+ mockFactory := mocks.NewMockWidgetFactory(t)
537+ mockBox := mocks.NewMockBoxWidget(t)
538+
539+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
540+ mockBox.EXPECT().SetHexpand(true).Once()
541+ mockBox.EXPECT().SetVexpand(true).Once()
542+
543+ wv := component.NewWorkspaceView(mockFactory)
544+
545+ // Act
546+ err := wv.Rebuild()
547+
548+ // Assert
549+ assert.ErrorIs(t, err, component.ErrNilWorkspace)
550+}
551+
552+func TestTreeRenderer_ReturnsRenderer(t *testing.T) {
553+ // Arrange
554+ mockFactory := mocks.NewMockWidgetFactory(t)
555+ mockBox := mocks.NewMockBoxWidget(t)
556+
557+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
558+ mockBox.EXPECT().SetHexpand(true).Once()
559+ mockBox.EXPECT().SetVexpand(true).Once()
560+
561+ wv := component.NewWorkspaceView(mockFactory)
562+
563+ // Act
564+ renderer := wv.TreeRenderer()
565+
566+ // Assert
567+ assert.NotNil(t, renderer)
568+}
569+
570+func TestWorkspace_ReturnsCurrentWorkspace(t *testing.T) {
571+ // Arrange
572+ mockFactory := mocks.NewMockWidgetFactory(t)
573+ mockBox := mocks.NewMockBoxWidget(t)
574+ mockOverlay := mocks.NewMockOverlayWidget(t)
575+ mockBorderBox := mocks.NewMockBoxWidget(t)
576+
577+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
578+ mockBox.EXPECT().SetHexpand(true).Once()
579+ mockBox.EXPECT().SetVexpand(true).Once()
580+
581+ wv := component.NewWorkspaceView(mockFactory)
582+
583+ // PaneView creation
584+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
585+ mockOverlay.EXPECT().SetHexpand(true).Once()
586+ mockOverlay.EXPECT().SetVexpand(true).Once()
587+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
588+ mockBorderBox.EXPECT().SetCanFocus(false).Once()
589+ mockBorderBox.EXPECT().AddCssClass("pane-border").Once()
590+ mockBorderBox.EXPECT().SetHexpand(true).Once()
591+ mockBorderBox.EXPECT().SetVexpand(true).Once()
592+ mockOverlay.EXPECT().AddOverlay(mockBorderBox).Once()
593+ mockOverlay.EXPECT().SetClipOverlay(mockBorderBox, false).Once()
594+ mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
595+ mockBox.EXPECT().Append(mockOverlay).Once()
596+ mockBorderBox.EXPECT().AddCssClass("active-pane").Once()
597+
598+ pane := entity.NewPane(entity.PaneID("pane-1"))
599+ node := &entity.PaneNode{ID: "node-1", Pane: pane}
600+ ws := &entity.Workspace{
601+ ID: "ws-1",
602+ Root: node,
603+ ActivePaneID: pane.ID,
604+ }
605+
606+ _ = wv.SetWorkspace(ws)
607+
608+ // Act
609+ result := wv.Workspace()
610+
611+ // Assert
612+ assert.Equal(t, ws, result)
613+}
614+
615+func TestWorkspace_NilBeforeSet(t *testing.T) {
616+ // Arrange
617+ mockFactory := mocks.NewMockWidgetFactory(t)
618+ mockBox := mocks.NewMockBoxWidget(t)
619+
620+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
621+ mockBox.EXPECT().SetHexpand(true).Once()
622+ mockBox.EXPECT().SetVexpand(true).Once()
623+
624+ wv := component.NewWorkspaceView(mockFactory)
625+
626+ // Act
627+ result := wv.Workspace()
628+
629+ // Assert
630+ assert.Nil(t, result)
631+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close