detached 47e07ec

test(ui): cover rebuilt pane reveal state

commit
47e07ec6af753736022fe394e025b3dd8c1e7569
author
brice · · 2026-07-15 04:41 UTC
committer
brice · · 2026-07-15 04:42 UTC
parent
9baacd8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +224 -57
M internal/ui/component/pane_view_internal_test.go +22 -57
@@ -3,6 +3,7 @@ package component
33 import (
44 "testing"
55
6+ "github.com/stretchr/testify/assert"
67 "github.com/stretchr/testify/mock"
78
89 "github.com/bnema/dumber/internal/ui/layout"
@@ -81,66 +82,26 @@ func TestPaneView_HideLoadingSkeleton_NoOpWhenNil(t *testing.T) {
8182 pv.HideLoadingSkeleton()
8283 }
8384
84-func TestPaneView_AttachWebViewWidget_UsesExplicitRevealStateAfterCleanup(t *testing.T) {
85- widget := mocks.NewMockWidget(t)
86- oldOverlay := mocks.NewMockOverlayWidget(t)
87- oldOverlay.EXPECT().SetChild(nil).Once()
88- (&PaneView{overlay: oldOverlay, webViewWidget: widget}).Cleanup()
89-
90- t.Run("revealed widget detached by cleanup hides fresh skeleton", func(t *testing.T) {
91- overlay := mocks.NewMockOverlayWidget(t)
92- loadingContainer := mocks.NewMockBoxWidget(t)
93- spinner := mocks.NewMockSpinnerWidget(t)
94- overlay.EXPECT().GetAllocatedWidth().Return(0).Twice()
95- overlay.EXPECT().GetAllocatedHeight().Return(0).Twice()
96- widget.EXPECT().GetAllocatedWidth().Return(0).Twice()
97- widget.EXPECT().GetAllocatedHeight().Return(0).Twice()
98- widget.EXPECT().GetParent().Return(nil).Once()
99- widget.EXPECT().IsVisible().Return(true).Once()
100- overlay.EXPECT().SetChild(widget).Once()
101- loadingContainer.EXPECT().SetVisible(false).Once()
102- spinner.EXPECT().Stop().Once()
103-
104- pv := &PaneView{
105- overlay: overlay,
106- loading: &LoadingSkeleton{container: loadingContainer, spinner: spinner},
107- }
108- pv.AttachWebViewWidget(widget, true)
109- })
110-
111- t.Run("new unrevealed widget keeps fresh skeleton visible", func(t *testing.T) {
112- overlay := mocks.NewMockOverlayWidget(t)
113- loadingContainer := mocks.NewMockBoxWidget(t)
114- overlay.EXPECT().GetAllocatedWidth().Return(0).Twice()
115- overlay.EXPECT().GetAllocatedHeight().Return(0).Twice()
116- widget.EXPECT().GetAllocatedWidth().Return(0).Twice()
117- widget.EXPECT().GetAllocatedHeight().Return(0).Twice()
118- widget.EXPECT().GetParent().Return(nil).Once()
119- widget.EXPECT().IsVisible().Return(true).Once()
120- overlay.EXPECT().SetChild(widget).Once()
121- loadingContainer.EXPECT().SetVisible(true).Once()
122-
123- pv := &PaneView{
124- overlay: overlay,
125- loading: &LoadingSkeleton{container: loadingContainer},
126- }
127- pv.AttachWebViewWidget(widget, false)
128- })
129-}
130-
131-func TestPaneView_AttachWebViewWidget_UnrevealedReplacementRestartsLoadingSkeleton(t *testing.T) {
85+func TestPaneView_AttachWebViewWidget_RevealedThenUnrevealedReplacementRestartsSkeleton(t *testing.T) {
13286 overlay := mocks.NewMockOverlayWidget(t)
133- widget := mocks.NewMockWidget(t)
87+ revealedWidget := mocks.NewMockWidget(t)
88+ unrevealedWidget := mocks.NewMockWidget(t)
13489 loadingContainer := mocks.NewMockBoxWidget(t)
13590 spinner := mocks.NewMockSpinnerWidget(t)
13691
137- overlay.EXPECT().GetAllocatedWidth().Return(0).Twice()
138- overlay.EXPECT().GetAllocatedHeight().Return(0).Twice()
139- widget.EXPECT().GetAllocatedWidth().Return(0).Twice()
140- widget.EXPECT().GetAllocatedHeight().Return(0).Twice()
141- widget.EXPECT().GetParent().Return(nil).Once()
142- widget.EXPECT().IsVisible().Return(true).Once()
143- overlay.EXPECT().SetChild(widget).Once()
92+ overlay.EXPECT().GetAllocatedWidth().Return(0).Times(4)
93+ overlay.EXPECT().GetAllocatedHeight().Return(0).Times(4)
94+ for _, widget := range []*mocks.MockWidget{revealedWidget, unrevealedWidget} {
95+ widget.EXPECT().GetAllocatedWidth().Return(0).Twice()
96+ widget.EXPECT().GetAllocatedHeight().Return(0).Twice()
97+ widget.EXPECT().GetParent().Return(nil).Once()
98+ widget.EXPECT().IsVisible().Return(true).Once()
99+ }
100+ overlay.EXPECT().SetChild(revealedWidget).Once()
101+ loadingContainer.EXPECT().SetVisible(false).Once()
102+ spinner.EXPECT().Stop().Once()
103+ overlay.EXPECT().SetChild(nil).Once()
104+ overlay.EXPECT().SetChild(unrevealedWidget).Once()
144105 loadingContainer.EXPECT().SetVisible(true).Once()
145106 spinner.EXPECT().Start().Once()
146107
@@ -148,5 +109,9 @@ func TestPaneView_AttachWebViewWidget_UnrevealedReplacementRestartsLoadingSkelet
148109 overlay: overlay,
149110 loading: &LoadingSkeleton{container: loadingContainer, spinner: spinner},
150111 }
151- pv.AttachWebViewWidget(widget, false)
112+
113+ pv.AttachWebViewWidget(revealedWidget, true)
114+ pv.AttachWebViewWidget(unrevealedWidget, false)
115+
116+ assert.Same(t, unrevealedWidget, pv.WebViewWidget())
152117 }
@@ -3,6 +3,7 @@ package component
3 import (3 import (
4 "testing"4 "testing"
5 5
6+ "github.com/stretchr/testify/assert"
6 "github.com/stretchr/testify/mock"7 "github.com/stretchr/testify/mock"
7 8
8 "github.com/bnema/dumber/internal/ui/layout"9 "github.com/bnema/dumber/internal/ui/layout"
@@ -81,66 +82,26 @@ func TestPaneView_HideLoadingSkeleton_NoOpWhenNil(t *testing.T) {
81 pv.HideLoadingSkeleton()82 pv.HideLoadingSkeleton()
82 }83 }
83 84
84-func TestPaneView_AttachWebViewWidget_UsesExplicitRevealStateAfterCleanup(t *testing.T) {85+func TestPaneView_AttachWebViewWidget_RevealedThenUnrevealedReplacementRestartsSkeleton(t *testing.T) {
85- widget := mocks.NewMockWidget(t)
86- oldOverlay := mocks.NewMockOverlayWidget(t)
87- oldOverlay.EXPECT().SetChild(nil).Once()
88- (&PaneView{overlay: oldOverlay, webViewWidget: widget}).Cleanup()
89-
90- t.Run("revealed widget detached by cleanup hides fresh skeleton", func(t *testing.T) {
91- overlay := mocks.NewMockOverlayWidget(t)
92- loadingContainer := mocks.NewMockBoxWidget(t)
93- spinner := mocks.NewMockSpinnerWidget(t)
94- overlay.EXPECT().GetAllocatedWidth().Return(0).Twice()
95- overlay.EXPECT().GetAllocatedHeight().Return(0).Twice()
96- widget.EXPECT().GetAllocatedWidth().Return(0).Twice()
97- widget.EXPECT().GetAllocatedHeight().Return(0).Twice()
98- widget.EXPECT().GetParent().Return(nil).Once()
99- widget.EXPECT().IsVisible().Return(true).Once()
100- overlay.EXPECT().SetChild(widget).Once()
101- loadingContainer.EXPECT().SetVisible(false).Once()
102- spinner.EXPECT().Stop().Once()
103-
104- pv := &PaneView{
105- overlay: overlay,
106- loading: &LoadingSkeleton{container: loadingContainer, spinner: spinner},
107- }
108- pv.AttachWebViewWidget(widget, true)
109- })
110-
111- t.Run("new unrevealed widget keeps fresh skeleton visible", func(t *testing.T) {
112- overlay := mocks.NewMockOverlayWidget(t)
113- loadingContainer := mocks.NewMockBoxWidget(t)
114- overlay.EXPECT().GetAllocatedWidth().Return(0).Twice()
115- overlay.EXPECT().GetAllocatedHeight().Return(0).Twice()
116- widget.EXPECT().GetAllocatedWidth().Return(0).Twice()
117- widget.EXPECT().GetAllocatedHeight().Return(0).Twice()
118- widget.EXPECT().GetParent().Return(nil).Once()
119- widget.EXPECT().IsVisible().Return(true).Once()
120- overlay.EXPECT().SetChild(widget).Once()
121- loadingContainer.EXPECT().SetVisible(true).Once()
122-
123- pv := &PaneView{
124- overlay: overlay,
125- loading: &LoadingSkeleton{container: loadingContainer},
126- }
127- pv.AttachWebViewWidget(widget, false)
128- })
129-}
130-
131-func TestPaneView_AttachWebViewWidget_UnrevealedReplacementRestartsLoadingSkeleton(t *testing.T) {
132 overlay := mocks.NewMockOverlayWidget(t)86 overlay := mocks.NewMockOverlayWidget(t)
133- widget := mocks.NewMockWidget(t)87+ revealedWidget := mocks.NewMockWidget(t)
88+ unrevealedWidget := mocks.NewMockWidget(t)
134 loadingContainer := mocks.NewMockBoxWidget(t)89 loadingContainer := mocks.NewMockBoxWidget(t)
135 spinner := mocks.NewMockSpinnerWidget(t)90 spinner := mocks.NewMockSpinnerWidget(t)
136 91
137- overlay.EXPECT().GetAllocatedWidth().Return(0).Twice()92+ overlay.EXPECT().GetAllocatedWidth().Return(0).Times(4)
138- overlay.EXPECT().GetAllocatedHeight().Return(0).Twice()93+ overlay.EXPECT().GetAllocatedHeight().Return(0).Times(4)
139- widget.EXPECT().GetAllocatedWidth().Return(0).Twice()94+ for _, widget := range []*mocks.MockWidget{revealedWidget, unrevealedWidget} {
140- widget.EXPECT().GetAllocatedHeight().Return(0).Twice()95+ widget.EXPECT().GetAllocatedWidth().Return(0).Twice()
141- widget.EXPECT().GetParent().Return(nil).Once()96+ widget.EXPECT().GetAllocatedHeight().Return(0).Twice()
142- widget.EXPECT().IsVisible().Return(true).Once()97+ widget.EXPECT().GetParent().Return(nil).Once()
143- overlay.EXPECT().SetChild(widget).Once()98+ widget.EXPECT().IsVisible().Return(true).Once()
99+ }
100+ overlay.EXPECT().SetChild(revealedWidget).Once()
101+ loadingContainer.EXPECT().SetVisible(false).Once()
102+ spinner.EXPECT().Stop().Once()
103+ overlay.EXPECT().SetChild(nil).Once()
104+ overlay.EXPECT().SetChild(unrevealedWidget).Once()
144 loadingContainer.EXPECT().SetVisible(true).Once()105 loadingContainer.EXPECT().SetVisible(true).Once()
145 spinner.EXPECT().Start().Once()106 spinner.EXPECT().Start().Once()
146 107
@@ -148,5 +109,9 @@ func TestPaneView_AttachWebViewWidget_UnrevealedReplacementRestartsLoadingSkelet
148 overlay: overlay,109 overlay: overlay,
149 loading: &LoadingSkeleton{container: loadingContainer, spinner: spinner},110 loading: &LoadingSkeleton{container: loadingContainer, spinner: spinner},
150 }111 }
151- pv.AttachWebViewWidget(widget, false)112+
113+ pv.AttachWebViewWidget(revealedWidget, true)
114+ pv.AttachWebViewWidget(unrevealedWidget, false)
115+
116+ assert.Same(t, unrevealedWidget, pv.WebViewWidget())
152 }117 }
M internal/ui/coordinator/content/lifecycle_test.go +202 -0
@@ -458,3 +458,205 @@ func TestLifecycle_SyncWebViewViewport_NoopWithoutCapability(t *testing.T) {
458458
459459 c.SyncWebViewViewport(context.Background(), "pane-1", "unit-test")
460460 }
461+
462+type nativeLifecycleWebView struct {
463+ port.WebView
464+ widget uintptr
465+}
466+
467+func (w *nativeLifecycleWebView) NativeWidget() uintptr { return w.widget }
468+
469+func TestLifecycle_AttachToWorkspace_RebuildPropagatesRevealStateToFreshPaneView(t *testing.T) {
470+ for _, tc := range []struct {
471+ name string
472+ revealed bool
473+ }{
474+ {name: "revealed current identity hides and stops skeleton", revealed: true},
475+ {name: "unrevealed current identity shows and starts skeleton", revealed: false},
476+ } {
477+ t.Run(tc.name, func(t *testing.T) {
478+ paneID := entity.PaneID("pane-1")
479+ ctx := context.Background()
480+ factory := layoutmocks.NewMockWidgetFactory(t)
481+ wsView, workspace, paneOverlay, loadingContainer, spinner := newLifecycleRebuildWorkspaceView(t, ctx, factory, paneID)
482+ wrappedWidget := layoutmocks.NewMockWidget(t)
483+ webViewMock := mocks.NewMockWebView(t)
484+ webViewMock.EXPECT().ID().Return(port.WebViewID(201)).Maybe()
485+ webViewMock.EXPECT().Generation().Return(uint64(3)).Maybe()
486+ webViewMock.EXPECT().IsDestroyed().Return(false).Once()
487+ webView := &nativeLifecycleWebView{WebView: webViewMock, widget: 0x351}
488+
489+ factory.EXPECT().WrapNativeWidget(uintptr(0x351)).Return(wrappedWidget).Once()
490+ wrappedWidget.EXPECT().GtkWidget().Return(nil).Once()
491+ wrappedWidget.EXPECT().GetAllocatedWidth().Return(0).Times(3)
492+ wrappedWidget.EXPECT().GetAllocatedHeight().Return(0).Times(3)
493+ wrappedWidget.EXPECT().GetParent().Return(nil).Once()
494+ wrappedWidget.EXPECT().IsVisible().Return(true).Once()
495+ paneOverlay.EXPECT().GetAllocatedWidth().Return(0).Twice()
496+ paneOverlay.EXPECT().GetAllocatedHeight().Return(0).Twice()
497+ paneOverlay.EXPECT().SetChild(wrappedWidget).Once()
498+ loadingContainer.EXPECT().SetVisible(!tc.revealed).Once()
499+ if tc.revealed {
500+ spinner.EXPECT().Stop().Once()
501+ } else {
502+ spinner.EXPECT().Start().Once()
503+ }
504+
505+ coordinator := newRevealTestCoordinator()
506+ coordinator.widgetFactory = factory
507+ coordinator.setWebViewLocked(paneID, webView)
508+ if tc.revealed {
509+ identity, ok := identityForWebView(webView)
510+ require.True(t, ok)
511+ coordinator.markWebViewRevealed(paneID, identity)
512+ }
513+
514+ coordinator.AttachToWorkspace(ctx, workspace, wsView)
515+
516+ assert.Same(t, wrappedWidget, wsView.GetPaneView(paneID).WebViewWidget())
517+ })
518+ }
519+}
520+
521+func newLifecycleRebuildWorkspaceView(
522+ t *testing.T,
523+ ctx context.Context,
524+ factory *layoutmocks.MockWidgetFactory,
525+ paneID entity.PaneID,
526+) (*component.WorkspaceView, *entity.Workspace, *layoutmocks.MockOverlayWidget, *layoutmocks.MockBoxWidget, *layoutmocks.MockSpinnerWidget) {
527+ t.Helper()
528+ workspaceContainer := layoutmocks.NewMockBoxWidget(t)
529+ workspaceOverlay := layoutmocks.NewMockOverlayWidget(t)
530+ factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(workspaceContainer).Once()
531+ workspaceContainer.EXPECT().SetHexpand(true).Once()
532+ workspaceContainer.EXPECT().SetVexpand(true).Once()
533+ workspaceContainer.EXPECT().SetVisible(true).Once()
534+ factory.EXPECT().NewOverlay().Return(workspaceOverlay).Once()
535+ workspaceOverlay.EXPECT().SetHexpand(true).Once()
536+ workspaceOverlay.EXPECT().SetVexpand(true).Once()
537+ workspaceOverlay.EXPECT().SetChild(workspaceContainer).Once()
538+ workspaceOverlay.EXPECT().SetVisible(true).Once()
539+ wsView := component.NewWorkspaceView(ctx, factory)
540+
541+ paneOverlay := layoutmocks.NewMockOverlayWidget(t)
542+ loadingContainer := layoutmocks.NewMockBoxWidget(t)
543+ loadingContent := layoutmocks.NewMockBoxWidget(t)
544+ spinner := layoutmocks.NewMockSpinnerWidget(t)
545+ logo := layoutmocks.NewMockImageWidget(t)
546+ version := layoutmocks.NewMockLabelWidget(t)
547+ border := layoutmocks.NewMockBoxWidget(t)
548+
549+ factory.EXPECT().NewOverlay().Return(paneOverlay).Once()
550+ paneOverlay.EXPECT().SetHexpand(true).Once()
551+ paneOverlay.EXPECT().SetVexpand(true).Once()
552+ paneOverlay.EXPECT().SetVisible(true).Once()
553+ paneOverlay.EXPECT().AddCssClass("pane-overlay").Once()
554+ factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(loadingContainer).Once()
555+ loadingContainer.EXPECT().SetHexpand(true).Once()
556+ loadingContainer.EXPECT().SetVexpand(true).Once()
557+ loadingContainer.EXPECT().SetHalign(mock.Anything).Once()
558+ loadingContainer.EXPECT().SetValign(mock.Anything).Once()
559+ loadingContainer.EXPECT().SetCanFocus(false).Times(2)
560+ loadingContainer.EXPECT().SetCanTarget(false).Times(2)
561+ loadingContainer.EXPECT().AddCssClass("loading-skeleton").Once()
562+ factory.EXPECT().NewBox(layout.OrientationVertical, 6).Return(loadingContent).Once()
563+ loadingContent.EXPECT().SetHalign(mock.Anything).Once()
564+ loadingContent.EXPECT().SetValign(mock.Anything).Once()
565+ loadingContent.EXPECT().SetCanFocus(false).Once()
566+ loadingContent.EXPECT().SetCanTarget(false).Once()
567+ loadingContent.EXPECT().AddCssClass("loading-skeleton-content").Once()
568+ factory.EXPECT().NewImage().Return(logo).Once()
569+ logo.EXPECT().SetHalign(mock.Anything).Once()
570+ logo.EXPECT().SetValign(mock.Anything).Once()
571+ logo.EXPECT().SetCanFocus(false).Once()
572+ logo.EXPECT().SetCanTarget(false).Once()
573+ logo.EXPECT().SetSizeRequest(256, 256).Once()
574+ logo.EXPECT().SetPixelSize(256).Once()
575+ logo.EXPECT().AddCssClass("loading-skeleton-logo").Once()
576+ logo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
577+ factory.EXPECT().NewSpinner().Return(spinner).Once()
578+ spinner.EXPECT().SetHalign(mock.Anything).Once()
579+ spinner.EXPECT().SetValign(mock.Anything).Once()
580+ spinner.EXPECT().SetCanFocus(false).Once()
581+ spinner.EXPECT().SetCanTarget(false).Once()
582+ spinner.EXPECT().SetSizeRequest(32, 32).Once()
583+ spinner.EXPECT().AddCssClass("loading-skeleton-spinner").Once()
584+ factory.EXPECT().NewLabel(mock.Anything).Return(version).Once()
585+ version.EXPECT().SetHalign(mock.Anything).Once()
586+ version.EXPECT().SetValign(mock.Anything).Once()
587+ version.EXPECT().SetCanFocus(false).Once()
588+ version.EXPECT().SetCanTarget(false).Once()
589+ version.EXPECT().SetMaxWidthChars(mock.Anything).Once()
590+ version.EXPECT().SetEllipsize(mock.Anything).Once()
591+ version.EXPECT().AddCssClass("loading-skeleton-version").Once()
592+ loadingContent.EXPECT().Append(logo).Once()
593+ loadingContent.EXPECT().Append(spinner).Once()
594+ loadingContent.EXPECT().Append(version).Once()
595+ loadingContainer.EXPECT().Append(loadingContent).Once()
596+ loadingContainer.EXPECT().SetVisible(true).Once()
597+ spinner.EXPECT().Start().Once()
598+ paneOverlay.EXPECT().AddOverlay(loadingContainer).Once()
599+ paneOverlay.EXPECT().SetClipOverlay(loadingContainer, false).Once()
600+ paneOverlay.EXPECT().SetMeasureOverlay(loadingContainer, false).Once()
601+ factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(border).Once()
602+ border.EXPECT().SetCanFocus(false).Once()
603+ border.EXPECT().SetCanTarget(false).Once()
604+ border.EXPECT().AddCssClass("pane-border").Once()
605+ border.EXPECT().SetHexpand(true).Once()
606+ border.EXPECT().SetVexpand(true).Once()
607+ paneOverlay.EXPECT().AddOverlay(border).Once()
608+ paneOverlay.EXPECT().SetClipOverlay(border, false).Once()
609+ paneOverlay.EXPECT().SetMeasureOverlay(border, false).Once()
610+
611+ stackBox := layoutmocks.NewMockBoxWidget(t)
612+ titleBar := layoutmocks.NewMockBoxWidget(t)
613+ favicon := layoutmocks.NewMockImageWidget(t)
614+ title := layoutmocks.NewMockLabelWidget(t)
615+ closeButton := layoutmocks.NewMockButtonWidget(t)
616+ factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(stackBox).Once()
617+ stackBox.EXPECT().SetHexpand(true).Once()
618+ stackBox.EXPECT().SetVexpand(true).Once()
619+ factory.EXPECT().NewBox(layout.OrientationHorizontal, 4).Return(titleBar).Once()
620+ titleBar.EXPECT().AddCssClass("stacked-pane-titlebar").Once()
621+ titleBar.EXPECT().AddCssClass("stacked-pane-title-clickable").Once()
622+ titleBar.EXPECT().SetVexpand(false).Once()
623+ titleBar.EXPECT().SetHexpand(true).Once()
624+ factory.EXPECT().NewImage().Return(favicon).Once()
625+ favicon.EXPECT().SetFromIconName(mock.Anything).Once()
626+ favicon.EXPECT().SetPixelSize(16).Once()
627+ titleBar.EXPECT().Append(favicon).Once()
628+ factory.EXPECT().NewLabel(mock.Anything).Return(title).Once()
629+ title.EXPECT().SetEllipsize(layout.EllipsizeEnd).Once()
630+ title.EXPECT().SetMaxWidthChars(30).Once()
631+ title.EXPECT().SetHexpand(true).Once()
632+ title.EXPECT().SetXalign(float32(0)).Once()
633+ titleBar.EXPECT().Append(title).Once()
634+ factory.EXPECT().NewButton().Return(closeButton).Once()
635+ closeButton.EXPECT().SetIconName("window-close-symbolic").Once()
636+ closeButton.EXPECT().AddCssClass("stacked-pane-close-button").Once()
637+ closeButton.EXPECT().SetFocusOnClick(false).Once()
638+ closeButton.EXPECT().SetVexpand(false).Once()
639+ closeButton.EXPECT().SetHexpand(false).Once()
640+ titleBar.EXPECT().Append(closeButton).Once()
641+ titleBar.EXPECT().AddController(mock.Anything).Once()
642+ closeButton.EXPECT().ConnectClicked(mock.Anything).Return(uint(2)).Once()
643+ closeButton.EXPECT().GtkWidget().Return(nil).Maybe()
644+ stackBox.EXPECT().Append(titleBar).Once()
645+ stackBox.EXPECT().Append(paneOverlay).Once()
646+ titleBar.EXPECT().SetVisible(false).Once()
647+ paneOverlay.EXPECT().SetVisible(true).Once()
648+ titleBar.EXPECT().AddCssClass("active").Once()
649+ paneOverlay.EXPECT().GtkWidget().Return(nil).Maybe()
650+ stackBox.EXPECT().SetVisible(true).Once()
651+ workspaceContainer.EXPECT().Append(stackBox).Once()
652+ workspaceContainer.EXPECT().AddCssClass("single-pane").Once()
653+ border.EXPECT().AddCssClass("pane-active").Once()
654+
655+ workspace := &entity.Workspace{
656+ ID: "workspace-1",
657+ Root: &entity.PaneNode{ID: "node-1", Pane: entity.NewPane(paneID)},
658+ ActivePaneID: paneID,
659+ }
660+ require.NoError(t, wsView.SetWorkspace(ctx, workspace))
661+ return wsView, workspace, paneOverlay, loadingContainer, spinner
662+}
@@ -458,3 +458,205 @@ func TestLifecycle_SyncWebViewViewport_NoopWithoutCapability(t *testing.T) {
458 458
459 c.SyncWebViewViewport(context.Background(), "pane-1", "unit-test")459 c.SyncWebViewViewport(context.Background(), "pane-1", "unit-test")
460 }460 }
461+
462+type nativeLifecycleWebView struct {
463+ port.WebView
464+ widget uintptr
465+}
466+
467+func (w *nativeLifecycleWebView) NativeWidget() uintptr { return w.widget }
468+
469+func TestLifecycle_AttachToWorkspace_RebuildPropagatesRevealStateToFreshPaneView(t *testing.T) {
470+ for _, tc := range []struct {
471+ name string
472+ revealed bool
473+ }{
474+ {name: "revealed current identity hides and stops skeleton", revealed: true},
475+ {name: "unrevealed current identity shows and starts skeleton", revealed: false},
476+ } {
477+ t.Run(tc.name, func(t *testing.T) {
478+ paneID := entity.PaneID("pane-1")
479+ ctx := context.Background()
480+ factory := layoutmocks.NewMockWidgetFactory(t)
481+ wsView, workspace, paneOverlay, loadingContainer, spinner := newLifecycleRebuildWorkspaceView(t, ctx, factory, paneID)
482+ wrappedWidget := layoutmocks.NewMockWidget(t)
483+ webViewMock := mocks.NewMockWebView(t)
484+ webViewMock.EXPECT().ID().Return(port.WebViewID(201)).Maybe()
485+ webViewMock.EXPECT().Generation().Return(uint64(3)).Maybe()
486+ webViewMock.EXPECT().IsDestroyed().Return(false).Once()
487+ webView := &nativeLifecycleWebView{WebView: webViewMock, widget: 0x351}
488+
489+ factory.EXPECT().WrapNativeWidget(uintptr(0x351)).Return(wrappedWidget).Once()
490+ wrappedWidget.EXPECT().GtkWidget().Return(nil).Once()
491+ wrappedWidget.EXPECT().GetAllocatedWidth().Return(0).Times(3)
492+ wrappedWidget.EXPECT().GetAllocatedHeight().Return(0).Times(3)
493+ wrappedWidget.EXPECT().GetParent().Return(nil).Once()
494+ wrappedWidget.EXPECT().IsVisible().Return(true).Once()
495+ paneOverlay.EXPECT().GetAllocatedWidth().Return(0).Twice()
496+ paneOverlay.EXPECT().GetAllocatedHeight().Return(0).Twice()
497+ paneOverlay.EXPECT().SetChild(wrappedWidget).Once()
498+ loadingContainer.EXPECT().SetVisible(!tc.revealed).Once()
499+ if tc.revealed {
500+ spinner.EXPECT().Stop().Once()
501+ } else {
502+ spinner.EXPECT().Start().Once()
503+ }
504+
505+ coordinator := newRevealTestCoordinator()
506+ coordinator.widgetFactory = factory
507+ coordinator.setWebViewLocked(paneID, webView)
508+ if tc.revealed {
509+ identity, ok := identityForWebView(webView)
510+ require.True(t, ok)
511+ coordinator.markWebViewRevealed(paneID, identity)
512+ }
513+
514+ coordinator.AttachToWorkspace(ctx, workspace, wsView)
515+
516+ assert.Same(t, wrappedWidget, wsView.GetPaneView(paneID).WebViewWidget())
517+ })
518+ }
519+}
520+
521+func newLifecycleRebuildWorkspaceView(
522+ t *testing.T,
523+ ctx context.Context,
524+ factory *layoutmocks.MockWidgetFactory,
525+ paneID entity.PaneID,
526+) (*component.WorkspaceView, *entity.Workspace, *layoutmocks.MockOverlayWidget, *layoutmocks.MockBoxWidget, *layoutmocks.MockSpinnerWidget) {
527+ t.Helper()
528+ workspaceContainer := layoutmocks.NewMockBoxWidget(t)
529+ workspaceOverlay := layoutmocks.NewMockOverlayWidget(t)
530+ factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(workspaceContainer).Once()
531+ workspaceContainer.EXPECT().SetHexpand(true).Once()
532+ workspaceContainer.EXPECT().SetVexpand(true).Once()
533+ workspaceContainer.EXPECT().SetVisible(true).Once()
534+ factory.EXPECT().NewOverlay().Return(workspaceOverlay).Once()
535+ workspaceOverlay.EXPECT().SetHexpand(true).Once()
536+ workspaceOverlay.EXPECT().SetVexpand(true).Once()
537+ workspaceOverlay.EXPECT().SetChild(workspaceContainer).Once()
538+ workspaceOverlay.EXPECT().SetVisible(true).Once()
539+ wsView := component.NewWorkspaceView(ctx, factory)
540+
541+ paneOverlay := layoutmocks.NewMockOverlayWidget(t)
542+ loadingContainer := layoutmocks.NewMockBoxWidget(t)
543+ loadingContent := layoutmocks.NewMockBoxWidget(t)
544+ spinner := layoutmocks.NewMockSpinnerWidget(t)
545+ logo := layoutmocks.NewMockImageWidget(t)
546+ version := layoutmocks.NewMockLabelWidget(t)
547+ border := layoutmocks.NewMockBoxWidget(t)
548+
549+ factory.EXPECT().NewOverlay().Return(paneOverlay).Once()
550+ paneOverlay.EXPECT().SetHexpand(true).Once()
551+ paneOverlay.EXPECT().SetVexpand(true).Once()
552+ paneOverlay.EXPECT().SetVisible(true).Once()
553+ paneOverlay.EXPECT().AddCssClass("pane-overlay").Once()
554+ factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(loadingContainer).Once()
555+ loadingContainer.EXPECT().SetHexpand(true).Once()
556+ loadingContainer.EXPECT().SetVexpand(true).Once()
557+ loadingContainer.EXPECT().SetHalign(mock.Anything).Once()
558+ loadingContainer.EXPECT().SetValign(mock.Anything).Once()
559+ loadingContainer.EXPECT().SetCanFocus(false).Times(2)
560+ loadingContainer.EXPECT().SetCanTarget(false).Times(2)
561+ loadingContainer.EXPECT().AddCssClass("loading-skeleton").Once()
562+ factory.EXPECT().NewBox(layout.OrientationVertical, 6).Return(loadingContent).Once()
563+ loadingContent.EXPECT().SetHalign(mock.Anything).Once()
564+ loadingContent.EXPECT().SetValign(mock.Anything).Once()
565+ loadingContent.EXPECT().SetCanFocus(false).Once()
566+ loadingContent.EXPECT().SetCanTarget(false).Once()
567+ loadingContent.EXPECT().AddCssClass("loading-skeleton-content").Once()
568+ factory.EXPECT().NewImage().Return(logo).Once()
569+ logo.EXPECT().SetHalign(mock.Anything).Once()
570+ logo.EXPECT().SetValign(mock.Anything).Once()
571+ logo.EXPECT().SetCanFocus(false).Once()
572+ logo.EXPECT().SetCanTarget(false).Once()
573+ logo.EXPECT().SetSizeRequest(256, 256).Once()
574+ logo.EXPECT().SetPixelSize(256).Once()
575+ logo.EXPECT().AddCssClass("loading-skeleton-logo").Once()
576+ logo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
577+ factory.EXPECT().NewSpinner().Return(spinner).Once()
578+ spinner.EXPECT().SetHalign(mock.Anything).Once()
579+ spinner.EXPECT().SetValign(mock.Anything).Once()
580+ spinner.EXPECT().SetCanFocus(false).Once()
581+ spinner.EXPECT().SetCanTarget(false).Once()
582+ spinner.EXPECT().SetSizeRequest(32, 32).Once()
583+ spinner.EXPECT().AddCssClass("loading-skeleton-spinner").Once()
584+ factory.EXPECT().NewLabel(mock.Anything).Return(version).Once()
585+ version.EXPECT().SetHalign(mock.Anything).Once()
586+ version.EXPECT().SetValign(mock.Anything).Once()
587+ version.EXPECT().SetCanFocus(false).Once()
588+ version.EXPECT().SetCanTarget(false).Once()
589+ version.EXPECT().SetMaxWidthChars(mock.Anything).Once()
590+ version.EXPECT().SetEllipsize(mock.Anything).Once()
591+ version.EXPECT().AddCssClass("loading-skeleton-version").Once()
592+ loadingContent.EXPECT().Append(logo).Once()
593+ loadingContent.EXPECT().Append(spinner).Once()
594+ loadingContent.EXPECT().Append(version).Once()
595+ loadingContainer.EXPECT().Append(loadingContent).Once()
596+ loadingContainer.EXPECT().SetVisible(true).Once()
597+ spinner.EXPECT().Start().Once()
598+ paneOverlay.EXPECT().AddOverlay(loadingContainer).Once()
599+ paneOverlay.EXPECT().SetClipOverlay(loadingContainer, false).Once()
600+ paneOverlay.EXPECT().SetMeasureOverlay(loadingContainer, false).Once()
601+ factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(border).Once()
602+ border.EXPECT().SetCanFocus(false).Once()
603+ border.EXPECT().SetCanTarget(false).Once()
604+ border.EXPECT().AddCssClass("pane-border").Once()
605+ border.EXPECT().SetHexpand(true).Once()
606+ border.EXPECT().SetVexpand(true).Once()
607+ paneOverlay.EXPECT().AddOverlay(border).Once()
608+ paneOverlay.EXPECT().SetClipOverlay(border, false).Once()
609+ paneOverlay.EXPECT().SetMeasureOverlay(border, false).Once()
610+
611+ stackBox := layoutmocks.NewMockBoxWidget(t)
612+ titleBar := layoutmocks.NewMockBoxWidget(t)
613+ favicon := layoutmocks.NewMockImageWidget(t)
614+ title := layoutmocks.NewMockLabelWidget(t)
615+ closeButton := layoutmocks.NewMockButtonWidget(t)
616+ factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(stackBox).Once()
617+ stackBox.EXPECT().SetHexpand(true).Once()
618+ stackBox.EXPECT().SetVexpand(true).Once()
619+ factory.EXPECT().NewBox(layout.OrientationHorizontal, 4).Return(titleBar).Once()
620+ titleBar.EXPECT().AddCssClass("stacked-pane-titlebar").Once()
621+ titleBar.EXPECT().AddCssClass("stacked-pane-title-clickable").Once()
622+ titleBar.EXPECT().SetVexpand(false).Once()
623+ titleBar.EXPECT().SetHexpand(true).Once()
624+ factory.EXPECT().NewImage().Return(favicon).Once()
625+ favicon.EXPECT().SetFromIconName(mock.Anything).Once()
626+ favicon.EXPECT().SetPixelSize(16).Once()
627+ titleBar.EXPECT().Append(favicon).Once()
628+ factory.EXPECT().NewLabel(mock.Anything).Return(title).Once()
629+ title.EXPECT().SetEllipsize(layout.EllipsizeEnd).Once()
630+ title.EXPECT().SetMaxWidthChars(30).Once()
631+ title.EXPECT().SetHexpand(true).Once()
632+ title.EXPECT().SetXalign(float32(0)).Once()
633+ titleBar.EXPECT().Append(title).Once()
634+ factory.EXPECT().NewButton().Return(closeButton).Once()
635+ closeButton.EXPECT().SetIconName("window-close-symbolic").Once()
636+ closeButton.EXPECT().AddCssClass("stacked-pane-close-button").Once()
637+ closeButton.EXPECT().SetFocusOnClick(false).Once()
638+ closeButton.EXPECT().SetVexpand(false).Once()
639+ closeButton.EXPECT().SetHexpand(false).Once()
640+ titleBar.EXPECT().Append(closeButton).Once()
641+ titleBar.EXPECT().AddController(mock.Anything).Once()
642+ closeButton.EXPECT().ConnectClicked(mock.Anything).Return(uint(2)).Once()
643+ closeButton.EXPECT().GtkWidget().Return(nil).Maybe()
644+ stackBox.EXPECT().Append(titleBar).Once()
645+ stackBox.EXPECT().Append(paneOverlay).Once()
646+ titleBar.EXPECT().SetVisible(false).Once()
647+ paneOverlay.EXPECT().SetVisible(true).Once()
648+ titleBar.EXPECT().AddCssClass("active").Once()
649+ paneOverlay.EXPECT().GtkWidget().Return(nil).Maybe()
650+ stackBox.EXPECT().SetVisible(true).Once()
651+ workspaceContainer.EXPECT().Append(stackBox).Once()
652+ workspaceContainer.EXPECT().AddCssClass("single-pane").Once()
653+ border.EXPECT().AddCssClass("pane-active").Once()
654+
655+ workspace := &entity.Workspace{
656+ ID: "workspace-1",
657+ Root: &entity.PaneNode{ID: "node-1", Pane: entity.NewPane(paneID)},
658+ ActivePaneID: paneID,
659+ }
660+ require.NoError(t, wsView.SetWorkspace(ctx, workspace))
661+ return wsView, workspace, paneOverlay, loadingContainer, spinner
662+}

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