1package coordinator
2
3import (
4 "context"
5 "fmt"
6
7 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/application/usecase"
9 "github.com/bnema/dumber/internal/domain/entity"
10 "github.com/bnema/dumber/internal/logging"
11 "github.com/bnema/dumber/internal/ui/component"
12 "github.com/bnema/dumber/internal/ui/window"
13)
14
15// TabTarget identifies which TabList and MainWindow a tab operation applies to.
16// Each browser window owns a dedicated TabTarget; operations are scoped to that target.
17type TabTarget struct {
18 Tabs *entity.TabList
19 MainWindow *window.MainWindow
20}
21
22// TabCoordinator manages tab lifecycle operations within a target window.
23// Public methods accept an explicit TabTarget so callers control which window's
24// tabs are mutated. The currentTarget field provides a fallback for callers that
25// operate on the "currently active" browser window (e.g., the keyboard dispatcher).
26type TabCoordinator struct {
27 tabsUC *usecase.ManageTabsUseCase
28 currentTarget TabTarget
29 mainWindow *window.MainWindow // retained for GetTabBar and SetMainWindow
30 hideTabBarWhenSingleTab bool
31
32 // Callbacks to avoid circular dependencies
33 onTabCreated func(ctx context.Context, target TabTarget, tab *entity.Tab)
34 onTabSwitched func(ctx context.Context, target TabTarget, tab *entity.Tab)
35 onTabClosed func(ctx context.Context, target TabTarget, tab *entity.Tab)
36 onQuit func()
37 onCurrentWindowEmpty func(ctx context.Context, target TabTarget)
38 onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) error // For popup tabs
39 onStateChanged func() // For session snapshots
40}
41
42// TabCoordinatorConfig holds configuration for TabCoordinator.
43type TabCoordinatorConfig struct {
44 TabsUC *usecase.ManageTabsUseCase
45 Tabs *entity.TabList // Initial tab list for the default target
46 MainWindow *window.MainWindow
47 HideTabBarWhenSingleTab bool
48}
49
50// NewTabCoordinator creates a new TabCoordinator.
51func NewTabCoordinator(ctx context.Context, cfg TabCoordinatorConfig) *TabCoordinator {
52 log := logging.FromContext(ctx)
53 log.Debug().Msg("creating tab coordinator")
54
55 return &TabCoordinator{
56 tabsUC: cfg.TabsUC,
57 currentTarget: TabTarget{
58 Tabs: cfg.Tabs,
59 MainWindow: cfg.MainWindow,
60 },
61 mainWindow: cfg.MainWindow,
62 hideTabBarWhenSingleTab: cfg.HideTabBarWhenSingleTab,
63 }
64}
65
66// SetCurrentTarget updates the fallback target used by methods that don't receive
67// an explicit TabTarget. This should be called whenever the active browser window changes.
68func (c *TabCoordinator) SetCurrentTarget(target TabTarget) {
69 c.currentTarget = target
70 if target.MainWindow != nil {
71 c.mainWindow = target.MainWindow
72 }
73}
74
75// CurrentTarget returns the currently active fallback target.
76func (c *TabCoordinator) CurrentTarget() TabTarget {
77 return c.currentTarget
78}
79
80// SetOnTabCreated sets the callback for when a tab is created.
81func (c *TabCoordinator) SetOnTabCreated(fn func(ctx context.Context, target TabTarget, tab *entity.Tab)) {
82 c.onTabCreated = fn
83}
84
85// SetOnTabSwitched sets the callback for when a tab switch occurs.
86// This is used to swap workspace views in the content area.
87func (c *TabCoordinator) SetOnTabSwitched(fn func(ctx context.Context, target TabTarget, tab *entity.Tab)) {
88 c.onTabSwitched = fn
89}
90
91// SetOnTabClosed sets the callback for when a tab is removed from its target.
92func (c *TabCoordinator) SetOnTabClosed(fn func(ctx context.Context, target TabTarget, tab *entity.Tab)) {
93 c.onTabClosed = fn
94}
95
96// SetOnQuit sets the callback for when the last tab is closed.
97func (c *TabCoordinator) SetOnQuit(fn func()) {
98 c.onQuit = fn
99}
100
101// SetOnStateChanged sets the callback for when tab state changes (for session snapshots).
102func (c *TabCoordinator) SetOnStateChanged(fn func()) {
103 c.onStateChanged = fn
104}
105
106// SetOnCurrentWindowEmpty sets the callback for when the last tab in the current
107// window is closed. This allows the app to close the window gracefully.
108func (c *TabCoordinator) SetOnCurrentWindowEmpty(fn func(ctx context.Context, target TabTarget)) {
109 c.onCurrentWindowEmpty = fn
110}
111
112// SetMainWindow updates only the main window reference for coordinator setup.
113//
114// Deprecated: Use SetCurrentTarget for runtime targeting. This method preserves
115// currentTarget.Tabs and is safe only during app-level setup.
116func (c *TabCoordinator) SetMainWindow(mainWindow *window.MainWindow) {
117 oldMainWindow := c.mainWindow
118 c.mainWindow = mainWindow
119 if c.currentTarget.MainWindow != mainWindow {
120 c.currentTarget = TabTarget{
121 Tabs: c.currentTarget.Tabs,
122 MainWindow: mainWindow,
123 }
124 }
125 if c.currentTarget.Tabs != nil && c.currentTarget.Tabs.Count() > 0 && oldMainWindow != mainWindow {
126 logging.FromContext(context.Background()).Debug().
127 Int("tab_count", c.currentTarget.Tabs.Count()).
128 Str("old_main_window", fmt.Sprintf("%p", oldMainWindow)).
129 Str("new_main_window", fmt.Sprintf("%p", mainWindow)).
130 Msg("SetMainWindow: changing mainWindow while tabs exist")
131 }
132}
133
134// SetOnAttachPopupToTab sets the callback for attaching popup WebViews to tabs.
135func (c *TabCoordinator) SetOnAttachPopupToTab(fn func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) error) {
136 c.onAttachPopupToTab = fn
137}
138
139// notifyStateChanged triggers the state changed callback if set.
140func (c *TabCoordinator) notifyStateChanged() {
141 if c.onStateChanged != nil {
142 c.onStateChanged()
143 }
144}
145
146// indexOfTab returns the 0-based index of the given tab ID in a slice of tabs,
147// or -1 if not found.
148func indexOfTab(tabs []*entity.Tab, id entity.TabID) int {
149 for i, tab := range tabs {
150 if tab != nil && tab.ID == id {
151 return i
152 }
153 }
154 return -1
155}
156
157// Create creates a new tab in the given target with the given initial URL.
158func (c *TabCoordinator) Create(ctx context.Context, target TabTarget, initialURL string) (*entity.Tab, error) {
159 return c.create(ctx, target, initialURL, true)
160}
161
162func (c *TabCoordinator) create(ctx context.Context, target TabTarget, initialURL string, activate bool) (*entity.Tab, error) {
163 log := logging.FromContext(ctx)
164
165 if target.Tabs == nil {
166 return nil, fmt.Errorf("target.Tabs is nil")
167 }
168
169 output, err := c.tabsUC.Create(ctx, usecase.CreateTabInput{
170 TabList: target.Tabs,
171 Name: "",
172 InitialURL: initialURL,
173 })
174 if err != nil {
175 log.Error().Err(err).Msg("failed to create tab")
176 return nil, err
177 }
178
179 if activate {
180 // Set new tab as active (updates domain state and tracks previous)
181 target.Tabs.SetActive(output.Tab.ID)
182 }
183
184 // Notify app before adding the tab to the visible tab bar.
185 // The app assigns window ownership and the window-scoped default name here,
186 // and TabBar.AddTab reads tab.Title() immediately when creating the button.
187 if c.onTabCreated != nil {
188 c.onTabCreated(ctx, target, output.Tab)
189 }
190
191 // Update tab bar
192 if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
193 target.MainWindow.TabBar().AddTab(output.Tab)
194 if activate {
195 target.MainWindow.TabBar().SetActive(output.Tab.ID)
196 }
197 }
198
199 // Update tab bar visibility
200 c.UpdateBarVisibility(ctx, target)
201
202 // Switch to the new tab's workspace view when activation is requested.
203 if activate && c.onTabSwitched != nil {
204 c.onTabSwitched(ctx, target, output.Tab)
205 }
206
207 // Notify state change for session snapshots
208 c.notifyStateChanged()
209
210 log.Debug().Str("tab_id", string(output.Tab.ID)).Msg("tab created")
211 return output.Tab, nil
212}
213
214// Close closes the active tab in the given target.
215// When the target's TabList becomes empty, fires onCurrentWindowEmpty.
216// The caller (App) is responsible for deciding whether all windows are empty
217// and the process should quit. onQuit is NOT fired by Close.
218func (c *TabCoordinator) Close(ctx context.Context, target TabTarget) error {
219 log := logging.FromContext(ctx)
220
221 if target.Tabs == nil {
222 return fmt.Errorf("target.Tabs is nil")
223 }
224
225 activeID := target.Tabs.ActiveTabID
226 if activeID == "" {
227 log.Debug().Msg("no active tab to close")
228 return nil
229 }
230
231 tabs := target.Tabs.Tabs
232
233 // Compute the next tab to activate BEFORE closing
234 activeIdx := indexOfTab(tabs, activeID)
235
236 var nextID entity.TabID
237 if activeIdx >= 0 && len(tabs) > 1 {
238 if activeIdx < len(tabs)-1 {
239 nextID = tabs[activeIdx+1].ID
240 } else {
241 nextID = tabs[activeIdx-1].ID
242 }
243 }
244
245 closedTab := target.Tabs.Find(activeID)
246 wasLast, err := c.tabsUC.Close(ctx, target.Tabs, activeID)
247 if err != nil {
248 log.Error().Err(err).Msg("failed to close tab")
249 return err
250 }
251
252 if c.onTabClosed != nil && closedTab != nil {
253 c.onTabClosed(ctx, target, closedTab)
254 }
255
256 if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
257 target.MainWindow.TabBar().RemoveTab(activeID)
258 }
259
260 // Update tab bar visibility
261 c.UpdateBarVisibility(ctx, target)
262
263 // Target TabList became empty — signal window-empty.
264 // App decides whether to quit (all windows empty) or only close this window.
265 if wasLast {
266 c.notifyStateChanged()
267 if c.onCurrentWindowEmpty != nil {
268 c.onCurrentWindowEmpty(ctx, target)
269 }
270 log.Debug().Str("tab_id", string(activeID)).Msg("tab closed, target window empty")
271 return nil
272 }
273
274 // If there's a sibling tab in the same window, switch to it
275 if nextID != "" {
276 if err := c.tabsUC.Switch(ctx, target.Tabs, nextID); err != nil {
277 log.Error().Err(err).Str("tab_id", string(nextID)).Msg("failed to switch to sibling")
278 return err
279 }
280
281 if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
282 target.MainWindow.TabBar().SetActive(nextID)
283 }
284
285 if c.onTabSwitched != nil {
286 if tab := target.Tabs.Find(nextID); tab != nil {
287 c.onTabSwitched(ctx, target, tab)
288 }
289 }
290
291 // Notify state change for session snapshots
292 c.notifyStateChanged()
293
294 log.Debug().Str("tab_id", string(activeID)).Str("next", string(nextID)).Msg("tab closed, switched to sibling")
295 return nil
296 }
297
298 return c.recoverCloseWithoutSibling(ctx, target, activeID)
299}
300
301func (c *TabCoordinator) recoverCloseWithoutSibling(ctx context.Context, target TabTarget, closedID entity.TabID) error {
302 log := logging.FromContext(ctx)
303 // Defensive corrupted-state guard: only treat the target as empty if it is
304 // actually empty after close; otherwise recover by activating a surviving tab.
305 c.notifyStateChanged()
306 if len(target.Tabs.Tabs) == 0 {
307 if c.onCurrentWindowEmpty != nil {
308 c.onCurrentWindowEmpty(ctx, target)
309 }
310 log.Debug().Str("tab_id", string(closedID)).Msg("tab closed, target window empty")
311 return nil
312 }
313 for _, tab := range target.Tabs.Tabs {
314 if tab != nil {
315 return c.Switch(ctx, target, tab.ID)
316 }
317 }
318 return fmt.Errorf("tab closed but no surviving tab could be activated")
319}
320
321// Switch switches to a specific tab by ID in the given target.
322func (c *TabCoordinator) Switch(ctx context.Context, target TabTarget, tabID entity.TabID) error {
323 log := logging.FromContext(ctx)
324
325 if target.Tabs == nil {
326 return fmt.Errorf("target.Tabs is nil")
327 }
328
329 // Skip if already active
330 if tabID == target.Tabs.ActiveTabID {
331 return nil
332 }
333
334 // Verify the tab exists in this target
335 tab := target.Tabs.Find(tabID)
336 if tab == nil {
337 log.Debug().Str("tab_id", string(tabID)).Msg("tab not found in target")
338 return nil
339 }
340
341 log.Debug().
342 Str("from", string(target.Tabs.ActiveTabID)).
343 Str("to", string(tabID)).
344 Msg("switching tab")
345
346 // Update domain state
347 if err := c.tabsUC.Switch(ctx, target.Tabs, tabID); err != nil {
348 log.Error().Err(err).Str("tab_id", string(tabID)).Msg("failed to switch tab")
349 return err
350 }
351
352 // Update tab bar UI
353 if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
354 target.MainWindow.TabBar().SetActive(tabID)
355 }
356
357 // Invoke callback for workspace view switching
358 if c.onTabSwitched != nil {
359 c.onTabSwitched(ctx, target, tab)
360 }
361
362 c.notifyStateChanged()
363
364 return nil
365}
366
367// switchRelative switches to the tab delta positions away within the target.
368// delta of +1 switches next, -1 switches previous, wrapping within the list.
369func (c *TabCoordinator) switchRelative(ctx context.Context, target TabTarget, delta int) error {
370 if target.Tabs == nil {
371 return fmt.Errorf("target.Tabs is nil")
372 }
373 tabs := target.Tabs.Tabs
374 if len(tabs) <= 1 {
375 return nil
376 }
377
378 current := indexOfTab(tabs, target.Tabs.ActiveTabID)
379 if current < 0 {
380 current = 0
381 } else {
382 current = (current + delta + len(tabs)) % len(tabs)
383 }
384
385 return c.Switch(ctx, target, tabs[current].ID)
386}
387
388// SwitchNext switches to the next tab within the given target.
389func (c *TabCoordinator) SwitchNext(ctx context.Context, target TabTarget) error {
390 return c.switchRelative(ctx, target, 1)
391}
392
393// SwitchPrev switches to the previous tab within the given target.
394func (c *TabCoordinator) SwitchPrev(ctx context.Context, target TabTarget) error {
395 return c.switchRelative(ctx, target, -1)
396}
397
398// SwitchByIndexCreating switches to a tab by 0-based index within the given target,
399// creating new tabs as needed to reach the requested index.
400// If the requested index is beyond the current tab count and initialURL is empty,
401// it returns an error without mutating tabs. If index < 0, it logs debug and returns nil.
402func (c *TabCoordinator) SwitchByIndexCreating(ctx context.Context, target TabTarget, index int, initialURL string) error {
403 log := logging.FromContext(ctx)
404
405 if target.Tabs == nil {
406 return fmt.Errorf("target.Tabs is nil")
407 }
408 if index < 0 {
409 log.Debug().Int("index", index).Msg("invalid tab index, ignoring")
410 return nil
411 }
412 if target.Tabs.Count() <= index && initialURL == "" {
413 return fmt.Errorf("initial URL is required to create missing tabs")
414 }
415 // Intermediate tabs are created inactive (create(..., false)) and the final
416 // missing tab is created active (create(..., true)) before SwitchByIndex.
417 for target.Tabs.Count() < index {
418 if _, err := c.create(ctx, target, initialURL, false); err != nil {
419 return err
420 }
421 }
422 if target.Tabs.Count() <= index {
423 if _, err := c.create(ctx, target, initialURL, true); err != nil {
424 return err
425 }
426 }
427 return c.SwitchByIndex(ctx, target, index)
428}
429
430// SwitchByIndex switches to a tab by 0-based index within the given target.
431func (c *TabCoordinator) SwitchByIndex(ctx context.Context, target TabTarget, index int) error {
432 log := logging.FromContext(ctx)
433
434 if target.Tabs == nil {
435 return fmt.Errorf("target.Tabs is nil")
436 }
437 tabs := target.Tabs.Tabs
438 if index < 0 || index >= len(tabs) {
439 log.Debug().Int("index", index).Int("count", len(tabs)).Msg("invalid tab index")
440 return nil
441 }
442
443 return c.Switch(ctx, target, tabs[index].ID)
444}
445
446// SwitchToLastActive switches to the previously active tab (Alt+Tab style)
447// within the given target.
448func (c *TabCoordinator) SwitchToLastActive(ctx context.Context, target TabTarget) error {
449 if target.Tabs == nil {
450 return nil
451 }
452
453 prevID := target.Tabs.PreviousActiveTabID
454 if prevID == "" || prevID == target.Tabs.ActiveTabID {
455 return nil
456 }
457
458 tab := target.Tabs.Find(prevID)
459 if tab == nil {
460 target.Tabs.PreviousActiveTabID = ""
461 return nil
462 }
463
464 return c.Switch(ctx, target, prevID)
465}
466
467// UpdateBarVisibility shows or hides the tab bar based on tab count in the target.
468func (c *TabCoordinator) UpdateBarVisibility(ctx context.Context, target TabTarget) {
469 log := logging.FromContext(ctx)
470
471 if target.MainWindow == nil || target.MainWindow.TabBar() == nil {
472 log.Debug().Msg("mainWindow or tabBar is nil, skipping visibility update")
473 return
474 }
475
476 // Check if feature is enabled
477 if !c.hideTabBarWhenSingleTab {
478 log.Debug().Msg("tab bar auto-hide disabled by config")
479 target.MainWindow.TabBar().SetAutoHidden(false)
480 target.MainWindow.SetTabBarContentInsetVisible(true)
481 return
482 }
483
484 tabCount := target.MainWindow.TabBar().Count()
485 shouldShow := tabCount > 1
486
487 log.Debug().Int("tab_count", tabCount).Bool("should_show", shouldShow).Msg("setting tab bar visibility")
488 target.MainWindow.TabBar().SetAutoHidden(!shouldShow)
489 target.MainWindow.SetTabBarContentInsetVisible(shouldShow)
490}
491
492// GetTabBar returns the tab bar component from the current main window.
493func (c *TabCoordinator) GetTabBar() *component.TabBar {
494 if c.mainWindow != nil {
495 return c.mainWindow.TabBar()
496 }
497 return nil
498}
499
500// CreateWithPane creates a new tab with a pre-created pane and WebView in the given target.
501// This is used for tabbed popup behavior where the popup pane already exists.
502func (c *TabCoordinator) CreateWithPane(
503 ctx context.Context,
504 target TabTarget,
505 pane *entity.Pane,
506 wv port.WebView,
507 initialURL string,
508) (*entity.Tab, error) {
509 log := logging.FromContext(ctx)
510
511 if target.Tabs == nil {
512 return nil, fmt.Errorf("target.Tabs is nil")
513 }
514
515 output, err := c.tabsUC.CreateWithPane(ctx, usecase.CreateTabWithPaneInput{
516 TabList: target.Tabs,
517 Name: pane.Title,
518 Pane: pane,
519 InitialURL: initialURL,
520 })
521 if err != nil {
522 log.Error().Err(err).Msg("failed to create tab with pane")
523 return nil, err
524 }
525
526 // Set new tab as active
527 target.Tabs.SetActive(output.Tab.ID)
528
529 // Notify app before adding tab to the visible tab bar.
530 if c.onTabCreated != nil {
531 c.onTabCreated(ctx, target, output.Tab)
532 }
533
534 // Update tab bar
535 if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
536 target.MainWindow.TabBar().AddTab(output.Tab)
537 target.MainWindow.TabBar().SetActive(output.Tab.ID)
538 }
539
540 // Update tab bar visibility
541 c.UpdateBarVisibility(ctx, target)
542
543 // Attach the popup WebView to the new tab's workspace. The caller retains
544 // WebView ownership until this succeeds.
545 if c.onAttachPopupToTab != nil {
546 if err := c.onAttachPopupToTab(ctx, output.Tab.ID, pane, wv); err != nil {
547 target.Tabs.Remove(output.Tab.ID)
548 if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
549 target.MainWindow.TabBar().RemoveTab(output.Tab.ID)
550 }
551 c.UpdateBarVisibility(ctx, target)
552 if c.onTabClosed != nil {
553 c.onTabClosed(ctx, target, output.Tab)
554 }
555 return nil, fmt.Errorf("attach popup webview: %w", err)
556 }
557 }
558
559 // Switch to the new tab's workspace view
560 if c.onTabSwitched != nil {
561 c.onTabSwitched(ctx, target, output.Tab)
562 }
563
564 // Notify state change for session snapshots
565 c.notifyStateChanged()
566
567 log.Debug().
568 Str("tab_id", string(output.Tab.ID)).
569 Str("pane_id", string(pane.ID)).
570 Msg("tab created with popup pane")
571 return output.Tab, nil
572}