1package browser
2
3import (
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.
16type 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 (exported for border styling)
26 tabModeTarget gtk.Widgetter // Widget currently holding tab mode border margins
27
28 // Modal state
29 tabModeActive bool
30 tabModeTimer *time.Timer
31
32 // Tab button click handling (pattern from StackedPaneManager)
33 buttonToTab map[uint64]*Tab // Maps button ID to tab for click handling
34 nextButtonID uint64 // Atomic counter for generating unique button IDs
35
36 // Synchronization
37 mu sync.RWMutex
38}
39
40// Tab represents a single browser tab containing a complete workspace.
41type Tab struct {
42 id string
43 title string
44 customTitle string // User-provided custom name (persists across page loads)
45 workspace *WorkspaceManager
46 titleButton gtk.Widgetter
47 isActive bool
48}
49
50// NewTabManager creates a new tab manager instance.
51func NewTabManager(app *BrowserApp, window *webkit.Window) *TabManager {
52 tm := &TabManager{
53 tabs: make([]*Tab, 0),
54 activeIndex: -1,
55 window: window,
56 app: app,
57 buttonToTab: make(map[uint64]*Tab),
58 }
59
60 logging.Info("[tabs] Tab manager initialized")
61 return tm
62}
63
64// Initialize sets up the tab bar and creates the initial tab.
65func (tm *TabManager) Initialize(initialURL string) error {
66 tm.mu.Lock()
67 defer tm.mu.Unlock()
68
69 logging.Info("[tabs] Initializing tab system")
70
71 // Create root container layout
72 if err := tm.createRootContainer(); err != nil {
73 return fmt.Errorf("failed to create root container: %w", err)
74 }
75
76 // Create initial tab
77 if err := tm.createTabInternal(initialURL); err != nil {
78 return fmt.Errorf("failed to create initial tab: %w", err)
79 }
80
81 logging.Info("[tabs] Tab system initialized with 1 tab")
82 return nil
83}
84
85// createRootContainer creates the main vertical container with tab bar and content area.
86func (tm *TabManager) createRootContainer() error {
87 logging.Info("[tabs] Creating root container with tab bar")
88
89 // Get tab bar position from config
90 cfg := tm.getConfig()
91 position := cfg.Workspace.TabBarPosition
92 if position != "top" && position != "bottom" {
93 position = "bottom" // Fallback to default
94 }
95
96 logging.Debug(fmt.Sprintf("[tabs] Tab bar position: %s", position))
97
98 // Create main vertical box (tab bar + content area)
99 rootBox := gtk.NewBox(gtk.OrientationVertical, 0)
100 if rootBox == nil {
101 return fmt.Errorf("failed to create root container box")
102 }
103
104 rootBox.SetHExpand(true)
105 rootBox.SetVExpand(true)
106
107 // Create tab bar
108 tabBar := tm.createTabBar()
109 if tabBar == nil {
110 return fmt.Errorf("failed to create tab bar")
111 }
112
113 // Create content area for active workspace
114 contentArea := gtk.NewBox(gtk.OrientationVertical, 0)
115 if contentArea == nil {
116 return fmt.Errorf("failed to create content area")
117 }
118 contentArea.SetHExpand(true)
119 contentArea.SetVExpand(true)
120 contentArea.AddCSSClass("tab-content-area")
121
122 // Add widgets in order based on position
123 if position == "top" {
124 rootBox.Append(tabBar)
125 rootBox.Append(contentArea)
126 } else {
127 rootBox.Append(contentArea)
128 rootBox.Append(tabBar)
129 }
130
131 // Store references
132 tm.rootContainer = rootBox
133 tm.tabBar = tabBar
134 tm.ContentArea = contentArea
135
136 logging.Info(fmt.Sprintf("[tabs] Root container created with tab bar at %s", position))
137 return nil
138}
139
140// CreateTab creates a new tab and switches to it.
141// URL can be empty string to use default homepage.
142func (tm *TabManager) CreateTab(url string) error {
143 tm.mu.Lock()
144 defer tm.mu.Unlock()
145
146 return tm.createTabInternal(url)
147}
148
149// createTabInternal creates a new tab (must be called with lock held).
150func (tm *TabManager) createTabInternal(url string) error {
151 logging.Info(fmt.Sprintf("[tabs] Creating new tab with URL: %s", url))
152
153 // Use default URL if empty
154 if url == "" {
155 url = "about:blank"
156 }
157
158 // Generate unique tab ID and default name
159 tabNumber := len(tm.tabs) + 1
160 tabID := fmt.Sprintf("tab-%d-%d", tabNumber, time.Now().Unix())
161 defaultTitle := fmt.Sprintf("Tab %d", tabNumber)
162
163 // Create tab structure
164 tab := &Tab{
165 id: tabID,
166 title: defaultTitle,
167 isActive: false,
168 }
169
170 // Create WebView for this tab
171 cfg, err := tm.app.buildWebkitConfig()
172 if err != nil {
173 return fmt.Errorf("failed to build webkit config: %w", err)
174 }
175 cfg.CreateWindow = false // Tab webviews are embedded, don't create their own window
176
177 view, err := webkit.NewWebView(cfg)
178 if err != nil {
179 return fmt.Errorf("failed to create webview: %w", err)
180 }
181
182 // Create pane for this WebView
183 pane, err := tm.app.createPaneForView(view)
184 if err != nil {
185 return fmt.Errorf("failed to create pane: %w", err)
186 }
187
188 // Create workspace for this tab
189 workspace := NewWorkspaceManager(tm.app, pane)
190 tab.workspace = workspace
191
192 // Verify workspace root exists
193 rootWidget := workspace.GetRootWidget()
194 if rootWidget == nil {
195 return fmt.Errorf("workspace root widget is nil")
196 }
197 logging.Info(fmt.Sprintf("[tabs] New tab created with root container: %p (workspace %p)", rootWidget, workspace))
198
199 // Load the URL in the workspace's pane
200 if url != "" {
201 if err := view.LoadURL(url); err != nil {
202 logging.Warn(fmt.Sprintf("[tabs] Failed to load URL %s: %v", url, err))
203 }
204 }
205
206 // Create tab button in tab bar
207 button := tm.createTabButton(tab)
208 if button == nil {
209 return fmt.Errorf("failed to create tab button")
210 }
211 tab.titleButton = button
212
213 // Add button to tab bar
214 tm.addTabToBar(tab)
215
216 // Add to tabs slice
217 tm.tabs = append(tm.tabs, tab)
218
219 // Attach click handler with unique ID (pattern from stacked panes)
220 tm.attachTabClickHandler(tab.titleButton, tab)
221
222 // Switch to new tab
223 newIndex := len(tm.tabs) - 1
224 if err := tm.switchToTabInternal(newIndex); err != nil {
225 return fmt.Errorf("failed to switch to new tab: %w", err)
226 }
227
228 // Update tab bar visibility (hide if only 1 tab)
229 tm.updateTabBarVisibility()
230
231 logging.Info(fmt.Sprintf("[tabs] Created tab %s (total: %d)", tabID, len(tm.tabs)))
232 return nil
233}
234
235// SwitchToTab switches to the tab at the given index.
236func (tm *TabManager) SwitchToTab(index int) error {
237 tm.mu.Lock()
238 defer tm.mu.Unlock()
239
240 return tm.switchToTabInternal(index)
241}
242
243// switchToTabInternal switches to a tab (must be called with lock held).
244func (tm *TabManager) switchToTabInternal(index int) error {
245 // Validate index
246 if index < 0 || index >= len(tm.tabs) {
247 logging.Warn(fmt.Sprintf("[tabs] Invalid tab index: %d (valid: 0-%d)", index, len(tm.tabs)-1))
248 return fmt.Errorf("tab index %d out of range", index)
249 }
250
251 // Check if already active
252 if index == tm.activeIndex {
253 logging.Debug(fmt.Sprintf("[tabs] Tab %d already active", index))
254 return nil
255 }
256
257 logging.Info(fmt.Sprintf("[tabs] Switching from tab %d to %d", tm.activeIndex, index))
258
259 // Hide current tab if any
260 if tm.activeIndex >= 0 && tm.activeIndex < len(tm.tabs) {
261 oldTab := tm.tabs[tm.activeIndex]
262 oldTab.isActive = false
263
264 // Clear GTK focus from old tab's workspace to prevent it from catching keyboard events
265 if oldTab.workspace != nil {
266 oldTab.workspace.ClearFocus()
267 }
268
269 // Hide old workspace container and remove from content area
270 // CRITICAL: Get the CURRENT root widget from workspace, not the cached tab.container,
271 // because splits/stacks change the workspace root
272 if oldTab.workspace != nil {
273 currentRoot := oldTab.workspace.GetRootWidget()
274 if currentRoot != nil {
275 webkit.RunOnMainThread(func() {
276 if contentBox, ok := tm.ContentArea.(*gtk.Box); ok {
277 contentBox.Remove(currentRoot)
278 logging.Info(fmt.Sprintf("[tabs] Removed tab %d container %p from ContentArea (current root)", tm.activeIndex, currentRoot))
279 }
280 })
281 }
282 }
283
284 // Remove active CSS class from old button
285 tm.setTabActiveStyle(oldTab, false)
286 }
287
288 // Show new tab
289 newTab := tm.tabs[index]
290 newTab.isActive = true
291 tm.activeIndex = index
292
293 // Add new workspace container to content area
294 // CRITICAL: Get the CURRENT root widget from workspace, not the cached tab.container
295 if newTab.workspace != nil {
296 currentRoot := newTab.workspace.GetRootWidget()
297 if currentRoot != nil {
298 webkit.RunOnMainThread(func() {
299 if contentBox, ok := tm.ContentArea.(*gtk.Box); ok {
300 contentBox.Append(currentRoot)
301 webkit.WidgetSetVisible(currentRoot, true)
302 logging.Info(fmt.Sprintf("[tabs] Added tab %d container %p to ContentArea (current root)", index, currentRoot))
303 }
304 })
305 }
306 }
307
308 // Add active CSS class to new button
309 tm.setTabActiveStyle(newTab, true)
310
311 // Update app-level workspace/panes references for compatibility
312 if newTab.workspace != nil {
313 tm.app.workspace = newTab.workspace
314 tm.app.panes = newTab.workspace.GetAllPanes()
315 tm.app.activePane = newTab.workspace.GetActivePane()
316
317 // Focus the new workspace's active pane
318 newTab.workspace.RestoreFocus()
319 }
320
321 logging.Info(fmt.Sprintf("[tabs] Switched to tab %d (%s)", index, newTab.id))
322 return nil
323}
324
325// CloseTab closes the tab at the given index.
326func (tm *TabManager) CloseTab(index int) error {
327 tm.mu.Lock()
328 defer tm.mu.Unlock()
329
330 return tm.closeTabInternal(index)
331}
332
333// closeTabInternal closes a tab (must be called with lock held).
334func (tm *TabManager) closeTabInternal(index int) error {
335 // Validate index
336 if index < 0 || index >= len(tm.tabs) {
337 logging.Warn(fmt.Sprintf("[tabs] Cannot close: invalid tab index %d", index))
338 return fmt.Errorf("tab index %d out of range", index)
339 }
340
341 // Prevent closing last tab
342 if len(tm.tabs) == 1 {
343 logging.Warn("[tabs] Cannot close last tab")
344 return fmt.Errorf("cannot close the last tab")
345 }
346
347 tab := tm.tabs[index]
348 logging.Info(fmt.Sprintf("[tabs] Closing tab %d (%s)", index, tab.id))
349
350 // Cleanup workspace (WorkspaceManager cleanup is handled automatically when tabs are removed)
351
352 // Clean up button mapping (find and remove this tab's button ID)
353 for buttonID, mappedTab := range tm.buttonToTab {
354 if mappedTab == tab {
355 delete(tm.buttonToTab, buttonID)
356 logging.Debug(fmt.Sprintf("[tabs] Cleaned up button mapping for closed tab %s (buttonID=%d)", tab.id, buttonID))
357 break
358 }
359 }
360
361 // Remove tab button from tab bar
362 tm.removeTabFromBar(tab)
363
364 // Remove workspace container from content area if it's currently visible
365 // CRITICAL: Get the CURRENT root widget from workspace, not the cached tab.container
366 if tab.isActive && tab.workspace != nil {
367 currentRoot := tab.workspace.GetRootWidget()
368 if currentRoot != nil {
369 webkit.RunOnMainThread(func() {
370 if contentBox, ok := tm.ContentArea.(*gtk.Box); ok {
371 contentBox.Remove(currentRoot)
372 logging.Info(fmt.Sprintf("[tabs] Removed closing tab %d container %p from ContentArea (current root)", index, currentRoot))
373 }
374 })
375 }
376 }
377
378 // Remove from slice
379 tm.tabs = append(tm.tabs[:index], tm.tabs[index+1:]...)
380
381 // Determine which tab to switch to
382 newIndex := index
383 if index >= len(tm.tabs) {
384 newIndex = len(tm.tabs) - 1
385 }
386
387 // Update active index and switch
388 if index == tm.activeIndex {
389 tm.activeIndex = -1 // Temporarily invalid
390 if err := tm.switchToTabInternal(newIndex); err != nil {
391 return fmt.Errorf("failed to switch after close: %w", err)
392 }
393 } else if index < tm.activeIndex {
394 // Adjust active index if a tab before it was closed
395 tm.activeIndex--
396 }
397
398 // Update tab bar visibility (hide if only 1 tab)
399 tm.updateTabBarVisibility()
400
401 logging.Info(fmt.Sprintf("[tabs] Tab closed (remaining: %d)", len(tm.tabs)))
402 return nil
403}
404
405// RenameTab sets a custom title for the tab at the given index.
406func (tm *TabManager) RenameTab(index int, customTitle string) error {
407 tm.mu.Lock()
408 defer tm.mu.Unlock()
409
410 if index < 0 || index >= len(tm.tabs) {
411 return fmt.Errorf("tab index %d out of range", index)
412 }
413
414 tab := tm.tabs[index]
415 tab.customTitle = customTitle
416
417 logging.Info(fmt.Sprintf("[tabs] Renamed tab %d to '%s'", index, customTitle))
418
419 // Update tab button label
420 tm.updateTabButton(tab)
421
422 return nil
423}
424
425// Note: Tab titles are static (Tab 1, Tab 2, etc.) and don't change based on page titles.
426// This is different from stacked panes which show page titles.
427// Users can only change tab names via explicit rename action (Alt+T → r)
428
429// GetActiveTab returns the currently active tab.
430func (tm *TabManager) GetActiveTab() *Tab {
431 tm.mu.RLock()
432 defer tm.mu.RUnlock()
433
434 if tm.activeIndex >= 0 && tm.activeIndex < len(tm.tabs) {
435 return tm.tabs[tm.activeIndex]
436 }
437 return nil
438}
439
440// GetTabCount returns the number of open tabs.
441func (tm *TabManager) GetTabCount() int {
442 tm.mu.RLock()
443 defer tm.mu.RUnlock()
444
445 return len(tm.tabs)
446}
447
448// IsTabModeActive returns whether tab mode is currently active.
449func (tm *TabManager) IsTabModeActive() bool {
450 tm.mu.RLock()
451 defer tm.mu.RUnlock()
452
453 return tm.tabModeActive
454}
455
456// NextTab switches to the next tab (wraps around).
457func (tm *TabManager) NextTab() error {
458 tm.mu.Lock()
459 defer tm.mu.Unlock()
460
461 if len(tm.tabs) <= 1 {
462 return nil // No other tab to switch to
463 }
464
465 nextIndex := (tm.activeIndex + 1) % len(tm.tabs)
466 return tm.switchToTabInternal(nextIndex)
467}
468
469// PreviousTab switches to the previous tab (wraps around).
470func (tm *TabManager) PreviousTab() error {
471 tm.mu.Lock()
472 defer tm.mu.Unlock()
473
474 if len(tm.tabs) <= 1 {
475 return nil // No other tab to switch to
476 }
477
478 prevIndex := tm.activeIndex - 1
479 if prevIndex < 0 {
480 prevIndex = len(tm.tabs) - 1
481 }
482 return tm.switchToTabInternal(prevIndex)
483}
484
485// GetRootContainer returns the main container widget that should be set as window child.
486func (tm *TabManager) GetRootContainer() gtk.Widgetter {
487 tm.mu.RLock()
488 defer tm.mu.RUnlock()
489
490 return tm.rootContainer
491}
492
493// GetWindow returns the GTK window backing the tab manager.
494func (tm *TabManager) GetWindow() *webkit.Window {
495 tm.mu.RLock()
496 defer tm.mu.RUnlock()
497
498 return tm.window
499}
500
501// Cleanup performs cleanup of all tabs and resources.
502func (tm *TabManager) Cleanup() {
503 tm.mu.Lock()
504 defer tm.mu.Unlock()
505
506 logging.Info("[tabs] Cleaning up tab manager")
507
508 // Stop tab mode timer if active
509 if tm.tabModeTimer != nil {
510 tm.tabModeTimer.Stop()
511 }
512
513 // Cleanup all tabs (workspaces cleanup automatically when dereferenced)
514
515 logging.Info("[tabs] Tab manager cleanup complete")
516}
517
518// updateTabBarVisibility shows or hides the tab bar based on tab count.
519// Tab bar is hidden when there's only 1 tab (no need to show it).
520func (tm *TabManager) updateTabBarVisibility() {
521 if tm.tabBar == nil {
522 return
523 }
524
525 tabCount := len(tm.tabs)
526 shouldShow := tabCount > 1
527
528 webkit.WidgetSetVisible(tm.tabBar, shouldShow)
529
530 if shouldShow {
531 logging.Debug(fmt.Sprintf("[tabs] Tab bar visible (%d tabs)", tabCount))
532 } else {
533 logging.Debug("[tabs] Tab bar hidden (only 1 tab)")
534 }
535}
536
537// GetConfig is a helper to get the current configuration.
538func (tm *TabManager) getConfig() *config.Config {
539 return config.Get()
540}