detached 52bc74a

feat(tabs): implement core tab management system

- Add TabManager for global tab coordination
- Add Tab struct containing independent workspaces
- Implement CreateTab, SwitchToTab, CloseTab, RenameTab operations
- Add NextTab/PreviousTab with wraparound navigation
- Add tab bar auto-hide when only 1 tab exists
- Implement click-to-switch on tab buttons
- Support simple text-only tab buttons (no favicons)
- Each tab contains complete WorkspaceManager with pane tree
commit
52bc74abf6d41a6307c0f90c9fa4ca441bc76df9
author
bnema · · 2025-11-17 19:40 UTC
parent
56c25f7
signature
unsigned
2 files · +700 -0
A internal/app/browser/tab_bar.go +206 -0
@@ -0,0 +1,206 @@
1+package browser
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/dumber/internal/logging"
7+ "github.com/bnema/dumber/pkg/webkit"
8+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
9+)
10+
11+// createTabBar creates the horizontal tab bar container.
12+// Returns the tab bar widget.
13+func (tm *TabManager) createTabBar() gtk.Widgetter {
14+ logging.Debug("[tabs] Creating tab bar container")
15+
16+ tabBar := gtk.NewBox(gtk.OrientationHorizontal, 0)
17+ if tabBar == nil {
18+ logging.Error("[tabs] Failed to create tab bar box")
19+ return nil
20+ }
21+
22+ tabBar.AddCSSClass("tab-bar")
23+ tabBar.SetHExpand(true)
24+ tabBar.SetVExpand(false)
25+
26+ tm.tabBar = tabBar
27+ logging.Debug("[tabs] Tab bar container created")
28+ return tabBar
29+}
30+
31+// createTabButton creates a simple GTK button widget for a tab with just a text label.
32+func (tm *TabManager) createTabButton(tab *Tab) gtk.Widgetter {
33+ logging.Debug(fmt.Sprintf("[tabs] Creating button for tab %s", tab.id))
34+
35+ // Create button
36+ button := gtk.NewButton()
37+ if button == nil {
38+ logging.Error("[tabs] Failed to create tab button")
39+ return nil
40+ }
41+
42+ // Apply CSS classes
43+ button.AddCSSClass("tab-button")
44+
45+ // Get display title (custom name or default name)
46+ displayTitle := tab.title
47+ if tab.customTitle != "" {
48+ displayTitle = tab.customTitle
49+ }
50+
51+ // Create title label
52+ titleLabel := gtk.NewLabel(displayTitle)
53+ if titleLabel != nil {
54+ titleLabel.AddCSSClass("tab-title")
55+ titleLabel.SetEllipsize(2) // Pango ellipsize mode: middle
56+ titleLabel.SetMaxWidthChars(20)
57+ webkit.WidgetSetVisible(titleLabel, true)
58+ }
59+
60+ // Set button child
61+ button.SetChild(titleLabel)
62+
63+ // Store button reference for later updates
64+ tab.titleButton = button
65+
66+ // Attach click handler
67+ tm.attachTabClickHandler(button, tab)
68+
69+ // Set active class if this is the active tab
70+ if tab.isActive {
71+ webkit.WidgetAddCSSClass(button, "tab-button-active")
72+ }
73+
74+ webkit.WidgetSetVisible(button, true)
75+ logging.Debug(fmt.Sprintf("[tabs] Button created for tab %s", tab.id))
76+ return button
77+}
78+
79+// attachTabClickHandler attaches a click handler to switch to a tab when clicked.
80+func (tm *TabManager) attachTabClickHandler(button gtk.Widgetter, tab *Tab) {
81+ // Find tab index
82+ tabIndex := -1
83+ for i, t := range tm.tabs {
84+ if t == tab {
85+ tabIndex = i
86+ break
87+ }
88+ }
89+
90+ if tabIndex < 0 {
91+ logging.Error(fmt.Sprintf("[tabs] Cannot attach click handler: tab %s not found", tab.id))
92+ return
93+ }
94+
95+ // Create click gesture controller
96+ clickController := gtk.NewGestureClick()
97+ clickController.SetButton(1) // Left mouse button
98+
99+ // Connect click signal
100+ clickController.ConnectPressed(func(_ int, _, _ float64) {
101+ logging.Debug(fmt.Sprintf("[tabs] Tab button clicked: %s (index %d)", tab.id, tabIndex))
102+
103+ // Switch to this tab
104+ webkit.RunOnMainThread(func() {
105+ if err := tm.SwitchToTab(tabIndex); err != nil {
106+ logging.Error(fmt.Sprintf("[tabs] Failed to switch to tab %d: %v", tabIndex, err))
107+ }
108+ })
109+ })
110+
111+ // Add gesture controller to button
112+ if gtkWidget, ok := button.(interface{ AddController(gtk.EventControllerer) }); ok {
113+ gtkWidget.AddController(clickController)
114+ logging.Debug(fmt.Sprintf("[tabs] Click handler attached to tab %s", tab.id))
115+ } else {
116+ logging.Error(fmt.Sprintf("[tabs] Failed to attach click handler to tab %s", tab.id))
117+ }
118+}
119+
120+// updateTabButton updates the tab button's title label.
121+func (tm *TabManager) updateTabButton(tab *Tab) {
122+ if tab.titleButton == nil {
123+ logging.Warn(fmt.Sprintf("[tabs] Cannot update button for tab %s: button is nil", tab.id))
124+ return
125+ }
126+
127+ webkit.RunOnMainThread(func() {
128+ button, ok := tab.titleButton.(*gtk.Button)
129+ if !ok {
130+ logging.Error(fmt.Sprintf("[tabs] Tab button is not a *gtk.Button: %s", tab.id))
131+ return
132+ }
133+
134+ // Get button child (the label)
135+ child := button.Child()
136+ if child == nil {
137+ logging.Warn(fmt.Sprintf("[tabs] Tab button has no child: %s", tab.id))
138+ return
139+ }
140+
141+ label, ok := child.(*gtk.Label)
142+ if !ok {
143+ logging.Error(fmt.Sprintf("[tabs] Tab button child is not a *gtk.Label: %s", tab.id))
144+ return
145+ }
146+
147+ // Update label text
148+ displayTitle := tab.title
149+ if tab.customTitle != "" {
150+ displayTitle = tab.customTitle
151+ }
152+ label.SetLabel(displayTitle)
153+ logging.Debug(fmt.Sprintf("[tabs] Updated tab button label: %s -> %s", tab.id, displayTitle))
154+ })
155+}
156+
157+// addTabToBar adds a tab button to the tab bar.
158+func (tm *TabManager) addTabToBar(tab *Tab) {
159+ if tm.tabBar == nil {
160+ logging.Error("[tabs] Cannot add tab to bar: tab bar is nil")
161+ return
162+ }
163+
164+ if tab.titleButton == nil {
165+ logging.Error(fmt.Sprintf("[tabs] Cannot add tab %s to bar: button is nil", tab.id))
166+ return
167+ }
168+
169+ webkit.RunOnMainThread(func() {
170+ if tabBarBox, ok := tm.tabBar.(*gtk.Box); ok {
171+ tabBarBox.Append(tab.titleButton)
172+ logging.Debug(fmt.Sprintf("[tabs] Added tab %s to tab bar", tab.id))
173+ } else {
174+ logging.Error("[tabs] Tab bar is not a *gtk.Box")
175+ }
176+ })
177+}
178+
179+// removeTabFromBar removes a tab button from the tab bar.
180+func (tm *TabManager) removeTabFromBar(tab *Tab) {
181+ if tm.tabBar == nil || tab.titleButton == nil {
182+ return
183+ }
184+
185+ webkit.RunOnMainThread(func() {
186+ if tabBarBox, ok := tm.tabBar.(*gtk.Box); ok {
187+ tabBarBox.Remove(tab.titleButton)
188+ logging.Debug(fmt.Sprintf("[tabs] Removed tab %s from tab bar", tab.id))
189+ }
190+ })
191+}
192+
193+// setTabActiveStyle applies or removes the active CSS class on a tab button.
194+func (tm *TabManager) setTabActiveStyle(tab *Tab, active bool) {
195+ if tab.titleButton == nil {
196+ return
197+ }
198+
199+ webkit.RunOnMainThread(func() {
200+ if active {
201+ webkit.WidgetAddCSSClass(tab.titleButton, "tab-button-active")
202+ } else {
203+ webkit.WidgetRemoveCSSClass(tab.titleButton, "tab-button-active")
204+ }
205+ })
206+}
@@ -0,0 +1,206 @@
1+package browser
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/dumber/internal/logging"
7+ "github.com/bnema/dumber/pkg/webkit"
8+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
9+)
10+
11+// createTabBar creates the horizontal tab bar container.
12+// Returns the tab bar widget.
13+func (tm *TabManager) createTabBar() gtk.Widgetter {
14+ logging.Debug("[tabs] Creating tab bar container")
15+
16+ tabBar := gtk.NewBox(gtk.OrientationHorizontal, 0)
17+ if tabBar == nil {
18+ logging.Error("[tabs] Failed to create tab bar box")
19+ return nil
20+ }
21+
22+ tabBar.AddCSSClass("tab-bar")
23+ tabBar.SetHExpand(true)
24+ tabBar.SetVExpand(false)
25+
26+ tm.tabBar = tabBar
27+ logging.Debug("[tabs] Tab bar container created")
28+ return tabBar
29+}
30+
31+// createTabButton creates a simple GTK button widget for a tab with just a text label.
32+func (tm *TabManager) createTabButton(tab *Tab) gtk.Widgetter {
33+ logging.Debug(fmt.Sprintf("[tabs] Creating button for tab %s", tab.id))
34+
35+ // Create button
36+ button := gtk.NewButton()
37+ if button == nil {
38+ logging.Error("[tabs] Failed to create tab button")
39+ return nil
40+ }
41+
42+ // Apply CSS classes
43+ button.AddCSSClass("tab-button")
44+
45+ // Get display title (custom name or default name)
46+ displayTitle := tab.title
47+ if tab.customTitle != "" {
48+ displayTitle = tab.customTitle
49+ }
50+
51+ // Create title label
52+ titleLabel := gtk.NewLabel(displayTitle)
53+ if titleLabel != nil {
54+ titleLabel.AddCSSClass("tab-title")
55+ titleLabel.SetEllipsize(2) // Pango ellipsize mode: middle
56+ titleLabel.SetMaxWidthChars(20)
57+ webkit.WidgetSetVisible(titleLabel, true)
58+ }
59+
60+ // Set button child
61+ button.SetChild(titleLabel)
62+
63+ // Store button reference for later updates
64+ tab.titleButton = button
65+
66+ // Attach click handler
67+ tm.attachTabClickHandler(button, tab)
68+
69+ // Set active class if this is the active tab
70+ if tab.isActive {
71+ webkit.WidgetAddCSSClass(button, "tab-button-active")
72+ }
73+
74+ webkit.WidgetSetVisible(button, true)
75+ logging.Debug(fmt.Sprintf("[tabs] Button created for tab %s", tab.id))
76+ return button
77+}
78+
79+// attachTabClickHandler attaches a click handler to switch to a tab when clicked.
80+func (tm *TabManager) attachTabClickHandler(button gtk.Widgetter, tab *Tab) {
81+ // Find tab index
82+ tabIndex := -1
83+ for i, t := range tm.tabs {
84+ if t == tab {
85+ tabIndex = i
86+ break
87+ }
88+ }
89+
90+ if tabIndex < 0 {
91+ logging.Error(fmt.Sprintf("[tabs] Cannot attach click handler: tab %s not found", tab.id))
92+ return
93+ }
94+
95+ // Create click gesture controller
96+ clickController := gtk.NewGestureClick()
97+ clickController.SetButton(1) // Left mouse button
98+
99+ // Connect click signal
100+ clickController.ConnectPressed(func(_ int, _, _ float64) {
101+ logging.Debug(fmt.Sprintf("[tabs] Tab button clicked: %s (index %d)", tab.id, tabIndex))
102+
103+ // Switch to this tab
104+ webkit.RunOnMainThread(func() {
105+ if err := tm.SwitchToTab(tabIndex); err != nil {
106+ logging.Error(fmt.Sprintf("[tabs] Failed to switch to tab %d: %v", tabIndex, err))
107+ }
108+ })
109+ })
110+
111+ // Add gesture controller to button
112+ if gtkWidget, ok := button.(interface{ AddController(gtk.EventControllerer) }); ok {
113+ gtkWidget.AddController(clickController)
114+ logging.Debug(fmt.Sprintf("[tabs] Click handler attached to tab %s", tab.id))
115+ } else {
116+ logging.Error(fmt.Sprintf("[tabs] Failed to attach click handler to tab %s", tab.id))
117+ }
118+}
119+
120+// updateTabButton updates the tab button's title label.
121+func (tm *TabManager) updateTabButton(tab *Tab) {
122+ if tab.titleButton == nil {
123+ logging.Warn(fmt.Sprintf("[tabs] Cannot update button for tab %s: button is nil", tab.id))
124+ return
125+ }
126+
127+ webkit.RunOnMainThread(func() {
128+ button, ok := tab.titleButton.(*gtk.Button)
129+ if !ok {
130+ logging.Error(fmt.Sprintf("[tabs] Tab button is not a *gtk.Button: %s", tab.id))
131+ return
132+ }
133+
134+ // Get button child (the label)
135+ child := button.Child()
136+ if child == nil {
137+ logging.Warn(fmt.Sprintf("[tabs] Tab button has no child: %s", tab.id))
138+ return
139+ }
140+
141+ label, ok := child.(*gtk.Label)
142+ if !ok {
143+ logging.Error(fmt.Sprintf("[tabs] Tab button child is not a *gtk.Label: %s", tab.id))
144+ return
145+ }
146+
147+ // Update label text
148+ displayTitle := tab.title
149+ if tab.customTitle != "" {
150+ displayTitle = tab.customTitle
151+ }
152+ label.SetLabel(displayTitle)
153+ logging.Debug(fmt.Sprintf("[tabs] Updated tab button label: %s -> %s", tab.id, displayTitle))
154+ })
155+}
156+
157+// addTabToBar adds a tab button to the tab bar.
158+func (tm *TabManager) addTabToBar(tab *Tab) {
159+ if tm.tabBar == nil {
160+ logging.Error("[tabs] Cannot add tab to bar: tab bar is nil")
161+ return
162+ }
163+
164+ if tab.titleButton == nil {
165+ logging.Error(fmt.Sprintf("[tabs] Cannot add tab %s to bar: button is nil", tab.id))
166+ return
167+ }
168+
169+ webkit.RunOnMainThread(func() {
170+ if tabBarBox, ok := tm.tabBar.(*gtk.Box); ok {
171+ tabBarBox.Append(tab.titleButton)
172+ logging.Debug(fmt.Sprintf("[tabs] Added tab %s to tab bar", tab.id))
173+ } else {
174+ logging.Error("[tabs] Tab bar is not a *gtk.Box")
175+ }
176+ })
177+}
178+
179+// removeTabFromBar removes a tab button from the tab bar.
180+func (tm *TabManager) removeTabFromBar(tab *Tab) {
181+ if tm.tabBar == nil || tab.titleButton == nil {
182+ return
183+ }
184+
185+ webkit.RunOnMainThread(func() {
186+ if tabBarBox, ok := tm.tabBar.(*gtk.Box); ok {
187+ tabBarBox.Remove(tab.titleButton)
188+ logging.Debug(fmt.Sprintf("[tabs] Removed tab %s from tab bar", tab.id))
189+ }
190+ })
191+}
192+
193+// setTabActiveStyle applies or removes the active CSS class on a tab button.
194+func (tm *TabManager) setTabActiveStyle(tab *Tab, active bool) {
195+ if tab.titleButton == nil {
196+ return
197+ }
198+
199+ webkit.RunOnMainThread(func() {
200+ if active {
201+ webkit.WidgetAddCSSClass(tab.titleButton, "tab-button-active")
202+ } else {
203+ webkit.WidgetRemoveCSSClass(tab.titleButton, "tab-button-active")
204+ }
205+ })
206+}
A internal/app/browser/tab_manager.go +494 -0
@@ -0,0 +1,494 @@
1+package browser
2+
3+import (
4+ "fmt"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/config"
9+ "github.com/bnema/dumber/internal/logging"
10+ "github.com/bnema/dumber/pkg/webkit"
11+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
12+)
13+
14+// TabManager manages the global tab system for the browser.
15+// Each tab contains an independent workspace with its own pane tree.
16+type TabManager struct {
17+ tabs []*Tab
18+ activeIndex int
19+ window *webkit.Window
20+ app *BrowserApp
21+
22+ // GTK widgets
23+ rootContainer gtk.Widgetter // Main vertical box containing tab bar + content
24+ tabBar gtk.Widgetter // Horizontal tab bar container
25+ contentArea gtk.Widgetter // Container for active workspace
26+
27+ // Modal state
28+ tabModeActive bool
29+ tabModeTimer *time.Timer
30+
31+ // Synchronization
32+ mu sync.RWMutex
33+}
34+
35+// Tab represents a single browser tab containing a complete workspace.
36+type Tab struct {
37+ id string
38+ title string
39+ customTitle string // User-provided custom name (persists across page loads)
40+ workspace *WorkspaceManager
41+ titleButton gtk.Widgetter
42+ container gtk.Widgetter
43+ isActive bool
44+}
45+
46+// NewTabManager creates a new tab manager instance.
47+func NewTabManager(app *BrowserApp, window *webkit.Window) *TabManager {
48+ tm := &TabManager{
49+ tabs: make([]*Tab, 0),
50+ activeIndex: -1,
51+ window: window,
52+ app: app,
53+ }
54+
55+ logging.Info("[tabs] Tab manager initialized")
56+ return tm
57+}
58+
59+// Initialize sets up the tab bar and creates the initial tab.
60+func (tm *TabManager) Initialize(initialURL string) error {
61+ tm.mu.Lock()
62+ defer tm.mu.Unlock()
63+
64+ logging.Info("[tabs] Initializing tab system")
65+
66+ // Create root container layout
67+ if err := tm.createRootContainer(); err != nil {
68+ return fmt.Errorf("failed to create root container: %w", err)
69+ }
70+
71+ // Create initial tab
72+ if err := tm.createTabInternal(initialURL); err != nil {
73+ return fmt.Errorf("failed to create initial tab: %w", err)
74+ }
75+
76+ logging.Info("[tabs] Tab system initialized with 1 tab")
77+ return nil
78+}
79+
80+// createRootContainer creates the main vertical container with tab bar and content area.
81+func (tm *TabManager) createRootContainer() error {
82+ logging.Info("[tabs] Creating root container with tab bar")
83+
84+ // Get tab bar position from config
85+ cfg := tm.getConfig()
86+ position := cfg.Workspace.TabBarPosition
87+ if position != "top" && position != "bottom" {
88+ position = "bottom" // Fallback to default
89+ }
90+
91+ logging.Debug(fmt.Sprintf("[tabs] Tab bar position: %s", position))
92+
93+ // Create main vertical box (tab bar + content area)
94+ rootBox := gtk.NewBox(gtk.OrientationVertical, 0)
95+ if rootBox == nil {
96+ return fmt.Errorf("failed to create root container box")
97+ }
98+
99+ rootBox.SetHExpand(true)
100+ rootBox.SetVExpand(true)
101+
102+ // Create tab bar
103+ tabBar := tm.createTabBar()
104+ if tabBar == nil {
105+ return fmt.Errorf("failed to create tab bar")
106+ }
107+
108+ // Create content area for active workspace
109+ contentArea := gtk.NewBox(gtk.OrientationVertical, 0)
110+ if contentArea == nil {
111+ return fmt.Errorf("failed to create content area")
112+ }
113+ contentArea.SetHExpand(true)
114+ contentArea.SetVExpand(true)
115+ contentArea.AddCSSClass("tab-content-area")
116+
117+ // Add widgets in order based on position
118+ if position == "top" {
119+ rootBox.Append(tabBar)
120+ rootBox.Append(contentArea)
121+ } else {
122+ rootBox.Append(contentArea)
123+ rootBox.Append(tabBar)
124+ }
125+
126+ // Store references
127+ tm.rootContainer = rootBox
128+ tm.tabBar = tabBar
129+ tm.contentArea = contentArea
130+
131+ logging.Info(fmt.Sprintf("[tabs] Root container created with tab bar at %s", position))
132+ return nil
133+}
134+
135+// CreateTab creates a new tab and switches to it.
136+// URL can be empty string to use default homepage.
137+func (tm *TabManager) CreateTab(url string) error {
138+ tm.mu.Lock()
139+ defer tm.mu.Unlock()
140+
141+ return tm.createTabInternal(url)
142+}
143+
144+// createTabInternal creates a new tab (must be called with lock held).
145+func (tm *TabManager) createTabInternal(url string) error {
146+ logging.Info(fmt.Sprintf("[tabs] Creating new tab with URL: %s", url))
147+
148+ // Use default URL if empty
149+ if url == "" {
150+ url = "about:blank"
151+ }
152+
153+ // Generate unique tab ID and default name
154+ tabNumber := len(tm.tabs) + 1
155+ tabID := fmt.Sprintf("tab-%d-%d", tabNumber, time.Now().Unix())
156+ defaultTitle := fmt.Sprintf("Tab %d", tabNumber)
157+
158+ // Create tab structure
159+ tab := &Tab{
160+ id: tabID,
161+ title: defaultTitle,
162+ isActive: false,
163+ }
164+
165+ // Create WebView for this tab
166+ cfg, err := tm.app.buildWebkitConfig()
167+ if err != nil {
168+ return fmt.Errorf("failed to build webkit config: %w", err)
169+ }
170+ cfg.CreateWindow = false // Tab webviews are embedded, don't create their own window
171+
172+ view, err := webkit.NewWebView(cfg)
173+ if err != nil {
174+ return fmt.Errorf("failed to create webview: %w", err)
175+ }
176+
177+ // Create pane for this WebView
178+ pane, err := tm.app.createPaneForView(view)
179+ if err != nil {
180+ return fmt.Errorf("failed to create pane: %w", err)
181+ }
182+
183+ // Create workspace for this tab
184+ workspace := NewWorkspaceManager(tm.app, pane)
185+ tab.workspace = workspace
186+
187+ // Get workspace root container
188+ rootWidget := workspace.GetRootWidget()
189+ if rootWidget == nil {
190+ return fmt.Errorf("workspace root widget is nil")
191+ }
192+ tab.container = rootWidget
193+
194+ // Load the URL in the workspace's pane
195+ if url != "" {
196+ if err := view.LoadURL(url); err != nil {
197+ logging.Warn(fmt.Sprintf("[tabs] Failed to load URL %s: %v", url, err))
198+ }
199+ }
200+
201+ // Create tab button in tab bar
202+ button := tm.createTabButton(tab)
203+ if button == nil {
204+ return fmt.Errorf("failed to create tab button")
205+ }
206+ tab.titleButton = button
207+
208+ // Add button to tab bar
209+ tm.addTabToBar(tab)
210+
211+ // Add to tabs slice
212+ tm.tabs = append(tm.tabs, tab)
213+ newIndex := len(tm.tabs) - 1
214+
215+ // Switch to new tab
216+ if err := tm.switchToTabInternal(newIndex); err != nil {
217+ return fmt.Errorf("failed to switch to new tab: %w", err)
218+ }
219+
220+ // Update tab bar visibility (hide if only 1 tab)
221+ tm.updateTabBarVisibility()
222+
223+ logging.Info(fmt.Sprintf("[tabs] Created tab %s (total: %d)", tabID, len(tm.tabs)))
224+ return nil
225+}
226+
227+// SwitchToTab switches to the tab at the given index.
228+func (tm *TabManager) SwitchToTab(index int) error {
229+ tm.mu.Lock()
230+ defer tm.mu.Unlock()
231+
232+ return tm.switchToTabInternal(index)
233+}
234+
235+// switchToTabInternal switches to a tab (must be called with lock held).
236+func (tm *TabManager) switchToTabInternal(index int) error {
237+ // Validate index
238+ if index < 0 || index >= len(tm.tabs) {
239+ logging.Warn(fmt.Sprintf("[tabs] Invalid tab index: %d (valid: 0-%d)", index, len(tm.tabs)-1))
240+ return fmt.Errorf("tab index %d out of range", index)
241+ }
242+
243+ // Check if already active
244+ if index == tm.activeIndex {
245+ logging.Debug(fmt.Sprintf("[tabs] Tab %d already active", index))
246+ return nil
247+ }
248+
249+ logging.Info(fmt.Sprintf("[tabs] Switching from tab %d to %d", tm.activeIndex, index))
250+
251+ // Hide current tab if any
252+ if tm.activeIndex >= 0 && tm.activeIndex < len(tm.tabs) {
253+ oldTab := tm.tabs[tm.activeIndex]
254+ oldTab.isActive = false
255+
256+ // Hide old workspace container and remove from content area
257+ if oldTab.container != nil {
258+ webkit.RunOnMainThread(func() {
259+ if contentBox, ok := tm.contentArea.(*gtk.Box); ok {
260+ contentBox.Remove(oldTab.container)
261+ }
262+ })
263+ }
264+
265+ // Remove active CSS class from old button
266+ tm.setTabActiveStyle(oldTab, false)
267+ }
268+
269+ // Show new tab
270+ newTab := tm.tabs[index]
271+ newTab.isActive = true
272+ tm.activeIndex = index
273+
274+ // Add new workspace container to content area
275+ if newTab.container != nil {
276+ webkit.RunOnMainThread(func() {
277+ if contentBox, ok := tm.contentArea.(*gtk.Box); ok {
278+ contentBox.Append(newTab.container)
279+ webkit.WidgetSetVisible(newTab.container, true)
280+ }
281+ })
282+ }
283+
284+ // Add active CSS class to new button
285+ tm.setTabActiveStyle(newTab, true)
286+
287+ // Update app-level workspace/panes references for compatibility
288+ if newTab.workspace != nil {
289+ tm.app.workspace = newTab.workspace
290+ tm.app.panes = newTab.workspace.GetAllPanes()
291+ tm.app.activePane = newTab.workspace.GetActivePane()
292+
293+ // Focus the new workspace's active pane
294+ newTab.workspace.RestoreFocus()
295+ }
296+
297+ logging.Info(fmt.Sprintf("[tabs] Switched to tab %d (%s)", index, newTab.id))
298+ return nil
299+}
300+
301+// CloseTab closes the tab at the given index.
302+func (tm *TabManager) CloseTab(index int) error {
303+ tm.mu.Lock()
304+ defer tm.mu.Unlock()
305+
306+ return tm.closeTabInternal(index)
307+}
308+
309+// closeTabInternal closes a tab (must be called with lock held).
310+func (tm *TabManager) closeTabInternal(index int) error {
311+ // Validate index
312+ if index < 0 || index >= len(tm.tabs) {
313+ logging.Warn(fmt.Sprintf("[tabs] Cannot close: invalid tab index %d", index))
314+ return fmt.Errorf("tab index %d out of range", index)
315+ }
316+
317+ // Prevent closing last tab
318+ if len(tm.tabs) == 1 {
319+ logging.Warn("[tabs] Cannot close last tab")
320+ return fmt.Errorf("cannot close the last tab")
321+ }
322+
323+ tab := tm.tabs[index]
324+ logging.Info(fmt.Sprintf("[tabs] Closing tab %d (%s)", index, tab.id))
325+
326+ // Cleanup workspace (WorkspaceManager cleanup is handled automatically when tabs are removed)
327+
328+ // Remove tab button from tab bar
329+ tm.removeTabFromBar(tab)
330+
331+ // Remove workspace container from content area if it's currently visible
332+ if tab.isActive && tab.container != nil {
333+ webkit.RunOnMainThread(func() {
334+ if contentBox, ok := tm.contentArea.(*gtk.Box); ok {
335+ contentBox.Remove(tab.container)
336+ }
337+ })
338+ }
339+
340+ // Remove from slice
341+ tm.tabs = append(tm.tabs[:index], tm.tabs[index+1:]...)
342+
343+ // Determine which tab to switch to
344+ newIndex := index
345+ if index >= len(tm.tabs) {
346+ newIndex = len(tm.tabs) - 1
347+ }
348+
349+ // Update active index and switch
350+ if index == tm.activeIndex {
351+ tm.activeIndex = -1 // Temporarily invalid
352+ if err := tm.switchToTabInternal(newIndex); err != nil {
353+ return fmt.Errorf("failed to switch after close: %w", err)
354+ }
355+ } else if index < tm.activeIndex {
356+ // Adjust active index if a tab before it was closed
357+ tm.activeIndex--
358+ }
359+
360+ // Update tab bar visibility (hide if only 1 tab)
361+ tm.updateTabBarVisibility()
362+
363+ logging.Info(fmt.Sprintf("[tabs] Tab closed (remaining: %d)", len(tm.tabs)))
364+ return nil
365+}
366+
367+// RenameTab sets a custom title for the tab at the given index.
368+func (tm *TabManager) RenameTab(index int, customTitle string) error {
369+ tm.mu.Lock()
370+ defer tm.mu.Unlock()
371+
372+ if index < 0 || index >= len(tm.tabs) {
373+ return fmt.Errorf("tab index %d out of range", index)
374+ }
375+
376+ tab := tm.tabs[index]
377+ tab.customTitle = customTitle
378+
379+ logging.Info(fmt.Sprintf("[tabs] Renamed tab %d to '%s'", index, customTitle))
380+
381+ // Update tab button label
382+ tm.updateTabButton(tab)
383+
384+ return nil
385+}
386+
387+// Note: Tab titles are static (Tab 1, Tab 2, etc.) and don't change based on page titles.
388+// This is different from stacked panes which show page titles.
389+// Users can only change tab names via explicit rename action (Alt+T → r)
390+
391+// GetActiveTab returns the currently active tab.
392+func (tm *TabManager) GetActiveTab() *Tab {
393+ tm.mu.RLock()
394+ defer tm.mu.RUnlock()
395+
396+ if tm.activeIndex >= 0 && tm.activeIndex < len(tm.tabs) {
397+ return tm.tabs[tm.activeIndex]
398+ }
399+ return nil
400+}
401+
402+// GetTabCount returns the number of open tabs.
403+func (tm *TabManager) GetTabCount() int {
404+ tm.mu.RLock()
405+ defer tm.mu.RUnlock()
406+
407+ return len(tm.tabs)
408+}
409+
410+// IsTabModeActive returns whether tab mode is currently active.
411+func (tm *TabManager) IsTabModeActive() bool {
412+ tm.mu.RLock()
413+ defer tm.mu.RUnlock()
414+
415+ return tm.tabModeActive
416+}
417+
418+// NextTab switches to the next tab (wraps around).
419+func (tm *TabManager) NextTab() error {
420+ tm.mu.Lock()
421+ defer tm.mu.Unlock()
422+
423+ if len(tm.tabs) <= 1 {
424+ return nil // No other tab to switch to
425+ }
426+
427+ nextIndex := (tm.activeIndex + 1) % len(tm.tabs)
428+ return tm.switchToTabInternal(nextIndex)
429+}
430+
431+// PreviousTab switches to the previous tab (wraps around).
432+func (tm *TabManager) PreviousTab() error {
433+ tm.mu.Lock()
434+ defer tm.mu.Unlock()
435+
436+ if len(tm.tabs) <= 1 {
437+ return nil // No other tab to switch to
438+ }
439+
440+ prevIndex := tm.activeIndex - 1
441+ if prevIndex < 0 {
442+ prevIndex = len(tm.tabs) - 1
443+ }
444+ return tm.switchToTabInternal(prevIndex)
445+}
446+
447+// GetRootContainer returns the main container widget that should be set as window child.
448+func (tm *TabManager) GetRootContainer() gtk.Widgetter {
449+ tm.mu.RLock()
450+ defer tm.mu.RUnlock()
451+
452+ return tm.rootContainer
453+}
454+
455+// Cleanup performs cleanup of all tabs and resources.
456+func (tm *TabManager) Cleanup() {
457+ tm.mu.Lock()
458+ defer tm.mu.Unlock()
459+
460+ logging.Info("[tabs] Cleaning up tab manager")
461+
462+ // Stop tab mode timer if active
463+ if tm.tabModeTimer != nil {
464+ tm.tabModeTimer.Stop()
465+ }
466+
467+ // Cleanup all tabs (workspaces cleanup automatically when dereferenced)
468+
469+ logging.Info("[tabs] Tab manager cleanup complete")
470+}
471+
472+// updateTabBarVisibility shows or hides the tab bar based on tab count.
473+// Tab bar is hidden when there's only 1 tab (no need to show it).
474+func (tm *TabManager) updateTabBarVisibility() {
475+ if tm.tabBar == nil {
476+ return
477+ }
478+
479+ tabCount := len(tm.tabs)
480+ shouldShow := tabCount > 1
481+
482+ webkit.WidgetSetVisible(tm.tabBar, shouldShow)
483+
484+ if shouldShow {
485+ logging.Debug(fmt.Sprintf("[tabs] Tab bar visible (%d tabs)", tabCount))
486+ } else {
487+ logging.Debug("[tabs] Tab bar hidden (only 1 tab)")
488+ }
489+}
490+
491+// GetConfig is a helper to get the current configuration.
492+func (tm *TabManager) getConfig() *config.Config {
493+ return config.Get()
494+}
@@ -0,0 +1,494 @@
1+package browser
2+
3+import (
4+ "fmt"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/config"
9+ "github.com/bnema/dumber/internal/logging"
10+ "github.com/bnema/dumber/pkg/webkit"
11+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
12+)
13+
14+// TabManager manages the global tab system for the browser.
15+// Each tab contains an independent workspace with its own pane tree.
16+type TabManager struct {
17+ tabs []*Tab
18+ activeIndex int
19+ window *webkit.Window
20+ app *BrowserApp
21+
22+ // GTK widgets
23+ rootContainer gtk.Widgetter // Main vertical box containing tab bar + content
24+ tabBar gtk.Widgetter // Horizontal tab bar container
25+ contentArea gtk.Widgetter // Container for active workspace
26+
27+ // Modal state
28+ tabModeActive bool
29+ tabModeTimer *time.Timer
30+
31+ // Synchronization
32+ mu sync.RWMutex
33+}
34+
35+// Tab represents a single browser tab containing a complete workspace.
36+type Tab struct {
37+ id string
38+ title string
39+ customTitle string // User-provided custom name (persists across page loads)
40+ workspace *WorkspaceManager
41+ titleButton gtk.Widgetter
42+ container gtk.Widgetter
43+ isActive bool
44+}
45+
46+// NewTabManager creates a new tab manager instance.
47+func NewTabManager(app *BrowserApp, window *webkit.Window) *TabManager {
48+ tm := &TabManager{
49+ tabs: make([]*Tab, 0),
50+ activeIndex: -1,
51+ window: window,
52+ app: app,
53+ }
54+
55+ logging.Info("[tabs] Tab manager initialized")
56+ return tm
57+}
58+
59+// Initialize sets up the tab bar and creates the initial tab.
60+func (tm *TabManager) Initialize(initialURL string) error {
61+ tm.mu.Lock()
62+ defer tm.mu.Unlock()
63+
64+ logging.Info("[tabs] Initializing tab system")
65+
66+ // Create root container layout
67+ if err := tm.createRootContainer(); err != nil {
68+ return fmt.Errorf("failed to create root container: %w", err)
69+ }
70+
71+ // Create initial tab
72+ if err := tm.createTabInternal(initialURL); err != nil {
73+ return fmt.Errorf("failed to create initial tab: %w", err)
74+ }
75+
76+ logging.Info("[tabs] Tab system initialized with 1 tab")
77+ return nil
78+}
79+
80+// createRootContainer creates the main vertical container with tab bar and content area.
81+func (tm *TabManager) createRootContainer() error {
82+ logging.Info("[tabs] Creating root container with tab bar")
83+
84+ // Get tab bar position from config
85+ cfg := tm.getConfig()
86+ position := cfg.Workspace.TabBarPosition
87+ if position != "top" && position != "bottom" {
88+ position = "bottom" // Fallback to default
89+ }
90+
91+ logging.Debug(fmt.Sprintf("[tabs] Tab bar position: %s", position))
92+
93+ // Create main vertical box (tab bar + content area)
94+ rootBox := gtk.NewBox(gtk.OrientationVertical, 0)
95+ if rootBox == nil {
96+ return fmt.Errorf("failed to create root container box")
97+ }
98+
99+ rootBox.SetHExpand(true)
100+ rootBox.SetVExpand(true)
101+
102+ // Create tab bar
103+ tabBar := tm.createTabBar()
104+ if tabBar == nil {
105+ return fmt.Errorf("failed to create tab bar")
106+ }
107+
108+ // Create content area for active workspace
109+ contentArea := gtk.NewBox(gtk.OrientationVertical, 0)
110+ if contentArea == nil {
111+ return fmt.Errorf("failed to create content area")
112+ }
113+ contentArea.SetHExpand(true)
114+ contentArea.SetVExpand(true)
115+ contentArea.AddCSSClass("tab-content-area")
116+
117+ // Add widgets in order based on position
118+ if position == "top" {
119+ rootBox.Append(tabBar)
120+ rootBox.Append(contentArea)
121+ } else {
122+ rootBox.Append(contentArea)
123+ rootBox.Append(tabBar)
124+ }
125+
126+ // Store references
127+ tm.rootContainer = rootBox
128+ tm.tabBar = tabBar
129+ tm.contentArea = contentArea
130+
131+ logging.Info(fmt.Sprintf("[tabs] Root container created with tab bar at %s", position))
132+ return nil
133+}
134+
135+// CreateTab creates a new tab and switches to it.
136+// URL can be empty string to use default homepage.
137+func (tm *TabManager) CreateTab(url string) error {
138+ tm.mu.Lock()
139+ defer tm.mu.Unlock()
140+
141+ return tm.createTabInternal(url)
142+}
143+
144+// createTabInternal creates a new tab (must be called with lock held).
145+func (tm *TabManager) createTabInternal(url string) error {
146+ logging.Info(fmt.Sprintf("[tabs] Creating new tab with URL: %s", url))
147+
148+ // Use default URL if empty
149+ if url == "" {
150+ url = "about:blank"
151+ }
152+
153+ // Generate unique tab ID and default name
154+ tabNumber := len(tm.tabs) + 1
155+ tabID := fmt.Sprintf("tab-%d-%d", tabNumber, time.Now().Unix())
156+ defaultTitle := fmt.Sprintf("Tab %d", tabNumber)
157+
158+ // Create tab structure
159+ tab := &Tab{
160+ id: tabID,
161+ title: defaultTitle,
162+ isActive: false,
163+ }
164+
165+ // Create WebView for this tab
166+ cfg, err := tm.app.buildWebkitConfig()
167+ if err != nil {
168+ return fmt.Errorf("failed to build webkit config: %w", err)
169+ }
170+ cfg.CreateWindow = false // Tab webviews are embedded, don't create their own window
171+
172+ view, err := webkit.NewWebView(cfg)
173+ if err != nil {
174+ return fmt.Errorf("failed to create webview: %w", err)
175+ }
176+
177+ // Create pane for this WebView
178+ pane, err := tm.app.createPaneForView(view)
179+ if err != nil {
180+ return fmt.Errorf("failed to create pane: %w", err)
181+ }
182+
183+ // Create workspace for this tab
184+ workspace := NewWorkspaceManager(tm.app, pane)
185+ tab.workspace = workspace
186+
187+ // Get workspace root container
188+ rootWidget := workspace.GetRootWidget()
189+ if rootWidget == nil {
190+ return fmt.Errorf("workspace root widget is nil")
191+ }
192+ tab.container = rootWidget
193+
194+ // Load the URL in the workspace's pane
195+ if url != "" {
196+ if err := view.LoadURL(url); err != nil {
197+ logging.Warn(fmt.Sprintf("[tabs] Failed to load URL %s: %v", url, err))
198+ }
199+ }
200+
201+ // Create tab button in tab bar
202+ button := tm.createTabButton(tab)
203+ if button == nil {
204+ return fmt.Errorf("failed to create tab button")
205+ }
206+ tab.titleButton = button
207+
208+ // Add button to tab bar
209+ tm.addTabToBar(tab)
210+
211+ // Add to tabs slice
212+ tm.tabs = append(tm.tabs, tab)
213+ newIndex := len(tm.tabs) - 1
214+
215+ // Switch to new tab
216+ if err := tm.switchToTabInternal(newIndex); err != nil {
217+ return fmt.Errorf("failed to switch to new tab: %w", err)
218+ }
219+
220+ // Update tab bar visibility (hide if only 1 tab)
221+ tm.updateTabBarVisibility()
222+
223+ logging.Info(fmt.Sprintf("[tabs] Created tab %s (total: %d)", tabID, len(tm.tabs)))
224+ return nil
225+}
226+
227+// SwitchToTab switches to the tab at the given index.
228+func (tm *TabManager) SwitchToTab(index int) error {
229+ tm.mu.Lock()
230+ defer tm.mu.Unlock()
231+
232+ return tm.switchToTabInternal(index)
233+}
234+
235+// switchToTabInternal switches to a tab (must be called with lock held).
236+func (tm *TabManager) switchToTabInternal(index int) error {
237+ // Validate index
238+ if index < 0 || index >= len(tm.tabs) {
239+ logging.Warn(fmt.Sprintf("[tabs] Invalid tab index: %d (valid: 0-%d)", index, len(tm.tabs)-1))
240+ return fmt.Errorf("tab index %d out of range", index)
241+ }
242+
243+ // Check if already active
244+ if index == tm.activeIndex {
245+ logging.Debug(fmt.Sprintf("[tabs] Tab %d already active", index))
246+ return nil
247+ }
248+
249+ logging.Info(fmt.Sprintf("[tabs] Switching from tab %d to %d", tm.activeIndex, index))
250+
251+ // Hide current tab if any
252+ if tm.activeIndex >= 0 && tm.activeIndex < len(tm.tabs) {
253+ oldTab := tm.tabs[tm.activeIndex]
254+ oldTab.isActive = false
255+
256+ // Hide old workspace container and remove from content area
257+ if oldTab.container != nil {
258+ webkit.RunOnMainThread(func() {
259+ if contentBox, ok := tm.contentArea.(*gtk.Box); ok {
260+ contentBox.Remove(oldTab.container)
261+ }
262+ })
263+ }
264+
265+ // Remove active CSS class from old button
266+ tm.setTabActiveStyle(oldTab, false)
267+ }
268+
269+ // Show new tab
270+ newTab := tm.tabs[index]
271+ newTab.isActive = true
272+ tm.activeIndex = index
273+
274+ // Add new workspace container to content area
275+ if newTab.container != nil {
276+ webkit.RunOnMainThread(func() {
277+ if contentBox, ok := tm.contentArea.(*gtk.Box); ok {
278+ contentBox.Append(newTab.container)
279+ webkit.WidgetSetVisible(newTab.container, true)
280+ }
281+ })
282+ }
283+
284+ // Add active CSS class to new button
285+ tm.setTabActiveStyle(newTab, true)
286+
287+ // Update app-level workspace/panes references for compatibility
288+ if newTab.workspace != nil {
289+ tm.app.workspace = newTab.workspace
290+ tm.app.panes = newTab.workspace.GetAllPanes()
291+ tm.app.activePane = newTab.workspace.GetActivePane()
292+
293+ // Focus the new workspace's active pane
294+ newTab.workspace.RestoreFocus()
295+ }
296+
297+ logging.Info(fmt.Sprintf("[tabs] Switched to tab %d (%s)", index, newTab.id))
298+ return nil
299+}
300+
301+// CloseTab closes the tab at the given index.
302+func (tm *TabManager) CloseTab(index int) error {
303+ tm.mu.Lock()
304+ defer tm.mu.Unlock()
305+
306+ return tm.closeTabInternal(index)
307+}
308+
309+// closeTabInternal closes a tab (must be called with lock held).
310+func (tm *TabManager) closeTabInternal(index int) error {
311+ // Validate index
312+ if index < 0 || index >= len(tm.tabs) {
313+ logging.Warn(fmt.Sprintf("[tabs] Cannot close: invalid tab index %d", index))
314+ return fmt.Errorf("tab index %d out of range", index)
315+ }
316+
317+ // Prevent closing last tab
318+ if len(tm.tabs) == 1 {
319+ logging.Warn("[tabs] Cannot close last tab")
320+ return fmt.Errorf("cannot close the last tab")
321+ }
322+
323+ tab := tm.tabs[index]
324+ logging.Info(fmt.Sprintf("[tabs] Closing tab %d (%s)", index, tab.id))
325+
326+ // Cleanup workspace (WorkspaceManager cleanup is handled automatically when tabs are removed)
327+
328+ // Remove tab button from tab bar
329+ tm.removeTabFromBar(tab)
330+
331+ // Remove workspace container from content area if it's currently visible
332+ if tab.isActive && tab.container != nil {
333+ webkit.RunOnMainThread(func() {
334+ if contentBox, ok := tm.contentArea.(*gtk.Box); ok {
335+ contentBox.Remove(tab.container)
336+ }
337+ })
338+ }
339+
340+ // Remove from slice
341+ tm.tabs = append(tm.tabs[:index], tm.tabs[index+1:]...)
342+
343+ // Determine which tab to switch to
344+ newIndex := index
345+ if index >= len(tm.tabs) {
346+ newIndex = len(tm.tabs) - 1
347+ }
348+
349+ // Update active index and switch
350+ if index == tm.activeIndex {
351+ tm.activeIndex = -1 // Temporarily invalid
352+ if err := tm.switchToTabInternal(newIndex); err != nil {
353+ return fmt.Errorf("failed to switch after close: %w", err)
354+ }
355+ } else if index < tm.activeIndex {
356+ // Adjust active index if a tab before it was closed
357+ tm.activeIndex--
358+ }
359+
360+ // Update tab bar visibility (hide if only 1 tab)
361+ tm.updateTabBarVisibility()
362+
363+ logging.Info(fmt.Sprintf("[tabs] Tab closed (remaining: %d)", len(tm.tabs)))
364+ return nil
365+}
366+
367+// RenameTab sets a custom title for the tab at the given index.
368+func (tm *TabManager) RenameTab(index int, customTitle string) error {
369+ tm.mu.Lock()
370+ defer tm.mu.Unlock()
371+
372+ if index < 0 || index >= len(tm.tabs) {
373+ return fmt.Errorf("tab index %d out of range", index)
374+ }
375+
376+ tab := tm.tabs[index]
377+ tab.customTitle = customTitle
378+
379+ logging.Info(fmt.Sprintf("[tabs] Renamed tab %d to '%s'", index, customTitle))
380+
381+ // Update tab button label
382+ tm.updateTabButton(tab)
383+
384+ return nil
385+}
386+
387+// Note: Tab titles are static (Tab 1, Tab 2, etc.) and don't change based on page titles.
388+// This is different from stacked panes which show page titles.
389+// Users can only change tab names via explicit rename action (Alt+T → r)
390+
391+// GetActiveTab returns the currently active tab.
392+func (tm *TabManager) GetActiveTab() *Tab {
393+ tm.mu.RLock()
394+ defer tm.mu.RUnlock()
395+
396+ if tm.activeIndex >= 0 && tm.activeIndex < len(tm.tabs) {
397+ return tm.tabs[tm.activeIndex]
398+ }
399+ return nil
400+}
401+
402+// GetTabCount returns the number of open tabs.
403+func (tm *TabManager) GetTabCount() int {
404+ tm.mu.RLock()
405+ defer tm.mu.RUnlock()
406+
407+ return len(tm.tabs)
408+}
409+
410+// IsTabModeActive returns whether tab mode is currently active.
411+func (tm *TabManager) IsTabModeActive() bool {
412+ tm.mu.RLock()
413+ defer tm.mu.RUnlock()
414+
415+ return tm.tabModeActive
416+}
417+
418+// NextTab switches to the next tab (wraps around).
419+func (tm *TabManager) NextTab() error {
420+ tm.mu.Lock()
421+ defer tm.mu.Unlock()
422+
423+ if len(tm.tabs) <= 1 {
424+ return nil // No other tab to switch to
425+ }
426+
427+ nextIndex := (tm.activeIndex + 1) % len(tm.tabs)
428+ return tm.switchToTabInternal(nextIndex)
429+}
430+
431+// PreviousTab switches to the previous tab (wraps around).
432+func (tm *TabManager) PreviousTab() error {
433+ tm.mu.Lock()
434+ defer tm.mu.Unlock()
435+
436+ if len(tm.tabs) <= 1 {
437+ return nil // No other tab to switch to
438+ }
439+
440+ prevIndex := tm.activeIndex - 1
441+ if prevIndex < 0 {
442+ prevIndex = len(tm.tabs) - 1
443+ }
444+ return tm.switchToTabInternal(prevIndex)
445+}
446+
447+// GetRootContainer returns the main container widget that should be set as window child.
448+func (tm *TabManager) GetRootContainer() gtk.Widgetter {
449+ tm.mu.RLock()
450+ defer tm.mu.RUnlock()
451+
452+ return tm.rootContainer
453+}
454+
455+// Cleanup performs cleanup of all tabs and resources.
456+func (tm *TabManager) Cleanup() {
457+ tm.mu.Lock()
458+ defer tm.mu.Unlock()
459+
460+ logging.Info("[tabs] Cleaning up tab manager")
461+
462+ // Stop tab mode timer if active
463+ if tm.tabModeTimer != nil {
464+ tm.tabModeTimer.Stop()
465+ }
466+
467+ // Cleanup all tabs (workspaces cleanup automatically when dereferenced)
468+
469+ logging.Info("[tabs] Tab manager cleanup complete")
470+}
471+
472+// updateTabBarVisibility shows or hides the tab bar based on tab count.
473+// Tab bar is hidden when there's only 1 tab (no need to show it).
474+func (tm *TabManager) updateTabBarVisibility() {
475+ if tm.tabBar == nil {
476+ return
477+ }
478+
479+ tabCount := len(tm.tabs)
480+ shouldShow := tabCount > 1
481+
482+ webkit.WidgetSetVisible(tm.tabBar, shouldShow)
483+
484+ if shouldShow {
485+ logging.Debug(fmt.Sprintf("[tabs] Tab bar visible (%d tabs)", tabCount))
486+ } else {
487+ logging.Debug("[tabs] Tab bar hidden (only 1 tab)")
488+ }
489+}
490+
491+// GetConfig is a helper to get the current configuration.
492+func (tm *TabManager) getConfig() *config.Config {
493+ return config.Get()
494+}

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