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