detached 3183bf0

Isolate tab snapshots

commit
3183bf0fd76a31b6a5b743b476bf3adaed50b5ad
author
brice · · 2026-05-05 17:53 UTC
parent
0747243
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +163 -15
M internal/domain/entity/session_state_test.go +53 -0
@@ -262,6 +262,59 @@ func TestTabList_ReplaceFrom(t *testing.T) {
262262 assert.Equal(t, "https://new1.com", ref.Tabs[0].Workspace.Root.Pane.URI)
263263 }
264264
265+func TestTabList_SnapshotClonesTabsAndWorkspace(t *testing.T) {
266+ parentPaneID := entity.PaneID("parent-pane")
267+ pane := entity.NewPane("pane-1")
268+ pane.URI = "https://original.example"
269+ pane.ParentPaneID = &parentPaneID
270+ tab := entity.NewTab("tab-1", "workspace-1", pane)
271+ tab.Name = "Original"
272+ tabs := entity.NewTabList()
273+ tabs.Add(tab)
274+
275+ snapshot := tabs.Snapshot()
276+
277+ tab.Position = 99
278+ tab.Name = "Mutated"
279+ tab.Workspace.Root.Pane.URI = "https://mutated.example"
280+ *tab.Workspace.Root.Pane.ParentPaneID = entity.PaneID("mutated-parent")
281+
282+ require.Len(t, snapshot.Tabs, 1)
283+ clonedTab := snapshot.Tabs[0]
284+ assert.NotSame(t, tab, clonedTab)
285+ assert.Equal(t, 0, clonedTab.Position)
286+ assert.Equal(t, "Original", clonedTab.Name)
287+ require.NotNil(t, clonedTab.Workspace)
288+ assert.NotSame(t, tab.Workspace, clonedTab.Workspace)
289+ require.NotNil(t, clonedTab.Workspace.Root)
290+ assert.NotSame(t, tab.Workspace.Root, clonedTab.Workspace.Root)
291+ require.NotNil(t, clonedTab.Workspace.Root.Pane)
292+ assert.NotSame(t, tab.Workspace.Root.Pane, clonedTab.Workspace.Root.Pane)
293+ assert.Equal(t, "https://original.example", clonedTab.Workspace.Root.Pane.URI)
294+ require.NotNil(t, clonedTab.Workspace.Root.Pane.ParentPaneID)
295+ assert.Equal(t, entity.PaneID("parent-pane"), *clonedTab.Workspace.Root.Pane.ParentPaneID)
296+}
297+
298+func TestTabList_ReplaceFromClonesSnapshotSource(t *testing.T) {
299+ pane := entity.NewPane("pane-1")
300+ pane.URI = "https://original.example"
301+ tab := entity.NewTab("tab-1", "workspace-1", pane)
302+ other := entity.NewTabList()
303+ other.Add(tab)
304+
305+ original := entity.NewTabList()
306+ original.ReplaceFrom(other)
307+
308+ tab.Name = "Mutated"
309+ tab.Workspace.Root.Pane.URI = "https://mutated.example"
310+
311+ restored := original.Find("tab-1")
312+ require.NotNil(t, restored)
313+ assert.NotSame(t, tab, restored)
314+ assert.Empty(t, restored.Name)
315+ assert.Equal(t, "https://original.example", restored.Workspace.Root.Pane.URI)
316+}
317+
265318 func TestTabList_ReplaceFrom_Nil(t *testing.T) {
266319 original := entity.NewTabList()
267320 pane := entity.NewPane("pane1")
@@ -262,6 +262,59 @@ func TestTabList_ReplaceFrom(t *testing.T) {
262 assert.Equal(t, "https://new1.com", ref.Tabs[0].Workspace.Root.Pane.URI)262 assert.Equal(t, "https://new1.com", ref.Tabs[0].Workspace.Root.Pane.URI)
263 }263 }
264 264
265+func TestTabList_SnapshotClonesTabsAndWorkspace(t *testing.T) {
266+ parentPaneID := entity.PaneID("parent-pane")
267+ pane := entity.NewPane("pane-1")
268+ pane.URI = "https://original.example"
269+ pane.ParentPaneID = &parentPaneID
270+ tab := entity.NewTab("tab-1", "workspace-1", pane)
271+ tab.Name = "Original"
272+ tabs := entity.NewTabList()
273+ tabs.Add(tab)
274+
275+ snapshot := tabs.Snapshot()
276+
277+ tab.Position = 99
278+ tab.Name = "Mutated"
279+ tab.Workspace.Root.Pane.URI = "https://mutated.example"
280+ *tab.Workspace.Root.Pane.ParentPaneID = entity.PaneID("mutated-parent")
281+
282+ require.Len(t, snapshot.Tabs, 1)
283+ clonedTab := snapshot.Tabs[0]
284+ assert.NotSame(t, tab, clonedTab)
285+ assert.Equal(t, 0, clonedTab.Position)
286+ assert.Equal(t, "Original", clonedTab.Name)
287+ require.NotNil(t, clonedTab.Workspace)
288+ assert.NotSame(t, tab.Workspace, clonedTab.Workspace)
289+ require.NotNil(t, clonedTab.Workspace.Root)
290+ assert.NotSame(t, tab.Workspace.Root, clonedTab.Workspace.Root)
291+ require.NotNil(t, clonedTab.Workspace.Root.Pane)
292+ assert.NotSame(t, tab.Workspace.Root.Pane, clonedTab.Workspace.Root.Pane)
293+ assert.Equal(t, "https://original.example", clonedTab.Workspace.Root.Pane.URI)
294+ require.NotNil(t, clonedTab.Workspace.Root.Pane.ParentPaneID)
295+ assert.Equal(t, entity.PaneID("parent-pane"), *clonedTab.Workspace.Root.Pane.ParentPaneID)
296+}
297+
298+func TestTabList_ReplaceFromClonesSnapshotSource(t *testing.T) {
299+ pane := entity.NewPane("pane-1")
300+ pane.URI = "https://original.example"
301+ tab := entity.NewTab("tab-1", "workspace-1", pane)
302+ other := entity.NewTabList()
303+ other.Add(tab)
304+
305+ original := entity.NewTabList()
306+ original.ReplaceFrom(other)
307+
308+ tab.Name = "Mutated"
309+ tab.Workspace.Root.Pane.URI = "https://mutated.example"
310+
311+ restored := original.Find("tab-1")
312+ require.NotNil(t, restored)
313+ assert.NotSame(t, tab, restored)
314+ assert.Empty(t, restored.Name)
315+ assert.Equal(t, "https://original.example", restored.Workspace.Root.Pane.URI)
316+}
317+
265 func TestTabList_ReplaceFrom_Nil(t *testing.T) {318 func TestTabList_ReplaceFrom_Nil(t *testing.T) {
266 original := entity.NewTabList()319 original := entity.NewTabList()
267 pane := entity.NewPane("pane1")320 pane := entity.NewPane("pane1")
M internal/domain/entity/tab.go +62 -7
@@ -111,6 +111,10 @@ func (tl *TabList) Find(id TabID) *Tab {
111111 tl.mu.RLock()
112112 defer tl.mu.RUnlock()
113113
114+ return tl.findNoLock(id)
115+}
116+
117+func (tl *TabList) findNoLock(id TabID) *Tab {
114118 for _, tab := range tl.Tabs {
115119 if tab.ID == id {
116120 return tab
@@ -121,7 +125,10 @@ func (tl *TabList) Find(id TabID) *Tab {
121125
122126 // ActiveTab returns the currently active tab.
123127 func (tl *TabList) ActiveTab() *Tab {
124- return tl.Find(tl.ActiveTabID)
128+ tl.mu.RLock()
129+ defer tl.mu.RUnlock()
130+
131+ return tl.findNoLock(tl.ActiveTabID)
125132 }
126133
127134 // Count returns the number of tabs.
@@ -185,10 +192,8 @@ func (tl *TabList) Move(id TabID, newPos int) bool {
185192 return true
186193 }
187194
188-// Snapshot returns a shallow copy of the TabList safe for concurrent read.
189-// The returned TabList shares the underlying Tab pointers but has its own
190-// slice header and scalar fields, protecting against races from concurrent
191-// structural mutations (add/remove) and ActiveTabID changes on the original.
195+// Snapshot returns an isolated copy of the TabList safe for concurrent read.
196+// The returned TabList has its own slice, Tab values, and workspace tree.
192197 func (tl *TabList) Snapshot() *TabList {
193198 if tl == nil {
194199 return nil
@@ -196,8 +201,10 @@ func (tl *TabList) Snapshot() *TabList {
196201 tl.mu.RLock()
197202 defer tl.mu.RUnlock()
198203
199- tabs := make([]*Tab, len(tl.Tabs))
200- copy(tabs, tl.Tabs)
204+ tabs := make([]*Tab, 0, len(tl.Tabs))
205+ for _, tab := range tl.Tabs {
206+ tabs = append(tabs, cloneTab(tab))
207+ }
201208 return &TabList{
202209 Tabs: tabs,
203210 ActiveTabID: tl.ActiveTabID,
@@ -205,6 +212,54 @@ func (tl *TabList) Snapshot() *TabList {
205212 }
206213 }
207214
215+func cloneTab(tab *Tab) *Tab {
216+ if tab == nil {
217+ return nil
218+ }
219+ cloned := *tab
220+ cloned.Workspace = cloneWorkspace(tab.Workspace)
221+ return &cloned
222+}
223+
224+func cloneWorkspace(workspace *Workspace) *Workspace {
225+ if workspace == nil {
226+ return nil
227+ }
228+ cloned := *workspace
229+ cloned.Root = clonePaneNode(workspace.Root, nil)
230+ return &cloned
231+}
232+
233+func clonePaneNode(node, parent *PaneNode) *PaneNode {
234+ if node == nil {
235+ return nil
236+ }
237+ cloned := *node
238+ cloned.Parent = parent
239+ cloned.Pane = clonePane(node.Pane)
240+ if len(node.Children) > 0 {
241+ cloned.Children = make([]*PaneNode, 0, len(node.Children))
242+ for _, child := range node.Children {
243+ cloned.Children = append(cloned.Children, clonePaneNode(child, &cloned))
244+ }
245+ } else {
246+ cloned.Children = nil
247+ }
248+ return &cloned
249+}
250+
251+func clonePane(pane *Pane) *Pane {
252+ if pane == nil {
253+ return nil
254+ }
255+ cloned := *pane
256+ if pane.ParentPaneID != nil {
257+ parentPaneID := *pane.ParentPaneID
258+ cloned.ParentPaneID = &parentPaneID
259+ }
260+ return &cloned
261+}
262+
208263 // ReplaceFrom replaces this TabList's contents with those from another TabList.
209264 // This modifies in-place so existing references to this TabList remain valid.
210265 func (tl *TabList) ReplaceFrom(other *TabList) {
@@ -111,6 +111,10 @@ func (tl *TabList) Find(id TabID) *Tab {
111 tl.mu.RLock()111 tl.mu.RLock()
112 defer tl.mu.RUnlock()112 defer tl.mu.RUnlock()
113 113
114+ return tl.findNoLock(id)
115+}
116+
117+func (tl *TabList) findNoLock(id TabID) *Tab {
114 for _, tab := range tl.Tabs {118 for _, tab := range tl.Tabs {
115 if tab.ID == id {119 if tab.ID == id {
116 return tab120 return tab
@@ -121,7 +125,10 @@ func (tl *TabList) Find(id TabID) *Tab {
121 125
122 // ActiveTab returns the currently active tab.126 // ActiveTab returns the currently active tab.
123 func (tl *TabList) ActiveTab() *Tab {127 func (tl *TabList) ActiveTab() *Tab {
124- return tl.Find(tl.ActiveTabID)128+ tl.mu.RLock()
129+ defer tl.mu.RUnlock()
130+
131+ return tl.findNoLock(tl.ActiveTabID)
125 }132 }
126 133
127 // Count returns the number of tabs.134 // Count returns the number of tabs.
@@ -185,10 +192,8 @@ func (tl *TabList) Move(id TabID, newPos int) bool {
185 return true192 return true
186 }193 }
187 194
188-// Snapshot returns a shallow copy of the TabList safe for concurrent read.195+// Snapshot returns an isolated copy of the TabList safe for concurrent read.
189-// The returned TabList shares the underlying Tab pointers but has its own196+// The returned TabList has its own slice, Tab values, and workspace tree.
190-// slice header and scalar fields, protecting against races from concurrent
191-// structural mutations (add/remove) and ActiveTabID changes on the original.
192 func (tl *TabList) Snapshot() *TabList {197 func (tl *TabList) Snapshot() *TabList {
193 if tl == nil {198 if tl == nil {
194 return nil199 return nil
@@ -196,8 +201,10 @@ func (tl *TabList) Snapshot() *TabList {
196 tl.mu.RLock()201 tl.mu.RLock()
197 defer tl.mu.RUnlock()202 defer tl.mu.RUnlock()
198 203
199- tabs := make([]*Tab, len(tl.Tabs))204+ tabs := make([]*Tab, 0, len(tl.Tabs))
200- copy(tabs, tl.Tabs)205+ for _, tab := range tl.Tabs {
206+ tabs = append(tabs, cloneTab(tab))
207+ }
201 return &TabList{208 return &TabList{
202 Tabs: tabs,209 Tabs: tabs,
203 ActiveTabID: tl.ActiveTabID,210 ActiveTabID: tl.ActiveTabID,
@@ -205,6 +212,54 @@ func (tl *TabList) Snapshot() *TabList {
205 }212 }
206 }213 }
207 214
215+func cloneTab(tab *Tab) *Tab {
216+ if tab == nil {
217+ return nil
218+ }
219+ cloned := *tab
220+ cloned.Workspace = cloneWorkspace(tab.Workspace)
221+ return &cloned
222+}
223+
224+func cloneWorkspace(workspace *Workspace) *Workspace {
225+ if workspace == nil {
226+ return nil
227+ }
228+ cloned := *workspace
229+ cloned.Root = clonePaneNode(workspace.Root, nil)
230+ return &cloned
231+}
232+
233+func clonePaneNode(node, parent *PaneNode) *PaneNode {
234+ if node == nil {
235+ return nil
236+ }
237+ cloned := *node
238+ cloned.Parent = parent
239+ cloned.Pane = clonePane(node.Pane)
240+ if len(node.Children) > 0 {
241+ cloned.Children = make([]*PaneNode, 0, len(node.Children))
242+ for _, child := range node.Children {
243+ cloned.Children = append(cloned.Children, clonePaneNode(child, &cloned))
244+ }
245+ } else {
246+ cloned.Children = nil
247+ }
248+ return &cloned
249+}
250+
251+func clonePane(pane *Pane) *Pane {
252+ if pane == nil {
253+ return nil
254+ }
255+ cloned := *pane
256+ if pane.ParentPaneID != nil {
257+ parentPaneID := *pane.ParentPaneID
258+ cloned.ParentPaneID = &parentPaneID
259+ }
260+ return &cloned
261+}
262+
208 // ReplaceFrom replaces this TabList's contents with those from another TabList.263 // ReplaceFrom replaces this TabList's contents with those from another TabList.
209 // This modifies in-place so existing references to this TabList remain valid.264 // This modifies in-place so existing references to this TabList remain valid.
210 func (tl *TabList) ReplaceFrom(other *TabList) {265 func (tl *TabList) ReplaceFrom(other *TabList) {
M internal/ui/coordinator/navigation_webview_targeted_test.go +34 -0
@@ -61,12 +61,18 @@ func TestNavigationCoordinator_WebViewTargetedActionsUseProvidedWebView(t *testi
6161 t.Run("OpenDevToolsWebView with nil webview returns error", func(t *testing.T) {
6262 testOpenDevToolsWebViewNilWebView(t, ctx)
6363 })
64+ t.Run("OpenDevToolsWebView unsupported capability returns error", func(t *testing.T) {
65+ testOpenDevToolsWebViewUnsupportedCapability(t, ctx)
66+ })
6467 t.Run("OpenDevToolsWebView calls OpenDevTools once", func(t *testing.T) {
6568 testOpenDevToolsWebViewTargetsProvidedWebView(t, ctx)
6669 })
6770 t.Run("PrintWebView with nil webview returns error", func(t *testing.T) {
6871 testPrintWebViewNilWebView(t, ctx)
6972 })
73+ t.Run("PrintWebView unsupported capability returns error", func(t *testing.T) {
74+ testPrintWebViewUnsupportedCapability(t, ctx)
75+ })
7076 t.Run("PrintWebView calls PrintPage once", func(t *testing.T) {
7177 testPrintWebViewTargetsProvidedWebView(t, ctx)
7278 })
@@ -198,6 +204,20 @@ func testOpenDevToolsWebViewNilWebView(t *testing.T, ctx context.Context) {
198204 }
199205 }
200206
207+func testOpenDevToolsWebViewUnsupportedCapability(t *testing.T, ctx context.Context) {
208+ t.Helper()
209+ wv := mocks.NewMockWebView(t)
210+ wv.EXPECT().ID().Return(port.WebViewID(1)).Once()
211+ c := &NavigationCoordinator{}
212+ err := c.OpenDevToolsWebView(ctx, wv)
213+ if err == nil {
214+ t.Fatal("expected unsupported devtools error, got nil")
215+ }
216+ if got, want := err.Error(), "webview does not support devtools"; got != want {
217+ t.Fatalf("error = %q, want %q", got, want)
218+ }
219+}
220+
201221 func testOpenDevToolsWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) {
202222 t.Helper()
203223 base := mocks.NewMockWebView(t)
@@ -221,6 +241,20 @@ func testPrintWebViewNilWebView(t *testing.T, ctx context.Context) {
221241 }
222242 }
223243
244+func testPrintWebViewUnsupportedCapability(t *testing.T, ctx context.Context) {
245+ t.Helper()
246+ wv := mocks.NewMockWebView(t)
247+ wv.EXPECT().ID().Return(port.WebViewID(1)).Once()
248+ c := &NavigationCoordinator{}
249+ err := c.PrintWebView(ctx, wv)
250+ if err == nil {
251+ t.Fatal("expected unsupported printing error, got nil")
252+ }
253+ if got, want := err.Error(), "webview does not support printing"; got != want {
254+ t.Fatalf("error = %q, want %q", got, want)
255+ }
256+}
257+
224258 func testPrintWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) {
225259 t.Helper()
226260 base := mocks.NewMockWebView(t)
@@ -61,12 +61,18 @@ func TestNavigationCoordinator_WebViewTargetedActionsUseProvidedWebView(t *testi
61 t.Run("OpenDevToolsWebView with nil webview returns error", func(t *testing.T) {61 t.Run("OpenDevToolsWebView with nil webview returns error", func(t *testing.T) {
62 testOpenDevToolsWebViewNilWebView(t, ctx)62 testOpenDevToolsWebViewNilWebView(t, ctx)
63 })63 })
64+ t.Run("OpenDevToolsWebView unsupported capability returns error", func(t *testing.T) {
65+ testOpenDevToolsWebViewUnsupportedCapability(t, ctx)
66+ })
64 t.Run("OpenDevToolsWebView calls OpenDevTools once", func(t *testing.T) {67 t.Run("OpenDevToolsWebView calls OpenDevTools once", func(t *testing.T) {
65 testOpenDevToolsWebViewTargetsProvidedWebView(t, ctx)68 testOpenDevToolsWebViewTargetsProvidedWebView(t, ctx)
66 })69 })
67 t.Run("PrintWebView with nil webview returns error", func(t *testing.T) {70 t.Run("PrintWebView with nil webview returns error", func(t *testing.T) {
68 testPrintWebViewNilWebView(t, ctx)71 testPrintWebViewNilWebView(t, ctx)
69 })72 })
73+ t.Run("PrintWebView unsupported capability returns error", func(t *testing.T) {
74+ testPrintWebViewUnsupportedCapability(t, ctx)
75+ })
70 t.Run("PrintWebView calls PrintPage once", func(t *testing.T) {76 t.Run("PrintWebView calls PrintPage once", func(t *testing.T) {
71 testPrintWebViewTargetsProvidedWebView(t, ctx)77 testPrintWebViewTargetsProvidedWebView(t, ctx)
72 })78 })
@@ -198,6 +204,20 @@ func testOpenDevToolsWebViewNilWebView(t *testing.T, ctx context.Context) {
198 }204 }
199 }205 }
200 206
207+func testOpenDevToolsWebViewUnsupportedCapability(t *testing.T, ctx context.Context) {
208+ t.Helper()
209+ wv := mocks.NewMockWebView(t)
210+ wv.EXPECT().ID().Return(port.WebViewID(1)).Once()
211+ c := &NavigationCoordinator{}
212+ err := c.OpenDevToolsWebView(ctx, wv)
213+ if err == nil {
214+ t.Fatal("expected unsupported devtools error, got nil")
215+ }
216+ if got, want := err.Error(), "webview does not support devtools"; got != want {
217+ t.Fatalf("error = %q, want %q", got, want)
218+ }
219+}
220+
201 func testOpenDevToolsWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) {221 func testOpenDevToolsWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) {
202 t.Helper()222 t.Helper()
203 base := mocks.NewMockWebView(t)223 base := mocks.NewMockWebView(t)
@@ -221,6 +241,20 @@ func testPrintWebViewNilWebView(t *testing.T, ctx context.Context) {
221 }241 }
222 }242 }
223 243
244+func testPrintWebViewUnsupportedCapability(t *testing.T, ctx context.Context) {
245+ t.Helper()
246+ wv := mocks.NewMockWebView(t)
247+ wv.EXPECT().ID().Return(port.WebViewID(1)).Once()
248+ c := &NavigationCoordinator{}
249+ err := c.PrintWebView(ctx, wv)
250+ if err == nil {
251+ t.Fatal("expected unsupported printing error, got nil")
252+ }
253+ if got, want := err.Error(), "webview does not support printing"; got != want {
254+ t.Fatalf("error = %q, want %q", got, want)
255+ }
256+}
257+
224 func testPrintWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) {258 func testPrintWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) {
225 t.Helper()259 t.Helper()
226 base := mocks.NewMockWebView(t)260 base := mocks.NewMockWebView(t)
M internal/ui/theme/css_test.go +14 -8
@@ -188,16 +188,12 @@ func TestGenerateCSSWithScale_UsesMediumWeightForOmniboxSuggestionTitle(t *testi
188188 func TestGenerateCSS_ContentAreaTabBarInset(t *testing.T) {
189189 css := GenerateCSS(DefaultDarkPalette())
190190
191- if !strings.Contains(css, ".content-area-tabbar-inset-top") {
192- t.Fatal("expected .content-area-tabbar-inset-top selector in generated CSS")
193- }
194- if !strings.Contains(css, "padding-top: 2em;") {
191+ topBlock := cssRuleBlock(t, css, ".content-area-tabbar-inset-top")
192+ if !strings.Contains(topBlock, "padding-top: 2em;") {
195193 t.Fatal("expected padding-top: 2em in .content-area-tabbar-inset-top block")
196194 }
197- if !strings.Contains(css, ".content-area-tabbar-inset-bottom") {
198- t.Fatal("expected .content-area-tabbar-inset-bottom selector in generated CSS")
199- }
200- if !strings.Contains(css, "padding-bottom: 2em;") {
195+ bottomBlock := cssRuleBlock(t, css, ".content-area-tabbar-inset-bottom")
196+ if !strings.Contains(bottomBlock, "padding-bottom: 2em;") {
201197 t.Fatal("expected padding-bottom: 2em in .content-area-tabbar-inset-bottom block")
202198 }
203199 if !strings.Contains(css, ".tab-bar") {
@@ -208,6 +204,16 @@ func TestGenerateCSS_ContentAreaTabBarInset(t *testing.T) {
208204 }
209205 }
210206
207+func cssRuleBlock(t *testing.T, css, selector string) string {
208+ t.Helper()
209+ re := regexp.MustCompile(regexp.QuoteMeta(selector) + `\s*\{([^}]*)\}`)
210+ matches := re.FindStringSubmatch(css)
211+ if len(matches) != 2 {
212+ t.Fatalf("expected %s selector in generated CSS", selector)
213+ }
214+ return matches[1]
215+}
216+
211217 func TestGenerateCSS_DoesNotEmitUnsupportedGTKProperties(t *testing.T) {
212218 css := GenerateCSS(DefaultDarkPalette())
213219
@@ -188,16 +188,12 @@ func TestGenerateCSSWithScale_UsesMediumWeightForOmniboxSuggestionTitle(t *testi
188 func TestGenerateCSS_ContentAreaTabBarInset(t *testing.T) {188 func TestGenerateCSS_ContentAreaTabBarInset(t *testing.T) {
189 css := GenerateCSS(DefaultDarkPalette())189 css := GenerateCSS(DefaultDarkPalette())
190 190
191- if !strings.Contains(css, ".content-area-tabbar-inset-top") {191+ topBlock := cssRuleBlock(t, css, ".content-area-tabbar-inset-top")
192- t.Fatal("expected .content-area-tabbar-inset-top selector in generated CSS")192+ if !strings.Contains(topBlock, "padding-top: 2em;") {
193- }
194- if !strings.Contains(css, "padding-top: 2em;") {
195 t.Fatal("expected padding-top: 2em in .content-area-tabbar-inset-top block")193 t.Fatal("expected padding-top: 2em in .content-area-tabbar-inset-top block")
196 }194 }
197- if !strings.Contains(css, ".content-area-tabbar-inset-bottom") {195+ bottomBlock := cssRuleBlock(t, css, ".content-area-tabbar-inset-bottom")
198- t.Fatal("expected .content-area-tabbar-inset-bottom selector in generated CSS")196+ if !strings.Contains(bottomBlock, "padding-bottom: 2em;") {
199- }
200- if !strings.Contains(css, "padding-bottom: 2em;") {
201 t.Fatal("expected padding-bottom: 2em in .content-area-tabbar-inset-bottom block")197 t.Fatal("expected padding-bottom: 2em in .content-area-tabbar-inset-bottom block")
202 }198 }
203 if !strings.Contains(css, ".tab-bar") {199 if !strings.Contains(css, ".tab-bar") {
@@ -208,6 +204,16 @@ func TestGenerateCSS_ContentAreaTabBarInset(t *testing.T) {
208 }204 }
209 }205 }
210 206
207+func cssRuleBlock(t *testing.T, css, selector string) string {
208+ t.Helper()
209+ re := regexp.MustCompile(regexp.QuoteMeta(selector) + `\s*\{([^}]*)\}`)
210+ matches := re.FindStringSubmatch(css)
211+ if len(matches) != 2 {
212+ t.Fatalf("expected %s selector in generated CSS", selector)
213+ }
214+ return matches[1]
215+}
216+
211 func TestGenerateCSS_DoesNotEmitUnsupportedGTKProperties(t *testing.T) {217 func TestGenerateCSS_DoesNotEmitUnsupportedGTKProperties(t *testing.T) {
212 css := GenerateCSS(DefaultDarkPalette())218 css := GenerateCSS(DefaultDarkPalette())
213 219

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