detached 180a0d0

perf(startup): defer non-critical init until after first navigation starts

commit
180a0d03742b922453dd24c078054a805916193e
author
brice · · 2026-01-23 06:25 UTC
parent
428bf73
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +213 -99
M cmd/dumber/main.go +79 -43
@@ -29,6 +29,7 @@ import (
2929 "github.com/bnema/dumber/internal/logging"
3030 "github.com/bnema/dumber/internal/ui"
3131 "github.com/bnema/dumber/internal/ui/theme"
32+ "github.com/rs/zerolog"
3233 )
3334
3435 // Build-time variables (set via ldflags).
@@ -75,30 +76,15 @@ func runGUI() int {
7576 cfg := initConfig()
7677 timer.Mark("config")
7778
78- // Initialize startup trace for cold start tracking (debug/trace only)
79- logging.InitStartupTrace(cfg.Logging.Level)
80- logging.Trace().Mark("config_loaded")
81-
82- ctx := initStartupContext(cfg)
79+ ctx := initStartupContextWithTrace(cfg)
8380 timer.Mark("logger")
8481 bootstrapLog := logging.FromContext(ctx)
8582
86- // Set logger for startup trace and mark logger init
87- logging.Trace().SetLogger(bootstrapLog)
88- logging.Trace().Mark("logger_init")
89-
90- // Parallel phase: directories and theme setup
91- logging.Trace().Mark("parallel_start")
92- initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{
93- Ctx: ctx,
94- Config: cfg,
95- })
83+ initResult, err := runParallelInitPhase(ctx, cfg)
9684 if err != nil {
97- handleParallelInitError(ctx, err)
9885 return 1
9986 }
10087 timer.MarkDuration("parallel_phase", initResult.Duration)
101- logging.Trace().Mark("parallel_done")
10288
10389 needsEagerDB := restoreSessionID != "" || cfg.Session.AutoRestore
10490
@@ -111,48 +97,27 @@ func runGUI() int {
11197 }
11298 timer.Mark("db_webkit_parallel")
11399
114- browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, repos.session, !needsEagerDB)
115- if err != nil {
116- bootstrapLog.Fatal().Err(err).Msg("failed to start session")
117- }
118- if browserSession.LogCleanup != nil {
119- defer browserSession.LogCleanup()
120- }
121- defer func() { _ = browserSession.End(sessionCtx) }()
100+ ctx, browserSession, sessionCleanup := initBrowserSession(ctx, cfg, repos, bootstrapLog)
101+ defer sessionCleanup()
122102 timer.Mark("session")
123103
124- ctx = sessionCtx
125104 log := logging.FromContext(ctx)
126-
127- // Update startup trace with session logger so milestones go to log file
128105 logging.Trace().UpdateLogger(log)
129106
130107 if stack.MessageRouter != nil {
131108 stack.MessageRouter.SetBaseContext(ctx)
132109 }
133110
134- // Repositories and use cases
135111 useCases := createUseCases(repos, cfg)
136112 if needsEagerDB {
137113 handleAutoRestore(ctx, cfg, useCases, browserSession.Session.ID)
138114 }
139115
140116 idleInhibitor := idle.NewPortalInhibitor(ctx)
141- defer func() {
142- if idleInhibitor != nil {
143- _ = idleInhibitor.Close()
144- }
145- }()
117+ defer closeIdleInhibitor(idleInhibitor)
146118 timer.Mark("use_cases")
147119
148- // Build UI
149- uiDeps := buildUIDependencies(
150- ctx, cfg, initResult.ThemeManager,
151- initResult.ColorResolver, initResult.AdwaitaDetector,
152- &stack, repos, useCases, idleInhibitor, browserSession.Session.ID,
153- )
154- configureDeferredInit(uiDeps, cfg, browserSession)
155- app, err := ui.New(uiDeps)
120+ app, err := buildAndConfigureApp(ctx, cfg, initResult, &stack, repos, useCases, idleInhibitor, browserSession)
156121 if err != nil {
157122 log.Error().Err(err).Msg("failed to create application")
158123 return 1
@@ -160,12 +125,83 @@ func runGUI() int {
160125 timer.Mark("ui_deps")
161126 timer.Log(ctx)
162127
163- // Signal handling
164128 setupSignalHandler(ctx, app)
165129
166130 return app.Run(ctx, os.Args)
167131 }
168132
133+func initStartupContextWithTrace(cfg *config.Config) context.Context {
134+ logging.InitStartupTrace(cfg.Logging.Level)
135+ logging.Trace().Mark("config_loaded")
136+
137+ ctx := initStartupContext(cfg)
138+ bootstrapLog := logging.FromContext(ctx)
139+
140+ logging.Trace().SetLogger(bootstrapLog)
141+ logging.Trace().Mark("logger_init")
142+
143+ return ctx
144+}
145+
146+func runParallelInitPhase(ctx context.Context, cfg *config.Config) (*bootstrap.ParallelInitResult, error) {
147+ logging.Trace().Mark("parallel_start")
148+ initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{
149+ Ctx: ctx,
150+ Config: cfg,
151+ })
152+ if err != nil {
153+ handleParallelInitError(ctx, err)
154+ return nil, err
155+ }
156+ logging.Trace().Mark("parallel_done")
157+ return initResult, nil
158+}
159+
160+func initBrowserSession(
161+ ctx context.Context,
162+ cfg *config.Config,
163+ repos *repositories,
164+ bootstrapLog *zerolog.Logger,
165+) (context.Context, *bootstrap.BrowserSession, func()) {
166+ deferPersist := restoreSessionID == "" && !cfg.Session.AutoRestore
167+ browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, repos.session, deferPersist)
168+ if err != nil {
169+ bootstrapLog.Fatal().Err(err).Msg("failed to start session")
170+ }
171+ cleanup := func() {
172+ if browserSession.LogCleanup != nil {
173+ browserSession.LogCleanup()
174+ }
175+ _ = browserSession.End(sessionCtx)
176+ }
177+ return sessionCtx, browserSession, cleanup
178+}
179+
180+func closeIdleInhibitor(inhibitor port.IdleInhibitor) {
181+ if inhibitor != nil {
182+ _ = inhibitor.Close()
183+ }
184+}
185+
186+func buildAndConfigureApp(
187+ ctx context.Context,
188+ cfg *config.Config,
189+ initResult *bootstrap.ParallelInitResult,
190+ stack *bootstrap.WebKitStack,
191+ repos *repositories,
192+ useCases *useCases,
193+ idleInhibitor port.IdleInhibitor,
194+ browserSession *bootstrap.BrowserSession,
195+) (*ui.App, error) {
196+ uiDeps := buildUIDependencies(
197+ ctx, cfg, initResult.ThemeManager,
198+ initResult.ColorResolver, initResult.AdwaitaDetector,
199+ stack, repos, useCases, idleInhibitor, browserSession.Session.ID,
200+ )
201+ configureDeferredInit(uiDeps, cfg, browserSession)
202+ return ui.New(uiDeps)
203+}
204+
169205 func initStartupContext(cfg *config.Config) context.Context {
170206 deps.ApplyPrefixEnv(cfg.Runtime.Prefix)
171207 bootstrapLogger := logging.NewFromConfigValuesWithTimeFormat(
@@ -29,6 +29,7 @@ import (
29 "github.com/bnema/dumber/internal/logging"29 "github.com/bnema/dumber/internal/logging"
30 "github.com/bnema/dumber/internal/ui"30 "github.com/bnema/dumber/internal/ui"
31 "github.com/bnema/dumber/internal/ui/theme"31 "github.com/bnema/dumber/internal/ui/theme"
32+ "github.com/rs/zerolog"
32 )33 )
33 34
34 // Build-time variables (set via ldflags).35 // Build-time variables (set via ldflags).
@@ -75,30 +76,15 @@ func runGUI() int {
75 cfg := initConfig()76 cfg := initConfig()
76 timer.Mark("config")77 timer.Mark("config")
77 78
78- // Initialize startup trace for cold start tracking (debug/trace only)79+ ctx := initStartupContextWithTrace(cfg)
79- logging.InitStartupTrace(cfg.Logging.Level)
80- logging.Trace().Mark("config_loaded")
81-
82- ctx := initStartupContext(cfg)
83 timer.Mark("logger")80 timer.Mark("logger")
84 bootstrapLog := logging.FromContext(ctx)81 bootstrapLog := logging.FromContext(ctx)
85 82
86- // Set logger for startup trace and mark logger init83+ initResult, err := runParallelInitPhase(ctx, cfg)
87- logging.Trace().SetLogger(bootstrapLog)
88- logging.Trace().Mark("logger_init")
89-
90- // Parallel phase: directories and theme setup
91- logging.Trace().Mark("parallel_start")
92- initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{
93- Ctx: ctx,
94- Config: cfg,
95- })
96 if err != nil {84 if err != nil {
97- handleParallelInitError(ctx, err)
98 return 185 return 1
99 }86 }
100 timer.MarkDuration("parallel_phase", initResult.Duration)87 timer.MarkDuration("parallel_phase", initResult.Duration)
101- logging.Trace().Mark("parallel_done")
102 88
103 needsEagerDB := restoreSessionID != "" || cfg.Session.AutoRestore89 needsEagerDB := restoreSessionID != "" || cfg.Session.AutoRestore
104 90
@@ -111,48 +97,27 @@ func runGUI() int {
111 }97 }
112 timer.Mark("db_webkit_parallel")98 timer.Mark("db_webkit_parallel")
113 99
114- browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, repos.session, !needsEagerDB)100+ ctx, browserSession, sessionCleanup := initBrowserSession(ctx, cfg, repos, bootstrapLog)
115- if err != nil {101+ defer sessionCleanup()
116- bootstrapLog.Fatal().Err(err).Msg("failed to start session")
117- }
118- if browserSession.LogCleanup != nil {
119- defer browserSession.LogCleanup()
120- }
121- defer func() { _ = browserSession.End(sessionCtx) }()
122 timer.Mark("session")102 timer.Mark("session")
123 103
124- ctx = sessionCtx
125 log := logging.FromContext(ctx)104 log := logging.FromContext(ctx)
126-
127- // Update startup trace with session logger so milestones go to log file
128 logging.Trace().UpdateLogger(log)105 logging.Trace().UpdateLogger(log)
129 106
130 if stack.MessageRouter != nil {107 if stack.MessageRouter != nil {
131 stack.MessageRouter.SetBaseContext(ctx)108 stack.MessageRouter.SetBaseContext(ctx)
132 }109 }
133 110
134- // Repositories and use cases
135 useCases := createUseCases(repos, cfg)111 useCases := createUseCases(repos, cfg)
136 if needsEagerDB {112 if needsEagerDB {
137 handleAutoRestore(ctx, cfg, useCases, browserSession.Session.ID)113 handleAutoRestore(ctx, cfg, useCases, browserSession.Session.ID)
138 }114 }
139 115
140 idleInhibitor := idle.NewPortalInhibitor(ctx)116 idleInhibitor := idle.NewPortalInhibitor(ctx)
141- defer func() {117+ defer closeIdleInhibitor(idleInhibitor)
142- if idleInhibitor != nil {
143- _ = idleInhibitor.Close()
144- }
145- }()
146 timer.Mark("use_cases")118 timer.Mark("use_cases")
147 119
148- // Build UI120+ app, err := buildAndConfigureApp(ctx, cfg, initResult, &stack, repos, useCases, idleInhibitor, browserSession)
149- uiDeps := buildUIDependencies(
150- ctx, cfg, initResult.ThemeManager,
151- initResult.ColorResolver, initResult.AdwaitaDetector,
152- &stack, repos, useCases, idleInhibitor, browserSession.Session.ID,
153- )
154- configureDeferredInit(uiDeps, cfg, browserSession)
155- app, err := ui.New(uiDeps)
156 if err != nil {121 if err != nil {
157 log.Error().Err(err).Msg("failed to create application")122 log.Error().Err(err).Msg("failed to create application")
158 return 1123 return 1
@@ -160,12 +125,83 @@ func runGUI() int {
160 timer.Mark("ui_deps")125 timer.Mark("ui_deps")
161 timer.Log(ctx)126 timer.Log(ctx)
162 127
163- // Signal handling
164 setupSignalHandler(ctx, app)128 setupSignalHandler(ctx, app)
165 129
166 return app.Run(ctx, os.Args)130 return app.Run(ctx, os.Args)
167 }131 }
168 132
133+func initStartupContextWithTrace(cfg *config.Config) context.Context {
134+ logging.InitStartupTrace(cfg.Logging.Level)
135+ logging.Trace().Mark("config_loaded")
136+
137+ ctx := initStartupContext(cfg)
138+ bootstrapLog := logging.FromContext(ctx)
139+
140+ logging.Trace().SetLogger(bootstrapLog)
141+ logging.Trace().Mark("logger_init")
142+
143+ return ctx
144+}
145+
146+func runParallelInitPhase(ctx context.Context, cfg *config.Config) (*bootstrap.ParallelInitResult, error) {
147+ logging.Trace().Mark("parallel_start")
148+ initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{
149+ Ctx: ctx,
150+ Config: cfg,
151+ })
152+ if err != nil {
153+ handleParallelInitError(ctx, err)
154+ return nil, err
155+ }
156+ logging.Trace().Mark("parallel_done")
157+ return initResult, nil
158+}
159+
160+func initBrowserSession(
161+ ctx context.Context,
162+ cfg *config.Config,
163+ repos *repositories,
164+ bootstrapLog *zerolog.Logger,
165+) (context.Context, *bootstrap.BrowserSession, func()) {
166+ deferPersist := restoreSessionID == "" && !cfg.Session.AutoRestore
167+ browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, repos.session, deferPersist)
168+ if err != nil {
169+ bootstrapLog.Fatal().Err(err).Msg("failed to start session")
170+ }
171+ cleanup := func() {
172+ if browserSession.LogCleanup != nil {
173+ browserSession.LogCleanup()
174+ }
175+ _ = browserSession.End(sessionCtx)
176+ }
177+ return sessionCtx, browserSession, cleanup
178+}
179+
180+func closeIdleInhibitor(inhibitor port.IdleInhibitor) {
181+ if inhibitor != nil {
182+ _ = inhibitor.Close()
183+ }
184+}
185+
186+func buildAndConfigureApp(
187+ ctx context.Context,
188+ cfg *config.Config,
189+ initResult *bootstrap.ParallelInitResult,
190+ stack *bootstrap.WebKitStack,
191+ repos *repositories,
192+ useCases *useCases,
193+ idleInhibitor port.IdleInhibitor,
194+ browserSession *bootstrap.BrowserSession,
195+) (*ui.App, error) {
196+ uiDeps := buildUIDependencies(
197+ ctx, cfg, initResult.ThemeManager,
198+ initResult.ColorResolver, initResult.AdwaitaDetector,
199+ stack, repos, useCases, idleInhibitor, browserSession.Session.ID,
200+ )
201+ configureDeferredInit(uiDeps, cfg, browserSession)
202+ return ui.New(uiDeps)
203+}
204+
169 func initStartupContext(cfg *config.Config) context.Context {205 func initStartupContext(cfg *config.Config) context.Context {
170 deps.ApplyPrefixEnv(cfg.Runtime.Prefix)206 deps.ApplyPrefixEnv(cfg.Runtime.Prefix)
171 bootstrapLogger := logging.NewFromConfigValuesWithTimeFormat(207 bootstrapLogger := logging.NewFromConfigValuesWithTimeFormat(
M internal/bootstrap/webkit_stack.go +70 -41
@@ -39,13 +39,41 @@ type WebKitStack struct {
3939 func BuildWebKitStack(input WebKitStackInput) WebKitStack {
4040 ctx := input.Ctx
4141 cfg := input.Config
42- dataDir := input.DataDir
43- cacheDir := input.CacheDir
44- themeManager := input.ThemeManager
45- colorResolver := input.ColorResolver
4642 logger := input.Logger
4743
48- // Detect hardware for performance profile scaling
44+ perfSettings := surveyHardwareAndResolveProfile(ctx, cfg, logger)
45+
46+ configureRenderingEnvironment(ctx, cfg, &perfSettings, logger)
47+ logging.Trace().Mark("render_env")
48+
49+ wkOpts := buildWebKitContextOptions(input.DataDir, input.CacheDir, &perfSettings)
50+ wkCtx, err := webkit.NewWebKitContextWithOptions(ctx, wkOpts)
51+ if err != nil {
52+ logger.Fatal().Err(err).Msg("failed to initialize WebKit context")
53+ }
54+ logging.Trace().Mark("webkit_context")
55+
56+ filterManager := initFilterManager(ctx, cfg, input.DataDir, logger)
57+
58+ schemeHandler := webkit.NewDumbSchemeHandler(ctx)
59+ schemeHandler.SetAssets(assets.WebUIAssets)
60+ schemeHandler.RegisterWithContext(wkCtx)
61+
62+ settings, injector, messageRouter := initWebKitComponents(ctx, cfg, input.ThemeManager, input.ColorResolver, logger)
63+
64+ pool := initWebViewPool(ctx, wkCtx, settings, injector, messageRouter, input.ThemeManager, filterManager, &perfSettings, logger)
65+
66+ return WebKitStack{
67+ Context: wkCtx,
68+ Settings: settings,
69+ Injector: injector,
70+ MessageRouter: messageRouter,
71+ Pool: pool,
72+ FilterManager: filterManager,
73+ }
74+}
75+
76+func surveyHardwareAndResolveProfile(ctx context.Context, cfg *config.Config, logger zerolog.Logger) config.ResolvedPerformanceSettings {
4977 hwSurveyor := env.NewHardwareSurveyor()
5078 hwInfo := hwSurveyor.Survey(ctx)
5179 logging.Trace().Mark("hardware_survey")
@@ -57,7 +85,6 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack {
5785 Uint64("vram_mb", hwInfo.VRAM/(1024*1024)).
5886 Msg("hardware survey completed")
5987
60- // Resolve performance profile to get actual settings
6188 perfSettings := config.ResolvePerformanceProfile(&cfg.Performance, &hwInfo)
6289 logging.Trace().Mark("performance_profile")
6390 logger.Info().
@@ -67,18 +94,16 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack {
6794 Int("webview_pool_prewarm", perfSettings.WebViewPoolPrewarmCount).
6895 Msg("resolved performance profile")
6996
70- // Configure rendering environment (must happen before WebKit/GTK init)
71- configureRenderingEnvironment(ctx, cfg, &perfSettings, logger)
72- logging.Trace().Mark("render_env")
97+ return perfSettings
98+}
7399
74- // Build WebKitContext options with memory pressure settings
100+func buildWebKitContextOptions(dataDir, cacheDir string, perfSettings *config.ResolvedPerformanceSettings) port.WebKitContextOptions {
75101 wkOpts := port.WebKitContextOptions{
76102 DataDir: dataDir,
77103 CacheDir: cacheDir,
78104 }
79105
80- // Configure web process memory pressure if any setting is configured
81- if hasWebProcessMemoryConfig(&perfSettings) {
106+ if hasWebProcessMemoryConfig(perfSettings) {
82107 wkOpts.WebProcessMemory = &port.MemoryPressureConfig{
83108 MemoryLimitMB: perfSettings.WebProcessMemoryLimitMB,
84109 PollIntervalSec: perfSettings.WebProcessMemoryPollIntervalSec,
@@ -87,8 +112,7 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack {
87112 }
88113 }
89114
90- // Configure network process memory pressure if any setting is configured
91- if hasNetworkProcessMemoryConfig(&perfSettings) {
115+ if hasNetworkProcessMemoryConfig(perfSettings) {
92116 wkOpts.NetworkProcessMemory = &port.MemoryPressureConfig{
93117 MemoryLimitMB: perfSettings.NetworkProcessMemoryLimitMB,
94118 PollIntervalSec: perfSettings.NetworkProcessMemoryPollIntervalSec,
@@ -97,12 +121,10 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack {
97121 }
98122 }
99123
100- wkCtx, err := webkit.NewWebKitContextWithOptions(ctx, wkOpts)
101- if err != nil {
102- logger.Fatal().Err(err).Msg("failed to initialize WebKit context")
103- }
104- logging.Trace().Mark("webkit_context")
124+ return wkOpts
125+}
105126
127+func initFilterManager(ctx context.Context, cfg *config.Config, dataDir string, logger zerolog.Logger) *filtering.Manager {
106128 filterStoreDir := filepath.Join(dataDir, "filters", "store")
107129 filterJSONDir := filepath.Join(dataDir, "filters", "json")
108130 filterManager, err := filtering.NewManager(filtering.ManagerConfig{
@@ -113,21 +135,25 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack {
113135 })
114136 if err != nil {
115137 logger.Warn().Err(err).Msg("failed to create filter manager, continuing without content filtering")
116- } else {
117- if err := filterManager.Initialize(ctx); err != nil {
118- logger.Warn().Err(err).Msg("failed to initialize filters, will load async")
119- }
138+ return nil
139+ }
140+ if err := filterManager.Initialize(ctx); err != nil {
141+ logger.Warn().Err(err).Msg("failed to initialize filters, will load async")
120142 }
121143 logging.Trace().Mark("filter_manager")
144+ return filterManager
145+}
122146
123- schemeHandler := webkit.NewDumbSchemeHandler(ctx)
124- schemeHandler.SetAssets(assets.WebUIAssets)
125- schemeHandler.RegisterWithContext(wkCtx)
126-
147+func initWebKitComponents(
148+ ctx context.Context,
149+ cfg *config.Config,
150+ themeManager *theme.Manager,
151+ colorResolver port.ColorSchemeResolver,
152+ logger zerolog.Logger,
153+) (*webkit.SettingsManager, *webkit.ContentInjector, *webkit.MessageRouter) {
127154 settings := webkit.NewSettingsManager(ctx, cfg)
128155 injector := webkit.NewContentInjector(colorResolver)
129156
130- // Set up auto-copy on selection config getter
131157 injector.SetAutoCopyConfigGetter(func() bool {
132158 return config.Get().Clipboard.AutoCopyOnSelection
133159 })
@@ -141,13 +167,26 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack {
141167 messageRouter := webkit.NewMessageRouter(ctx)
142168 logging.Trace().Mark("settings_manager")
143169
170+ return settings, injector, messageRouter
171+}
172+
173+func initWebViewPool(
174+ ctx context.Context,
175+ wkCtx *webkit.WebKitContext,
176+ settings *webkit.SettingsManager,
177+ injector *webkit.ContentInjector,
178+ messageRouter *webkit.MessageRouter,
179+ themeManager *theme.Manager,
180+ filterManager *filtering.Manager,
181+ perfSettings *config.ResolvedPerformanceSettings,
182+ logger zerolog.Logger,
183+) *webkit.WebViewPool {
144184 poolCfg := webkit.DefaultPoolConfig()
145- // Override prewarm count from resolved profile settings
146185 if perfSettings.WebViewPoolPrewarmCount > 0 {
147186 poolCfg.PrewarmCount = perfSettings.WebViewPoolPrewarmCount
148187 }
149188 pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter)
150- // Ensure prewarmed WebViews pick up the theme background color.
189+
151190 bgR, bgG, bgB, bgA := themeManager.GetBackgroundRGBA()
152191 pool.SetBackgroundColor(bgR, bgG, bgB, bgA)
153192
@@ -155,22 +194,12 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack {
155194 pool.SetFilterApplier(filterManager)
156195 }
157196
158- // Pre-create ONE WebView synchronously for instant first navigation.
159- // This is the heaviest operation but happens before window is shown,
160- // so users perceive it as part of the normal "loading" phase.
161197 if err := pool.PrewarmFirst(ctx); err != nil {
162198 logger.Warn().Err(err).Msg("failed to prewarm first webview, first tab may be slower")
163199 }
164200 logging.Trace().Mark("pool_prewarm_first")
165201
166- return WebKitStack{
167- Context: wkCtx,
168- Settings: settings,
169- Injector: injector,
170- MessageRouter: messageRouter,
171- Pool: pool,
172- FilterManager: filterManager,
173- }
202+ return pool
174203 }
175204
176205 // hasWebProcessMemoryConfig returns true if any web process memory setting is configured.
@@ -39,13 +39,41 @@ type WebKitStack struct {
39 func BuildWebKitStack(input WebKitStackInput) WebKitStack {39 func BuildWebKitStack(input WebKitStackInput) WebKitStack {
40 ctx := input.Ctx40 ctx := input.Ctx
41 cfg := input.Config41 cfg := input.Config
42- dataDir := input.DataDir
43- cacheDir := input.CacheDir
44- themeManager := input.ThemeManager
45- colorResolver := input.ColorResolver
46 logger := input.Logger42 logger := input.Logger
47 43
48- // Detect hardware for performance profile scaling44+ perfSettings := surveyHardwareAndResolveProfile(ctx, cfg, logger)
45+
46+ configureRenderingEnvironment(ctx, cfg, &perfSettings, logger)
47+ logging.Trace().Mark("render_env")
48+
49+ wkOpts := buildWebKitContextOptions(input.DataDir, input.CacheDir, &perfSettings)
50+ wkCtx, err := webkit.NewWebKitContextWithOptions(ctx, wkOpts)
51+ if err != nil {
52+ logger.Fatal().Err(err).Msg("failed to initialize WebKit context")
53+ }
54+ logging.Trace().Mark("webkit_context")
55+
56+ filterManager := initFilterManager(ctx, cfg, input.DataDir, logger)
57+
58+ schemeHandler := webkit.NewDumbSchemeHandler(ctx)
59+ schemeHandler.SetAssets(assets.WebUIAssets)
60+ schemeHandler.RegisterWithContext(wkCtx)
61+
62+ settings, injector, messageRouter := initWebKitComponents(ctx, cfg, input.ThemeManager, input.ColorResolver, logger)
63+
64+ pool := initWebViewPool(ctx, wkCtx, settings, injector, messageRouter, input.ThemeManager, filterManager, &perfSettings, logger)
65+
66+ return WebKitStack{
67+ Context: wkCtx,
68+ Settings: settings,
69+ Injector: injector,
70+ MessageRouter: messageRouter,
71+ Pool: pool,
72+ FilterManager: filterManager,
73+ }
74+}
75+
76+func surveyHardwareAndResolveProfile(ctx context.Context, cfg *config.Config, logger zerolog.Logger) config.ResolvedPerformanceSettings {
49 hwSurveyor := env.NewHardwareSurveyor()77 hwSurveyor := env.NewHardwareSurveyor()
50 hwInfo := hwSurveyor.Survey(ctx)78 hwInfo := hwSurveyor.Survey(ctx)
51 logging.Trace().Mark("hardware_survey")79 logging.Trace().Mark("hardware_survey")
@@ -57,7 +85,6 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack {
57 Uint64("vram_mb", hwInfo.VRAM/(1024*1024)).85 Uint64("vram_mb", hwInfo.VRAM/(1024*1024)).
58 Msg("hardware survey completed")86 Msg("hardware survey completed")
59 87
60- // Resolve performance profile to get actual settings
61 perfSettings := config.ResolvePerformanceProfile(&cfg.Performance, &hwInfo)88 perfSettings := config.ResolvePerformanceProfile(&cfg.Performance, &hwInfo)
62 logging.Trace().Mark("performance_profile")89 logging.Trace().Mark("performance_profile")
63 logger.Info().90 logger.Info().
@@ -67,18 +94,16 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack {
67 Int("webview_pool_prewarm", perfSettings.WebViewPoolPrewarmCount).94 Int("webview_pool_prewarm", perfSettings.WebViewPoolPrewarmCount).
68 Msg("resolved performance profile")95 Msg("resolved performance profile")
69 96
70- // Configure rendering environment (must happen before WebKit/GTK init)97+ return perfSettings
71- configureRenderingEnvironment(ctx, cfg, &perfSettings, logger)98+}
72- logging.Trace().Mark("render_env")
73 99
74- // Build WebKitContext options with memory pressure settings100+func buildWebKitContextOptions(dataDir, cacheDir string, perfSettings *config.ResolvedPerformanceSettings) port.WebKitContextOptions {
75 wkOpts := port.WebKitContextOptions{101 wkOpts := port.WebKitContextOptions{
76 DataDir: dataDir,102 DataDir: dataDir,
77 CacheDir: cacheDir,103 CacheDir: cacheDir,
78 }104 }
79 105
80- // Configure web process memory pressure if any setting is configured106+ if hasWebProcessMemoryConfig(perfSettings) {
81- if hasWebProcessMemoryConfig(&perfSettings) {
82 wkOpts.WebProcessMemory = &port.MemoryPressureConfig{107 wkOpts.WebProcessMemory = &port.MemoryPressureConfig{
83 MemoryLimitMB: perfSettings.WebProcessMemoryLimitMB,108 MemoryLimitMB: perfSettings.WebProcessMemoryLimitMB,
84 PollIntervalSec: perfSettings.WebProcessMemoryPollIntervalSec,109 PollIntervalSec: perfSettings.WebProcessMemoryPollIntervalSec,
@@ -87,8 +112,7 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack {
87 }112 }
88 }113 }
89 114
90- // Configure network process memory pressure if any setting is configured115+ if hasNetworkProcessMemoryConfig(perfSettings) {
91- if hasNetworkProcessMemoryConfig(&perfSettings) {
92 wkOpts.NetworkProcessMemory = &port.MemoryPressureConfig{116 wkOpts.NetworkProcessMemory = &port.MemoryPressureConfig{
93 MemoryLimitMB: perfSettings.NetworkProcessMemoryLimitMB,117 MemoryLimitMB: perfSettings.NetworkProcessMemoryLimitMB,
94 PollIntervalSec: perfSettings.NetworkProcessMemoryPollIntervalSec,118 PollIntervalSec: perfSettings.NetworkProcessMemoryPollIntervalSec,
@@ -97,12 +121,10 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack {
97 }121 }
98 }122 }
99 123
100- wkCtx, err := webkit.NewWebKitContextWithOptions(ctx, wkOpts)124+ return wkOpts
101- if err != nil {125+}
102- logger.Fatal().Err(err).Msg("failed to initialize WebKit context")
103- }
104- logging.Trace().Mark("webkit_context")
105 126
127+func initFilterManager(ctx context.Context, cfg *config.Config, dataDir string, logger zerolog.Logger) *filtering.Manager {
106 filterStoreDir := filepath.Join(dataDir, "filters", "store")128 filterStoreDir := filepath.Join(dataDir, "filters", "store")
107 filterJSONDir := filepath.Join(dataDir, "filters", "json")129 filterJSONDir := filepath.Join(dataDir, "filters", "json")
108 filterManager, err := filtering.NewManager(filtering.ManagerConfig{130 filterManager, err := filtering.NewManager(filtering.ManagerConfig{
@@ -113,21 +135,25 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack {
113 })135 })
114 if err != nil {136 if err != nil {
115 logger.Warn().Err(err).Msg("failed to create filter manager, continuing without content filtering")137 logger.Warn().Err(err).Msg("failed to create filter manager, continuing without content filtering")
116- } else {138+ return nil
117- if err := filterManager.Initialize(ctx); err != nil {139+ }
118- logger.Warn().Err(err).Msg("failed to initialize filters, will load async")140+ if err := filterManager.Initialize(ctx); err != nil {
119- }141+ logger.Warn().Err(err).Msg("failed to initialize filters, will load async")
120 }142 }
121 logging.Trace().Mark("filter_manager")143 logging.Trace().Mark("filter_manager")
144+ return filterManager
145+}
122 146
123- schemeHandler := webkit.NewDumbSchemeHandler(ctx)147+func initWebKitComponents(
124- schemeHandler.SetAssets(assets.WebUIAssets)148+ ctx context.Context,
125- schemeHandler.RegisterWithContext(wkCtx)149+ cfg *config.Config,
126-150+ themeManager *theme.Manager,
151+ colorResolver port.ColorSchemeResolver,
152+ logger zerolog.Logger,
153+) (*webkit.SettingsManager, *webkit.ContentInjector, *webkit.MessageRouter) {
127 settings := webkit.NewSettingsManager(ctx, cfg)154 settings := webkit.NewSettingsManager(ctx, cfg)
128 injector := webkit.NewContentInjector(colorResolver)155 injector := webkit.NewContentInjector(colorResolver)
129 156
130- // Set up auto-copy on selection config getter
131 injector.SetAutoCopyConfigGetter(func() bool {157 injector.SetAutoCopyConfigGetter(func() bool {
132 return config.Get().Clipboard.AutoCopyOnSelection158 return config.Get().Clipboard.AutoCopyOnSelection
133 })159 })
@@ -141,13 +167,26 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack {
141 messageRouter := webkit.NewMessageRouter(ctx)167 messageRouter := webkit.NewMessageRouter(ctx)
142 logging.Trace().Mark("settings_manager")168 logging.Trace().Mark("settings_manager")
143 169
170+ return settings, injector, messageRouter
171+}
172+
173+func initWebViewPool(
174+ ctx context.Context,
175+ wkCtx *webkit.WebKitContext,
176+ settings *webkit.SettingsManager,
177+ injector *webkit.ContentInjector,
178+ messageRouter *webkit.MessageRouter,
179+ themeManager *theme.Manager,
180+ filterManager *filtering.Manager,
181+ perfSettings *config.ResolvedPerformanceSettings,
182+ logger zerolog.Logger,
183+) *webkit.WebViewPool {
144 poolCfg := webkit.DefaultPoolConfig()184 poolCfg := webkit.DefaultPoolConfig()
145- // Override prewarm count from resolved profile settings
146 if perfSettings.WebViewPoolPrewarmCount > 0 {185 if perfSettings.WebViewPoolPrewarmCount > 0 {
147 poolCfg.PrewarmCount = perfSettings.WebViewPoolPrewarmCount186 poolCfg.PrewarmCount = perfSettings.WebViewPoolPrewarmCount
148 }187 }
149 pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter)188 pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter)
150- // Ensure prewarmed WebViews pick up the theme background color.189+
151 bgR, bgG, bgB, bgA := themeManager.GetBackgroundRGBA()190 bgR, bgG, bgB, bgA := themeManager.GetBackgroundRGBA()
152 pool.SetBackgroundColor(bgR, bgG, bgB, bgA)191 pool.SetBackgroundColor(bgR, bgG, bgB, bgA)
153 192
@@ -155,22 +194,12 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack {
155 pool.SetFilterApplier(filterManager)194 pool.SetFilterApplier(filterManager)
156 }195 }
157 196
158- // Pre-create ONE WebView synchronously for instant first navigation.
159- // This is the heaviest operation but happens before window is shown,
160- // so users perceive it as part of the normal "loading" phase.
161 if err := pool.PrewarmFirst(ctx); err != nil {197 if err := pool.PrewarmFirst(ctx); err != nil {
162 logger.Warn().Err(err).Msg("failed to prewarm first webview, first tab may be slower")198 logger.Warn().Err(err).Msg("failed to prewarm first webview, first tab may be slower")
163 }199 }
164 logging.Trace().Mark("pool_prewarm_first")200 logging.Trace().Mark("pool_prewarm_first")
165 201
166- return WebKitStack{202+ return pool
167- Context: wkCtx,
168- Settings: settings,
169- Injector: injector,
170- MessageRouter: messageRouter,
171- Pool: pool,
172- FilterManager: filterManager,
173- }
174 }203 }
175 204
176 // hasWebProcessMemoryConfig returns true if any web process memory setting is configured.205 // hasWebProcessMemoryConfig returns true if any web process memory setting is configured.
M internal/infrastructure/webkit/pool.go +2 -1
@@ -312,7 +312,8 @@ func (p *WebViewPool) PrewarmAsync(ctx context.Context, count int) {
312312 schedule()
313313 return false
314314 })
315- glib.IdleAdd(&cb, 0)
315+ // Use LOW priority so initial navigation and other high-priority work runs first
316+ glib.IdleAddFull(glib.PRIORITY_LOW, &cb, 0, nil)
316317 }
317318
318319 schedule()
@@ -312,7 +312,8 @@ func (p *WebViewPool) PrewarmAsync(ctx context.Context, count int) {
312 schedule()312 schedule()
313 return false313 return false
314 })314 })
315- glib.IdleAdd(&cb, 0)315+ // Use LOW priority so initial navigation and other high-priority work runs first
316+ glib.IdleAddFull(glib.PRIORITY_LOW, &cb, 0, nil)
316 }317 }
317 318
318 schedule()319 schedule()
M internal/ui/app.go +43 -14
@@ -115,6 +115,10 @@ type App struct {
115115 insertAccentUC *usecase.InsertAccentUseCase
116116 accentFocusProvider *textinput.FocusProvider
117117
118+ // Deferred initialization - runs after first load_started to avoid blocking initial navigation
119+ deferredInitOnce sync.Once
120+ deferredInitFn func()
121+
118122 // lifecycle
119123 cancel context.CancelCauseFunc
120124 }
@@ -256,8 +260,6 @@ func (a *App) onActivate(ctx context.Context) {
256260 a.initSnapshotService(ctx)
257261 a.initUpdateCoordinator(ctx)
258262 a.createInitialTab(ctx)
259- // Prewarm the remaining WebViews after the initial tab starts loading.
260- a.prewarmWebViewPoolAsync(ctx)
261263 a.finalizeActivation(ctx)
262264 }
263265
@@ -1158,26 +1160,48 @@ func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) er
11581160 func (a *App) finalizeActivation(ctx context.Context) {
11591161 log := logging.FromContext(ctx)
11601162
1161- // Show the window.
1163+ // Show the window
11621164 if a.mainWindow != nil {
11631165 a.mainWindow.Show()
11641166 }
11651167 log.Info().Msg("main window displayed")
11661168
1167- // Start watching config for appearance changes.
1168- a.initConfigWatcher(ctx)
1169+ // Defer non-critical initialization until after first navigation starts.
1170+ // This keeps pool prewarm, config watcher, and filter loading from
1171+ // competing with the initial page load.
1172+ a.runAfterFirstLoadStarted(func() {
1173+ a.prewarmWebViewPoolAsync(ctx)
1174+ a.initConfigWatcher(ctx)
1175+ a.initFilteringAsync(ctx)
1176+ a.checkConfigMigration(ctx)
1177+ })
1178+}
11691179
1170- // Ensure the current appearance config is applied once at startup.
1171- // This avoids the WebUI showing default tokens until the first config change event.
1172- if a.deps != nil {
1173- a.applyAppearanceConfig(ctx, a.deps.Config)
1174- }
1180+// runAfterFirstLoadStarted schedules work to run after the first navigation starts.
1181+// This keeps the GTK main loop free to process the initial load_uri() quickly,
1182+// reducing the gap between webview_attached and load_started.
1183+func (a *App) runAfterFirstLoadStarted(fn func()) {
1184+ a.deferredInitFn = fn
1185+}
11751186
1176- // Start async filter loading after window is visible.
1177- a.initFilteringAsync(ctx)
1187+// triggerDeferredInit is called from ContentCoordinator on first load_started.
1188+// It runs deferred initialization at LOW priority so navigation continues unblocked.
1189+func (a *App) triggerDeferredInit(ctx context.Context) {
1190+ a.deferredInitOnce.Do(func() {
1191+ if a.deferredInitFn == nil {
1192+ return
1193+ }
1194+ log := logging.FromContext(ctx)
1195+ log.Debug().Msg("triggering deferred initialization")
11781196
1179- // Check for config migration after window is visible.
1180- a.checkConfigMigration(ctx)
1197+ fn := a.deferredInitFn
1198+ cb := glib.SourceFunc(func(_ uintptr) bool {
1199+ fn()
1200+ return false
1201+ })
1202+ // Use LOW priority so navigation processing continues first
1203+ glib.IdleAddFull(glib.PRIORITY_LOW, &cb, 0, nil)
1204+ })
11811205 }
11821206
11831207 // onShutdown is called when the GTK application is shutting down.
@@ -1251,6 +1275,11 @@ func (a *App) initCoordinators(ctx context.Context) {
12511275 a.contentCoord.SetIdleInhibitor(a.deps.IdleInhibitor)
12521276 }
12531277
1278+ // Wire deferred init trigger - runs after first navigation starts
1279+ a.contentCoord.SetOnFirstLoadStarted(func() {
1280+ a.triggerDeferredInit(ctx)
1281+ })
1282+
12541283 // 2. Tab Coordinator
12551284 a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
12561285 TabsUC: a.tabsUC,
@@ -115,6 +115,10 @@ type App struct {
115 insertAccentUC *usecase.InsertAccentUseCase115 insertAccentUC *usecase.InsertAccentUseCase
116 accentFocusProvider *textinput.FocusProvider116 accentFocusProvider *textinput.FocusProvider
117 117
118+ // Deferred initialization - runs after first load_started to avoid blocking initial navigation
119+ deferredInitOnce sync.Once
120+ deferredInitFn func()
121+
118 // lifecycle122 // lifecycle
119 cancel context.CancelCauseFunc123 cancel context.CancelCauseFunc
120 }124 }
@@ -256,8 +260,6 @@ func (a *App) onActivate(ctx context.Context) {
256 a.initSnapshotService(ctx)260 a.initSnapshotService(ctx)
257 a.initUpdateCoordinator(ctx)261 a.initUpdateCoordinator(ctx)
258 a.createInitialTab(ctx)262 a.createInitialTab(ctx)
259- // Prewarm the remaining WebViews after the initial tab starts loading.
260- a.prewarmWebViewPoolAsync(ctx)
261 a.finalizeActivation(ctx)263 a.finalizeActivation(ctx)
262 }264 }
263 265
@@ -1158,26 +1160,48 @@ func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) er
1158 func (a *App) finalizeActivation(ctx context.Context) {1160 func (a *App) finalizeActivation(ctx context.Context) {
1159 log := logging.FromContext(ctx)1161 log := logging.FromContext(ctx)
1160 1162
1161- // Show the window.1163+ // Show the window
1162 if a.mainWindow != nil {1164 if a.mainWindow != nil {
1163 a.mainWindow.Show()1165 a.mainWindow.Show()
1164 }1166 }
1165 log.Info().Msg("main window displayed")1167 log.Info().Msg("main window displayed")
1166 1168
1167- // Start watching config for appearance changes.1169+ // Defer non-critical initialization until after first navigation starts.
1168- a.initConfigWatcher(ctx)1170+ // This keeps pool prewarm, config watcher, and filter loading from
1171+ // competing with the initial page load.
1172+ a.runAfterFirstLoadStarted(func() {
1173+ a.prewarmWebViewPoolAsync(ctx)
1174+ a.initConfigWatcher(ctx)
1175+ a.initFilteringAsync(ctx)
1176+ a.checkConfigMigration(ctx)
1177+ })
1178+}
1169 1179
1170- // Ensure the current appearance config is applied once at startup.1180+// runAfterFirstLoadStarted schedules work to run after the first navigation starts.
1171- // This avoids the WebUI showing default tokens until the first config change event.1181+// This keeps the GTK main loop free to process the initial load_uri() quickly,
1172- if a.deps != nil {1182+// reducing the gap between webview_attached and load_started.
1173- a.applyAppearanceConfig(ctx, a.deps.Config)1183+func (a *App) runAfterFirstLoadStarted(fn func()) {
1174- }1184+ a.deferredInitFn = fn
1185+}
1175 1186
1176- // Start async filter loading after window is visible.1187+// triggerDeferredInit is called from ContentCoordinator on first load_started.
1177- a.initFilteringAsync(ctx)1188+// It runs deferred initialization at LOW priority so navigation continues unblocked.
1189+func (a *App) triggerDeferredInit(ctx context.Context) {
1190+ a.deferredInitOnce.Do(func() {
1191+ if a.deferredInitFn == nil {
1192+ return
1193+ }
1194+ log := logging.FromContext(ctx)
1195+ log.Debug().Msg("triggering deferred initialization")
1178 1196
1179- // Check for config migration after window is visible.1197+ fn := a.deferredInitFn
1180- a.checkConfigMigration(ctx)1198+ cb := glib.SourceFunc(func(_ uintptr) bool {
1199+ fn()
1200+ return false
1201+ })
1202+ // Use LOW priority so navigation processing continues first
1203+ glib.IdleAddFull(glib.PRIORITY_LOW, &cb, 0, nil)
1204+ })
1181 }1205 }
1182 1206
1183 // onShutdown is called when the GTK application is shutting down.1207 // onShutdown is called when the GTK application is shutting down.
@@ -1251,6 +1275,11 @@ func (a *App) initCoordinators(ctx context.Context) {
1251 a.contentCoord.SetIdleInhibitor(a.deps.IdleInhibitor)1275 a.contentCoord.SetIdleInhibitor(a.deps.IdleInhibitor)
1252 }1276 }
1253 1277
1278+ // Wire deferred init trigger - runs after first navigation starts
1279+ a.contentCoord.SetOnFirstLoadStarted(func() {
1280+ a.triggerDeferredInit(ctx)
1281+ })
1282+
1254 // 2. Tab Coordinator1283 // 2. Tab Coordinator
1255 a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{1284 a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
1256 TabsUC: a.tabsUC,1285 TabsUC: a.tabsUC,
M internal/ui/coordinator/content.go +19 -0
@@ -98,6 +98,10 @@ type ContentCoordinator struct {
9898
9999 // Callback when WebView gains focus (for accent picker text input targeting)
100100 onWebViewFocused func(paneID entity.PaneID, wv *webkit.WebView)
101+
102+ // Callback for first load_started event (triggers deferred initialization)
103+ onFirstLoadStarted func()
104+ loadStartedOnce sync.Once
101105 }
102106
103107 type pendingThemeUpdate struct {
@@ -176,6 +180,13 @@ func (c *ContentCoordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, w
176180 c.onWebViewFocused = fn
177181 }
178182
183+// SetOnFirstLoadStarted sets the callback for when the first navigation starts.
184+// This is used to trigger deferred initialization after the initial load_uri()
185+// has been processed by the GTK main loop.
186+func (c *ContentCoordinator) SetOnFirstLoadStarted(fn func()) {
187+ c.onFirstLoadStarted = fn
188+}
189+
179190 // EnsureWebView acquires or reuses a WebView for the given pane.
180191 func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {
181192 log := logging.FromContext(ctx)
@@ -1033,6 +1044,14 @@ func (c *ContentCoordinator) updatePaneURI(paneID entity.PaneID, url string) {
10331044 func (c *ContentCoordinator) onLoadStarted(paneID entity.PaneID) {
10341045 logging.Trace().Mark("load_started")
10351046
1047+ // Trigger deferred initialization on first load_started.
1048+ // This ensures non-critical init runs after initial navigation starts.
1049+ c.loadStartedOnce.Do(func() {
1050+ if c.onFirstLoadStarted != nil {
1051+ c.onFirstLoadStarted()
1052+ }
1053+ })
1054+
10361055 _, wsView := c.getActiveWS()
10371056 if wsView == nil {
10381057 return
@@ -98,6 +98,10 @@ type ContentCoordinator struct {
98 98
99 // Callback when WebView gains focus (for accent picker text input targeting)99 // Callback when WebView gains focus (for accent picker text input targeting)
100 onWebViewFocused func(paneID entity.PaneID, wv *webkit.WebView)100 onWebViewFocused func(paneID entity.PaneID, wv *webkit.WebView)
101+
102+ // Callback for first load_started event (triggers deferred initialization)
103+ onFirstLoadStarted func()
104+ loadStartedOnce sync.Once
101 }105 }
102 106
103 type pendingThemeUpdate struct {107 type pendingThemeUpdate struct {
@@ -176,6 +180,13 @@ func (c *ContentCoordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, w
176 c.onWebViewFocused = fn180 c.onWebViewFocused = fn
177 }181 }
178 182
183+// SetOnFirstLoadStarted sets the callback for when the first navigation starts.
184+// This is used to trigger deferred initialization after the initial load_uri()
185+// has been processed by the GTK main loop.
186+func (c *ContentCoordinator) SetOnFirstLoadStarted(fn func()) {
187+ c.onFirstLoadStarted = fn
188+}
189+
179 // EnsureWebView acquires or reuses a WebView for the given pane.190 // EnsureWebView acquires or reuses a WebView for the given pane.
180 func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {191 func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {
181 log := logging.FromContext(ctx)192 log := logging.FromContext(ctx)
@@ -1033,6 +1044,14 @@ func (c *ContentCoordinator) updatePaneURI(paneID entity.PaneID, url string) {
1033 func (c *ContentCoordinator) onLoadStarted(paneID entity.PaneID) {1044 func (c *ContentCoordinator) onLoadStarted(paneID entity.PaneID) {
1034 logging.Trace().Mark("load_started")1045 logging.Trace().Mark("load_started")
1035 1046
1047+ // Trigger deferred initialization on first load_started.
1048+ // This ensures non-critical init runs after initial navigation starts.
1049+ c.loadStartedOnce.Do(func() {
1050+ if c.onFirstLoadStarted != nil {
1051+ c.onFirstLoadStarted()
1052+ }
1053+ })
1054+
1036 _, wsView := c.getActiveWS()1055 _, wsView := c.getActiveWS()
1037 if wsView == nil {1056 if wsView == nil {
1038 return1057 return

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