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