detached e97fa5f

Guard nil workspace pane views

commit
e97fa5f7852af8ce02751f6a3b68ac68c8ed7134
author
brice · · 2026-05-11 18:35 UTC
parent
9e90b30
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +38 -6
M internal/ui/component/workspace_view.go +6 -6
@@ -252,7 +252,7 @@ func (wv *WorkspaceView) restoreActivePaneInternal() {
252252
253253 paneID := wv.workspace.ActivePaneID
254254 if paneID != "" {
255- if _, ok := wv.paneViews[paneID]; ok {
255+ if pv, ok := wv.paneViews[paneID]; ok && pv != nil {
256256 _ = wv.setActivePaneIDInternal(paneID)
257257 return
258258 }
@@ -278,8 +278,8 @@ func (wv *WorkspaceView) firstPaneIDInNodeInternal(node *entity.PaneNode) (entit
278278 }
279279 if node.Pane != nil {
280280 paneID := node.Pane.ID
281- _, ok := wv.paneViews[paneID]
282- return paneID, ok
281+ pv, ok := wv.paneViews[paneID]
282+ return paneID, ok && pv != nil
283283 }
284284 for _, child := range node.Children {
285285 if paneID, ok := wv.firstPaneIDInNodeInternal(child); ok {
@@ -302,7 +302,7 @@ func (wv *WorkspaceView) setActivePaneIDInternal(paneID entity.PaneID) error {
302302 // Validate the target pane before mutating UI state. Invalid/stale IDs
303303 // should leave the current active pane styling and overlays untouched.
304304 newPV, ok := wv.paneViews[paneID]
305- if !ok {
305+ if !ok || newPV == nil {
306306 return ErrPaneNotFound
307307 }
308308
@@ -318,7 +318,7 @@ func (wv *WorkspaceView) setActivePaneIDInternal(paneID entity.PaneID) error {
318318
319319 // Deactivate current active pane
320320 if currentActiveID != "" {
321- if oldPV, ok := wv.paneViews[currentActiveID]; ok {
321+ if oldPV, ok := wv.paneViews[currentActiveID]; ok && oldPV != nil {
322322 oldPV.SetActive(false)
323323 }
324324 }
@@ -481,7 +481,7 @@ func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Wi
481481 callback := wv.onWebViewAttached
482482 wv.mu.RUnlock()
483483
484- if !ok {
484+ if !ok || pv == nil {
485485 return ErrPaneNotFound
486486 }
487487
@@ -252,7 +252,7 @@ func (wv *WorkspaceView) restoreActivePaneInternal() {
252 252
253 paneID := wv.workspace.ActivePaneID253 paneID := wv.workspace.ActivePaneID
254 if paneID != "" {254 if paneID != "" {
255- if _, ok := wv.paneViews[paneID]; ok {255+ if pv, ok := wv.paneViews[paneID]; ok && pv != nil {
256 _ = wv.setActivePaneIDInternal(paneID)256 _ = wv.setActivePaneIDInternal(paneID)
257 return257 return
258 }258 }
@@ -278,8 +278,8 @@ func (wv *WorkspaceView) firstPaneIDInNodeInternal(node *entity.PaneNode) (entit
278 }278 }
279 if node.Pane != nil {279 if node.Pane != nil {
280 paneID := node.Pane.ID280 paneID := node.Pane.ID
281- _, ok := wv.paneViews[paneID]281+ pv, ok := wv.paneViews[paneID]
282- return paneID, ok282+ return paneID, ok && pv != nil
283 }283 }
284 for _, child := range node.Children {284 for _, child := range node.Children {
285 if paneID, ok := wv.firstPaneIDInNodeInternal(child); ok {285 if paneID, ok := wv.firstPaneIDInNodeInternal(child); ok {
@@ -302,7 +302,7 @@ func (wv *WorkspaceView) setActivePaneIDInternal(paneID entity.PaneID) error {
302 // Validate the target pane before mutating UI state. Invalid/stale IDs302 // Validate the target pane before mutating UI state. Invalid/stale IDs
303 // should leave the current active pane styling and overlays untouched.303 // should leave the current active pane styling and overlays untouched.
304 newPV, ok := wv.paneViews[paneID]304 newPV, ok := wv.paneViews[paneID]
305- if !ok {305+ if !ok || newPV == nil {
306 return ErrPaneNotFound306 return ErrPaneNotFound
307 }307 }
308 308
@@ -318,7 +318,7 @@ func (wv *WorkspaceView) setActivePaneIDInternal(paneID entity.PaneID) error {
318 318
319 // Deactivate current active pane319 // Deactivate current active pane
320 if currentActiveID != "" {320 if currentActiveID != "" {
321- if oldPV, ok := wv.paneViews[currentActiveID]; ok {321+ if oldPV, ok := wv.paneViews[currentActiveID]; ok && oldPV != nil {
322 oldPV.SetActive(false)322 oldPV.SetActive(false)
323 }323 }
324 }324 }
@@ -481,7 +481,7 @@ func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Wi
481 callback := wv.onWebViewAttached481 callback := wv.onWebViewAttached
482 wv.mu.RUnlock()482 wv.mu.RUnlock()
483 483
484- if !ok {484+ if !ok || pv == nil {
485 return ErrPaneNotFound485 return ErrPaneNotFound
486 }486 }
487 487
M internal/ui/component/workspace_view_test.go +32 -0
@@ -530,6 +530,38 @@ func TestSetActivePaneID_InvalidID_ReturnsError(t *testing.T) {
530530 assert.ErrorIs(t, err, component.ErrPaneNotFound)
531531 }
532532
533+func TestSetActivePaneID_NilRegisteredPaneView_ReturnsError(t *testing.T) {
534+ // Arrange
535+ mockFactory := mocks.NewMockWidgetFactory(t)
536+ ctx, mockBox := setupWorkspaceViewBase(t, mockFactory)
537+
538+ wv := component.NewWorkspaceView(ctx, mockFactory)
539+ mockBox.EXPECT().AddCssClass("single-pane").Once()
540+ wv.RegisterPaneView(entity.PaneID("pane-1"), nil)
541+
542+ // Act
543+ err := wv.SetActivePaneID(entity.PaneID("pane-1"))
544+
545+ // Assert
546+ require.ErrorIs(t, err, component.ErrPaneNotFound)
547+}
548+
549+func TestSetWebViewWidget_NilRegisteredPaneView_ReturnsError(t *testing.T) {
550+ // Arrange
551+ mockFactory := mocks.NewMockWidgetFactory(t)
552+ ctx, mockBox := setupWorkspaceViewBase(t, mockFactory)
553+
554+ wv := component.NewWorkspaceView(ctx, mockFactory)
555+ mockBox.EXPECT().AddCssClass("single-pane").Once()
556+ wv.RegisterPaneView(entity.PaneID("pane-1"), nil)
557+
558+ // Act
559+ err := wv.SetWebViewWidget(entity.PaneID("pane-1"), mocks.NewMockWidget(t))
560+
561+ // Assert
562+ require.ErrorIs(t, err, component.ErrPaneNotFound)
563+}
564+
533565 func TestSetActivePaneID_InvalidID_PreservesCurrentActivePane(t *testing.T) {
534566 // Arrange
535567 mockFactory := mocks.NewMockWidgetFactory(t)
@@ -530,6 +530,38 @@ func TestSetActivePaneID_InvalidID_ReturnsError(t *testing.T) {
530 assert.ErrorIs(t, err, component.ErrPaneNotFound)530 assert.ErrorIs(t, err, component.ErrPaneNotFound)
531 }531 }
532 532
533+func TestSetActivePaneID_NilRegisteredPaneView_ReturnsError(t *testing.T) {
534+ // Arrange
535+ mockFactory := mocks.NewMockWidgetFactory(t)
536+ ctx, mockBox := setupWorkspaceViewBase(t, mockFactory)
537+
538+ wv := component.NewWorkspaceView(ctx, mockFactory)
539+ mockBox.EXPECT().AddCssClass("single-pane").Once()
540+ wv.RegisterPaneView(entity.PaneID("pane-1"), nil)
541+
542+ // Act
543+ err := wv.SetActivePaneID(entity.PaneID("pane-1"))
544+
545+ // Assert
546+ require.ErrorIs(t, err, component.ErrPaneNotFound)
547+}
548+
549+func TestSetWebViewWidget_NilRegisteredPaneView_ReturnsError(t *testing.T) {
550+ // Arrange
551+ mockFactory := mocks.NewMockWidgetFactory(t)
552+ ctx, mockBox := setupWorkspaceViewBase(t, mockFactory)
553+
554+ wv := component.NewWorkspaceView(ctx, mockFactory)
555+ mockBox.EXPECT().AddCssClass("single-pane").Once()
556+ wv.RegisterPaneView(entity.PaneID("pane-1"), nil)
557+
558+ // Act
559+ err := wv.SetWebViewWidget(entity.PaneID("pane-1"), mocks.NewMockWidget(t))
560+
561+ // Assert
562+ require.ErrorIs(t, err, component.ErrPaneNotFound)
563+}
564+
533 func TestSetActivePaneID_InvalidID_PreservesCurrentActivePane(t *testing.T) {565 func TestSetActivePaneID_InvalidID_PreservesCurrentActivePane(t *testing.T) {
534 // Arrange566 // Arrange
535 mockFactory := mocks.NewMockWidgetFactory(t)567 mockFactory := mocks.NewMockWidgetFactory(t)

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