detached 33d4791

refactor(app): migrate to coordinator-based architecture

Refactor App to use new coordinator pattern:
- Initialize ContentCoordinator, TabCoordinator, WorkspaceCoordinator,
  NavigationCoordinator, and KeyboardDispatcher during activation
- Remove legacy handleKeyboardAction and individual keyboard handlers
- Wire omnibox and tab bar creation through coordinators
- Remove direct webViews and paneTitles maps (managed by ContentCoordinator)
- Delegate tab creation to TabCoordinator

This migration removes 1129 lines of legacy handler code and improves
separation of concerns while maintaining all functionality.
commit
33d4791f79a9080c84bd6665e2ae236965b1a9d6
author
brice · · 2025-12-15 21:18 UTC
committer
brice · · 2025-12-15 17:04 UTC
parent
ccef613
signature
unsigned
1 file · +84 -1129
M internal/ui/app.go +84 -1129
@@ -3,7 +3,6 @@ package ui
33 import (
44 "context"
55 "errors"
6- "fmt"
76 "os"
87 "sync"
98
@@ -15,6 +14,8 @@ import (
1514 "github.com/bnema/dumber/internal/infrastructure/webkit"
1615 "github.com/bnema/dumber/internal/logging"
1716 "github.com/bnema/dumber/internal/ui/component"
17+ "github.com/bnema/dumber/internal/ui/coordinator"
18+ "github.com/bnema/dumber/internal/ui/dispatcher"
1819 "github.com/bnema/dumber/internal/ui/focus"
1920 "github.com/bnema/dumber/internal/ui/input"
2021 "github.com/bnema/dumber/internal/ui/layout"
@@ -36,7 +37,14 @@ type App struct {
3637 tabs *entity.TabList
3738 tabsUC *usecase.ManageTabsUseCase
3839
39- // Pane management
40+ // Coordinators (new architecture)
41+ contentCoord *coordinator.ContentCoordinator
42+ tabCoord *coordinator.TabCoordinator
43+ wsCoord *coordinator.WorkspaceCoordinator
44+ navCoord *coordinator.NavigationCoordinator
45+ kbDispatcher *dispatcher.KeyboardDispatcher
46+
47+ // Pane management (used by coordinators)
4048 panesUC *usecase.ManagePanesUseCase
4149 workspaceViews map[entity.TabID]*component.WorkspaceView
4250 widgetFactory layout.WidgetFactory
@@ -52,14 +60,11 @@ type App struct {
5260 // Native omnibox
5361 omnibox *component.Omnibox
5462
55- // Web content
56- pool *webkit.WebViewPool
57- injector *webkit.ContentInjector
58- router *webkit.MessageRouter
59- settings *webkit.SettingsManager
60- webViews map[entity.PaneID]*webkit.WebView
61- paneTitles map[entity.PaneID]string // Dynamic title tracking
62- titleMu sync.RWMutex
63+ // Web content (managed by ContentCoordinator)
64+ pool *webkit.WebViewPool
65+ injector *webkit.ContentInjector
66+ router *webkit.MessageRouter
67+ settings *webkit.SettingsManager
6368
6469 // ID generator for tabs/panes
6570 idCounter uint64
@@ -87,8 +92,6 @@ func New(deps *Dependencies) (*App, error) {
8792 injector: deps.Injector,
8893 router: deps.MessageRouter,
8994 settings: deps.Settings,
90- webViews: make(map[entity.PaneID]*webkit.WebView),
91- paneTitles: make(map[entity.PaneID]string),
9295 cancel: cancel,
9396 }
9497 if app.router == nil {
@@ -167,13 +170,17 @@ func (a *App) onActivate(ctx context.Context) {
167170 // Initialize border manager for mode indicators
168171 a.borderMgr = focus.NewBorderManager(a.widgetFactory)
169172
170- // Create keyboard handler
173+ // Initialize coordinators
174+ a.initCoordinators(ctx)
175+
176+ // Create keyboard handler and wire to dispatcher
171177 a.keyboardHandler = input.NewKeyboardHandler(
172178 ctx,
173179 &a.deps.Config.Workspace,
174180 )
175- a.keyboardHandler.SetOnAction(a.handleKeyboardAction)
176- // Wrap handleModeChange to match the callback signature
181+ a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error {
182+ return a.kbDispatcher.Dispatch(ctx, action)
183+ })
177184 a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {
178185 a.handleModeChange(ctx, from, to)
179186 })
@@ -189,15 +196,18 @@ func (a *App) onActivate(ctx context.Context) {
189196 })
190197 if a.omnibox != nil {
191198 a.omnibox.SetOnNavigate(func(url string) {
192- a.navigateActivePane(ctx, url)
199+ a.navCoord.Navigate(ctx, url)
193200 })
201+ a.navCoord.SetOmnibox(a.omnibox)
194202 log.Debug().Msg("native omnibox created")
195203 } else {
196204 log.Warn().Msg("failed to create native omnibox")
197205 }
198206
199- // Create an initial tab
200- a.createInitialTab(ctx)
207+ // Create an initial tab using coordinator
208+ if _, err := a.tabCoord.Create(ctx, "https://duckduckgo.com"); err != nil {
209+ log.Error().Err(err).Msg("failed to create initial tab")
210+ }
201211
202212 // Show the window
203213 a.mainWindow.Show()
@@ -221,38 +231,67 @@ func (a *App) onShutdown(ctx context.Context) {
221231 log.Info().Msg("application shutdown complete")
222232 }
223233
224-// createInitialTab creates the first tab when the application starts.
225-func (a *App) createInitialTab(ctx context.Context) {
234+// initCoordinators initializes all coordinators and wires their callbacks.
235+func (a *App) initCoordinators(ctx context.Context) {
226236 log := logging.FromContext(ctx)
237+ log.Debug().Msg("initializing coordinators")
227238
228- if a.tabsUC == nil {
229- // Create use case if not injected
230- a.tabsUC = usecase.NewManageTabsUseCase(a.generateID)
239+ // Helper to get active workspace and view
240+ getActiveWS := func() (*entity.Workspace, *component.WorkspaceView) {
241+ return a.activeWorkspace(), a.activeWorkspaceView()
231242 }
232243
233- output, err := a.tabsUC.Create(ctx, usecase.CreateTabInput{
234- TabList: a.tabs,
235- Name: "",
236- InitialURL: "https://duckduckgo.com",
237- })
238- if err != nil {
239- log.Error().Err(err).Msg("failed to create initial tab")
240- return
241- }
244+ // 1. Content Coordinator (no dependencies on other coordinators)
245+ a.contentCoord = coordinator.NewContentCoordinator(
246+ ctx,
247+ a.pool,
248+ a.widgetFactory,
249+ getActiveWS,
250+ )
242251
243- // Update tab bar
244- if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
245- a.mainWindow.TabBar().AddTab(output.Tab)
246- a.mainWindow.TabBar().SetActive(output.Tab.ID)
247- }
252+ // 2. Tab Coordinator
253+ a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
254+ TabsUC: a.tabsUC,
255+ Tabs: a.tabs,
256+ MainWindow: a.mainWindow,
257+ Config: a.deps.Config,
258+ })
259+ a.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {
260+ a.createWorkspaceView(ctx, tab)
261+ })
262+ a.tabCoord.SetOnQuit(a.Quit)
263+
264+ // 3. Workspace Coordinator
265+ a.wsCoord = coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{
266+ PanesUC: a.panesUC,
267+ FocusMgr: a.focusMgr,
268+ StackedPaneMgr: a.stackedPaneMgr,
269+ WidgetFactory: a.widgetFactory,
270+ ContentCoord: a.contentCoord,
271+ GetActiveWS: getActiveWS,
272+ GenerateID: a.generateID,
273+ })
274+ a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error {
275+ return a.tabCoord.Close(ctx)
276+ })
248277
249- // Update tab bar visibility (hide if only 1 tab)
250- a.updateTabBarVisibility(ctx)
278+ // 4. Navigation Coordinator
279+ a.navCoord = coordinator.NewNavigationCoordinator(
280+ ctx,
281+ a.deps.NavigateUC,
282+ a.contentCoord,
283+ )
251284
252- // Create workspace view for this tab
253- a.createWorkspaceView(ctx, output.Tab)
285+ // 5. Keyboard Dispatcher
286+ a.kbDispatcher = dispatcher.NewKeyboardDispatcher(
287+ ctx,
288+ a.tabCoord,
289+ a.wsCoord,
290+ a.navCoord,
291+ )
292+ a.kbDispatcher.SetOnQuit(a.Quit)
254293
255- log.Debug().Str("tab_id", string(output.Tab.ID)).Msg("initial tab created")
294+ log.Debug().Msg("coordinators initialized")
256295 }
257296
258297 // generateID generates a unique ID for tabs and panes.
@@ -291,87 +330,6 @@ func RunWithArgs(ctx context.Context, deps *Dependencies) int {
291330 return app.Run(ctx, os.Args)
292331 }
293332
294-// handleKeyboardAction dispatches keyboard actions to appropriate handlers.
295-func (a *App) handleKeyboardAction(ctx context.Context, action input.Action) error {
296- log := logging.FromContext(ctx)
297- log.Debug().Str("action", string(action)).Msg("handling keyboard action")
298-
299- switch action {
300- // Tab actions
301- case input.ActionNewTab:
302- return a.handleNewTab(ctx)
303- case input.ActionCloseTab:
304- return a.handleCloseTab(ctx)
305- case input.ActionNextTab:
306- return a.handleNextTab(ctx)
307- case input.ActionPreviousTab:
308- return a.handlePreviousTab(ctx)
309-
310- // Pane actions
311- case input.ActionSplitRight:
312- return a.handlePaneSplit(ctx, usecase.SplitRight)
313- case input.ActionSplitLeft:
314- return a.handlePaneSplit(ctx, usecase.SplitLeft)
315- case input.ActionSplitUp:
316- return a.handlePaneSplit(ctx, usecase.SplitUp)
317- case input.ActionSplitDown:
318- return a.handlePaneSplit(ctx, usecase.SplitDown)
319- case input.ActionClosePane:
320- return a.handleClosePane(ctx)
321- case input.ActionStackPane:
322- return a.handleStackPane(ctx)
323- case input.ActionFocusRight:
324- return a.handlePaneFocus(ctx, usecase.NavRight)
325- case input.ActionFocusLeft:
326- return a.handlePaneFocus(ctx, usecase.NavLeft)
327- case input.ActionFocusUp:
328- return a.handlePaneFocus(ctx, usecase.NavUp)
329- case input.ActionFocusDown:
330- return a.handlePaneFocus(ctx, usecase.NavDown)
331-
332- // Stack navigation
333- case input.ActionStackNavUp:
334- return a.handleStackNavigate(ctx, "up")
335- case input.ActionStackNavDown:
336- return a.handleStackNavigate(ctx, "down")
337-
338- // Navigation (stub implementations for now)
339- case input.ActionGoBack:
340- log.Debug().Msg("go back action (not yet implemented)")
341- case input.ActionGoForward:
342- log.Debug().Msg("go forward action (not yet implemented)")
343- case input.ActionReload:
344- log.Debug().Msg("reload action (not yet implemented)")
345- case input.ActionHardReload:
346- log.Debug().Msg("hard reload action (not yet implemented)")
347-
348- // Zoom (stub implementations for now)
349- case input.ActionZoomIn:
350- log.Debug().Msg("zoom in action (not yet implemented)")
351- case input.ActionZoomOut:
352- log.Debug().Msg("zoom out action (not yet implemented)")
353- case input.ActionZoomReset:
354- log.Debug().Msg("zoom reset action (not yet implemented)")
355-
356- // UI
357- case input.ActionOpenOmnibox:
358- return a.openOmnibox(ctx)
359- case input.ActionOpenDevTools:
360- return a.openDevTools(ctx)
361- case input.ActionToggleFullscreen:
362- log.Debug().Msg("toggle fullscreen action (not yet implemented)")
363-
364- // Application
365- case input.ActionQuit:
366- a.Quit()
367-
368- default:
369- log.Warn().Str("action", string(action)).Msg("unhandled keyboard action")
370- }
371-
372- return nil
373-}
374-
375333 // handleModeChange is called when the input mode changes.
376334 func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
377335 log := logging.FromContext(ctx)
@@ -383,124 +341,6 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
383341 }
384342 }
385343
386-// handleNewTab creates a new tab.
387-func (a *App) handleNewTab(ctx context.Context) error {
388- log := logging.FromContext(ctx)
389-
390- output, err := a.tabsUC.Create(ctx, usecase.CreateTabInput{
391- TabList: a.tabs,
392- Name: "",
393- InitialURL: "about:blank",
394- })
395- if err != nil {
396- return err
397- }
398-
399- if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
400- a.mainWindow.TabBar().AddTab(output.Tab)
401- a.mainWindow.TabBar().SetActive(output.Tab.ID)
402- }
403-
404- // Update tab bar visibility
405- a.updateTabBarVisibility(ctx)
406-
407- log.Debug().Str("tab_id", string(output.Tab.ID)).Msg("new tab created")
408-
409- return nil
410-}
411-
412-// handleCloseTab closes the active tab.
413-func (a *App) handleCloseTab(ctx context.Context) error {
414- activeID := a.tabs.ActiveTabID
415- if activeID == "" {
416- return nil
417- }
418-
419- wasLast, err := a.tabsUC.Close(ctx, a.tabs, activeID)
420- if err != nil {
421- return err
422- }
423-
424- if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
425- a.mainWindow.TabBar().RemoveTab(activeID)
426-
427- // Switch to new active tab if any
428- if a.tabs.ActiveTabID != "" {
429- a.mainWindow.TabBar().SetActive(a.tabs.ActiveTabID)
430- }
431- }
432-
433- // Update tab bar visibility
434- a.updateTabBarVisibility(ctx)
435-
436- // Quit if no tabs left
437- if wasLast {
438- a.Quit()
439- }
440-
441- return nil
442-}
443-
444-// updateTabBarVisibility shows or hides the tab bar based on tab count.
445-// Tab bar is hidden when there's only 1 tab (configurable).
446-func (a *App) updateTabBarVisibility(ctx context.Context) {
447- log := logging.FromContext(ctx)
448-
449- // Debug: trace entry
450- hideEnabled := true
451- if a.deps.Config != nil {
452- hideEnabled = a.deps.Config.Workspace.HideTabBarWhenSingleTab
453- }
454- log.Debug().Bool("hide_enabled", hideEnabled).Msg("updateTabBarVisibility called")
455-
456- // Check if feature is enabled
457- if a.deps.Config != nil && !a.deps.Config.Workspace.HideTabBarWhenSingleTab {
458- log.Debug().Msg("tab bar auto-hide disabled by config")
459- return
460- }
461-
462- if a.mainWindow == nil {
463- log.Debug().Msg("mainWindow is nil, skipping visibility update")
464- return
465- }
466- if a.mainWindow.TabBar() == nil {
467- log.Debug().Msg("tabBar is nil, skipping visibility update")
468- return
469- }
470-
471- tabCount := a.tabs.Count()
472- shouldShow := tabCount > 1
473-
474- log.Debug().Int("tab_count", tabCount).Bool("should_show", shouldShow).Msg("setting tab bar visibility")
475- a.mainWindow.TabBar().SetVisible(shouldShow)
476-}
477-
478-// handleNextTab switches to the next tab.
479-func (a *App) handleNextTab(ctx context.Context) error {
480- if err := a.tabsUC.SwitchNext(ctx, a.tabs); err != nil {
481- return err
482- }
483-
484- if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
485- a.mainWindow.TabBar().SetActive(a.tabs.ActiveTabID)
486- }
487-
488- return nil
489-}
490-
491-// handlePreviousTab switches to the previous tab.
492-func (a *App) handlePreviousTab(ctx context.Context) error {
493- if err := a.tabsUC.SwitchPrevious(ctx, a.tabs); err != nil {
494- return err
495- }
496-
497- if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
498- a.mainWindow.TabBar().SetActive(a.tabs.ActiveTabID)
499- }
500-
501- return nil
502-}
503-
504344 // createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.
505345 func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
506346 log := logging.FromContext(ctx)
@@ -524,7 +364,9 @@ func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
524364 }
525365
526366 // Ensure WebViews are attached to panes
527- a.attachWorkspaceWebViews(ctx, tab.Workspace, wsView)
367+ if a.contentCoord != nil {
368+ a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
369+ }
528370
529371 // Attach border manager overlay for mode indicators
530372 if a.borderMgr != nil {
@@ -565,890 +407,3 @@ func (a *App) activeWorkspaceView() *component.WorkspaceView {
565407 }
566408 return a.workspaceViews[activeTab.ID]
567409 }
568-
569-// handlePaneSplit splits the active pane in the given direction.
570-func (a *App) handlePaneSplit(ctx context.Context, direction usecase.SplitDirection) error {
571- log := logging.FromContext(ctx)
572-
573- if a.panesUC == nil {
574- log.Warn().Msg("panes use case not available")
575- return nil
576- }
577-
578- ws := a.activeWorkspace()
579- if ws == nil {
580- log.Warn().Msg("no active workspace")
581- return nil
582- }
583-
584- activePane := ws.ActivePane()
585- if activePane == nil {
586- log.Warn().Msg("no active pane to split")
587- return nil
588- }
589-
590- wsView := a.activeWorkspaceView()
591-
592- // Check if we're splitting from inside a stack (horizontal split around stack)
593- isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) &&
594- activePane.Parent != nil && activePane.Parent.IsStacked
595-
596- // Get the existing StackedView BEFORE domain changes (for incremental operation)
597- var existingStackWidget layout.Widget
598- if isStackSplit && wsView != nil {
599- tr := wsView.TreeRenderer()
600- if tr != nil {
601- // Get any pane's StackedView from the stack - they all map to the same one
602- stackedView := tr.GetStackedViewForPane(string(activePane.Pane.ID))
603- if stackedView != nil {
604- existingStackWidget = stackedView.Widget()
605- }
606- }
607- }
608-
609- output, err := a.panesUC.Split(ctx, usecase.SplitPaneInput{
610- Workspace: ws,
611- TargetPane: activePane,
612- Direction: direction,
613- })
614- if err != nil {
615- log.Error().Err(err).Str("direction", string(direction)).Msg("failed to split pane")
616- return err
617- }
618-
619- // Remember old active pane before changing
620- oldActivePaneID := activePane.Pane.ID
621-
622- // Set the new pane as active
623- ws.ActivePaneID = output.NewPaneNode.Pane.ID
624-
625- // Update the workspace view
626- if wsView != nil {
627- if isStackSplit && existingStackWidget != nil {
628- // Incremental split: reuse existing stack widget
629- if err := a.doIncrementalStackSplit(ctx, wsView, ws, output, direction, existingStackWidget, oldActivePaneID); err != nil {
630- log.Warn().Err(err).Msg("incremental split failed, falling back to rebuild")
631- // Fallback to full rebuild
632- if err := wsView.Rebuild(); err != nil {
633- log.Error().Err(err).Msg("failed to rebuild workspace view")
634- }
635- a.attachWorkspaceWebViews(ctx, ws, wsView)
636- }
637- } else {
638- // Regular split: full rebuild
639- if err := wsView.Rebuild(); err != nil {
640- log.Error().Err(err).Msg("failed to rebuild workspace view")
641- }
642- a.attachWorkspaceWebViews(ctx, ws, wsView)
643- }
644- wsView.FocusPane(ws.ActivePaneID)
645- }
646-
647- log.Info().Str("direction", string(direction)).Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).Msg("pane split completed")
648-
649- return nil
650-}
651-
652-// doIncrementalStackSplit performs an incremental split around an existing stacked pane.
653-// This preserves existing WebViews by reusing the existing StackedView widget and creating
654-// a new SplitView around it, rather than rebuilding the entire widget tree.
655-func (a *App) doIncrementalStackSplit(
656- ctx context.Context,
657- wsView *component.WorkspaceView,
658- ws *entity.Workspace,
659- output *usecase.SplitPaneOutput,
660- direction usecase.SplitDirection,
661- existingStackWidget layout.Widget,
662- oldActivePaneID entity.PaneID,
663-) error {
664- log := logging.FromContext(ctx)
665- factory := wsView.Factory()
666-
667- log.Debug().
668- Str("direction", string(direction)).
669- Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).
670- Str("old_active_pane_id", string(oldActivePaneID)).
671- Msg("performing incremental stack split")
672-
673- // 1. Deactivate the old active pane (in the stack)
674- if oldPaneView := wsView.GetPaneView(oldActivePaneID); oldPaneView != nil {
675- oldPaneView.SetActive(false)
676- }
677-
678- // 2. Unparent the existing stack widget from the workspace container
679- wsView.Container().Remove(existingStackWidget)
680-
681- // 2. Create new PaneView for the new pane (without WebView - will attach later)
682- newPaneView := component.NewPaneView(factory, output.NewPaneNode.Pane.ID, nil)
683-
684- // 3. Wrap the new PaneView in a StackedView
685- newStackedView := layout.NewStackedView(factory)
686- newStackedView.AddPane(ctx, output.NewPaneNode.Pane.Title, "", newPaneView.Widget())
687-
688- // 4. Create a SplitView with the appropriate orientation and child placement
689- var splitView *layout.SplitView
690- if direction == usecase.SplitLeft {
691- // New pane goes on the left, existing stack on the right
692- splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal,
693- newStackedView.Widget(), existingStackWidget, output.SplitRatio)
694- } else {
695- // New pane goes on the right, existing stack on the left (SplitRight)
696- splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal,
697- existingStackWidget, newStackedView.Widget(), output.SplitRatio)
698- }
699-
700- // 5. Replace the root widget in the workspace view
701- wsView.SetRootWidgetDirect(splitView.Widget())
702-
703- // 6. Register the new pane in tracking maps and activate it
704- wsView.RegisterPaneView(output.NewPaneNode.Pane.ID, newPaneView)
705- newPaneView.SetActive(true)
706-
707- tr := wsView.TreeRenderer()
708- if tr != nil {
709- // Register the new pane's StackedView mapping
710- tr.RegisterPaneInStack(string(output.NewPaneNode.Pane.ID), newStackedView)
711- // Register the split node widget
712- tr.RegisterWidget(output.ParentNode.ID, splitView.Widget())
713- }
714-
715- // 7. Attach WebView only for the new pane
716- wv, err := a.ensureWebViewForPane(ctx, output.NewPaneNode.Pane.ID)
717- if err != nil {
718- log.Warn().Err(err).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to ensure webview for new pane")
719- return err
720- }
721-
722- // Load the default URL for new pane
723- newPaneEntity := output.NewPaneNode.Pane
724- if newPaneEntity.URI != "" {
725- if err := wv.LoadURI(ctx, newPaneEntity.URI); err != nil {
726- log.Warn().Err(err).Str("uri", newPaneEntity.URI).Msg("failed to load URI for new pane")
727- }
728- }
729-
730- widget := a.wrapWebViewWidget(ctx, wv)
731- if widget != nil {
732- newPaneView.SetWebViewWidget(widget)
733- }
734-
735- log.Debug().Msg("incremental stack split completed successfully")
736- return nil
737-}
738-
739-// handleClosePane closes the active pane.
740-func (a *App) handleClosePane(ctx context.Context) error {
741- log := logging.FromContext(ctx)
742-
743- if a.panesUC == nil {
744- log.Warn().Msg("panes use case not available")
745- return nil
746- }
747-
748- ws := a.activeWorkspace()
749- if ws == nil {
750- log.Warn().Msg("no active workspace")
751- return nil
752- }
753-
754- activePane := ws.ActivePane()
755- if activePane == nil {
756- log.Warn().Msg("no active pane to close")
757- return nil
758- }
759- closingPaneID := activePane.Pane.ID
760-
761- // Don't close the last pane - close the tab instead
762- if ws.PaneCount() <= 1 {
763- return a.handleCloseTab(ctx)
764- }
765-
766- _, err := a.panesUC.Close(ctx, ws, activePane)
767- if err != nil {
768- log.Error().Err(err).Msg("failed to close pane")
769- return err
770- }
771-
772- // Rebuild the workspace view
773- wsView := a.activeWorkspaceView()
774- if wsView != nil {
775- if err := wsView.Rebuild(); err != nil {
776- log.Error().Err(err).Msg("failed to rebuild workspace view")
777- }
778- a.releasePaneWebView(ctx, closingPaneID)
779- a.attachWorkspaceWebViews(ctx, ws, wsView)
780- wsView.FocusPane(ws.ActivePaneID)
781- }
782-
783- log.Info().Msg("pane closed")
784- return nil
785-}
786-
787-// handlePaneFocus navigates focus to an adjacent pane.
788-func (a *App) handlePaneFocus(ctx context.Context, direction usecase.NavigateDirection) error {
789- log := logging.FromContext(ctx)
790-
791- ws := a.activeWorkspace()
792- if ws == nil {
793- log.Warn().Msg("no active workspace")
794- return nil
795- }
796-
797- wsView := a.activeWorkspaceView()
798- if wsView == nil {
799- log.Warn().Msg("no active workspace view")
800- return nil
801- }
802-
803- // Use geometric navigation if focus manager is available
804- var newPane *entity.PaneNode
805- var err error
806-
807- if a.focusMgr != nil {
808- newPane, err = a.focusMgr.NavigateGeometric(ctx, ws, wsView, direction)
809- } else if a.panesUC != nil {
810- // Fallback to structural navigation
811- newPane, err = a.panesUC.NavigateFocus(ctx, ws, direction)
812- } else {
813- log.Warn().Msg("no navigation manager available")
814- return nil
815- }
816-
817- if err != nil {
818- log.Error().Err(err).Str("direction", string(direction)).Msg("failed to navigate focus")
819- return err
820- }
821-
822- if newPane == nil {
823- log.Debug().Str("direction", string(direction)).Msg("no pane in that direction")
824- return nil
825- }
826-
827- // Update the workspace view's active pane
828- if err := wsView.SetActivePaneID(newPane.Pane.ID); err != nil {
829- log.Warn().Err(err).Msg("failed to update active pane in view")
830- } else {
831- wsView.FocusPane(newPane.Pane.ID)
832- }
833-
834- // Sync StackedView visibility if new pane is in a stack
835- if newPane.Parent != nil && newPane.Parent.IsStacked {
836- a.syncStackedViewActive(ctx, wsView, newPane)
837- }
838-
839- log.Debug().Str("direction", string(direction)).Str("new_pane_id", newPane.ID).Msg("focus navigated")
840-
841- return nil
842-}
843-
844-// syncStackedViewActive updates the StackedView's visibility to match the domain model.
845-// Call this after navigating focus to a pane inside a stack.
846-func (a *App) syncStackedViewActive(ctx context.Context, wsView *component.WorkspaceView, paneNode *entity.PaneNode) {
847- log := logging.FromContext(ctx)
848-
849- if paneNode == nil || paneNode.Parent == nil || !paneNode.Parent.IsStacked {
850- return
851- }
852-
853- tr := wsView.TreeRenderer()
854- if tr == nil {
855- return
856- }
857-
858- // Get the StackedView for this pane
859- stackedView := tr.GetStackedViewForPane(string(paneNode.Pane.ID))
860- if stackedView == nil {
861- log.Warn().Str("pane_id", string(paneNode.Pane.ID)).Msg("stacked view not found for pane")
862- return
863- }
864-
865- // Use the parent's ActiveStackIndex which was set by the focus manager
866- stackIndex := paneNode.Parent.ActiveStackIndex
867-
868- log.Debug().
869- Str("pane_id", string(paneNode.Pane.ID)).
870- Int("stack_index", stackIndex).
871- Msg("syncing stacked view visibility")
872-
873- if err := stackedView.SetActive(ctx, stackIndex); err != nil {
874- log.Warn().Err(err).Msg("failed to set stacked view active index")
875- }
876-}
877-
878-// handleStackPane adds a new pane stacked on top of the active pane.
879-// This modifies both the domain tree and the UI StackedView.
880-func (a *App) handleStackPane(ctx context.Context) error {
881- log := logging.FromContext(ctx)
882-
883- if a.stackedPaneMgr == nil {
884- log.Warn().Msg("stacked pane manager not available")
885- return nil
886- }
887-
888- ws := a.activeWorkspace()
889- if ws == nil {
890- log.Warn().Msg("no active workspace")
891- return nil
892- }
893-
894- activeNode := ws.ActivePane()
895- if activeNode == nil || activeNode.Pane == nil {
896- log.Warn().Msg("no active pane")
897- return nil
898- }
899-
900- wsView := a.activeWorkspaceView()
901- if wsView == nil {
902- log.Warn().Msg("no workspace view")
903- return nil
904- }
905-
906- activePaneID := activeNode.Pane.ID
907-
908- // Create a new pane entity
909- newPaneID := entity.PaneID(a.generateID())
910- newPane := entity.NewPane(newPaneID)
911- newPane.URI = "about:blank"
912- newPane.Title = "Untitled"
913-
914- // Get the original pane's current title
915- originalTitle := a.getPaneTitle(activePaneID)
916- if originalTitle == "" {
917- originalTitle = activeNode.Pane.Title
918- }
919- if originalTitle == "" {
920- originalTitle = "Untitled"
921- }
922-
923- // Update domain model: convert leaf to stacked if needed, add new pane
924- var stackNode *entity.PaneNode
925- var needsFirstPaneTitleUpdate bool
926- if activeNode.IsStacked {
927- // Already stacked, just add to it
928- stackNode = activeNode
929- } else {
930- // Convert leaf node to stacked container
931- // Move current pane to a child node
932- originalPane := activeNode.Pane
933- originalPane.Title = originalTitle // Ensure title is set
934- originalPaneChild := &entity.PaneNode{
935- ID: activeNode.ID + "_0",
936- Pane: originalPane,
937- Parent: activeNode,
938- }
939-
940- activeNode.Pane = nil // No longer a leaf
941- activeNode.IsStacked = true
942- activeNode.Children = []*entity.PaneNode{originalPaneChild}
943- stackNode = activeNode
944- needsFirstPaneTitleUpdate = true
945-
946- log.Debug().
947- Str("node_id", activeNode.ID).
948- Str("original_pane", string(originalPane.ID)).
949- Str("original_title", originalTitle).
950- Msg("converted leaf to stacked node")
951- }
952-
953- // Create new child node for the new pane
954- newChildNode := &entity.PaneNode{
955- ID: stackNode.ID + "_" + string(newPaneID),
956- Pane: newPane,
957- Parent: stackNode,
958- }
959- stackNode.Children = append(stackNode.Children, newChildNode)
960- stackNode.ActiveStackIndex = len(stackNode.Children) - 1
961-
962- log.Debug().
963- Int("stack_size", len(stackNode.Children)).
964- Int("active_index", stackNode.ActiveStackIndex).
965- Msg("domain tree updated")
966-
967- // Create PaneView for the new pane
968- newPaneView := component.NewPaneView(a.widgetFactory, newPaneID, nil)
969- wsView.RegisterPaneView(newPaneID, newPaneView)
970-
971- // Add to the UI StackedView
972- if err := a.stackedPaneMgr.AddPaneToStack(ctx, wsView, activePaneID, newPaneView, "Untitled"); err != nil {
973- log.Error().Err(err).Msg("failed to add pane to stack")
974- return err
975- }
976-
977- // Update the first pane's title if we just converted from leaf to stacked
978- if needsFirstPaneTitleUpdate {
979- tr := wsView.TreeRenderer()
980- if tr != nil {
981- stackedView := tr.GetStackedViewForPane(string(activePaneID))
982- if stackedView != nil {
983- if err := stackedView.UpdateTitle(0, originalTitle); err != nil {
984- log.Warn().Err(err).Str("title", originalTitle).Msg("failed to update first pane title")
985- } else {
986- log.Debug().Str("title", originalTitle).Msg("updated first pane title in StackedView")
987- }
988- }
989- }
990- }
991-
992- // Get WebView and attach
993- wv, err := a.ensureWebViewForPane(ctx, newPaneID)
994- if err != nil {
995- log.Warn().Err(err).Msg("failed to get webview for new pane")
996- } else {
997- widget := a.wrapWebViewWidget(ctx, wv)
998- if widget != nil {
999- newPaneView.SetWebViewWidget(widget)
1000- }
1001- // Load blank page
1002- if err := wv.LoadURI(ctx, "about:blank"); err != nil {
1003- log.Warn().Err(err).Msg("failed to load blank page")
1004- }
1005- }
1006-
1007- // Update workspace active pane ID
1008- ws.ActivePaneID = newPaneID
1009-
1010- // Update workspace view
1011- if err := wsView.SetActivePaneID(newPaneID); err != nil {
1012- log.Warn().Err(err).Msg("failed to set active pane")
1013- }
1014-
1015- // Set up title bar click callback
1016- tr := wsView.TreeRenderer()
1017- if tr != nil {
1018- stackedView := tr.GetStackedViewForPane(string(activePaneID))
1019- if stackedView != nil {
1020- // Capture stackNode for the callback
1021- capturedStackNode := stackNode
1022- stackedView.SetOnActivate(func(index int) {
1023- a.handleTitleBarClick(ctx, capturedStackNode, stackedView, index)
1024- })
1025- }
1026- }
1027-
1028- log.Info().
1029- Str("original_pane", string(activePaneID)).
1030- Str("new_pane", string(newPaneID)).
1031- Int("stack_size", len(stackNode.Children)).
1032- Msg("stacked new pane")
1033-
1034- return nil
1035-}
1036-
1037-// handleStackNavigate navigates up or down within a stacked pane container.
1038-// With the new architecture, every pane is in a StackedView - we just navigate within it.
1039-func (a *App) handleStackNavigate(ctx context.Context, direction string) error {
1040- log := logging.FromContext(ctx)
1041-
1042- if a.stackedPaneMgr == nil {
1043- return nil
1044- }
1045-
1046- ws := a.activeWorkspace()
1047- if ws == nil {
1048- return nil
1049- }
1050-
1051- activePane := ws.ActivePane()
1052- if activePane == nil || activePane.Pane == nil {
1053- return nil
1054- }
1055-
1056- wsView := a.activeWorkspaceView()
1057- if wsView == nil {
1058- return nil
1059- }
1060-
1061- currentPaneID := activePane.Pane.ID
1062-
1063- // Check if we're actually in a multi-pane stack
1064- if !a.stackedPaneMgr.IsStacked(wsView, currentPaneID) {
1065- log.Debug().Msg("current pane is not in a multi-pane stack")
1066- return nil
1067- }
1068-
1069- // Navigate within the stack
1070- _, err := a.stackedPaneMgr.NavigateStack(ctx, wsView, currentPaneID, direction)
1071- if err != nil {
1072- log.Warn().Err(err).Str("direction", direction).Msg("failed to navigate stack")
1073- return err
1074- }
1075-
1076- // Note: With the current StackedView navigation, the new pane ID is not returned
1077- // because the mapping from visual index to pane ID requires additional tracking.
1078- // For now, the StackedView handles the visual navigation.
1079- // TODO: Add pane ID tracking to StackedView for proper domain model sync
1080-
1081- log.Debug().
1082- Str("direction", direction).
1083- Str("current_pane", string(currentPaneID)).
1084- Msg("navigated stack")
1085-
1086- return nil
1087-}
1088-
1089-// handleStackPaneActivated is called when a pane in a stack is activated via title bar click.
1090-// This is a stub for future use when title bar click callbacks are wired up.
1091-// The paneID parameter would come from the StackedView's OnActivate callback.
1092-func (a *App) handleStackPaneActivated(ctx context.Context, paneID entity.PaneID) {
1093- log := logging.FromContext(ctx)
1094-
1095- if paneID == "" {
1096- return
1097- }
1098-
1099- // Update workspace active pane
1100- ws := a.activeWorkspace()
1101- if ws != nil {
1102- ws.ActivePaneID = paneID
1103- }
1104-
1105- // Update workspace view
1106- wsView := a.activeWorkspaceView()
1107- if wsView != nil {
1108- if err := wsView.SetActivePaneID(paneID); err != nil {
1109- log.Warn().Err(err).Msg("failed to set active pane after stack activation")
1110- }
1111- }
1112-
1113- log.Debug().
1114- Str("pane_id", string(paneID)).
1115- Msg("stack pane activated")
1116-}
1117-
1118-// handleTitleBarClick handles clicks on title bars to switch the active pane in a stack.
1119-func (a *App) handleTitleBarClick(ctx context.Context, stackNode *entity.PaneNode, sv *layout.StackedView, clickedIndex int) {
1120- log := logging.FromContext(ctx)
1121-
1122- if stackNode == nil || sv == nil {
1123- return
1124- }
1125-
1126- // Validate index
1127- if clickedIndex < 0 || clickedIndex >= len(stackNode.Children) {
1128- log.Warn().Int("index", clickedIndex).Int("children", len(stackNode.Children)).Msg("invalid stack index clicked")
1129- return
1130- }
1131-
1132- // Get the current active index
1133- currentIndex := sv.ActiveIndex()
1134- if clickedIndex == currentIndex {
1135- log.Debug().Int("index", clickedIndex).Msg("clicked pane is already active")
1136- return
1137- }
1138-
1139- // Update the outgoing pane's title bar with its current webpage title
1140- if currentIndex >= 0 && currentIndex < len(stackNode.Children) {
1141- outgoingChild := stackNode.Children[currentIndex]
1142- if outgoingChild.Pane != nil {
1143- outgoingPaneID := outgoingChild.Pane.ID
1144- outgoingTitle := a.getPaneTitle(outgoingPaneID)
1145- if outgoingTitle == "" {
1146- outgoingTitle = outgoingChild.Pane.Title
1147- }
1148- if outgoingTitle != "" {
1149- if err := sv.UpdateTitle(currentIndex, outgoingTitle); err != nil {
1150- log.Warn().Err(err).Msg("failed to update outgoing pane title")
1151- }
1152- }
1153- }
1154- }
1155-
1156- // Get the pane ID at the clicked index
1157- clickedChild := stackNode.Children[clickedIndex]
1158- if clickedChild.Pane == nil {
1159- log.Warn().Int("index", clickedIndex).Msg("clicked child has no pane")
1160- return
1161- }
1162- clickedPaneID := clickedChild.Pane.ID
1163-
1164- // Update StackedView active index
1165- if err := sv.SetActive(ctx, clickedIndex); err != nil {
1166- log.Warn().Err(err).Int("index", clickedIndex).Msg("failed to set active pane in stack")
1167- return
1168- }
1169-
1170- // Update domain model
1171- stackNode.ActiveStackIndex = clickedIndex
1172-
1173- // Update workspace active pane
1174- ws := a.activeWorkspace()
1175- if ws != nil {
1176- ws.ActivePaneID = clickedPaneID
1177- }
1178-
1179- // Update workspace view
1180- wsView := a.activeWorkspaceView()
1181- if wsView != nil {
1182- if err := wsView.SetActivePaneID(clickedPaneID); err != nil {
1183- log.Warn().Err(err).Msg("failed to set active pane in workspace view")
1184- }
1185- }
1186-
1187- log.Info().
1188- Int("from_index", currentIndex).
1189- Int("to_index", clickedIndex).
1190- Str("pane_id", string(clickedPaneID)).
1191- Msg("switched active pane via title bar click")
1192-}
1193-
1194-// attachWorkspaceWebViews ensures each pane has a WebView widget attached.
1195-func (a *App) attachWorkspaceWebViews(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) {
1196- log := logging.FromContext(ctx)
1197-
1198- if ws == nil || wsView == nil || a.widgetFactory == nil {
1199- return
1200- }
1201-
1202- for _, pane := range ws.AllPanes() {
1203- if pane == nil {
1204- continue
1205- }
1206-
1207- wv, err := a.ensureWebViewForPane(ctx, pane.ID)
1208- if err != nil {
1209- log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to ensure webview for pane")
1210- continue
1211- }
1212-
1213- // Load the pane's URI if set and different from current
1214- if pane.URI != "" && pane.URI != wv.URI() {
1215- if err := wv.LoadURI(ctx, pane.URI); err != nil {
1216- log.Warn().Err(err).Str("pane_id", string(pane.ID)).Str("uri", pane.URI).Msg("failed to load pane URI")
1217- }
1218- }
1219-
1220- widget := a.wrapWebViewWidget(ctx, wv)
1221- if widget == nil {
1222- continue
1223- }
1224-
1225- if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil {
1226- log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget")
1227- }
1228- }
1229-}
1230-
1231-// ensureWebViewForPane acquires or reuses a WebView for the given pane.
1232-func (a *App) ensureWebViewForPane(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {
1233- if wv, ok := a.webViews[paneID]; ok && wv != nil && !wv.IsDestroyed() {
1234- return wv, nil
1235- }
1236- if a.pool == nil {
1237- return nil, fmt.Errorf("webview pool not configured")
1238- }
1239-
1240- wv, err := a.pool.Acquire(ctx)
1241- if err != nil {
1242- return nil, err
1243- }
1244-
1245- a.webViews[paneID] = wv
1246-
1247- // Set up title change callback to track dynamic titles
1248- wv.OnTitleChanged = func(title string) {
1249- a.handlePaneTitleChanged(ctx, paneID, title)
1250- }
1251-
1252- return wv, nil
1253-}
1254-
1255-// handlePaneTitleChanged updates title tracking when a WebView's title changes.
1256-func (a *App) handlePaneTitleChanged(ctx context.Context, paneID entity.PaneID, title string) {
1257- log := logging.FromContext(ctx)
1258-
1259- // Update title map
1260- a.titleMu.Lock()
1261- a.paneTitles[paneID] = title
1262- a.titleMu.Unlock()
1263-
1264- // Update domain model
1265- ws := a.activeWorkspace()
1266- if ws != nil {
1267- paneNode := ws.FindPane(paneID)
1268- if paneNode != nil && paneNode.Pane != nil {
1269- paneNode.Pane.Title = title
1270- }
1271- }
1272-
1273- // Update StackedView title bar if this pane is in a stack
1274- wsView := a.activeWorkspaceView()
1275- if wsView != nil {
1276- tr := wsView.TreeRenderer()
1277- if tr != nil {
1278- stackedView := tr.GetStackedViewForPane(string(paneID))
1279- if stackedView != nil {
1280- // Find the pane's index in the stack and update title
1281- a.updateStackedPaneTitle(ctx, stackedView, paneID, title)
1282- }
1283- }
1284- }
1285-
1286- log.Debug().
1287- Str("pane_id", string(paneID)).
1288- Str("title", title).
1289- Msg("pane title updated")
1290-}
1291-
1292-// updateStackedPaneTitle updates the title of a pane in a StackedView.
1293-func (a *App) updateStackedPaneTitle(ctx context.Context, sv *layout.StackedView, paneID entity.PaneID, title string) {
1294- // We need to find which index this pane is at in the stack
1295- // For now, we track by iterating - could be optimized with reverse mapping
1296- wsView := a.activeWorkspaceView()
1297- if wsView == nil {
1298- return
1299- }
1300-
1301- tr := wsView.TreeRenderer()
1302- if tr == nil {
1303- return
1304- }
1305-
1306- // Find the stack node in the domain to get the index
1307- ws := a.activeWorkspace()
1308- if ws == nil {
1309- return
1310- }
1311-
1312- paneNode := ws.FindPane(paneID)
1313- if paneNode == nil {
1314- return
1315- }
1316-
1317- // If the pane is in a stacked parent, find its index
1318- if paneNode.Parent != nil && paneNode.Parent.IsStacked {
1319- for i, child := range paneNode.Parent.Children {
1320- if child.Pane != nil && child.Pane.ID == paneID {
1321- _ = sv.UpdateTitle(i, title)
1322- return
1323- }
1324- }
1325- }
1326-}
1327-
1328-// getPaneTitle returns the current title for a pane.
1329-func (a *App) getPaneTitle(paneID entity.PaneID) string {
1330- a.titleMu.RLock()
1331- defer a.titleMu.RUnlock()
1332- return a.paneTitles[paneID]
1333-}
1334-
1335-// releasePaneWebView returns the WebView for a pane to the pool.
1336-func (a *App) releasePaneWebView(ctx context.Context, paneID entity.PaneID) {
1337- wv, ok := a.webViews[paneID]
1338- if !ok || wv == nil {
1339- return
1340- }
1341- delete(a.webViews, paneID)
1342-
1343- if a.pool != nil {
1344- a.pool.Release(wv)
1345- } else {
1346- wv.Destroy()
1347- }
1348-}
1349-
1350-// wrapWebViewWidget converts a WebView to a layout.Widget for embedding.
1351-func (a *App) wrapWebViewWidget(ctx context.Context, wv *webkit.WebView) layout.Widget {
1352- if wv == nil || a.widgetFactory == nil {
1353- return nil
1354- }
1355- gtkView := wv.Widget()
1356- if gtkView == nil {
1357- return nil
1358- }
1359- return a.widgetFactory.WrapWidget(&gtkView.Widget)
1360-}
1361-
1362-// activeWebView returns the WebView for the active pane.
1363-func (a *App) activeWebView(ctx context.Context) *webkit.WebView {
1364- ws := a.activeWorkspace()
1365- if ws == nil {
1366- return nil
1367- }
1368- pane := ws.ActivePane()
1369- if pane == nil || pane.Pane == nil {
1370- return nil
1371- }
1372- return a.webViews[pane.Pane.ID]
1373-}
1374-
1375-// openOmnibox toggles the native GTK omnibox.
1376-func (a *App) openOmnibox(ctx context.Context) error {
1377- log := logging.FromContext(ctx)
1378-
1379- if a.omnibox == nil {
1380- log.Error().Msg("native omnibox not initialized")
1381- return fmt.Errorf("native omnibox not initialized")
1382- }
1383-
1384- log.Debug().Msg("toggling native omnibox")
1385- a.omnibox.Toggle(ctx)
1386- return nil
1387-}
1388-
1389-// navigateActivePane navigates the active pane to the given URL.
1390-// Records history for omnibox search to find it immediately.
1391-func (a *App) navigateActivePane(ctx context.Context, url string) {
1392- log := logging.FromContext(ctx)
1393-
1394- wv := a.activeWebView(ctx)
1395- if wv == nil {
1396- log.Warn().Str("url", url).Msg("no active webview for navigation")
1397- return
1398- }
1399-
1400- // Load the URL
1401- if err := wv.LoadURI(ctx, url); err != nil {
1402- log.Error().Err(err).Str("url", url).Msg("failed to navigate")
1403- return
1404- }
1405-
1406- // Record in history asynchronously so it appears in omnibox immediately
1407- if a.deps.HistoryRepo != nil {
1408- go a.recordHistory(ctx, url)
1409- }
1410-
1411- log.Debug().Str("url", url).Msg("navigated active pane")
1412-}
1413-
1414-// recordHistory saves or updates a history entry.
1415-func (a *App) recordHistory(ctx context.Context, url string) {
1416- log := logging.FromContext(ctx)
1417- log.Debug().Str("url", url).Msg("recording history entry")
1418-
1419- existing, err := a.deps.HistoryRepo.FindByURL(ctx, url)
1420- if err != nil {
1421- log.Warn().Err(err).Str("url", url).Msg("failed to check history")
1422- return
1423- }
1424-
1425- if existing != nil {
1426- // Increment visit count
1427- if err := a.deps.HistoryRepo.IncrementVisitCount(ctx, url); err != nil {
1428- log.Warn().Err(err).Str("url", url).Msg("failed to increment visit count")
1429- } else {
1430- log.Debug().Str("url", url).Int64("prev_count", existing.VisitCount).Msg("history visit count incremented")
1431- }
1432- } else {
1433- // Create new entry
1434- entry := entity.NewHistoryEntry(url, "")
1435- if err := a.deps.HistoryRepo.Save(ctx, entry); err != nil {
1436- log.Warn().Err(err).Str("url", url).Msg("failed to save history")
1437- } else {
1438- log.Debug().Str("url", url).Msg("new history entry saved")
1439- }
1440- }
1441-}
1442-
1443-// openDevTools opens the WebKit inspector for the active WebView.
1444-func (a *App) openDevTools(ctx context.Context) error {
1445- log := logging.FromContext(ctx)
1446-
1447- wv := a.activeWebView(ctx)
1448- if wv == nil {
1449- log.Warn().Msg("no active webview for devtools")
1450- return fmt.Errorf("no active webview for devtools")
1451- }
1452- log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("opening devtools")
1453- return wv.ShowDevTools()
1454-}
@@ -3,7 +3,6 @@ package ui
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6- "fmt"
7 "os"6 "os"
8 "sync"7 "sync"
9 8
@@ -15,6 +14,8 @@ import (
15 "github.com/bnema/dumber/internal/infrastructure/webkit"14 "github.com/bnema/dumber/internal/infrastructure/webkit"
16 "github.com/bnema/dumber/internal/logging"15 "github.com/bnema/dumber/internal/logging"
17 "github.com/bnema/dumber/internal/ui/component"16 "github.com/bnema/dumber/internal/ui/component"
17+ "github.com/bnema/dumber/internal/ui/coordinator"
18+ "github.com/bnema/dumber/internal/ui/dispatcher"
18 "github.com/bnema/dumber/internal/ui/focus"19 "github.com/bnema/dumber/internal/ui/focus"
19 "github.com/bnema/dumber/internal/ui/input"20 "github.com/bnema/dumber/internal/ui/input"
20 "github.com/bnema/dumber/internal/ui/layout"21 "github.com/bnema/dumber/internal/ui/layout"
@@ -36,7 +37,14 @@ type App struct {
36 tabs *entity.TabList37 tabs *entity.TabList
37 tabsUC *usecase.ManageTabsUseCase38 tabsUC *usecase.ManageTabsUseCase
38 39
39- // Pane management40+ // Coordinators (new architecture)
41+ contentCoord *coordinator.ContentCoordinator
42+ tabCoord *coordinator.TabCoordinator
43+ wsCoord *coordinator.WorkspaceCoordinator
44+ navCoord *coordinator.NavigationCoordinator
45+ kbDispatcher *dispatcher.KeyboardDispatcher
46+
47+ // Pane management (used by coordinators)
40 panesUC *usecase.ManagePanesUseCase48 panesUC *usecase.ManagePanesUseCase
41 workspaceViews map[entity.TabID]*component.WorkspaceView49 workspaceViews map[entity.TabID]*component.WorkspaceView
42 widgetFactory layout.WidgetFactory50 widgetFactory layout.WidgetFactory
@@ -52,14 +60,11 @@ type App struct {
52 // Native omnibox60 // Native omnibox
53 omnibox *component.Omnibox61 omnibox *component.Omnibox
54 62
55- // Web content63+ // Web content (managed by ContentCoordinator)
56- pool *webkit.WebViewPool64+ pool *webkit.WebViewPool
57- injector *webkit.ContentInjector65+ injector *webkit.ContentInjector
58- router *webkit.MessageRouter66+ router *webkit.MessageRouter
59- settings *webkit.SettingsManager67+ settings *webkit.SettingsManager
60- webViews map[entity.PaneID]*webkit.WebView
61- paneTitles map[entity.PaneID]string // Dynamic title tracking
62- titleMu sync.RWMutex
63 68
64 // ID generator for tabs/panes69 // ID generator for tabs/panes
65 idCounter uint6470 idCounter uint64
@@ -87,8 +92,6 @@ func New(deps *Dependencies) (*App, error) {
87 injector: deps.Injector,92 injector: deps.Injector,
88 router: deps.MessageRouter,93 router: deps.MessageRouter,
89 settings: deps.Settings,94 settings: deps.Settings,
90- webViews: make(map[entity.PaneID]*webkit.WebView),
91- paneTitles: make(map[entity.PaneID]string),
92 cancel: cancel,95 cancel: cancel,
93 }96 }
94 if app.router == nil {97 if app.router == nil {
@@ -167,13 +170,17 @@ func (a *App) onActivate(ctx context.Context) {
167 // Initialize border manager for mode indicators170 // Initialize border manager for mode indicators
168 a.borderMgr = focus.NewBorderManager(a.widgetFactory)171 a.borderMgr = focus.NewBorderManager(a.widgetFactory)
169 172
170- // Create keyboard handler173+ // Initialize coordinators
174+ a.initCoordinators(ctx)
175+
176+ // Create keyboard handler and wire to dispatcher
171 a.keyboardHandler = input.NewKeyboardHandler(177 a.keyboardHandler = input.NewKeyboardHandler(
172 ctx,178 ctx,
173 &a.deps.Config.Workspace,179 &a.deps.Config.Workspace,
174 )180 )
175- a.keyboardHandler.SetOnAction(a.handleKeyboardAction)181+ a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error {
176- // Wrap handleModeChange to match the callback signature182+ return a.kbDispatcher.Dispatch(ctx, action)
183+ })
177 a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {184 a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {
178 a.handleModeChange(ctx, from, to)185 a.handleModeChange(ctx, from, to)
179 })186 })
@@ -189,15 +196,18 @@ func (a *App) onActivate(ctx context.Context) {
189 })196 })
190 if a.omnibox != nil {197 if a.omnibox != nil {
191 a.omnibox.SetOnNavigate(func(url string) {198 a.omnibox.SetOnNavigate(func(url string) {
192- a.navigateActivePane(ctx, url)199+ a.navCoord.Navigate(ctx, url)
193 })200 })
201+ a.navCoord.SetOmnibox(a.omnibox)
194 log.Debug().Msg("native omnibox created")202 log.Debug().Msg("native omnibox created")
195 } else {203 } else {
196 log.Warn().Msg("failed to create native omnibox")204 log.Warn().Msg("failed to create native omnibox")
197 }205 }
198 206
199- // Create an initial tab207+ // Create an initial tab using coordinator
200- a.createInitialTab(ctx)208+ if _, err := a.tabCoord.Create(ctx, "https://duckduckgo.com"); err != nil {
209+ log.Error().Err(err).Msg("failed to create initial tab")
210+ }
201 211
202 // Show the window212 // Show the window
203 a.mainWindow.Show()213 a.mainWindow.Show()
@@ -221,38 +231,67 @@ func (a *App) onShutdown(ctx context.Context) {
221 log.Info().Msg("application shutdown complete")231 log.Info().Msg("application shutdown complete")
222 }232 }
223 233
224-// createInitialTab creates the first tab when the application starts.234+// initCoordinators initializes all coordinators and wires their callbacks.
225-func (a *App) createInitialTab(ctx context.Context) {235+func (a *App) initCoordinators(ctx context.Context) {
226 log := logging.FromContext(ctx)236 log := logging.FromContext(ctx)
237+ log.Debug().Msg("initializing coordinators")
227 238
228- if a.tabsUC == nil {239+ // Helper to get active workspace and view
229- // Create use case if not injected240+ getActiveWS := func() (*entity.Workspace, *component.WorkspaceView) {
230- a.tabsUC = usecase.NewManageTabsUseCase(a.generateID)241+ return a.activeWorkspace(), a.activeWorkspaceView()
231 }242 }
232 243
233- output, err := a.tabsUC.Create(ctx, usecase.CreateTabInput{244+ // 1. Content Coordinator (no dependencies on other coordinators)
234- TabList: a.tabs,245+ a.contentCoord = coordinator.NewContentCoordinator(
235- Name: "",246+ ctx,
236- InitialURL: "https://duckduckgo.com",247+ a.pool,
237- })248+ a.widgetFactory,
238- if err != nil {249+ getActiveWS,
239- log.Error().Err(err).Msg("failed to create initial tab")250+ )
240- return
241- }
242 251
243- // Update tab bar252+ // 2. Tab Coordinator
244- if a.mainWindow != nil && a.mainWindow.TabBar() != nil {253+ a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
245- a.mainWindow.TabBar().AddTab(output.Tab)254+ TabsUC: a.tabsUC,
246- a.mainWindow.TabBar().SetActive(output.Tab.ID)255+ Tabs: a.tabs,
247- }256+ MainWindow: a.mainWindow,
257+ Config: a.deps.Config,
258+ })
259+ a.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {
260+ a.createWorkspaceView(ctx, tab)
261+ })
262+ a.tabCoord.SetOnQuit(a.Quit)
263+
264+ // 3. Workspace Coordinator
265+ a.wsCoord = coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{
266+ PanesUC: a.panesUC,
267+ FocusMgr: a.focusMgr,
268+ StackedPaneMgr: a.stackedPaneMgr,
269+ WidgetFactory: a.widgetFactory,
270+ ContentCoord: a.contentCoord,
271+ GetActiveWS: getActiveWS,
272+ GenerateID: a.generateID,
273+ })
274+ a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error {
275+ return a.tabCoord.Close(ctx)
276+ })
248 277
249- // Update tab bar visibility (hide if only 1 tab)278+ // 4. Navigation Coordinator
250- a.updateTabBarVisibility(ctx)279+ a.navCoord = coordinator.NewNavigationCoordinator(
280+ ctx,
281+ a.deps.NavigateUC,
282+ a.contentCoord,
283+ )
251 284
252- // Create workspace view for this tab285+ // 5. Keyboard Dispatcher
253- a.createWorkspaceView(ctx, output.Tab)286+ a.kbDispatcher = dispatcher.NewKeyboardDispatcher(
287+ ctx,
288+ a.tabCoord,
289+ a.wsCoord,
290+ a.navCoord,
291+ )
292+ a.kbDispatcher.SetOnQuit(a.Quit)
254 293
255- log.Debug().Str("tab_id", string(output.Tab.ID)).Msg("initial tab created")294+ log.Debug().Msg("coordinators initialized")
256 }295 }
257 296
258 // generateID generates a unique ID for tabs and panes.297 // generateID generates a unique ID for tabs and panes.
@@ -291,87 +330,6 @@ func RunWithArgs(ctx context.Context, deps *Dependencies) int {
291 return app.Run(ctx, os.Args)330 return app.Run(ctx, os.Args)
292 }331 }
293 332
294-// handleKeyboardAction dispatches keyboard actions to appropriate handlers.
295-func (a *App) handleKeyboardAction(ctx context.Context, action input.Action) error {
296- log := logging.FromContext(ctx)
297- log.Debug().Str("action", string(action)).Msg("handling keyboard action")
298-
299- switch action {
300- // Tab actions
301- case input.ActionNewTab:
302- return a.handleNewTab(ctx)
303- case input.ActionCloseTab:
304- return a.handleCloseTab(ctx)
305- case input.ActionNextTab:
306- return a.handleNextTab(ctx)
307- case input.ActionPreviousTab:
308- return a.handlePreviousTab(ctx)
309-
310- // Pane actions
311- case input.ActionSplitRight:
312- return a.handlePaneSplit(ctx, usecase.SplitRight)
313- case input.ActionSplitLeft:
314- return a.handlePaneSplit(ctx, usecase.SplitLeft)
315- case input.ActionSplitUp:
316- return a.handlePaneSplit(ctx, usecase.SplitUp)
317- case input.ActionSplitDown:
318- return a.handlePaneSplit(ctx, usecase.SplitDown)
319- case input.ActionClosePane:
320- return a.handleClosePane(ctx)
321- case input.ActionStackPane:
322- return a.handleStackPane(ctx)
323- case input.ActionFocusRight:
324- return a.handlePaneFocus(ctx, usecase.NavRight)
325- case input.ActionFocusLeft:
326- return a.handlePaneFocus(ctx, usecase.NavLeft)
327- case input.ActionFocusUp:
328- return a.handlePaneFocus(ctx, usecase.NavUp)
329- case input.ActionFocusDown:
330- return a.handlePaneFocus(ctx, usecase.NavDown)
331-
332- // Stack navigation
333- case input.ActionStackNavUp:
334- return a.handleStackNavigate(ctx, "up")
335- case input.ActionStackNavDown:
336- return a.handleStackNavigate(ctx, "down")
337-
338- // Navigation (stub implementations for now)
339- case input.ActionGoBack:
340- log.Debug().Msg("go back action (not yet implemented)")
341- case input.ActionGoForward:
342- log.Debug().Msg("go forward action (not yet implemented)")
343- case input.ActionReload:
344- log.Debug().Msg("reload action (not yet implemented)")
345- case input.ActionHardReload:
346- log.Debug().Msg("hard reload action (not yet implemented)")
347-
348- // Zoom (stub implementations for now)
349- case input.ActionZoomIn:
350- log.Debug().Msg("zoom in action (not yet implemented)")
351- case input.ActionZoomOut:
352- log.Debug().Msg("zoom out action (not yet implemented)")
353- case input.ActionZoomReset:
354- log.Debug().Msg("zoom reset action (not yet implemented)")
355-
356- // UI
357- case input.ActionOpenOmnibox:
358- return a.openOmnibox(ctx)
359- case input.ActionOpenDevTools:
360- return a.openDevTools(ctx)
361- case input.ActionToggleFullscreen:
362- log.Debug().Msg("toggle fullscreen action (not yet implemented)")
363-
364- // Application
365- case input.ActionQuit:
366- a.Quit()
367-
368- default:
369- log.Warn().Str("action", string(action)).Msg("unhandled keyboard action")
370- }
371-
372- return nil
373-}
374-
375 // handleModeChange is called when the input mode changes.333 // handleModeChange is called when the input mode changes.
376 func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {334 func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
377 log := logging.FromContext(ctx)335 log := logging.FromContext(ctx)
@@ -383,124 +341,6 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
383 }341 }
384 }342 }
385 343
386-// handleNewTab creates a new tab.
387-func (a *App) handleNewTab(ctx context.Context) error {
388- log := logging.FromContext(ctx)
389-
390- output, err := a.tabsUC.Create(ctx, usecase.CreateTabInput{
391- TabList: a.tabs,
392- Name: "",
393- InitialURL: "about:blank",
394- })
395- if err != nil {
396- return err
397- }
398-
399- if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
400- a.mainWindow.TabBar().AddTab(output.Tab)
401- a.mainWindow.TabBar().SetActive(output.Tab.ID)
402- }
403-
404- // Update tab bar visibility
405- a.updateTabBarVisibility(ctx)
406-
407- log.Debug().Str("tab_id", string(output.Tab.ID)).Msg("new tab created")
408-
409- return nil
410-}
411-
412-// handleCloseTab closes the active tab.
413-func (a *App) handleCloseTab(ctx context.Context) error {
414- activeID := a.tabs.ActiveTabID
415- if activeID == "" {
416- return nil
417- }
418-
419- wasLast, err := a.tabsUC.Close(ctx, a.tabs, activeID)
420- if err != nil {
421- return err
422- }
423-
424- if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
425- a.mainWindow.TabBar().RemoveTab(activeID)
426-
427- // Switch to new active tab if any
428- if a.tabs.ActiveTabID != "" {
429- a.mainWindow.TabBar().SetActive(a.tabs.ActiveTabID)
430- }
431- }
432-
433- // Update tab bar visibility
434- a.updateTabBarVisibility(ctx)
435-
436- // Quit if no tabs left
437- if wasLast {
438- a.Quit()
439- }
440-
441- return nil
442-}
443-
444-// updateTabBarVisibility shows or hides the tab bar based on tab count.
445-// Tab bar is hidden when there's only 1 tab (configurable).
446-func (a *App) updateTabBarVisibility(ctx context.Context) {
447- log := logging.FromContext(ctx)
448-
449- // Debug: trace entry
450- hideEnabled := true
451- if a.deps.Config != nil {
452- hideEnabled = a.deps.Config.Workspace.HideTabBarWhenSingleTab
453- }
454- log.Debug().Bool("hide_enabled", hideEnabled).Msg("updateTabBarVisibility called")
455-
456- // Check if feature is enabled
457- if a.deps.Config != nil && !a.deps.Config.Workspace.HideTabBarWhenSingleTab {
458- log.Debug().Msg("tab bar auto-hide disabled by config")
459- return
460- }
461-
462- if a.mainWindow == nil {
463- log.Debug().Msg("mainWindow is nil, skipping visibility update")
464- return
465- }
466- if a.mainWindow.TabBar() == nil {
467- log.Debug().Msg("tabBar is nil, skipping visibility update")
468- return
469- }
470-
471- tabCount := a.tabs.Count()
472- shouldShow := tabCount > 1
473-
474- log.Debug().Int("tab_count", tabCount).Bool("should_show", shouldShow).Msg("setting tab bar visibility")
475- a.mainWindow.TabBar().SetVisible(shouldShow)
476-}
477-
478-// handleNextTab switches to the next tab.
479-func (a *App) handleNextTab(ctx context.Context) error {
480- if err := a.tabsUC.SwitchNext(ctx, a.tabs); err != nil {
481- return err
482- }
483-
484- if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
485- a.mainWindow.TabBar().SetActive(a.tabs.ActiveTabID)
486- }
487-
488- return nil
489-}
490-
491-// handlePreviousTab switches to the previous tab.
492-func (a *App) handlePreviousTab(ctx context.Context) error {
493- if err := a.tabsUC.SwitchPrevious(ctx, a.tabs); err != nil {
494- return err
495- }
496-
497- if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
498- a.mainWindow.TabBar().SetActive(a.tabs.ActiveTabID)
499- }
500-
501- return nil
502-}
503-
504 // createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.344 // createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.
505 func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {345 func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
506 log := logging.FromContext(ctx)346 log := logging.FromContext(ctx)
@@ -524,7 +364,9 @@ func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
524 }364 }
525 365
526 // Ensure WebViews are attached to panes366 // Ensure WebViews are attached to panes
527- a.attachWorkspaceWebViews(ctx, tab.Workspace, wsView)367+ if a.contentCoord != nil {
368+ a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
369+ }
528 370
529 // Attach border manager overlay for mode indicators371 // Attach border manager overlay for mode indicators
530 if a.borderMgr != nil {372 if a.borderMgr != nil {
@@ -565,890 +407,3 @@ func (a *App) activeWorkspaceView() *component.WorkspaceView {
565 }407 }
566 return a.workspaceViews[activeTab.ID]408 return a.workspaceViews[activeTab.ID]
567 }409 }
568-
569-// handlePaneSplit splits the active pane in the given direction.
570-func (a *App) handlePaneSplit(ctx context.Context, direction usecase.SplitDirection) error {
571- log := logging.FromContext(ctx)
572-
573- if a.panesUC == nil {
574- log.Warn().Msg("panes use case not available")
575- return nil
576- }
577-
578- ws := a.activeWorkspace()
579- if ws == nil {
580- log.Warn().Msg("no active workspace")
581- return nil
582- }
583-
584- activePane := ws.ActivePane()
585- if activePane == nil {
586- log.Warn().Msg("no active pane to split")
587- return nil
588- }
589-
590- wsView := a.activeWorkspaceView()
591-
592- // Check if we're splitting from inside a stack (horizontal split around stack)
593- isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) &&
594- activePane.Parent != nil && activePane.Parent.IsStacked
595-
596- // Get the existing StackedView BEFORE domain changes (for incremental operation)
597- var existingStackWidget layout.Widget
598- if isStackSplit && wsView != nil {
599- tr := wsView.TreeRenderer()
600- if tr != nil {
601- // Get any pane's StackedView from the stack - they all map to the same one
602- stackedView := tr.GetStackedViewForPane(string(activePane.Pane.ID))
603- if stackedView != nil {
604- existingStackWidget = stackedView.Widget()
605- }
606- }
607- }
608-
609- output, err := a.panesUC.Split(ctx, usecase.SplitPaneInput{
610- Workspace: ws,
611- TargetPane: activePane,
612- Direction: direction,
613- })
614- if err != nil {
615- log.Error().Err(err).Str("direction", string(direction)).Msg("failed to split pane")
616- return err
617- }
618-
619- // Remember old active pane before changing
620- oldActivePaneID := activePane.Pane.ID
621-
622- // Set the new pane as active
623- ws.ActivePaneID = output.NewPaneNode.Pane.ID
624-
625- // Update the workspace view
626- if wsView != nil {
627- if isStackSplit && existingStackWidget != nil {
628- // Incremental split: reuse existing stack widget
629- if err := a.doIncrementalStackSplit(ctx, wsView, ws, output, direction, existingStackWidget, oldActivePaneID); err != nil {
630- log.Warn().Err(err).Msg("incremental split failed, falling back to rebuild")
631- // Fallback to full rebuild
632- if err := wsView.Rebuild(); err != nil {
633- log.Error().Err(err).Msg("failed to rebuild workspace view")
634- }
635- a.attachWorkspaceWebViews(ctx, ws, wsView)
636- }
637- } else {
638- // Regular split: full rebuild
639- if err := wsView.Rebuild(); err != nil {
640- log.Error().Err(err).Msg("failed to rebuild workspace view")
641- }
642- a.attachWorkspaceWebViews(ctx, ws, wsView)
643- }
644- wsView.FocusPane(ws.ActivePaneID)
645- }
646-
647- log.Info().Str("direction", string(direction)).Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).Msg("pane split completed")
648-
649- return nil
650-}
651-
652-// doIncrementalStackSplit performs an incremental split around an existing stacked pane.
653-// This preserves existing WebViews by reusing the existing StackedView widget and creating
654-// a new SplitView around it, rather than rebuilding the entire widget tree.
655-func (a *App) doIncrementalStackSplit(
656- ctx context.Context,
657- wsView *component.WorkspaceView,
658- ws *entity.Workspace,
659- output *usecase.SplitPaneOutput,
660- direction usecase.SplitDirection,
661- existingStackWidget layout.Widget,
662- oldActivePaneID entity.PaneID,
663-) error {
664- log := logging.FromContext(ctx)
665- factory := wsView.Factory()
666-
667- log.Debug().
668- Str("direction", string(direction)).
669- Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).
670- Str("old_active_pane_id", string(oldActivePaneID)).
671- Msg("performing incremental stack split")
672-
673- // 1. Deactivate the old active pane (in the stack)
674- if oldPaneView := wsView.GetPaneView(oldActivePaneID); oldPaneView != nil {
675- oldPaneView.SetActive(false)
676- }
677-
678- // 2. Unparent the existing stack widget from the workspace container
679- wsView.Container().Remove(existingStackWidget)
680-
681- // 2. Create new PaneView for the new pane (without WebView - will attach later)
682- newPaneView := component.NewPaneView(factory, output.NewPaneNode.Pane.ID, nil)
683-
684- // 3. Wrap the new PaneView in a StackedView
685- newStackedView := layout.NewStackedView(factory)
686- newStackedView.AddPane(ctx, output.NewPaneNode.Pane.Title, "", newPaneView.Widget())
687-
688- // 4. Create a SplitView with the appropriate orientation and child placement
689- var splitView *layout.SplitView
690- if direction == usecase.SplitLeft {
691- // New pane goes on the left, existing stack on the right
692- splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal,
693- newStackedView.Widget(), existingStackWidget, output.SplitRatio)
694- } else {
695- // New pane goes on the right, existing stack on the left (SplitRight)
696- splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal,
697- existingStackWidget, newStackedView.Widget(), output.SplitRatio)
698- }
699-
700- // 5. Replace the root widget in the workspace view
701- wsView.SetRootWidgetDirect(splitView.Widget())
702-
703- // 6. Register the new pane in tracking maps and activate it
704- wsView.RegisterPaneView(output.NewPaneNode.Pane.ID, newPaneView)
705- newPaneView.SetActive(true)
706-
707- tr := wsView.TreeRenderer()
708- if tr != nil {
709- // Register the new pane's StackedView mapping
710- tr.RegisterPaneInStack(string(output.NewPaneNode.Pane.ID), newStackedView)
711- // Register the split node widget
712- tr.RegisterWidget(output.ParentNode.ID, splitView.Widget())
713- }
714-
715- // 7. Attach WebView only for the new pane
716- wv, err := a.ensureWebViewForPane(ctx, output.NewPaneNode.Pane.ID)
717- if err != nil {
718- log.Warn().Err(err).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to ensure webview for new pane")
719- return err
720- }
721-
722- // Load the default URL for new pane
723- newPaneEntity := output.NewPaneNode.Pane
724- if newPaneEntity.URI != "" {
725- if err := wv.LoadURI(ctx, newPaneEntity.URI); err != nil {
726- log.Warn().Err(err).Str("uri", newPaneEntity.URI).Msg("failed to load URI for new pane")
727- }
728- }
729-
730- widget := a.wrapWebViewWidget(ctx, wv)
731- if widget != nil {
732- newPaneView.SetWebViewWidget(widget)
733- }
734-
735- log.Debug().Msg("incremental stack split completed successfully")
736- return nil
737-}
738-
739-// handleClosePane closes the active pane.
740-func (a *App) handleClosePane(ctx context.Context) error {
741- log := logging.FromContext(ctx)
742-
743- if a.panesUC == nil {
744- log.Warn().Msg("panes use case not available")
745- return nil
746- }
747-
748- ws := a.activeWorkspace()
749- if ws == nil {
750- log.Warn().Msg("no active workspace")
751- return nil
752- }
753-
754- activePane := ws.ActivePane()
755- if activePane == nil {
756- log.Warn().Msg("no active pane to close")
757- return nil
758- }
759- closingPaneID := activePane.Pane.ID
760-
761- // Don't close the last pane - close the tab instead
762- if ws.PaneCount() <= 1 {
763- return a.handleCloseTab(ctx)
764- }
765-
766- _, err := a.panesUC.Close(ctx, ws, activePane)
767- if err != nil {
768- log.Error().Err(err).Msg("failed to close pane")
769- return err
770- }
771-
772- // Rebuild the workspace view
773- wsView := a.activeWorkspaceView()
774- if wsView != nil {
775- if err := wsView.Rebuild(); err != nil {
776- log.Error().Err(err).Msg("failed to rebuild workspace view")
777- }
778- a.releasePaneWebView(ctx, closingPaneID)
779- a.attachWorkspaceWebViews(ctx, ws, wsView)
780- wsView.FocusPane(ws.ActivePaneID)
781- }
782-
783- log.Info().Msg("pane closed")
784- return nil
785-}
786-
787-// handlePaneFocus navigates focus to an adjacent pane.
788-func (a *App) handlePaneFocus(ctx context.Context, direction usecase.NavigateDirection) error {
789- log := logging.FromContext(ctx)
790-
791- ws := a.activeWorkspace()
792- if ws == nil {
793- log.Warn().Msg("no active workspace")
794- return nil
795- }
796-
797- wsView := a.activeWorkspaceView()
798- if wsView == nil {
799- log.Warn().Msg("no active workspace view")
800- return nil
801- }
802-
803- // Use geometric navigation if focus manager is available
804- var newPane *entity.PaneNode
805- var err error
806-
807- if a.focusMgr != nil {
808- newPane, err = a.focusMgr.NavigateGeometric(ctx, ws, wsView, direction)
809- } else if a.panesUC != nil {
810- // Fallback to structural navigation
811- newPane, err = a.panesUC.NavigateFocus(ctx, ws, direction)
812- } else {
813- log.Warn().Msg("no navigation manager available")
814- return nil
815- }
816-
817- if err != nil {
818- log.Error().Err(err).Str("direction", string(direction)).Msg("failed to navigate focus")
819- return err
820- }
821-
822- if newPane == nil {
823- log.Debug().Str("direction", string(direction)).Msg("no pane in that direction")
824- return nil
825- }
826-
827- // Update the workspace view's active pane
828- if err := wsView.SetActivePaneID(newPane.Pane.ID); err != nil {
829- log.Warn().Err(err).Msg("failed to update active pane in view")
830- } else {
831- wsView.FocusPane(newPane.Pane.ID)
832- }
833-
834- // Sync StackedView visibility if new pane is in a stack
835- if newPane.Parent != nil && newPane.Parent.IsStacked {
836- a.syncStackedViewActive(ctx, wsView, newPane)
837- }
838-
839- log.Debug().Str("direction", string(direction)).Str("new_pane_id", newPane.ID).Msg("focus navigated")
840-
841- return nil
842-}
843-
844-// syncStackedViewActive updates the StackedView's visibility to match the domain model.
845-// Call this after navigating focus to a pane inside a stack.
846-func (a *App) syncStackedViewActive(ctx context.Context, wsView *component.WorkspaceView, paneNode *entity.PaneNode) {
847- log := logging.FromContext(ctx)
848-
849- if paneNode == nil || paneNode.Parent == nil || !paneNode.Parent.IsStacked {
850- return
851- }
852-
853- tr := wsView.TreeRenderer()
854- if tr == nil {
855- return
856- }
857-
858- // Get the StackedView for this pane
859- stackedView := tr.GetStackedViewForPane(string(paneNode.Pane.ID))
860- if stackedView == nil {
861- log.Warn().Str("pane_id", string(paneNode.Pane.ID)).Msg("stacked view not found for pane")
862- return
863- }
864-
865- // Use the parent's ActiveStackIndex which was set by the focus manager
866- stackIndex := paneNode.Parent.ActiveStackIndex
867-
868- log.Debug().
869- Str("pane_id", string(paneNode.Pane.ID)).
870- Int("stack_index", stackIndex).
871- Msg("syncing stacked view visibility")
872-
873- if err := stackedView.SetActive(ctx, stackIndex); err != nil {
874- log.Warn().Err(err).Msg("failed to set stacked view active index")
875- }
876-}
877-
878-// handleStackPane adds a new pane stacked on top of the active pane.
879-// This modifies both the domain tree and the UI StackedView.
880-func (a *App) handleStackPane(ctx context.Context) error {
881- log := logging.FromContext(ctx)
882-
883- if a.stackedPaneMgr == nil {
884- log.Warn().Msg("stacked pane manager not available")
885- return nil
886- }
887-
888- ws := a.activeWorkspace()
889- if ws == nil {
890- log.Warn().Msg("no active workspace")
891- return nil
892- }
893-
894- activeNode := ws.ActivePane()
895- if activeNode == nil || activeNode.Pane == nil {
896- log.Warn().Msg("no active pane")
897- return nil
898- }
899-
900- wsView := a.activeWorkspaceView()
901- if wsView == nil {
902- log.Warn().Msg("no workspace view")
903- return nil
904- }
905-
906- activePaneID := activeNode.Pane.ID
907-
908- // Create a new pane entity
909- newPaneID := entity.PaneID(a.generateID())
910- newPane := entity.NewPane(newPaneID)
911- newPane.URI = "about:blank"
912- newPane.Title = "Untitled"
913-
914- // Get the original pane's current title
915- originalTitle := a.getPaneTitle(activePaneID)
916- if originalTitle == "" {
917- originalTitle = activeNode.Pane.Title
918- }
919- if originalTitle == "" {
920- originalTitle = "Untitled"
921- }
922-
923- // Update domain model: convert leaf to stacked if needed, add new pane
924- var stackNode *entity.PaneNode
925- var needsFirstPaneTitleUpdate bool
926- if activeNode.IsStacked {
927- // Already stacked, just add to it
928- stackNode = activeNode
929- } else {
930- // Convert leaf node to stacked container
931- // Move current pane to a child node
932- originalPane := activeNode.Pane
933- originalPane.Title = originalTitle // Ensure title is set
934- originalPaneChild := &entity.PaneNode{
935- ID: activeNode.ID + "_0",
936- Pane: originalPane,
937- Parent: activeNode,
938- }
939-
940- activeNode.Pane = nil // No longer a leaf
941- activeNode.IsStacked = true
942- activeNode.Children = []*entity.PaneNode{originalPaneChild}
943- stackNode = activeNode
944- needsFirstPaneTitleUpdate = true
945-
946- log.Debug().
947- Str("node_id", activeNode.ID).
948- Str("original_pane", string(originalPane.ID)).
949- Str("original_title", originalTitle).
950- Msg("converted leaf to stacked node")
951- }
952-
953- // Create new child node for the new pane
954- newChildNode := &entity.PaneNode{
955- ID: stackNode.ID + "_" + string(newPaneID),
956- Pane: newPane,
957- Parent: stackNode,
958- }
959- stackNode.Children = append(stackNode.Children, newChildNode)
960- stackNode.ActiveStackIndex = len(stackNode.Children) - 1
961-
962- log.Debug().
963- Int("stack_size", len(stackNode.Children)).
964- Int("active_index", stackNode.ActiveStackIndex).
965- Msg("domain tree updated")
966-
967- // Create PaneView for the new pane
968- newPaneView := component.NewPaneView(a.widgetFactory, newPaneID, nil)
969- wsView.RegisterPaneView(newPaneID, newPaneView)
970-
971- // Add to the UI StackedView
972- if err := a.stackedPaneMgr.AddPaneToStack(ctx, wsView, activePaneID, newPaneView, "Untitled"); err != nil {
973- log.Error().Err(err).Msg("failed to add pane to stack")
974- return err
975- }
976-
977- // Update the first pane's title if we just converted from leaf to stacked
978- if needsFirstPaneTitleUpdate {
979- tr := wsView.TreeRenderer()
980- if tr != nil {
981- stackedView := tr.GetStackedViewForPane(string(activePaneID))
982- if stackedView != nil {
983- if err := stackedView.UpdateTitle(0, originalTitle); err != nil {
984- log.Warn().Err(err).Str("title", originalTitle).Msg("failed to update first pane title")
985- } else {
986- log.Debug().Str("title", originalTitle).Msg("updated first pane title in StackedView")
987- }
988- }
989- }
990- }
991-
992- // Get WebView and attach
993- wv, err := a.ensureWebViewForPane(ctx, newPaneID)
994- if err != nil {
995- log.Warn().Err(err).Msg("failed to get webview for new pane")
996- } else {
997- widget := a.wrapWebViewWidget(ctx, wv)
998- if widget != nil {
999- newPaneView.SetWebViewWidget(widget)
1000- }
1001- // Load blank page
1002- if err := wv.LoadURI(ctx, "about:blank"); err != nil {
1003- log.Warn().Err(err).Msg("failed to load blank page")
1004- }
1005- }
1006-
1007- // Update workspace active pane ID
1008- ws.ActivePaneID = newPaneID
1009-
1010- // Update workspace view
1011- if err := wsView.SetActivePaneID(newPaneID); err != nil {
1012- log.Warn().Err(err).Msg("failed to set active pane")
1013- }
1014-
1015- // Set up title bar click callback
1016- tr := wsView.TreeRenderer()
1017- if tr != nil {
1018- stackedView := tr.GetStackedViewForPane(string(activePaneID))
1019- if stackedView != nil {
1020- // Capture stackNode for the callback
1021- capturedStackNode := stackNode
1022- stackedView.SetOnActivate(func(index int) {
1023- a.handleTitleBarClick(ctx, capturedStackNode, stackedView, index)
1024- })
1025- }
1026- }
1027-
1028- log.Info().
1029- Str("original_pane", string(activePaneID)).
1030- Str("new_pane", string(newPaneID)).
1031- Int("stack_size", len(stackNode.Children)).
1032- Msg("stacked new pane")
1033-
1034- return nil
1035-}
1036-
1037-// handleStackNavigate navigates up or down within a stacked pane container.
1038-// With the new architecture, every pane is in a StackedView - we just navigate within it.
1039-func (a *App) handleStackNavigate(ctx context.Context, direction string) error {
1040- log := logging.FromContext(ctx)
1041-
1042- if a.stackedPaneMgr == nil {
1043- return nil
1044- }
1045-
1046- ws := a.activeWorkspace()
1047- if ws == nil {
1048- return nil
1049- }
1050-
1051- activePane := ws.ActivePane()
1052- if activePane == nil || activePane.Pane == nil {
1053- return nil
1054- }
1055-
1056- wsView := a.activeWorkspaceView()
1057- if wsView == nil {
1058- return nil
1059- }
1060-
1061- currentPaneID := activePane.Pane.ID
1062-
1063- // Check if we're actually in a multi-pane stack
1064- if !a.stackedPaneMgr.IsStacked(wsView, currentPaneID) {
1065- log.Debug().Msg("current pane is not in a multi-pane stack")
1066- return nil
1067- }
1068-
1069- // Navigate within the stack
1070- _, err := a.stackedPaneMgr.NavigateStack(ctx, wsView, currentPaneID, direction)
1071- if err != nil {
1072- log.Warn().Err(err).Str("direction", direction).Msg("failed to navigate stack")
1073- return err
1074- }
1075-
1076- // Note: With the current StackedView navigation, the new pane ID is not returned
1077- // because the mapping from visual index to pane ID requires additional tracking.
1078- // For now, the StackedView handles the visual navigation.
1079- // TODO: Add pane ID tracking to StackedView for proper domain model sync
1080-
1081- log.Debug().
1082- Str("direction", direction).
1083- Str("current_pane", string(currentPaneID)).
1084- Msg("navigated stack")
1085-
1086- return nil
1087-}
1088-
1089-// handleStackPaneActivated is called when a pane in a stack is activated via title bar click.
1090-// This is a stub for future use when title bar click callbacks are wired up.
1091-// The paneID parameter would come from the StackedView's OnActivate callback.
1092-func (a *App) handleStackPaneActivated(ctx context.Context, paneID entity.PaneID) {
1093- log := logging.FromContext(ctx)
1094-
1095- if paneID == "" {
1096- return
1097- }
1098-
1099- // Update workspace active pane
1100- ws := a.activeWorkspace()
1101- if ws != nil {
1102- ws.ActivePaneID = paneID
1103- }
1104-
1105- // Update workspace view
1106- wsView := a.activeWorkspaceView()
1107- if wsView != nil {
1108- if err := wsView.SetActivePaneID(paneID); err != nil {
1109- log.Warn().Err(err).Msg("failed to set active pane after stack activation")
1110- }
1111- }
1112-
1113- log.Debug().
1114- Str("pane_id", string(paneID)).
1115- Msg("stack pane activated")
1116-}
1117-
1118-// handleTitleBarClick handles clicks on title bars to switch the active pane in a stack.
1119-func (a *App) handleTitleBarClick(ctx context.Context, stackNode *entity.PaneNode, sv *layout.StackedView, clickedIndex int) {
1120- log := logging.FromContext(ctx)
1121-
1122- if stackNode == nil || sv == nil {
1123- return
1124- }
1125-
1126- // Validate index
1127- if clickedIndex < 0 || clickedIndex >= len(stackNode.Children) {
1128- log.Warn().Int("index", clickedIndex).Int("children", len(stackNode.Children)).Msg("invalid stack index clicked")
1129- return
1130- }
1131-
1132- // Get the current active index
1133- currentIndex := sv.ActiveIndex()
1134- if clickedIndex == currentIndex {
1135- log.Debug().Int("index", clickedIndex).Msg("clicked pane is already active")
1136- return
1137- }
1138-
1139- // Update the outgoing pane's title bar with its current webpage title
1140- if currentIndex >= 0 && currentIndex < len(stackNode.Children) {
1141- outgoingChild := stackNode.Children[currentIndex]
1142- if outgoingChild.Pane != nil {
1143- outgoingPaneID := outgoingChild.Pane.ID
1144- outgoingTitle := a.getPaneTitle(outgoingPaneID)
1145- if outgoingTitle == "" {
1146- outgoingTitle = outgoingChild.Pane.Title
1147- }
1148- if outgoingTitle != "" {
1149- if err := sv.UpdateTitle(currentIndex, outgoingTitle); err != nil {
1150- log.Warn().Err(err).Msg("failed to update outgoing pane title")
1151- }
1152- }
1153- }
1154- }
1155-
1156- // Get the pane ID at the clicked index
1157- clickedChild := stackNode.Children[clickedIndex]
1158- if clickedChild.Pane == nil {
1159- log.Warn().Int("index", clickedIndex).Msg("clicked child has no pane")
1160- return
1161- }
1162- clickedPaneID := clickedChild.Pane.ID
1163-
1164- // Update StackedView active index
1165- if err := sv.SetActive(ctx, clickedIndex); err != nil {
1166- log.Warn().Err(err).Int("index", clickedIndex).Msg("failed to set active pane in stack")
1167- return
1168- }
1169-
1170- // Update domain model
1171- stackNode.ActiveStackIndex = clickedIndex
1172-
1173- // Update workspace active pane
1174- ws := a.activeWorkspace()
1175- if ws != nil {
1176- ws.ActivePaneID = clickedPaneID
1177- }
1178-
1179- // Update workspace view
1180- wsView := a.activeWorkspaceView()
1181- if wsView != nil {
1182- if err := wsView.SetActivePaneID(clickedPaneID); err != nil {
1183- log.Warn().Err(err).Msg("failed to set active pane in workspace view")
1184- }
1185- }
1186-
1187- log.Info().
1188- Int("from_index", currentIndex).
1189- Int("to_index", clickedIndex).
1190- Str("pane_id", string(clickedPaneID)).
1191- Msg("switched active pane via title bar click")
1192-}
1193-
1194-// attachWorkspaceWebViews ensures each pane has a WebView widget attached.
1195-func (a *App) attachWorkspaceWebViews(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) {
1196- log := logging.FromContext(ctx)
1197-
1198- if ws == nil || wsView == nil || a.widgetFactory == nil {
1199- return
1200- }
1201-
1202- for _, pane := range ws.AllPanes() {
1203- if pane == nil {
1204- continue
1205- }
1206-
1207- wv, err := a.ensureWebViewForPane(ctx, pane.ID)
1208- if err != nil {
1209- log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to ensure webview for pane")
1210- continue
1211- }
1212-
1213- // Load the pane's URI if set and different from current
1214- if pane.URI != "" && pane.URI != wv.URI() {
1215- if err := wv.LoadURI(ctx, pane.URI); err != nil {
1216- log.Warn().Err(err).Str("pane_id", string(pane.ID)).Str("uri", pane.URI).Msg("failed to load pane URI")
1217- }
1218- }
1219-
1220- widget := a.wrapWebViewWidget(ctx, wv)
1221- if widget == nil {
1222- continue
1223- }
1224-
1225- if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil {
1226- log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget")
1227- }
1228- }
1229-}
1230-
1231-// ensureWebViewForPane acquires or reuses a WebView for the given pane.
1232-func (a *App) ensureWebViewForPane(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {
1233- if wv, ok := a.webViews[paneID]; ok && wv != nil && !wv.IsDestroyed() {
1234- return wv, nil
1235- }
1236- if a.pool == nil {
1237- return nil, fmt.Errorf("webview pool not configured")
1238- }
1239-
1240- wv, err := a.pool.Acquire(ctx)
1241- if err != nil {
1242- return nil, err
1243- }
1244-
1245- a.webViews[paneID] = wv
1246-
1247- // Set up title change callback to track dynamic titles
1248- wv.OnTitleChanged = func(title string) {
1249- a.handlePaneTitleChanged(ctx, paneID, title)
1250- }
1251-
1252- return wv, nil
1253-}
1254-
1255-// handlePaneTitleChanged updates title tracking when a WebView's title changes.
1256-func (a *App) handlePaneTitleChanged(ctx context.Context, paneID entity.PaneID, title string) {
1257- log := logging.FromContext(ctx)
1258-
1259- // Update title map
1260- a.titleMu.Lock()
1261- a.paneTitles[paneID] = title
1262- a.titleMu.Unlock()
1263-
1264- // Update domain model
1265- ws := a.activeWorkspace()
1266- if ws != nil {
1267- paneNode := ws.FindPane(paneID)
1268- if paneNode != nil && paneNode.Pane != nil {
1269- paneNode.Pane.Title = title
1270- }
1271- }
1272-
1273- // Update StackedView title bar if this pane is in a stack
1274- wsView := a.activeWorkspaceView()
1275- if wsView != nil {
1276- tr := wsView.TreeRenderer()
1277- if tr != nil {
1278- stackedView := tr.GetStackedViewForPane(string(paneID))
1279- if stackedView != nil {
1280- // Find the pane's index in the stack and update title
1281- a.updateStackedPaneTitle(ctx, stackedView, paneID, title)
1282- }
1283- }
1284- }
1285-
1286- log.Debug().
1287- Str("pane_id", string(paneID)).
1288- Str("title", title).
1289- Msg("pane title updated")
1290-}
1291-
1292-// updateStackedPaneTitle updates the title of a pane in a StackedView.
1293-func (a *App) updateStackedPaneTitle(ctx context.Context, sv *layout.StackedView, paneID entity.PaneID, title string) {
1294- // We need to find which index this pane is at in the stack
1295- // For now, we track by iterating - could be optimized with reverse mapping
1296- wsView := a.activeWorkspaceView()
1297- if wsView == nil {
1298- return
1299- }
1300-
1301- tr := wsView.TreeRenderer()
1302- if tr == nil {
1303- return
1304- }
1305-
1306- // Find the stack node in the domain to get the index
1307- ws := a.activeWorkspace()
1308- if ws == nil {
1309- return
1310- }
1311-
1312- paneNode := ws.FindPane(paneID)
1313- if paneNode == nil {
1314- return
1315- }
1316-
1317- // If the pane is in a stacked parent, find its index
1318- if paneNode.Parent != nil && paneNode.Parent.IsStacked {
1319- for i, child := range paneNode.Parent.Children {
1320- if child.Pane != nil && child.Pane.ID == paneID {
1321- _ = sv.UpdateTitle(i, title)
1322- return
1323- }
1324- }
1325- }
1326-}
1327-
1328-// getPaneTitle returns the current title for a pane.
1329-func (a *App) getPaneTitle(paneID entity.PaneID) string {
1330- a.titleMu.RLock()
1331- defer a.titleMu.RUnlock()
1332- return a.paneTitles[paneID]
1333-}
1334-
1335-// releasePaneWebView returns the WebView for a pane to the pool.
1336-func (a *App) releasePaneWebView(ctx context.Context, paneID entity.PaneID) {
1337- wv, ok := a.webViews[paneID]
1338- if !ok || wv == nil {
1339- return
1340- }
1341- delete(a.webViews, paneID)
1342-
1343- if a.pool != nil {
1344- a.pool.Release(wv)
1345- } else {
1346- wv.Destroy()
1347- }
1348-}
1349-
1350-// wrapWebViewWidget converts a WebView to a layout.Widget for embedding.
1351-func (a *App) wrapWebViewWidget(ctx context.Context, wv *webkit.WebView) layout.Widget {
1352- if wv == nil || a.widgetFactory == nil {
1353- return nil
1354- }
1355- gtkView := wv.Widget()
1356- if gtkView == nil {
1357- return nil
1358- }
1359- return a.widgetFactory.WrapWidget(&gtkView.Widget)
1360-}
1361-
1362-// activeWebView returns the WebView for the active pane.
1363-func (a *App) activeWebView(ctx context.Context) *webkit.WebView {
1364- ws := a.activeWorkspace()
1365- if ws == nil {
1366- return nil
1367- }
1368- pane := ws.ActivePane()
1369- if pane == nil || pane.Pane == nil {
1370- return nil
1371- }
1372- return a.webViews[pane.Pane.ID]
1373-}
1374-
1375-// openOmnibox toggles the native GTK omnibox.
1376-func (a *App) openOmnibox(ctx context.Context) error {
1377- log := logging.FromContext(ctx)
1378-
1379- if a.omnibox == nil {
1380- log.Error().Msg("native omnibox not initialized")
1381- return fmt.Errorf("native omnibox not initialized")
1382- }
1383-
1384- log.Debug().Msg("toggling native omnibox")
1385- a.omnibox.Toggle(ctx)
1386- return nil
1387-}
1388-
1389-// navigateActivePane navigates the active pane to the given URL.
1390-// Records history for omnibox search to find it immediately.
1391-func (a *App) navigateActivePane(ctx context.Context, url string) {
1392- log := logging.FromContext(ctx)
1393-
1394- wv := a.activeWebView(ctx)
1395- if wv == nil {
1396- log.Warn().Str("url", url).Msg("no active webview for navigation")
1397- return
1398- }
1399-
1400- // Load the URL
1401- if err := wv.LoadURI(ctx, url); err != nil {
1402- log.Error().Err(err).Str("url", url).Msg("failed to navigate")
1403- return
1404- }
1405-
1406- // Record in history asynchronously so it appears in omnibox immediately
1407- if a.deps.HistoryRepo != nil {
1408- go a.recordHistory(ctx, url)
1409- }
1410-
1411- log.Debug().Str("url", url).Msg("navigated active pane")
1412-}
1413-
1414-// recordHistory saves or updates a history entry.
1415-func (a *App) recordHistory(ctx context.Context, url string) {
1416- log := logging.FromContext(ctx)
1417- log.Debug().Str("url", url).Msg("recording history entry")
1418-
1419- existing, err := a.deps.HistoryRepo.FindByURL(ctx, url)
1420- if err != nil {
1421- log.Warn().Err(err).Str("url", url).Msg("failed to check history")
1422- return
1423- }
1424-
1425- if existing != nil {
1426- // Increment visit count
1427- if err := a.deps.HistoryRepo.IncrementVisitCount(ctx, url); err != nil {
1428- log.Warn().Err(err).Str("url", url).Msg("failed to increment visit count")
1429- } else {
1430- log.Debug().Str("url", url).Int64("prev_count", existing.VisitCount).Msg("history visit count incremented")
1431- }
1432- } else {
1433- // Create new entry
1434- entry := entity.NewHistoryEntry(url, "")
1435- if err := a.deps.HistoryRepo.Save(ctx, entry); err != nil {
1436- log.Warn().Err(err).Str("url", url).Msg("failed to save history")
1437- } else {
1438- log.Debug().Str("url", url).Msg("new history entry saved")
1439- }
1440- }
1441-}
1442-
1443-// openDevTools opens the WebKit inspector for the active WebView.
1444-func (a *App) openDevTools(ctx context.Context) error {
1445- log := logging.FromContext(ctx)
1446-
1447- wv := a.activeWebView(ctx)
1448- if wv == nil {
1449- log.Warn().Msg("no active webview for devtools")
1450- return fmt.Errorf("no active webview for devtools")
1451- }
1452- log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("opening devtools")
1453- return wv.ShowDevTools()
1454-}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close