detached c73da61

perf(startup): defer checks and unify logging

commit
c73da61e705430fac47ea1bf299efe2915ec2b25
author
bnema · · 2025-12-27 22:31 UTC
parent
1db819e
signature
GPG Unverified2B82207D370127A0 The key is unknown
7 files · +317 -101
M cmd/dumber/main.go +127 -26
@@ -73,17 +73,11 @@ func runGUI() int {
7373 cfg := initConfig()
7474 timer.Mark("config")
7575
76- deps.ApplyPrefixEnv(cfg.Runtime.Prefix)
77- bootstrapLogger := logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format)
78- bootstrapLogger.Info().
79- Str("version", version).
80- Str("commit", commit).
81- Str("build_date", buildDate).
82- Msg("starting dumber")
83- ctx := logging.WithContext(context.Background(), bootstrapLogger)
76+ ctx := initStartupContext(cfg)
8477 timer.Mark("logger")
78+ bootstrapLog := logging.FromContext(ctx)
8579
86- // Parallel phase: directories, runtime/media checks, theme, WASM precompile
80+ // Parallel phase: directories and theme setup
8781 initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{
8882 Ctx: ctx,
8983 Config: cfg,
@@ -94,27 +88,20 @@ func runGUI() int {
9488 }
9589 timer.MarkDuration("parallel_phase", initResult.Duration)
9690
97- // Parallel phase 2: Database + WebKit stack initialize concurrently
98- dbWebKit, err := bootstrap.RunParallelDBWebKit(bootstrap.ParallelDBWebKitInput{
99- Ctx: ctx,
100- Config: cfg,
101- DataDir: initResult.DataDir,
102- CacheDir: initResult.CacheDir,
103- ThemeManager: initResult.ThemeManager,
104- })
91+ needsEagerDB := restoreSessionID != "" || cfg.Session.AutoRestore
92+
93+ stack, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, needsEagerDB)
10594 if err != nil {
106- bootstrapLogger.Fatal().Err(err).Msg("failed to initialize database/webkit")
95+ bootstrapLog.Fatal().Err(err).Msg("failed to initialize database/webkit")
10796 }
108- if dbWebKit.DBCleanup != nil {
109- defer dbWebKit.DBCleanup()
97+ if dbCleanup != nil {
98+ defer dbCleanup()
11099 }
111- db := dbWebKit.DB
112- stack := dbWebKit.Stack
113100 timer.Mark("db_webkit_parallel")
114101
115- browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, db)
102+ browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, repos.session, !needsEagerDB)
116103 if err != nil {
117- bootstrapLogger.Fatal().Err(err).Msg("failed to start session")
104+ bootstrapLog.Fatal().Err(err).Msg("failed to start session")
118105 }
119106 if browserSession.LogCleanup != nil {
120107 defer browserSession.LogCleanup()
@@ -124,11 +111,15 @@ func runGUI() int {
124111
125112 ctx = sessionCtx
126113 log := logging.FromContext(ctx)
114+ if stack.MessageRouter != nil {
115+ stack.MessageRouter.SetBaseContext(ctx)
116+ }
127117
128118 // Repositories and use cases
129- repos := createRepositories(db)
130119 useCases := createUseCases(repos, cfg)
131- handleAutoRestore(ctx, cfg, useCases, browserSession.Session.ID)
120+ if needsEagerDB {
121+ handleAutoRestore(ctx, cfg, useCases, browserSession.Session.ID)
122+ }
132123
133124 idleInhibitor := idle.NewPortalInhibitor(ctx)
134125 defer func() {
@@ -140,6 +131,7 @@ func runGUI() int {
140131
141132 // Build UI
142133 uiDeps := buildUIDependencies(ctx, cfg, initResult.ThemeManager, &stack, repos, useCases, idleInhibitor, browserSession.Session.ID)
134+ configureDeferredInit(uiDeps, cfg, browserSession)
143135 app, err := ui.New(uiDeps)
144136 if err != nil {
145137 log.Error().Err(err).Msg("failed to create application")
@@ -154,6 +146,82 @@ func runGUI() int {
154146 return app.Run(ctx, os.Args)
155147 }
156148
149+func initStartupContext(cfg *config.Config) context.Context {
150+ deps.ApplyPrefixEnv(cfg.Runtime.Prefix)
151+ bootstrapLogger := logging.NewFromConfigValuesWithTimeFormat(
152+ cfg.Logging.Level,
153+ cfg.Logging.Format,
154+ logging.ConsoleTimeFormat,
155+ )
156+ bootstrapLogger.Info().
157+ Str("version", version).
158+ Str("commit", commit).
159+ Str("build_date", buildDate).
160+ Msg("starting dumber")
161+ ctx := logging.WithContext(context.Background(), bootstrapLogger)
162+ return ctx
163+}
164+
165+func initStackAndRepos(
166+ ctx context.Context,
167+ cfg *config.Config,
168+ initResult *bootstrap.ParallelInitResult,
169+ needsEagerDB bool,
170+) (bootstrap.WebKitStack, *repositories, func(), error) {
171+ if needsEagerDB {
172+ // Parallel phase 2: Database + WebKit stack initialize concurrently
173+ dbWebKit, err := bootstrap.RunParallelDBWebKit(bootstrap.ParallelDBWebKitInput{
174+ Ctx: ctx,
175+ Config: cfg,
176+ DataDir: initResult.DataDir,
177+ CacheDir: initResult.CacheDir,
178+ ThemeManager: initResult.ThemeManager,
179+ })
180+ if err != nil {
181+ return bootstrap.WebKitStack{}, nil, nil, err
182+ }
183+ return dbWebKit.Stack, createRepositories(dbWebKit.DB), dbWebKit.DBCleanup, nil
184+ }
185+
186+ log := logging.FromContext(ctx)
187+ stack := bootstrap.BuildWebKitStack(ctx, cfg, initResult.DataDir, initResult.CacheDir, initResult.ThemeManager, *log)
188+
189+ lazyDB, err := bootstrap.CreateLazyDatabase()
190+ if err != nil {
191+ return stack, nil, nil, err
192+ }
193+ dbCleanup := func() { _ = lazyDB.Close() }
194+ return stack, createLazyRepositories(lazyDB), dbCleanup, nil
195+}
196+
197+func configureDeferredInit(
198+ uiDeps *ui.Dependencies,
199+ cfg *config.Config,
200+ session *bootstrap.BrowserSession,
201+) {
202+ if uiDeps == nil {
203+ return
204+ }
205+ uiDeps.OnFirstWebViewShown = func(cbCtx context.Context) {
206+ logger := logging.FromContext(cbCtx)
207+ bgCtx := logging.WithContext(context.Background(), *logger)
208+ go func() {
209+ result := bootstrap.RunDeferredInit(bootstrap.DeferredInitInput{
210+ Ctx: bgCtx,
211+ Config: cfg,
212+ })
213+ logDeferredInitResults(bgCtx, result)
214+ }()
215+ if session != nil && session.Persist != nil {
216+ go func() {
217+ if persistErr := session.Persist(bgCtx); persistErr != nil {
218+ logger.Error().Err(persistErr).Msg("deferred session persistence failed")
219+ }
220+ }()
221+ }
222+ }
223+}
224+
157225 func initConfig() *config.Config {
158226 if err := config.Init(); err != nil {
159227 fmt.Fprintf(os.Stderr, "Failed to initialize configuration: %v\n", err)
@@ -173,6 +241,27 @@ func handleParallelInitError(ctx context.Context, err error) {
173241 log.Fatal().Err(err).Msg("initialization failed")
174242 }
175243
244+func logDeferredInitResults(ctx context.Context, result bootstrap.DeferredInitResult) {
245+ log := logging.FromContext(ctx)
246+ if result.SQLiteErr != nil {
247+ log.Warn().Err(result.SQLiteErr).Msg("deferred sqlite wasm init failed")
248+ }
249+ if result.RuntimeErr != nil {
250+ if runtimeErr, ok := result.RuntimeErr.(*bootstrap.RuntimeRequirementsError); ok {
251+ runtimeErr.LogDetails(ctx)
252+ log.Warn().Err(runtimeErr).
253+ Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)").
254+ Msg("runtime requirements not met")
255+ } else {
256+ log.Warn().Err(result.RuntimeErr).Msg("runtime requirements check failed")
257+ }
258+ }
259+ if result.MediaErr != nil {
260+ log.Warn().Err(result.MediaErr).Msg("media check failed")
261+ }
262+ log.Debug().Dur("duration", result.Duration).Msg("deferred init complete")
263+}
264+
176265 func handleAutoRestore(
177266 ctx context.Context,
178267 cfg *config.Config,
@@ -234,6 +323,18 @@ func createRepositories(db *sql.DB) *repositories {
234323 }
235324 }
236325
326+func createLazyRepositories(provider port.DatabaseProvider) *repositories {
327+ return &repositories{
328+ history: sqlite.NewLazyHistoryRepository(provider),
329+ favorite: sqlite.NewLazyFavoriteRepository(provider),
330+ folder: sqlite.NewLazyFolderRepository(provider),
331+ tag: sqlite.NewLazyTagRepository(provider),
332+ zoom: sqlite.NewLazyZoomRepository(provider),
333+ session: sqlite.NewLazySessionRepository(provider),
334+ sessionState: sqlite.NewLazySessionStateRepository(provider),
335+ }
336+}
337+
237338 // useCases groups application layer use case implementations.
238339 type useCases struct {
239340 tabs *usecase.ManageTabsUseCase
@@ -73,17 +73,11 @@ func runGUI() int {
73 cfg := initConfig()73 cfg := initConfig()
74 timer.Mark("config")74 timer.Mark("config")
75 75
76- deps.ApplyPrefixEnv(cfg.Runtime.Prefix)76+ ctx := initStartupContext(cfg)
77- bootstrapLogger := logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format)
78- bootstrapLogger.Info().
79- Str("version", version).
80- Str("commit", commit).
81- Str("build_date", buildDate).
82- Msg("starting dumber")
83- ctx := logging.WithContext(context.Background(), bootstrapLogger)
84 timer.Mark("logger")77 timer.Mark("logger")
78+ bootstrapLog := logging.FromContext(ctx)
85 79
86- // Parallel phase: directories, runtime/media checks, theme, WASM precompile80+ // Parallel phase: directories and theme setup
87 initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{81 initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{
88 Ctx: ctx,82 Ctx: ctx,
89 Config: cfg,83 Config: cfg,
@@ -94,27 +88,20 @@ func runGUI() int {
94 }88 }
95 timer.MarkDuration("parallel_phase", initResult.Duration)89 timer.MarkDuration("parallel_phase", initResult.Duration)
96 90
97- // Parallel phase 2: Database + WebKit stack initialize concurrently91+ needsEagerDB := restoreSessionID != "" || cfg.Session.AutoRestore
98- dbWebKit, err := bootstrap.RunParallelDBWebKit(bootstrap.ParallelDBWebKitInput{92+
99- Ctx: ctx,93+ stack, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, needsEagerDB)
100- Config: cfg,
101- DataDir: initResult.DataDir,
102- CacheDir: initResult.CacheDir,
103- ThemeManager: initResult.ThemeManager,
104- })
105 if err != nil {94 if err != nil {
106- bootstrapLogger.Fatal().Err(err).Msg("failed to initialize database/webkit")95+ bootstrapLog.Fatal().Err(err).Msg("failed to initialize database/webkit")
107 }96 }
108- if dbWebKit.DBCleanup != nil {97+ if dbCleanup != nil {
109- defer dbWebKit.DBCleanup()98+ defer dbCleanup()
110 }99 }
111- db := dbWebKit.DB
112- stack := dbWebKit.Stack
113 timer.Mark("db_webkit_parallel")100 timer.Mark("db_webkit_parallel")
114 101
115- browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, db)102+ browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, repos.session, !needsEagerDB)
116 if err != nil {103 if err != nil {
117- bootstrapLogger.Fatal().Err(err).Msg("failed to start session")104+ bootstrapLog.Fatal().Err(err).Msg("failed to start session")
118 }105 }
119 if browserSession.LogCleanup != nil {106 if browserSession.LogCleanup != nil {
120 defer browserSession.LogCleanup()107 defer browserSession.LogCleanup()
@@ -124,11 +111,15 @@ func runGUI() int {
124 111
125 ctx = sessionCtx112 ctx = sessionCtx
126 log := logging.FromContext(ctx)113 log := logging.FromContext(ctx)
114+ if stack.MessageRouter != nil {
115+ stack.MessageRouter.SetBaseContext(ctx)
116+ }
127 117
128 // Repositories and use cases118 // Repositories and use cases
129- repos := createRepositories(db)
130 useCases := createUseCases(repos, cfg)119 useCases := createUseCases(repos, cfg)
131- handleAutoRestore(ctx, cfg, useCases, browserSession.Session.ID)120+ if needsEagerDB {
121+ handleAutoRestore(ctx, cfg, useCases, browserSession.Session.ID)
122+ }
132 123
133 idleInhibitor := idle.NewPortalInhibitor(ctx)124 idleInhibitor := idle.NewPortalInhibitor(ctx)
134 defer func() {125 defer func() {
@@ -140,6 +131,7 @@ func runGUI() int {
140 131
141 // Build UI132 // Build UI
142 uiDeps := buildUIDependencies(ctx, cfg, initResult.ThemeManager, &stack, repos, useCases, idleInhibitor, browserSession.Session.ID)133 uiDeps := buildUIDependencies(ctx, cfg, initResult.ThemeManager, &stack, repos, useCases, idleInhibitor, browserSession.Session.ID)
134+ configureDeferredInit(uiDeps, cfg, browserSession)
143 app, err := ui.New(uiDeps)135 app, err := ui.New(uiDeps)
144 if err != nil {136 if err != nil {
145 log.Error().Err(err).Msg("failed to create application")137 log.Error().Err(err).Msg("failed to create application")
@@ -154,6 +146,82 @@ func runGUI() int {
154 return app.Run(ctx, os.Args)146 return app.Run(ctx, os.Args)
155 }147 }
156 148
149+func initStartupContext(cfg *config.Config) context.Context {
150+ deps.ApplyPrefixEnv(cfg.Runtime.Prefix)
151+ bootstrapLogger := logging.NewFromConfigValuesWithTimeFormat(
152+ cfg.Logging.Level,
153+ cfg.Logging.Format,
154+ logging.ConsoleTimeFormat,
155+ )
156+ bootstrapLogger.Info().
157+ Str("version", version).
158+ Str("commit", commit).
159+ Str("build_date", buildDate).
160+ Msg("starting dumber")
161+ ctx := logging.WithContext(context.Background(), bootstrapLogger)
162+ return ctx
163+}
164+
165+func initStackAndRepos(
166+ ctx context.Context,
167+ cfg *config.Config,
168+ initResult *bootstrap.ParallelInitResult,
169+ needsEagerDB bool,
170+) (bootstrap.WebKitStack, *repositories, func(), error) {
171+ if needsEagerDB {
172+ // Parallel phase 2: Database + WebKit stack initialize concurrently
173+ dbWebKit, err := bootstrap.RunParallelDBWebKit(bootstrap.ParallelDBWebKitInput{
174+ Ctx: ctx,
175+ Config: cfg,
176+ DataDir: initResult.DataDir,
177+ CacheDir: initResult.CacheDir,
178+ ThemeManager: initResult.ThemeManager,
179+ })
180+ if err != nil {
181+ return bootstrap.WebKitStack{}, nil, nil, err
182+ }
183+ return dbWebKit.Stack, createRepositories(dbWebKit.DB), dbWebKit.DBCleanup, nil
184+ }
185+
186+ log := logging.FromContext(ctx)
187+ stack := bootstrap.BuildWebKitStack(ctx, cfg, initResult.DataDir, initResult.CacheDir, initResult.ThemeManager, *log)
188+
189+ lazyDB, err := bootstrap.CreateLazyDatabase()
190+ if err != nil {
191+ return stack, nil, nil, err
192+ }
193+ dbCleanup := func() { _ = lazyDB.Close() }
194+ return stack, createLazyRepositories(lazyDB), dbCleanup, nil
195+}
196+
197+func configureDeferredInit(
198+ uiDeps *ui.Dependencies,
199+ cfg *config.Config,
200+ session *bootstrap.BrowserSession,
201+) {
202+ if uiDeps == nil {
203+ return
204+ }
205+ uiDeps.OnFirstWebViewShown = func(cbCtx context.Context) {
206+ logger := logging.FromContext(cbCtx)
207+ bgCtx := logging.WithContext(context.Background(), *logger)
208+ go func() {
209+ result := bootstrap.RunDeferredInit(bootstrap.DeferredInitInput{
210+ Ctx: bgCtx,
211+ Config: cfg,
212+ })
213+ logDeferredInitResults(bgCtx, result)
214+ }()
215+ if session != nil && session.Persist != nil {
216+ go func() {
217+ if persistErr := session.Persist(bgCtx); persistErr != nil {
218+ logger.Error().Err(persistErr).Msg("deferred session persistence failed")
219+ }
220+ }()
221+ }
222+ }
223+}
224+
157 func initConfig() *config.Config {225 func initConfig() *config.Config {
158 if err := config.Init(); err != nil {226 if err := config.Init(); err != nil {
159 fmt.Fprintf(os.Stderr, "Failed to initialize configuration: %v\n", err)227 fmt.Fprintf(os.Stderr, "Failed to initialize configuration: %v\n", err)
@@ -173,6 +241,27 @@ func handleParallelInitError(ctx context.Context, err error) {
173 log.Fatal().Err(err).Msg("initialization failed")241 log.Fatal().Err(err).Msg("initialization failed")
174 }242 }
175 243
244+func logDeferredInitResults(ctx context.Context, result bootstrap.DeferredInitResult) {
245+ log := logging.FromContext(ctx)
246+ if result.SQLiteErr != nil {
247+ log.Warn().Err(result.SQLiteErr).Msg("deferred sqlite wasm init failed")
248+ }
249+ if result.RuntimeErr != nil {
250+ if runtimeErr, ok := result.RuntimeErr.(*bootstrap.RuntimeRequirementsError); ok {
251+ runtimeErr.LogDetails(ctx)
252+ log.Warn().Err(runtimeErr).
253+ Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)").
254+ Msg("runtime requirements not met")
255+ } else {
256+ log.Warn().Err(result.RuntimeErr).Msg("runtime requirements check failed")
257+ }
258+ }
259+ if result.MediaErr != nil {
260+ log.Warn().Err(result.MediaErr).Msg("media check failed")
261+ }
262+ log.Debug().Dur("duration", result.Duration).Msg("deferred init complete")
263+}
264+
176 func handleAutoRestore(265 func handleAutoRestore(
177 ctx context.Context,266 ctx context.Context,
178 cfg *config.Config,267 cfg *config.Config,
@@ -234,6 +323,18 @@ func createRepositories(db *sql.DB) *repositories {
234 }323 }
235 }324 }
236 325
326+func createLazyRepositories(provider port.DatabaseProvider) *repositories {
327+ return &repositories{
328+ history: sqlite.NewLazyHistoryRepository(provider),
329+ favorite: sqlite.NewLazyFavoriteRepository(provider),
330+ folder: sqlite.NewLazyFolderRepository(provider),
331+ tag: sqlite.NewLazyTagRepository(provider),
332+ zoom: sqlite.NewLazyZoomRepository(provider),
333+ session: sqlite.NewLazySessionRepository(provider),
334+ sessionState: sqlite.NewLazySessionStateRepository(provider),
335+ }
336+}
337+
237 // useCases groups application layer use case implementations.338 // useCases groups application layer use case implementations.
238 type useCases struct {339 type useCases struct {
239 tabs *usecase.ManageTabsUseCase340 tabs *usecase.ManageTabsUseCase
M internal/bootstrap/gui.go +56 -30
@@ -34,12 +34,26 @@ type ParallelInitResult struct {
3434 Duration time.Duration
3535 }
3636
37+// DeferredInitResult holds results from deferred initialization checks.
38+type DeferredInitResult struct {
39+ RuntimeErr error
40+ MediaErr error
41+ SQLiteErr error
42+ Duration time.Duration
43+}
44+
3745 // ParallelInitInput holds the input for parallel initialization.
3846 type ParallelInitInput struct {
3947 Ctx context.Context
4048 Config *config.Config
4149 }
4250
51+// DeferredInitInput holds the input for deferred initialization.
52+type DeferredInitInput struct {
53+ Ctx context.Context
54+ Config *config.Config
55+}
56+
4357 // RuntimeRequirementsError contains details about missing runtime dependencies.
4458 type RuntimeRequirementsError struct {
4559 Checks []usecase.RuntimeDependencyStatus
@@ -69,28 +83,19 @@ func (e *RuntimeRequirementsError) LogDetails(ctx context.Context) {
6983 }
7084 }
7185
72-// RunParallelInit runs the parallel initialization phase.
73-// This includes directory resolution, runtime/media checks, and theme creation.
86+// RunParallelInit runs the essential parallel initialization phase.
87+// This includes directory resolution and theme creation.
7488 // Returns the first fatal error encountered, or nil with the results.
7589 func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
7690 var (
7791 dataDir, cacheDir string
7892 themeManager *theme.Manager
7993 dirsErr error
80- runtimeErr error
81- mediaErr error
8294 wg sync.WaitGroup
8395 )
8496
8597 start := time.Now()
86- wg.Add(5)
87-
88- // Pre-initialize SQLite WASM runtime (expensive compilation)
89- // This runs concurrently with other checks so it's hidden from critical path.
90- go func() {
91- defer wg.Done()
92- _ = sqlite3.Initialize()
93- }()
98+ wg.Add(2)
9499
95100 // Resolve directories
96101 go func() {
@@ -98,18 +103,6 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
98103 dataDir, cacheDir, dirsErr = resolveWebKitDirs()
99104 }()
100105
101- // Runtime checks (pkg-config subprocess)
102- go func() {
103- defer wg.Done()
104- runtimeErr = CheckRuntimeRequirements(input.Ctx, input.Config)
105- }()
106-
107- // Media checks (GStreamer subprocess)
108- go func() {
109- defer wg.Done()
110- mediaErr = CheckMediaRequirements(input.Ctx, input.Config)
111- }()
112-
113106 // Theme manager (CPU-bound, no I/O)
114107 go func() {
115108 defer wg.Done()
@@ -120,13 +113,8 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
120113 duration := time.Since(start)
121114
122115 // Return first fatal error
123- switch {
124- case dirsErr != nil:
116+ if dirsErr != nil {
125117 return nil, fmt.Errorf("resolve directories: %w", dirsErr)
126- case runtimeErr != nil:
127- return nil, runtimeErr
128- case mediaErr != nil:
129- return nil, fmt.Errorf("media check: %w", mediaErr)
130118 }
131119
132120 return &ParallelInitResult{
@@ -137,6 +125,44 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
137125 }, nil
138126 }
139127
128+// RunDeferredInit runs deferred initialization checks off the critical path.
129+// This includes SQLite WASM precompile, runtime requirements, and media checks.
130+func RunDeferredInit(input DeferredInitInput) DeferredInitResult {
131+ var (
132+ runtimeErr error
133+ mediaErr error
134+ sqliteErr error
135+ wg sync.WaitGroup
136+ )
137+
138+ start := time.Now()
139+ wg.Add(3)
140+
141+ go func() {
142+ defer wg.Done()
143+ sqliteErr = sqlite3.Initialize()
144+ }()
145+
146+ go func() {
147+ defer wg.Done()
148+ runtimeErr = CheckRuntimeRequirements(input.Ctx, input.Config)
149+ }()
150+
151+ go func() {
152+ defer wg.Done()
153+ mediaErr = CheckMediaRequirements(input.Ctx, input.Config)
154+ }()
155+
156+ wg.Wait()
157+
158+ return DeferredInitResult{
159+ RuntimeErr: runtimeErr,
160+ MediaErr: mediaErr,
161+ SQLiteErr: sqliteErr,
162+ Duration: time.Since(start),
163+ }
164+}
165+
140166 // CheckRuntimeRequirements verifies WebKitGTK and other runtime dependencies.
141167 // Returns error if requirements are not met; caller should log details and exit.
142168 func CheckRuntimeRequirements(ctx context.Context, cfg *config.Config) error {
@@ -34,12 +34,26 @@ type ParallelInitResult struct {
34 Duration time.Duration34 Duration time.Duration
35 }35 }
36 36
37+// DeferredInitResult holds results from deferred initialization checks.
38+type DeferredInitResult struct {
39+ RuntimeErr error
40+ MediaErr error
41+ SQLiteErr error
42+ Duration time.Duration
43+}
44+
37 // ParallelInitInput holds the input for parallel initialization.45 // ParallelInitInput holds the input for parallel initialization.
38 type ParallelInitInput struct {46 type ParallelInitInput struct {
39 Ctx context.Context47 Ctx context.Context
40 Config *config.Config48 Config *config.Config
41 }49 }
42 50
51+// DeferredInitInput holds the input for deferred initialization.
52+type DeferredInitInput struct {
53+ Ctx context.Context
54+ Config *config.Config
55+}
56+
43 // RuntimeRequirementsError contains details about missing runtime dependencies.57 // RuntimeRequirementsError contains details about missing runtime dependencies.
44 type RuntimeRequirementsError struct {58 type RuntimeRequirementsError struct {
45 Checks []usecase.RuntimeDependencyStatus59 Checks []usecase.RuntimeDependencyStatus
@@ -69,28 +83,19 @@ func (e *RuntimeRequirementsError) LogDetails(ctx context.Context) {
69 }83 }
70 }84 }
71 85
72-// RunParallelInit runs the parallel initialization phase.86+// RunParallelInit runs the essential parallel initialization phase.
73-// This includes directory resolution, runtime/media checks, and theme creation.87+// This includes directory resolution and theme creation.
74 // Returns the first fatal error encountered, or nil with the results.88 // Returns the first fatal error encountered, or nil with the results.
75 func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {89 func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
76 var (90 var (
77 dataDir, cacheDir string91 dataDir, cacheDir string
78 themeManager *theme.Manager92 themeManager *theme.Manager
79 dirsErr error93 dirsErr error
80- runtimeErr error
81- mediaErr error
82 wg sync.WaitGroup94 wg sync.WaitGroup
83 )95 )
84 96
85 start := time.Now()97 start := time.Now()
86- wg.Add(5)98+ wg.Add(2)
87-
88- // Pre-initialize SQLite WASM runtime (expensive compilation)
89- // This runs concurrently with other checks so it's hidden from critical path.
90- go func() {
91- defer wg.Done()
92- _ = sqlite3.Initialize()
93- }()
94 99
95 // Resolve directories100 // Resolve directories
96 go func() {101 go func() {
@@ -98,18 +103,6 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
98 dataDir, cacheDir, dirsErr = resolveWebKitDirs()103 dataDir, cacheDir, dirsErr = resolveWebKitDirs()
99 }()104 }()
100 105
101- // Runtime checks (pkg-config subprocess)
102- go func() {
103- defer wg.Done()
104- runtimeErr = CheckRuntimeRequirements(input.Ctx, input.Config)
105- }()
106-
107- // Media checks (GStreamer subprocess)
108- go func() {
109- defer wg.Done()
110- mediaErr = CheckMediaRequirements(input.Ctx, input.Config)
111- }()
112-
113 // Theme manager (CPU-bound, no I/O)106 // Theme manager (CPU-bound, no I/O)
114 go func() {107 go func() {
115 defer wg.Done()108 defer wg.Done()
@@ -120,13 +113,8 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
120 duration := time.Since(start)113 duration := time.Since(start)
121 114
122 // Return first fatal error115 // Return first fatal error
123- switch {116+ if dirsErr != nil {
124- case dirsErr != nil:
125 return nil, fmt.Errorf("resolve directories: %w", dirsErr)117 return nil, fmt.Errorf("resolve directories: %w", dirsErr)
126- case runtimeErr != nil:
127- return nil, runtimeErr
128- case mediaErr != nil:
129- return nil, fmt.Errorf("media check: %w", mediaErr)
130 }118 }
131 119
132 return &ParallelInitResult{120 return &ParallelInitResult{
@@ -137,6 +125,44 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
137 }, nil125 }, nil
138 }126 }
139 127
128+// RunDeferredInit runs deferred initialization checks off the critical path.
129+// This includes SQLite WASM precompile, runtime requirements, and media checks.
130+func RunDeferredInit(input DeferredInitInput) DeferredInitResult {
131+ var (
132+ runtimeErr error
133+ mediaErr error
134+ sqliteErr error
135+ wg sync.WaitGroup
136+ )
137+
138+ start := time.Now()
139+ wg.Add(3)
140+
141+ go func() {
142+ defer wg.Done()
143+ sqliteErr = sqlite3.Initialize()
144+ }()
145+
146+ go func() {
147+ defer wg.Done()
148+ runtimeErr = CheckRuntimeRequirements(input.Ctx, input.Config)
149+ }()
150+
151+ go func() {
152+ defer wg.Done()
153+ mediaErr = CheckMediaRequirements(input.Ctx, input.Config)
154+ }()
155+
156+ wg.Wait()
157+
158+ return DeferredInitResult{
159+ RuntimeErr: runtimeErr,
160+ MediaErr: mediaErr,
161+ SQLiteErr: sqliteErr,
162+ Duration: time.Since(start),
163+ }
164+}
165+
140 // CheckRuntimeRequirements verifies WebKitGTK and other runtime dependencies.166 // CheckRuntimeRequirements verifies WebKitGTK and other runtime dependencies.
141 // Returns error if requirements are not met; caller should log details and exit.167 // Returns error if requirements are not met; caller should log details and exit.
142 func CheckRuntimeRequirements(ctx context.Context, cfg *config.Config) error {168 func CheckRuntimeRequirements(ctx context.Context, cfg *config.Config) error {
M internal/bootstrap/session.go +98 -36
@@ -2,20 +2,20 @@ package bootstrap
22
33 import (
44 "context"
5- "database/sql"
65 "errors"
76 "fmt"
87 "os"
98 "path/filepath"
9+ "sync"
1010 "syscall"
1111 "time"
1212
1313 "github.com/bnema/dumber/internal/application/port"
1414 "github.com/bnema/dumber/internal/application/usecase"
1515 "github.com/bnema/dumber/internal/domain/entity"
16+ "github.com/bnema/dumber/internal/domain/repository"
1617 "github.com/bnema/dumber/internal/infrastructure/config"
1718 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
18- "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
1919 corelogging "github.com/bnema/dumber/internal/logging"
2020 "github.com/rs/zerolog"
2121 )
@@ -31,11 +31,20 @@ type BrowserSession struct {
3131 Logger zerolog.Logger
3232 LogCleanup func()
3333
34- End func(context.Context) error
34+ Persist func(context.Context) error
35+ End func(context.Context) error
3536 }
3637
37-func StartBrowserSession(ctx context.Context, cfg *config.Config, db *sql.DB) (*BrowserSession, context.Context, error) {
38+func StartBrowserSession(
39+ ctx context.Context,
40+ cfg *config.Config,
41+ sessionRepo repository.SessionRepository,
42+ deferPersist bool,
43+) (*BrowserSession, context.Context, error) {
3844 log := corelogging.FromContext(ctx)
45+ if sessionRepo == nil {
46+ return nil, ctx, errors.New("session repository is nil")
47+ }
3948
4049 lockDir := cfg.Logging.LogDir
4150 if lockDir == "" {
@@ -44,55 +53,108 @@ func StartBrowserSession(ctx context.Context, cfg *config.Config, db *sql.DB) (*
4453 }
4554 }
4655
47- sessionRepo := sqlite.NewSessionRepository(db)
4856 sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()
4957 sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)
5058 cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo)
5159
52- // Defer stale session cleanup and old session pruning to background.
53- // This avoids blocking startup for non-critical housekeeping tasks.
54- runSessionCleanupAsync(ctx, sessionUC, cleanupUC, cfg, lockDir, log)
55-
5660 now := time.Now()
57- out, err := sessionUC.StartSession(ctx, usecase.StartSessionInput{
58- Type: entity.SessionTypeBrowser,
59- Now: now,
60- LogConfig: port.SessionLogConfig{
61- Level: cfg.Logging.Level,
62- Format: cfg.Logging.Format,
63- TimeFormat: "15:04:05",
64- LogDir: cfg.Logging.LogDir,
65- WriteToStderr: true,
66- EnableFileLog: cfg.Logging.EnableFileLog,
67- },
68- })
69- if err != nil {
61+ session := &entity.Session{
62+ ID: entity.SessionID(corelogging.GenerateSessionID()),
63+ Type: entity.SessionTypeBrowser,
64+ StartedAt: now.UTC(),
65+ }
66+ if err := session.Validate(); err != nil {
7067 return nil, ctx, err
7168 }
7269
73- lockFile, lockPath, lockErr := lockSessionFile(lockDir, out.Session.ID)
74- if lockErr != nil {
75- // Don’t fail startup if locking fails; it only affects stale-session cleanup.
76- out.Logger.Warn().Err(lockErr).Msg("failed to acquire session lock")
70+ logger, cleanup, err := sessionLoggerAdapter.CreateLogger(ctx, session.ID, port.SessionLogConfig{
71+ Level: cfg.Logging.Level,
72+ Format: cfg.Logging.Format,
73+ TimeFormat: corelogging.ConsoleTimeFormat,
74+ LogDir: cfg.Logging.LogDir,
75+ WriteToStderr: true,
76+ EnableFileLog: cfg.Logging.EnableFileLog,
77+ })
78+ if err != nil {
79+ log.Warn().Err(err).Msg("failed to create session logger")
7780 }
7881
79- sessionCtx := corelogging.WithContext(context.Background(), out.Logger)
82+ sessionCtx := corelogging.WithContext(context.Background(), logger)
83+
84+ var (
85+ lockMu sync.Mutex
86+ lockFile *os.File
87+ lockPath string
88+ persistMu sync.Once
89+ persistErr error
90+ )
91+
92+ persistFn := func(persistCtx context.Context) error {
93+ persistMu.Do(func() {
94+ saveCtx := corelogging.WithContext(context.Background(), logger)
95+ if err := sessionRepo.Save(saveCtx, session); err != nil {
96+ persistErr = fmt.Errorf("save session: %w", err)
97+ return
98+ }
99+
100+ logger.Info().
101+ Str("session_id", string(session.ID)).
102+ Str("type", string(session.Type)).
103+ Msg("session started")
104+
105+ lf, lp, lockErr := lockSessionFile(lockDir, session.ID)
106+ if lockErr != nil {
107+ // Don’t fail startup if locking fails; it only affects stale-session cleanup.
108+ logger.Warn().Err(lockErr).Msg("failed to acquire session lock")
109+ } else {
110+ lockMu.Lock()
111+ lockFile = lf
112+ lockPath = lp
113+ lockMu.Unlock()
114+ }
115+
116+ // Defer stale session cleanup and old session pruning to background.
117+ // This avoids blocking startup for non-critical housekeeping tasks.
118+ runSessionCleanupAsync(persistCtx, sessionUC, cleanupUC, cfg, lockDir, log)
119+ })
120+ return persistErr
121+ }
80122
81123 endFn := func(endCtx context.Context) error {
82- endErr := sessionUC.EndSession(endCtx, out.Session.ID, time.Now())
83- if lockFile != nil {
84- _ = unlockAndClose(lockFile)
85- _ = os.Remove(lockPath)
124+ _ = persistFn(endCtx)
125+ endErr := sessionUC.EndSession(endCtx, session.ID, time.Now())
126+
127+ lockMu.Lock()
128+ lf := lockFile
129+ lp := lockPath
130+ lockMu.Unlock()
131+
132+ if lf != nil {
133+ _ = unlockAndClose(lf)
134+ _ = os.Remove(lp)
86135 }
87136 return endErr
88137 }
89138
90- return &BrowserSession{
91- Session: out.Session,
92- Logger: out.Logger,
93- LogCleanup: out.LogCleanup,
139+ browserSession := &BrowserSession{
140+ Session: session,
141+ Logger: logger,
142+ LogCleanup: cleanup,
94143 End: endFn,
95- }, sessionCtx, nil
144+ }
145+
146+ if deferPersist {
147+ browserSession.Persist = persistFn
148+ } else {
149+ if err := persistFn(ctx); err != nil {
150+ if cleanup != nil {
151+ cleanup()
152+ }
153+ return nil, ctx, err
154+ }
155+ }
156+
157+ return browserSession, sessionCtx, nil
96158 }
97159
98160 func endStaleActiveBrowserSessions(
@@ -2,20 +2,20 @@ package bootstrap
2 2
3 import (3 import (
4 "context"4 "context"
5- "database/sql"
6 "errors"5 "errors"
7 "fmt"6 "fmt"
8 "os"7 "os"
9 "path/filepath"8 "path/filepath"
9+ "sync"
10 "syscall"10 "syscall"
11 "time"11 "time"
12 12
13 "github.com/bnema/dumber/internal/application/port"13 "github.com/bnema/dumber/internal/application/port"
14 "github.com/bnema/dumber/internal/application/usecase"14 "github.com/bnema/dumber/internal/application/usecase"
15 "github.com/bnema/dumber/internal/domain/entity"15 "github.com/bnema/dumber/internal/domain/entity"
16+ "github.com/bnema/dumber/internal/domain/repository"
16 "github.com/bnema/dumber/internal/infrastructure/config"17 "github.com/bnema/dumber/internal/infrastructure/config"
17 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"18 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
18- "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
19 corelogging "github.com/bnema/dumber/internal/logging"19 corelogging "github.com/bnema/dumber/internal/logging"
20 "github.com/rs/zerolog"20 "github.com/rs/zerolog"
21 )21 )
@@ -31,11 +31,20 @@ type BrowserSession struct {
31 Logger zerolog.Logger31 Logger zerolog.Logger
32 LogCleanup func()32 LogCleanup func()
33 33
34- End func(context.Context) error34+ Persist func(context.Context) error
35+ End func(context.Context) error
35 }36 }
36 37
37-func StartBrowserSession(ctx context.Context, cfg *config.Config, db *sql.DB) (*BrowserSession, context.Context, error) {38+func StartBrowserSession(
39+ ctx context.Context,
40+ cfg *config.Config,
41+ sessionRepo repository.SessionRepository,
42+ deferPersist bool,
43+) (*BrowserSession, context.Context, error) {
38 log := corelogging.FromContext(ctx)44 log := corelogging.FromContext(ctx)
45+ if sessionRepo == nil {
46+ return nil, ctx, errors.New("session repository is nil")
47+ }
39 48
40 lockDir := cfg.Logging.LogDir49 lockDir := cfg.Logging.LogDir
41 if lockDir == "" {50 if lockDir == "" {
@@ -44,55 +53,108 @@ func StartBrowserSession(ctx context.Context, cfg *config.Config, db *sql.DB) (*
44 }53 }
45 }54 }
46 55
47- sessionRepo := sqlite.NewSessionRepository(db)
48 sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()56 sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()
49 sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)57 sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)
50 cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo)58 cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo)
51 59
52- // Defer stale session cleanup and old session pruning to background.
53- // This avoids blocking startup for non-critical housekeeping tasks.
54- runSessionCleanupAsync(ctx, sessionUC, cleanupUC, cfg, lockDir, log)
55-
56 now := time.Now()60 now := time.Now()
57- out, err := sessionUC.StartSession(ctx, usecase.StartSessionInput{61+ session := &entity.Session{
58- Type: entity.SessionTypeBrowser,62+ ID: entity.SessionID(corelogging.GenerateSessionID()),
59- Now: now,63+ Type: entity.SessionTypeBrowser,
60- LogConfig: port.SessionLogConfig{64+ StartedAt: now.UTC(),
61- Level: cfg.Logging.Level,65+ }
62- Format: cfg.Logging.Format,66+ if err := session.Validate(); err != nil {
63- TimeFormat: "15:04:05",
64- LogDir: cfg.Logging.LogDir,
65- WriteToStderr: true,
66- EnableFileLog: cfg.Logging.EnableFileLog,
67- },
68- })
69- if err != nil {
70 return nil, ctx, err67 return nil, ctx, err
71 }68 }
72 69
73- lockFile, lockPath, lockErr := lockSessionFile(lockDir, out.Session.ID)70+ logger, cleanup, err := sessionLoggerAdapter.CreateLogger(ctx, session.ID, port.SessionLogConfig{
74- if lockErr != nil {71+ Level: cfg.Logging.Level,
75- // Don’t fail startup if locking fails; it only affects stale-session cleanup.72+ Format: cfg.Logging.Format,
76- out.Logger.Warn().Err(lockErr).Msg("failed to acquire session lock")73+ TimeFormat: corelogging.ConsoleTimeFormat,
74+ LogDir: cfg.Logging.LogDir,
75+ WriteToStderr: true,
76+ EnableFileLog: cfg.Logging.EnableFileLog,
77+ })
78+ if err != nil {
79+ log.Warn().Err(err).Msg("failed to create session logger")
77 }80 }
78 81
79- sessionCtx := corelogging.WithContext(context.Background(), out.Logger)82+ sessionCtx := corelogging.WithContext(context.Background(), logger)
83+
84+ var (
85+ lockMu sync.Mutex
86+ lockFile *os.File
87+ lockPath string
88+ persistMu sync.Once
89+ persistErr error
90+ )
91+
92+ persistFn := func(persistCtx context.Context) error {
93+ persistMu.Do(func() {
94+ saveCtx := corelogging.WithContext(context.Background(), logger)
95+ if err := sessionRepo.Save(saveCtx, session); err != nil {
96+ persistErr = fmt.Errorf("save session: %w", err)
97+ return
98+ }
99+
100+ logger.Info().
101+ Str("session_id", string(session.ID)).
102+ Str("type", string(session.Type)).
103+ Msg("session started")
104+
105+ lf, lp, lockErr := lockSessionFile(lockDir, session.ID)
106+ if lockErr != nil {
107+ // Don’t fail startup if locking fails; it only affects stale-session cleanup.
108+ logger.Warn().Err(lockErr).Msg("failed to acquire session lock")
109+ } else {
110+ lockMu.Lock()
111+ lockFile = lf
112+ lockPath = lp
113+ lockMu.Unlock()
114+ }
115+
116+ // Defer stale session cleanup and old session pruning to background.
117+ // This avoids blocking startup for non-critical housekeeping tasks.
118+ runSessionCleanupAsync(persistCtx, sessionUC, cleanupUC, cfg, lockDir, log)
119+ })
120+ return persistErr
121+ }
80 122
81 endFn := func(endCtx context.Context) error {123 endFn := func(endCtx context.Context) error {
82- endErr := sessionUC.EndSession(endCtx, out.Session.ID, time.Now())124+ _ = persistFn(endCtx)
83- if lockFile != nil {125+ endErr := sessionUC.EndSession(endCtx, session.ID, time.Now())
84- _ = unlockAndClose(lockFile)126+
85- _ = os.Remove(lockPath)127+ lockMu.Lock()
128+ lf := lockFile
129+ lp := lockPath
130+ lockMu.Unlock()
131+
132+ if lf != nil {
133+ _ = unlockAndClose(lf)
134+ _ = os.Remove(lp)
86 }135 }
87 return endErr136 return endErr
88 }137 }
89 138
90- return &BrowserSession{139+ browserSession := &BrowserSession{
91- Session: out.Session,140+ Session: session,
92- Logger: out.Logger,141+ Logger: logger,
93- LogCleanup: out.LogCleanup,142+ LogCleanup: cleanup,
94 End: endFn,143 End: endFn,
95- }, sessionCtx, nil144+ }
145+
146+ if deferPersist {
147+ browserSession.Persist = persistFn
148+ } else {
149+ if err := persistFn(ctx); err != nil {
150+ if cleanup != nil {
151+ cleanup()
152+ }
153+ return nil, ctx, err
154+ }
155+ }
156+
157+ return browserSession, sessionCtx, nil
96 }158 }
97 159
98 func endStaleActiveBrowserSessions(160 func endStaleActiveBrowserSessions(
M internal/infrastructure/webkit/messaging.go +11 -0
@@ -62,6 +62,17 @@ func NewMessageRouter(ctx context.Context) *MessageRouter {
6262 }
6363 }
6464
65+// SetBaseContext updates the base context used for logging and handler execution.
66+func (r *MessageRouter) SetBaseContext(ctx context.Context) {
67+ if r == nil {
68+ return
69+ }
70+ if ctx == nil {
71+ ctx = context.Background()
72+ }
73+ r.baseCtx = ctx
74+}
75+
6576 // RegisterHandler registers a handler for a message type.
6677 func (r *MessageRouter) RegisterHandler(msgType string, handler MessageHandler) error {
6778 if msgType == "" {
@@ -62,6 +62,17 @@ func NewMessageRouter(ctx context.Context) *MessageRouter {
62 }62 }
63 }63 }
64 64
65+// SetBaseContext updates the base context used for logging and handler execution.
66+func (r *MessageRouter) SetBaseContext(ctx context.Context) {
67+ if r == nil {
68+ return
69+ }
70+ if ctx == nil {
71+ ctx = context.Background()
72+ }
73+ r.baseCtx = ctx
74+}
75+
65 // RegisterHandler registers a handler for a message type.76 // RegisterHandler registers a handler for a message type.
66 func (r *MessageRouter) RegisterHandler(msgType string, handler MessageHandler) error {77 func (r *MessageRouter) RegisterHandler(msgType string, handler MessageHandler) error {
67 if msgType == "" {78 if msgType == "" {
M internal/logging/logger.go +12 -3
@@ -10,9 +10,10 @@ import (
1010 )
1111
1212 const (
13- logFormatConsole = "console"
14- logFormatJSON = "json"
15- logFormatText = "text"
13+ logFormatConsole = "console"
14+ logFormatJSON = "json"
15+ logFormatText = "text"
16+ ConsoleTimeFormat = "15:04:05"
1617 )
1718
1819 // Config holds logging configuration
@@ -146,8 +147,16 @@ func NewFromEnv() zerolog.Logger {
146147 // This is used by main.go to create a logger from the config package's LoggingConfig
147148 // without creating an import cycle.
148149 func NewFromConfigValues(level, format string) zerolog.Logger {
150+ return NewFromConfigValuesWithTimeFormat(level, format, "")
151+}
152+
153+// NewFromConfigValuesWithTimeFormat creates a logger from level/format and overrides time format.
154+func NewFromConfigValuesWithTimeFormat(level, format, timeFormat string) zerolog.Logger {
149155 cfg := DefaultConfig()
150156 cfg.Level = ParseLevel(level)
157+ if timeFormat != "" {
158+ cfg.TimeFormat = timeFormat
159+ }
151160
152161 switch format {
153162 case logFormatJSON:
@@ -10,9 +10,10 @@ import (
10 )10 )
11 11
12 const (12 const (
13- logFormatConsole = "console"13+ logFormatConsole = "console"
14- logFormatJSON = "json"14+ logFormatJSON = "json"
15- logFormatText = "text"15+ logFormatText = "text"
16+ ConsoleTimeFormat = "15:04:05"
16 )17 )
17 18
18 // Config holds logging configuration19 // Config holds logging configuration
@@ -146,8 +147,16 @@ func NewFromEnv() zerolog.Logger {
146 // This is used by main.go to create a logger from the config package's LoggingConfig147 // This is used by main.go to create a logger from the config package's LoggingConfig
147 // without creating an import cycle.148 // without creating an import cycle.
148 func NewFromConfigValues(level, format string) zerolog.Logger {149 func NewFromConfigValues(level, format string) zerolog.Logger {
150+ return NewFromConfigValuesWithTimeFormat(level, format, "")
151+}
152+
153+// NewFromConfigValuesWithTimeFormat creates a logger from level/format and overrides time format.
154+func NewFromConfigValuesWithTimeFormat(level, format, timeFormat string) zerolog.Logger {
149 cfg := DefaultConfig()155 cfg := DefaultConfig()
150 cfg.Level = ParseLevel(level)156 cfg.Level = ParseLevel(level)
157+ if timeFormat != "" {
158+ cfg.TimeFormat = timeFormat
159+ }
151 160
152 switch format {161 switch format {
153 case logFormatJSON:162 case logFormatJSON:
M internal/ui/app.go +8 -2
@@ -99,8 +99,9 @@ type App struct {
9999 updateCoord *coordinator.UpdateCoordinator
100100
101101 // ID generator for tabs/panes
102- idCounter uint64
103- idMu sync.Mutex
102+ idCounter uint64
103+ idMu sync.Mutex
104+ firstWebViewShownOnce sync.Once
104105
105106 movePaneToTabUC *usecase.MovePaneToTabUseCase
106107
@@ -1128,6 +1129,11 @@ func (a *App) initCoordinators(ctx context.Context) {
11281129 pv.HideLoadingSkeleton()
11291130 }
11301131 }
1132+ if a.deps != nil && a.deps.OnFirstWebViewShown != nil {
1133+ a.firstWebViewShownOnce.Do(func() {
1134+ a.deps.OnFirstWebViewShown(ctx)
1135+ })
1136+ }
11311137 })
11321138
11331139 // 5. Keyboard Dispatcher
@@ -99,8 +99,9 @@ type App struct {
99 updateCoord *coordinator.UpdateCoordinator99 updateCoord *coordinator.UpdateCoordinator
100 100
101 // ID generator for tabs/panes101 // ID generator for tabs/panes
102- idCounter uint64102+ idCounter uint64
103- idMu sync.Mutex103+ idMu sync.Mutex
104+ firstWebViewShownOnce sync.Once
104 105
105 movePaneToTabUC *usecase.MovePaneToTabUseCase106 movePaneToTabUC *usecase.MovePaneToTabUseCase
106 107
@@ -1128,6 +1129,11 @@ func (a *App) initCoordinators(ctx context.Context) {
1128 pv.HideLoadingSkeleton()1129 pv.HideLoadingSkeleton()
1129 }1130 }
1130 }1131 }
1132+ if a.deps != nil && a.deps.OnFirstWebViewShown != nil {
1133+ a.firstWebViewShownOnce.Do(func() {
1134+ a.deps.OnFirstWebViewShown(ctx)
1135+ })
1136+ }
1131 })1137 })
1132 1138
1133 // 5. Keyboard Dispatcher1139 // 5. Keyboard Dispatcher
M internal/ui/deps.go +5 -4
@@ -19,10 +19,11 @@ import (
1919 // This struct is created once at startup and passed to UI components.
2020 type Dependencies struct {
2121 // Core context and configuration
22- Ctx context.Context
23- Config *config.Config
24- InitialURL string // URL to open on startup (optional)
25- RestoreSessionID string // Session ID to restore on startup (optional)
22+ Ctx context.Context
23+ Config *config.Config
24+ InitialURL string // URL to open on startup (optional)
25+ RestoreSessionID string // Session ID to restore on startup (optional)
26+ OnFirstWebViewShown func(context.Context)
2627
2728 // Theme management
2829 Theme *theme.Manager
@@ -19,10 +19,11 @@ import (
19 // This struct is created once at startup and passed to UI components.19 // This struct is created once at startup and passed to UI components.
20 type Dependencies struct {20 type Dependencies struct {
21 // Core context and configuration21 // Core context and configuration
22- Ctx context.Context22+ Ctx context.Context
23- Config *config.Config23+ Config *config.Config
24- InitialURL string // URL to open on startup (optional)24+ InitialURL string // URL to open on startup (optional)
25- RestoreSessionID string // Session ID to restore on startup (optional)25+ RestoreSessionID string // Session ID to restore on startup (optional)
26+ OnFirstWebViewShown func(context.Context)
26 27
27 // Theme management28 // Theme management
28 Theme *theme.Manager29 Theme *theme.Manager

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