1package coordinator
2
3import (
4 "context"
5 "fmt"
6 "time"
7
8 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/application/usecase"
10 "github.com/bnema/dumber/internal/domain/entity"
11 "github.com/bnema/dumber/internal/logging"
12 "github.com/bnema/dumber/internal/ui/coordinator/content"
13)
14
15// OmniboxProvider provides access to omnibox operations.
16type OmniboxProvider interface {
17 ToggleOmnibox(ctx context.Context)
18 UpdateOmniboxZoom(factor float64)
19}
20
21// NavigationCoordinator handles URL navigation, history, and browser controls.
22type NavigationCoordinator struct {
23 contextProvider func() context.Context
24 navigateUC *usecase.NavigateUseCase
25 historyRecorder *usecase.HistoryRecorderUseCase
26 pageScrollUC *usecase.PageScrollUseCase
27 contentCoord *content.Coordinator
28 omniboxProvider OmniboxProvider
29}
30
31const faviconPreloadTimeout = 300 * time.Millisecond
32
33// NewNavigationCoordinator creates a new NavigationCoordinator.
34func NewNavigationCoordinator(
35 ctx context.Context,
36 navigateUC *usecase.NavigateUseCase,
37 contentCoord *content.Coordinator,
38) *NavigationCoordinator {
39 return NewNavigationCoordinatorWithHistoryRecorder(ctx, navigateUC, nil, contentCoord)
40}
41
42// NewNavigationCoordinatorWithHistoryRecorder creates a new NavigationCoordinator with history recording.
43func NewNavigationCoordinatorWithHistoryRecorder(
44 ctx context.Context,
45 navigateUC *usecase.NavigateUseCase,
46 historyRecorder *usecase.HistoryRecorderUseCase,
47 contentCoord *content.Coordinator,
48) *NavigationCoordinator {
49 log := logging.FromContext(ctx)
50 log.Debug().Msg("creating navigation coordinator")
51 callbackLogger := *log
52
53 return &NavigationCoordinator{
54 contextProvider: func() context.Context {
55 return logging.WithContext(context.Background(), callbackLogger)
56 },
57 navigateUC: navigateUC,
58 historyRecorder: historyRecorder,
59 contentCoord: contentCoord,
60 }
61}
62
63// SetOmniboxProvider sets the omnibox provider for toggle/zoom operations.
64func (c *NavigationCoordinator) SetOmniboxProvider(provider OmniboxProvider) {
65 c.omniboxProvider = provider
66}
67
68// requireWebView returns an error if wv is nil, preserving stable error text.
69func requireWebView(wv port.WebView) error {
70 if wv == nil {
71 return fmt.Errorf("no webview provided")
72 }
73 return nil
74}
75
76func (c *NavigationCoordinator) trackNavigationOrigin(ctx context.Context, paneID entity.PaneID, url string) {
77 if c.contentCoord == nil || paneID == "" {
78 return
79 }
80 c.contentCoord.SetNavigationOrigin(paneID, url)
81 go func() {
82 preloadCtx, cancelPreload := context.WithTimeout(ctx, faviconPreloadTimeout)
83 defer cancelPreload()
84 c.contentCoord.PreloadCachedFavicon(preloadCtx, paneID, url)
85 }()
86}
87
88// NavigateWebView loads a URL in the specified pane using the provided WebView.
89// This is the explicit-target equivalent of Navigate: it uses NavigateUseCase if
90// available, falling back to direct LoadURI. Origin tracking via contentCoord is
91// performed when contentCoord and paneID are available.
92func (c *NavigationCoordinator) NavigateWebView(ctx context.Context, url string, paneID entity.PaneID, wv port.WebView) error {
93 log := logging.FromContext(ctx)
94
95 if err := requireWebView(wv); err != nil {
96 log.Warn().Str("url", url).Msg("NavigateWebView called with nil webview")
97 return err
98 }
99
100 paneIDStr := string(paneID)
101
102 if c.navigateUC != nil {
103 input := usecase.NavigateInput{
104 URL: url,
105 PaneID: paneIDStr,
106 WebView: wv,
107 }
108 output, err := c.navigateUC.Execute(ctx, input)
109 if err != nil {
110 log.Error().Err(err).Str("url", url).Str("pane_id", paneIDStr).Msg("navigation failed")
111 return err
112 }
113 c.trackNavigationOrigin(ctx, paneID, url)
114 log.Debug().
115 Str("url", url).
116 Str("pane_id", paneIDStr).
117 Float64("zoom", output.AppliedZoom).
118 Uint64("webview_id", uint64(wv.ID())).
119 Msg("navigation initiated via usecase")
120 return nil
121 }
122
123 log.Warn().Msg("navigateUC not available, using direct LoadURI")
124 if err := wv.LoadURI(ctx, url); err != nil {
125 log.Error().Err(err).Str("url", url).Msg("failed to navigate")
126 return err
127 }
128
129 c.trackNavigationOrigin(ctx, paneID, url)
130 log.Debug().Str("url", url).Uint64("webview_id", uint64(wv.ID())).Msg("navigated explicit webview (direct)")
131 return nil
132}
133
134// ReloadWebView reloads the page in the provided WebView, optionally bypassing cache.
135func (c *NavigationCoordinator) ReloadWebView(ctx context.Context, wv port.WebView, bypassCache bool) error {
136 log := logging.FromContext(ctx)
137
138 if err := requireWebView(wv); err != nil {
139 log.Debug().Msg("ReloadWebView called with nil webview")
140 return err
141 }
142
143 if c.navigateUC != nil {
144 return c.navigateUC.Reload(ctx, wv, bypassCache)
145 }
146
147 if bypassCache {
148 log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("reloading explicit webview bypassing cache")
149 return wv.ReloadBypassCache(ctx)
150 }
151
152 log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("reloading explicit webview")
153 return wv.Reload(ctx)
154}
155
156// StopWebView stops loading in the provided WebView.
157func (c *NavigationCoordinator) StopWebView(ctx context.Context, wv port.WebView) error {
158 log := logging.FromContext(ctx)
159
160 if err := requireWebView(wv); err != nil {
161 log.Debug().Msg("StopWebView called with nil webview")
162 return err
163 }
164
165 if c.navigateUC != nil {
166 return c.navigateUC.Stop(ctx, wv)
167 }
168
169 log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("stopping explicit webview")
170 return wv.Stop(ctx)
171}
172
173// GoBackWebView navigates back in history for the provided WebView.
174// Calls webview directly - the webview layer handles SPA navigation via JS fallback.
175func (c *NavigationCoordinator) GoBackWebView(ctx context.Context, wv port.WebView) error {
176 log := logging.FromContext(ctx)
177
178 if err := requireWebView(wv); err != nil {
179 log.Debug().Msg("GoBackWebView called with nil webview")
180 return err
181 }
182
183 log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("going back in explicit webview")
184 return wv.GoBack(ctx)
185}
186
187// GoForwardWebView navigates forward in history for the provided WebView.
188// Calls webview directly - the webview layer handles SPA navigation via JS fallback.
189func (c *NavigationCoordinator) GoForwardWebView(ctx context.Context, wv port.WebView) error {
190 log := logging.FromContext(ctx)
191
192 if err := requireWebView(wv); err != nil {
193 log.Debug().Msg("GoForwardWebView called with nil webview")
194 return err
195 }
196
197 log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("going forward in explicit webview")
198 return wv.GoForward(ctx)
199}
200
201// OpenOmnibox toggles the omnibox visibility.
202func (c *NavigationCoordinator) OpenOmnibox(ctx context.Context) error {
203 log := logging.FromContext(ctx)
204
205 if c.omniboxProvider == nil {
206 log.Error().Msg("omnibox provider not initialized")
207 return fmt.Errorf("omnibox provider not initialized")
208 }
209
210 log.Debug().Msg("toggling omnibox")
211 c.omniboxProvider.ToggleOmnibox(ctx)
212 return nil
213}
214
215// OpenDevToolsWebView opens the WebKit inspector for the provided WebView.
216func (c *NavigationCoordinator) OpenDevToolsWebView(ctx context.Context, wv port.WebView) error {
217 log := logging.FromContext(ctx)
218
219 if err := requireWebView(wv); err != nil {
220 log.Warn().Msg("OpenDevToolsWebView called with nil webview")
221 return err
222 }
223
224 log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("opening devtools")
225
226 if opener, ok := wv.(port.DevToolsOpener); ok {
227 opener.OpenDevTools()
228 return nil
229 }
230
231 return fmt.Errorf("webview does not support devtools")
232}
233
234// PrintWebView opens the print dialog for the provided WebView.
235func (c *NavigationCoordinator) PrintWebView(ctx context.Context, wv port.WebView) error {
236 log := logging.FromContext(ctx)
237
238 if err := requireWebView(wv); err != nil {
239 log.Warn().Msg("PrintWebView called with nil webview")
240 return err
241 }
242
243 log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("opening print dialog")
244
245 if printer, ok := wv.(port.Printer); ok {
246 printer.PrintPage()
247 return nil
248 }
249
250 return fmt.Errorf("webview does not support printing")
251}
252
253// UpdateHistoryTitle updates the title of a history entry after page load.
254func (c *NavigationCoordinator) UpdateHistoryTitle(ctx context.Context, paneID entity.PaneID, url, title string) {
255 if c.historyRecorder == nil {
256 return
257 }
258
259 c.historyRecorder.UpdateHistoryTitle(ctx, url, title)
260}
261
262// RecordHistory records a URL in history on page commit.
263func (c *NavigationCoordinator) RecordHistory(ctx context.Context, paneID entity.PaneID, url string) {
264 if c.historyRecorder == nil {
265 return
266 }
267
268 c.historyRecorder.RecordHistory(ctx, string(paneID), url)
269}
270
271// ClearPaneHistory clears per-pane navigation history deduplication state.
272func (c *NavigationCoordinator) ClearPaneHistory(paneID entity.PaneID) {
273 if c.historyRecorder == nil {
274 return
275 }
276 c.historyRecorder.ClearPaneHistory(string(paneID))
277}
278
279// SetPageScrollUseCase sets the PageScrollUseCase for page scrolling operations.
280// This is an optional dependency: ScrollWebView will return an error if not set.
281func (c *NavigationCoordinator) SetPageScrollUseCase(uc *usecase.PageScrollUseCase) {
282 c.pageScrollUC = uc
283}
284
285// ScrollWebView applies a semantic page scroll command to the provided WebView.
286// It delegates to PageScrollUseCase for delta computation and Scrollable dispatch.
287// The PageScrollUseCase must be set via SetPageScrollUseCase before calling this.
288func (c *NavigationCoordinator) ScrollWebView(ctx context.Context, wv port.WebView, cmd usecase.PageScrollCommand) error {
289 log := logging.FromContext(ctx)
290
291 if err := requireWebView(wv); err != nil {
292 log.Warn().Str("command", cmd.String()).Msg("ScrollWebView called with nil webview")
293 return err
294 }
295
296 log.Debug().Str("command", cmd.String()).Uint64("webview_id", uint64(wv.ID())).Msg("scrolling webview")
297
298 if c.pageScrollUC == nil {
299 err := fmt.Errorf("page scroll usecase not set")
300 log.Error().Msg(err.Error())
301 return err
302 }
303
304 return c.pageScrollUC.Scroll(ctx, wv, cmd)
305}
306
307// ScrollWebViewContinuous applies one autonomous held-key vim-scroll step.
308func (c *NavigationCoordinator) ScrollWebViewContinuous(ctx context.Context, wv port.WebView, cmd usecase.PageScrollCommand) error {
309 if err := requireWebView(wv); err != nil {
310 return err
311 }
312 if c.pageScrollUC == nil {
313 return fmt.Errorf("page scroll usecase not set")
314 }
315 return c.pageScrollUC.ScrollContinuous(ctx, wv, cmd)
316}
317
318// StopPageScroll discards queued continuous deltas for adapters that support it.
319func (c *NavigationCoordinator) StopPageScroll(ctx context.Context, wv port.WebView) error {
320 if err := requireWebView(wv); err != nil {
321 return err
322 }
323 if c.pageScrollUC == nil {
324 return fmt.Errorf("page scroll usecase not set")
325 }
326 return c.pageScrollUC.Stop(ctx, wv)
327}
328
329// NotifyZoomChanged updates the omnibox zoom indicator.
330func (c *NavigationCoordinator) NotifyZoomChanged(ctx context.Context, factor float64) {
331 if c.omniboxProvider != nil {
332 c.omniboxProvider.UpdateOmniboxZoom(factor)
333 }
334}