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 progressBar gtk.Widgetter // Thin load progress indicator
27
28 // Border overlays (float over content without layout shift)
29 paneModeOverlay gtk.Widgetter // Border overlay for pane mode
30 tabModeOverlay gtk.Widgetter // Border overlay for tab mode
31
32 // Modal state
33 tabModeActive bool
34 tabModeTimer *time.Timer
35 lastTabCreate time.Time // Debounce guard for rapid tab creation shortcuts
36
37 // Rename state
38 renameInProgress bool
39 renamingTab *Tab
40 // Stores original focus behaviour while inline rename is active
41 renamePrevFocusOnClick bool
42 renamePrevCanFocus bool
43
44 // Tab button click handling (pattern from StackedPaneManager)
45 buttonToTab map[uint64]*Tab // Maps button ID to tab for click handling
46 nextButtonID uint64 // Atomic counter for generating unique button IDs
47
48 // Synchronization
49 mu sync.RWMutex
50}
51
52// Tab represents a single browser tab containing a complete workspace.
53type Tab struct {
54 id string
55 title string
56 customTitle string // User-provided custom name (persists across page loads)
57 workspace *WorkspaceManager
58 titleButton gtk.Widgetter
59 isActive bool
60 progress float64
61 isLoading bool
62 webViews map[*webkit.WebView]struct{}
63}
64
65// NewTabManager creates a new tab manager instance.
66func NewTabManager(app *BrowserApp, window *webkit.Window) *TabManager {
67 tm := &TabManager{
68 tabs: make([]*Tab, 0),
69 activeIndex: -1,
70 window: window,
71 app: app,
72 buttonToTab: make(map[uint64]*Tab),
73 }
74
75 logging.Info("[tabs] Tab manager initialized")
76 return tm
77}
78
79// Initialize sets up the tab bar and creates the initial tab.
80func (tm *TabManager) Initialize(initialURL string) error {
81 tm.mu.Lock()
82 defer tm.mu.Unlock()
83
84 logging.Info("[tabs] Initializing tab system")
85
86 // Create root container layout
87 if err := tm.createRootContainer(); err != nil {
88 return fmt.Errorf("failed to create root container: %w", err)
89 }
90
91 // Create initial tab
92 if err := tm.createTabInternal(initialURL); err != nil {
93 return fmt.Errorf("failed to create initial tab: %w", err)
94 }
95
96 logging.Info("[tabs] Tab system initialized with 1 tab")
97 return nil
98}
99
100// createRootContainer creates the main vertical container with tab bar and content area.
101func (tm *TabManager) createRootContainer() error {
102 logging.Info("[tabs] Creating root container with tab bar")
103
104 // Get tab bar position from config
105 cfg := tm.getConfig()
106 position := cfg.Workspace.TabBarPosition
107 if position != "top" && position != "bottom" {
108 position = "bottom" // Fallback to default
109 }
110
111 logging.Debug(fmt.Sprintf("[tabs] Tab bar position: %s", position))
112
113 // Create main vertical box (tab bar + content area)
114 rootBox := gtk.NewBox(gtk.OrientationVertical, 0)
115 if rootBox == nil {
116 return fmt.Errorf("failed to create root container box")
117 }
118
119 rootBox.SetHExpand(true)
120 rootBox.SetVExpand(true)
121
122 // Create tab bar
123 tabBar := tm.createTabBar()
124 if tabBar == nil {
125 return fmt.Errorf("failed to create tab bar")
126 }
127
128 // Create content area for active workspace
129 contentArea := gtk.NewBox(gtk.OrientationVertical, 0)
130 if contentArea == nil {
131 return fmt.Errorf("failed to create content area")
132 }
133 contentArea.SetHExpand(true)
134 contentArea.SetVExpand(true)
135 contentArea.AddCSSClass("tab-content-area")
136
137 // Create progress bar (displayed at the bottom regardless of tab bar position)
138 progressBar := tm.createProgressBar()
139
140 // Add widgets in order based on position
141 if position == "top" {
142 rootBox.Append(tabBar)
143 rootBox.Append(contentArea)
144 } else {
145 rootBox.Append(contentArea)
146 rootBox.Append(tabBar)
147 }
148
149 // Wrap everything in an overlay so the progress bar can float without resizing layout
150 rootOverlay := gtk.NewOverlay()
151 if rootOverlay == nil {
152 return fmt.Errorf("failed to create root overlay")
153 }
154 rootOverlay.SetHExpand(true)
155 rootOverlay.SetVExpand(true)
156 rootOverlay.SetChild(rootBox)
157 if progressBar != nil {
158 if bar, ok := progressBar.(*gtk.ProgressBar); ok && bar != nil {
159 bar.SetHAlign(gtk.AlignFill)
160 bar.SetVAlign(gtk.AlignEnd)
161 }
162 rootOverlay.AddOverlay(progressBar)
163 }
164
165 // Create border overlays (hidden by default, shown when pane/tab mode is active)
166 paneModeOverlay := tm.createBorderOverlay("pane-mode-border")
167 tabModeOverlay := tm.createBorderOverlay("tab-mode-border")
168
169 if paneModeOverlay != nil {
170 rootOverlay.AddOverlay(paneModeOverlay)
171 webkit.WidgetSetVisible(paneModeOverlay, false)
172 }
173
174 if tabModeOverlay != nil {
175 rootOverlay.AddOverlay(tabModeOverlay)
176 webkit.WidgetSetVisible(tabModeOverlay, false)
177 }
178
179 // Store references
180 tm.rootContainer = rootOverlay
181 tm.tabBar = tabBar
182 tm.ContentArea = contentArea
183 tm.progressBar = progressBar
184 tm.paneModeOverlay = paneModeOverlay
185 tm.tabModeOverlay = tabModeOverlay
186
187 logging.Info(fmt.Sprintf("[tabs] Root container created with tab bar at %s", position))
188 return nil
189}
190
191// CreateTab creates a new tab and switches to it.
192// URL can be empty string to use default homepage.
193func (tm *TabManager) CreateTab(url string) error {
194 tm.mu.Lock()
195 defer tm.mu.Unlock()
196
197 return tm.createTabInternal(url)
198}
199
200// createTabInternal creates a new tab (must be called with lock held).
201func (tm *TabManager) createTabInternal(url string) error {
202 logging.Info(fmt.Sprintf("[tabs] Creating new tab with URL: %s", url))
203
204 const tabCreateDebounce = 200 * time.Millisecond
205 if !tm.lastTabCreate.IsZero() && time.Since(tm.lastTabCreate) < tabCreateDebounce {
206 logging.Warn("[tabs] Ignoring rapid tab creation request (debounced)")
207 return nil
208 }
209
210 // Use default URL if empty
211 if url == "" {
212 url = "about:blank"
213 }
214
215 // Generate unique tab ID and default name
216 tabNumber := len(tm.tabs) + 1
217 tabID := fmt.Sprintf("tab-%d-%d", tabNumber, time.Now().Unix())
218 defaultTitle := fmt.Sprintf("Tab %d", tabNumber)
219
220 // Create tab structure
221 tab := &Tab{
222 id: tabID,
223 title: defaultTitle,
224 isActive: false,
225 webViews: make(map[*webkit.WebView]struct{}),
226 }
227
228 // Create WebView for this tab
229 cfg, err := tm.app.buildWebkitConfig()
230 if err != nil {
231 return fmt.Errorf("failed to build webkit config: %w", err)
232 }
233 cfg.CreateWindow = false // Tab webviews are embedded, don't create their own window
234
235 view, err := webkit.NewWebView(cfg)
236 if err != nil {
237 return fmt.Errorf("failed to create webview: %w", err)
238 }
239
240 // Register with content blocking service for network/cosmetic filtering
241 tm.app.RegisterWebViewForFiltering(view)
242
243 // Create pane for this WebView
244 pane, err := tm.app.createPaneForView(view)
245 if err != nil {
246 return fmt.Errorf("failed to create pane: %w", err)
247 }
248
249 // Create workspace for this tab
250 workspace := NewWorkspaceManager(tm.app, pane)
251 tab.workspace = workspace
252 tm.registerWebViewLocked(tab, view)
253
254 // Verify workspace root exists
255 rootWidget := workspace.GetRootWidget()
256 if rootWidget == nil {
257 return fmt.Errorf("workspace root widget is nil")
258 }
259 logging.Info(fmt.Sprintf("[tabs] New tab created with root container: %p (workspace %p)", rootWidget, workspace))
260
261 // Load the URL in the workspace's pane
262 if url != "" {
263 if err := view.LoadURL(url); err != nil {
264 logging.Warn(fmt.Sprintf("[tabs] Failed to load URL %s: %v", url, err))
265 }
266 }
267
268 // Create tab button in tab bar
269 button := tm.createTabButton(tab)
270 if button == nil {
271 return fmt.Errorf("failed to create tab button")
272 }
273 tab.titleButton = button
274
275 // Add button to tab bar
276 tm.addTabToBar(tab)
277
278 // Add to tabs slice
279 tm.tabs = append(tm.tabs, tab)
280 tm.lastTabCreate = time.Now()
281
282 // Attach click handler with unique ID (pattern from stacked panes)
283 tm.attachTabClickHandler(tab.titleButton, tab)
284
285 // Switch to new tab
286 newIndex := len(tm.tabs) - 1
287 if err := tm.switchToTabInternal(newIndex); err != nil {
288 return fmt.Errorf("failed to switch to new tab: %w", err)
289 }
290
291 // Update tab bar visibility (hide if only 1 tab)
292 tm.updateTabBarVisibility()
293
294 logging.Info(fmt.Sprintf("[tabs] Created tab %s (total: %d)", tabID, len(tm.tabs)))
295 return nil
296}
297
298// SwitchToTab switches to the tab at the given index.
299func (tm *TabManager) SwitchToTab(index int) error {
300 tm.mu.Lock()
301 defer tm.mu.Unlock()
302
303 return tm.switchToTabInternal(index)
304}
305
306// switchToTabInternal switches to a tab (must be called with lock held).
307func (tm *TabManager) switchToTabInternal(index int) error {
308 // Validate index
309 if index < 0 || index >= len(tm.tabs) {
310 logging.Warn(fmt.Sprintf("[tabs] Invalid tab index: %d (valid: 0-%d)", index, len(tm.tabs)-1))
311 return fmt.Errorf("tab index %d out of range", index)
312 }
313
314 // Check if already active
315 if index == tm.activeIndex {
316 logging.Debug(fmt.Sprintf("[tabs] Tab %d already active", index))
317 return nil
318 }
319
320 logging.Info(fmt.Sprintf("[tabs] Switching from tab %d to %d", tm.activeIndex, index))
321
322 // Hide current tab if any
323 if tm.activeIndex >= 0 && tm.activeIndex < len(tm.tabs) {
324 oldTab := tm.tabs[tm.activeIndex]
325 oldTab.isActive = false
326
327 // Clear GTK focus from old tab's workspace to prevent it from catching keyboard events
328 if oldTab.workspace != nil {
329 oldTab.workspace.ClearFocus()
330 }
331
332 // Hide old workspace container and remove from content area
333 // CRITICAL: Get the CURRENT root widget from workspace, not the cached tab.container,
334 // because splits/stacks change the workspace root
335 if oldTab.workspace != nil {
336 currentRoot := oldTab.workspace.GetRootWidget()
337 if currentRoot != nil {
338 webkit.RunOnMainThread(func() {
339 if contentBox, ok := tm.ContentArea.(*gtk.Box); ok {
340 contentBox.Remove(currentRoot)
341 logging.Info(fmt.Sprintf("[tabs] Removed tab %d container %p from ContentArea (current root)", tm.activeIndex, currentRoot))
342 }
343 })
344 }
345 }
346
347 // Remove active CSS class from old button
348 tm.setTabActiveStyle(oldTab, false)
349 }
350
351 // Show new tab
352 newTab := tm.tabs[index]
353 newTab.isActive = true
354 tm.activeIndex = index
355
356 // Add new workspace container to content area
357 // CRITICAL: Get the CURRENT root widget from workspace, not the cached tab.container
358 if newTab.workspace != nil {
359 currentRoot := newTab.workspace.GetRootWidget()
360 if currentRoot != nil {
361 webkit.RunOnMainThread(func() {
362 if contentBox, ok := tm.ContentArea.(*gtk.Box); ok {
363 contentBox.Append(currentRoot)
364 webkit.WidgetSetVisible(currentRoot, true)
365 logging.Info(fmt.Sprintf("[tabs] Added tab %d container %p to ContentArea (current root)", index, currentRoot))
366 }
367 })
368 }
369 }
370
371 // Add active CSS class to new button
372 tm.setTabActiveStyle(newTab, true)
373
374 // Update app-level workspace/panes references for compatibility
375 if newTab.workspace != nil {
376 tm.app.workspace = newTab.workspace
377 tm.app.panes = newTab.workspace.GetAllPanes()
378 tm.app.activePane = newTab.workspace.GetActivePane()
379
380 // Focus the new workspace's active pane
381 newTab.workspace.RestoreFocus()
382 }
383
384 // Sync progress bar with the newly active tab
385 tm.updateProgressBarLocked()
386
387 logging.Info(fmt.Sprintf("[tabs] Switched to tab %d (%s)", index, newTab.id))
388 return nil
389}
390
391// CloseTab closes the tab at the given index.
392func (tm *TabManager) CloseTab(index int) error {
393 tm.mu.Lock()
394 defer tm.mu.Unlock()
395
396 return tm.closeTabInternal(index)
397}
398
399// closeTabInternal closes a tab (must be called with lock held).
400func (tm *TabManager) closeTabInternal(index int) error {
401 // Validate index
402 if index < 0 || index >= len(tm.tabs) {
403 logging.Warn(fmt.Sprintf("[tabs] Cannot close: invalid tab index %d", index))
404 return fmt.Errorf("tab index %d out of range", index)
405 }
406
407 // Prevent closing last tab
408 if len(tm.tabs) == 1 {
409 logging.Warn("[tabs] Cannot close last tab")
410 return fmt.Errorf("cannot close the last tab")
411 }
412
413 tab := tm.tabs[index]
414 logging.Info(fmt.Sprintf("[tabs] Closing tab %d (%s)", index, tab.id))
415
416 // Cleanup workspace (WorkspaceManager cleanup is handled automatically when tabs are removed)
417
418 // Clean up button mapping (find and remove this tab's button ID)
419 for buttonID, mappedTab := range tm.buttonToTab {
420 if mappedTab == tab {
421 delete(tm.buttonToTab, buttonID)
422 logging.Debug(fmt.Sprintf("[tabs] Cleaned up button mapping for closed tab %s (buttonID=%d)", tab.id, buttonID))
423 break
424 }
425 }
426
427 // Remove tab button from tab bar
428 tm.removeTabFromBar(tab)
429
430 // Remove workspace container from content area if it's currently visible
431 // CRITICAL: Get the CURRENT root widget from workspace, not the cached tab.container
432 if tab.isActive && tab.workspace != nil {
433 currentRoot := tab.workspace.GetRootWidget()
434 if currentRoot != nil {
435 webkit.RunOnMainThread(func() {
436 if contentBox, ok := tm.ContentArea.(*gtk.Box); ok {
437 contentBox.Remove(currentRoot)
438 logging.Info(fmt.Sprintf("[tabs] Removed closing tab %d container %p from ContentArea (current root)", index, currentRoot))
439 }
440 })
441 }
442 }
443
444 // Remove from slice
445 tm.tabs = append(tm.tabs[:index], tm.tabs[index+1:]...)
446
447 // Determine which tab to switch to
448 newIndex := index
449 if index >= len(tm.tabs) {
450 newIndex = len(tm.tabs) - 1
451 }
452
453 // Update active index and switch
454 if index == tm.activeIndex {
455 tm.activeIndex = -1 // Temporarily invalid
456 if err := tm.switchToTabInternal(newIndex); err != nil {
457 return fmt.Errorf("failed to switch after close: %w", err)
458 }
459 } else if index < tm.activeIndex {
460 // Adjust active index if a tab before it was closed
461 tm.activeIndex--
462 }
463
464 // Update tab bar visibility (hide if only 1 tab)
465 tm.updateTabBarVisibility()
466
467 // Update progress bar (active tab may have changed after close)
468 tm.updateProgressBarLocked()
469
470 logging.Info(fmt.Sprintf("[tabs] Tab closed (remaining: %d)", len(tm.tabs)))
471 return nil
472}
473
474// RenameTab sets a custom title for the tab at the given index.
475func (tm *TabManager) RenameTab(index int, customTitle string) error {
476 tm.mu.Lock()
477 defer tm.mu.Unlock()
478
479 if index < 0 || index >= len(tm.tabs) {
480 return fmt.Errorf("tab index %d out of range", index)
481 }
482
483 tab := tm.tabs[index]
484 tab.customTitle = customTitle
485
486 logging.Info(fmt.Sprintf("[tabs] Renamed tab %d to '%s'", index, customTitle))
487
488 // Update tab button label
489 tm.updateTabButton(tab)
490
491 return nil
492}
493
494// Note: Tab titles are static (Tab 1, Tab 2, etc.) and don't change based on page titles.
495// This is different from stacked panes which show page titles.
496// Users can only change tab names via explicit rename action (Alt+T → r)
497
498// GetActiveTab returns the currently active tab.
499func (tm *TabManager) GetActiveTab() *Tab {
500 tm.mu.RLock()
501 defer tm.mu.RUnlock()
502
503 if tm.activeIndex >= 0 && tm.activeIndex < len(tm.tabs) {
504 return tm.tabs[tm.activeIndex]
505 }
506 return nil
507}
508
509// GetTabCount returns the number of open tabs.
510func (tm *TabManager) GetTabCount() int {
511 tm.mu.RLock()
512 defer tm.mu.RUnlock()
513
514 return len(tm.tabs)
515}
516
517// IsTabModeActive returns whether tab mode is currently active.
518func (tm *TabManager) IsTabModeActive() bool {
519 tm.mu.RLock()
520 defer tm.mu.RUnlock()
521
522 return tm.tabModeActive
523}
524
525// IsRenameInProgress returns whether a tab rename is currently in progress.
526func (tm *TabManager) IsRenameInProgress() bool {
527 tm.mu.RLock()
528 defer tm.mu.RUnlock()
529
530 return tm.renameInProgress
531}
532
533// NextTab switches to the next tab (wraps around).
534func (tm *TabManager) NextTab() error {
535 tm.mu.Lock()
536 defer tm.mu.Unlock()
537
538 if len(tm.tabs) <= 1 {
539 return nil // No other tab to switch to
540 }
541
542 nextIndex := (tm.activeIndex + 1) % len(tm.tabs)
543 return tm.switchToTabInternal(nextIndex)
544}
545
546// PreviousTab switches to the previous tab (wraps around).
547func (tm *TabManager) PreviousTab() error {
548 tm.mu.Lock()
549 defer tm.mu.Unlock()
550
551 if len(tm.tabs) <= 1 {
552 return nil // No other tab to switch to
553 }
554
555 prevIndex := tm.activeIndex - 1
556 if prevIndex < 0 {
557 prevIndex = len(tm.tabs) - 1
558 }
559 return tm.switchToTabInternal(prevIndex)
560}
561
562// GetRootContainer returns the main container widget that should be set as window child.
563func (tm *TabManager) GetRootContainer() gtk.Widgetter {
564 tm.mu.RLock()
565 defer tm.mu.RUnlock()
566
567 return tm.rootContainer
568}
569
570// GetWindow returns the GTK window backing the tab manager.
571func (tm *TabManager) GetWindow() *webkit.Window {
572 tm.mu.RLock()
573 defer tm.mu.RUnlock()
574
575 return tm.window
576}
577
578// Cleanup performs cleanup of all tabs and resources.
579func (tm *TabManager) Cleanup() {
580 tm.mu.Lock()
581 defer tm.mu.Unlock()
582
583 logging.Info("[tabs] Cleaning up tab manager")
584
585 // Stop tab mode timer if active
586 if tm.tabModeTimer != nil {
587 tm.tabModeTimer.Stop()
588 }
589
590 // Cleanup all tabs (workspaces cleanup automatically when dereferenced)
591
592 logging.Info("[tabs] Tab manager cleanup complete")
593}
594
595// updateTabBarVisibility shows or hides the tab bar based on tab count.
596// Tab bar is hidden when there's only 1 tab (no need to show it).
597func (tm *TabManager) updateTabBarVisibility() {
598 if tm.tabBar == nil {
599 return
600 }
601
602 tabCount := len(tm.tabs)
603 shouldShow := tabCount > 1
604
605 webkit.WidgetSetVisible(tm.tabBar, shouldShow)
606
607 if shouldShow {
608 logging.Debug(fmt.Sprintf("[tabs] Tab bar visible (%d tabs)", tabCount))
609 } else {
610 logging.Debug("[tabs] Tab bar hidden (only 1 tab)")
611 }
612}
613
614// GetConfig is a helper to get the current configuration.
615func (tm *TabManager) getConfig() *config.Config {
616 return config.Get()
617}
618
619// updateProgressForWebView updates progress state for the tab that owns the given WebView.
620func (tm *TabManager) updateProgressForWebView(view *webkit.WebView, progress float64, loading bool) {
621 if view == nil || tm == nil {
622 return
623 }
624
625 tm.mu.Lock()
626 defer tm.mu.Unlock()
627
628 tab := tm.findTabForViewLocked(view)
629 if tab == nil {
630 return
631 }
632
633 // Clamp progress
634 if progress < 0 {
635 progress = 0
636 }
637 if progress > 1 {
638 progress = 1
639 }
640
641 // Only mark loading when progress is incomplete and the event indicates an active load.
642 tab.isLoading = loading && progress < 1.0
643 tab.progress = progress
644
645 tm.updateProgressBarLocked()
646}
647
648// findTabForViewLocked finds the tab owning the given WebView. Caller must hold tm.mu.
649func (tm *TabManager) findTabForViewLocked(view *webkit.WebView) *Tab {
650 for _, tab := range tm.tabs {
651 if _, ok := tab.webViews[view]; ok {
652 return tab
653 }
654 }
655 return nil
656}
657
658// registerWebViewLocked associates a WebView with a tab. Caller must hold tm.mu.
659func (tm *TabManager) registerWebViewLocked(tab *Tab, view *webkit.WebView) {
660 if tab == nil || view == nil {
661 return
662 }
663 if tab.webViews == nil {
664 tab.webViews = make(map[*webkit.WebView]struct{})
665 }
666 tab.webViews[view] = struct{}{}
667}
668
669// registerWebViewForWorkspace registers a webview to the tab that owns the workspace.
670func (tm *TabManager) registerWebViewForWorkspace(view *webkit.WebView, workspace *WorkspaceManager) {
671 if tm == nil || view == nil || workspace == nil {
672 return
673 }
674
675 tm.mu.Lock()
676 defer tm.mu.Unlock()
677
678 for _, tab := range tm.tabs {
679 if tab.workspace == workspace {
680 tm.registerWebViewLocked(tab, view)
681 return
682 }
683 }
684}
685
686// updateProgressBarLocked syncs the shared progress bar to the active tab. Caller must hold tm.mu.
687func (tm *TabManager) updateProgressBarLocked() {
688 if tm.progressBar == nil {
689 return
690 }
691
692 bar, ok := tm.progressBar.(*gtk.ProgressBar)
693 if !ok || bar == nil {
694 return
695 }
696
697 // No active tab -> hide bar
698 if tm.activeIndex < 0 || tm.activeIndex >= len(tm.tabs) {
699 webkit.RunOnMainThread(func() {
700 bar.SetFraction(0.0)
701 webkit.WidgetSetVisible(bar, false)
702 })
703 return
704 }
705
706 activeTab := tm.tabs[tm.activeIndex]
707 visible := activeTab.isLoading
708 fraction := activeTab.progress
709 if !visible {
710 fraction = 0.0
711 }
712
713 webkit.RunOnMainThread(func() {
714 bar.SetFraction(fraction)
715 webkit.WidgetSetVisible(bar, visible)
716 })
717}