detached 00ab191

chore: fix lint issues across phase streams

commit
00ab191e33cff1c712aee7411f50e1527118fe7d
author
brice · · 2026-02-07 05:17 UTC
parent
f270317
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +180 -164
M internal/application/usecase/navigate.go +14 -22
@@ -147,10 +147,10 @@ func (uc *NavigateUseCase) RecordHistory(ctx context.Context, paneID, rawURL str
147147 return
148148 }
149149
150- url := canonicalizeURLForHistory(rawURL, historyCanonicalizationOptions{
150+ canonicalURL := canonicalizeURLForHistory(rawURL, historyCanonicalizationOptions{
151151 StripTrackingParams: stripTrackingParamsForHistoryDedup,
152152 })
153- if url == "" {
153+ if canonicalURL == "" {
154154 return
155155 }
156156
@@ -172,7 +172,7 @@ func (uc *NavigateUseCase) RecordHistory(ctx context.Context, paneID, rawURL str
172172 }
173173
174174 // Per-pane short-window deduplication by canonical URL.
175- if state.lastCanonicalURL == url && now.Sub(state.lastRecordedAt) < historyDeduplicationWindow {
175+ if state.lastCanonicalURL == canonicalURL && now.Sub(state.lastRecordedAt) < historyDeduplicationWindow {
176176 state.lastRawURL = rawURL
177177 uc.recentVisits[paneID] = state
178178 uc.recentMu.Unlock()
@@ -180,17 +180,17 @@ func (uc *NavigateUseCase) RecordHistory(ctx context.Context, paneID, rawURL str
180180 }
181181
182182 state.lastRawURL = rawURL
183- state.lastCanonicalURL = url
183+ state.lastCanonicalURL = canonicalURL
184184 state.lastRecordedAt = now
185185 uc.recentVisits[paneID] = state
186186 uc.recentMu.Unlock()
187187
188188 // Non-blocking send to async queue
189189 select {
190- case uc.historyQueue <- historyRecord{url: url, visits: 1}:
190+ case uc.historyQueue <- historyRecord{url: canonicalURL, visits: 1}:
191191 default:
192192 // Queue full - log warning but don't block navigation
193- log.Warn().Str("url", logging.TruncateURL(url, logURLMaxLen)).Msg("history queue full, dropping record")
193+ log.Warn().Str("url", logging.TruncateURL(canonicalURL, logURLMaxLen)).Msg("history queue full, dropping record")
194194 }
195195 }
196196
@@ -258,7 +258,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
258258 }
259259
260260 if existing != nil {
261- for i := 0; i < max(1, record.visits); i++ {
261+ for i := 0; i < maxInt(1, record.visits); i++ {
262262 if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil {
263263 log.Warn().Err(err).Str("url", record.url).Msg("failed to increment visit count")
264264 return
@@ -267,7 +267,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
267267 } else {
268268 // Create new entry
269269 entry := entity.NewHistoryEntry(record.url, "")
270- entry.VisitCount = int64(max(1, record.visits))
270+ entry.VisitCount = int64(maxInt(1, record.visits))
271271 if err := uc.historyRepo.Save(ctx, entry); err != nil {
272272 log.Warn().Err(err).Str("url", record.url).Msg("failed to save history")
273273 }
@@ -275,16 +275,16 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
275275 }
276276
277277 // UpdateHistoryTitle updates the title of a history entry after page load.
278-func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, url, title string) error {
278+func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, historyURL, title string) error {
279279 log := logging.FromContext(ctx)
280280
281281 // Canonicalize URL the same way history records are persisted.
282- url = canonicalizeURLForHistory(url, historyCanonicalizationOptions{
282+ historyURL = canonicalizeURLForHistory(historyURL, historyCanonicalizationOptions{
283283 StripTrackingParams: stripTrackingParamsForHistoryDedup,
284284 })
285- log.Debug().Str("url", logging.TruncateURL(url, logURLMaxLen)).Str("title", title).Msg("updating history title")
285+ log.Debug().Str("url", logging.TruncateURL(historyURL, logURLMaxLen)).Str("title", title).Msg("updating history title")
286286
287- entry, err := uc.historyRepo.FindByURL(ctx, url)
287+ entry, err := uc.historyRepo.FindByURL(ctx, historyURL)
288288 if err != nil {
289289 return fmt.Errorf("failed to find history entry: %w", err)
290290 }
@@ -293,7 +293,7 @@ func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, url, title st
293293 // Entry doesn't exist - don't create it here
294294 // Initial navigation should have already created the entry
295295 // This can happen if URL changed during page load (SPA, redirect)
296- log.Debug().Str("url", url).Msg("no history entry found for URL, skipping title update")
296+ log.Debug().Str("url", historyURL).Msg("no history entry found for URL, skipping title update")
297297 return nil
298298 }
299299
@@ -324,14 +324,6 @@ func (uc *NavigateUseCase) Stop(ctx context.Context, webview port.WebView) error
324324 return webview.Stop(ctx)
325325 }
326326
327-// normalizeURLForHistory normalizes a URL for history storage.
328-// Strips trailing slash to avoid duplicates like github.com vs github.com/
329-func normalizeURLForHistory(raw string) string {
330- return canonicalizeURLForHistory(raw, historyCanonicalizationOptions{
331- StripTrackingParams: false,
332- })
333-}
334-
335327 func canonicalizeURLForHistory(raw string, opts historyCanonicalizationOptions) string {
336328 raw = strings.TrimSpace(raw)
337329 if raw == "" {
@@ -412,7 +404,7 @@ func isHashOnlyTransition(previous, current string) bool {
412404 return prevParsed.Fragment != currParsed.Fragment
413405 }
414406
415-func max(a, b int) int {
407+func maxInt(a, b int) int {
416408 if a > b {
417409 return a
418410 }
@@ -147,10 +147,10 @@ func (uc *NavigateUseCase) RecordHistory(ctx context.Context, paneID, rawURL str
147 return147 return
148 }148 }
149 149
150- url := canonicalizeURLForHistory(rawURL, historyCanonicalizationOptions{150+ canonicalURL := canonicalizeURLForHistory(rawURL, historyCanonicalizationOptions{
151 StripTrackingParams: stripTrackingParamsForHistoryDedup,151 StripTrackingParams: stripTrackingParamsForHistoryDedup,
152 })152 })
153- if url == "" {153+ if canonicalURL == "" {
154 return154 return
155 }155 }
156 156
@@ -172,7 +172,7 @@ func (uc *NavigateUseCase) RecordHistory(ctx context.Context, paneID, rawURL str
172 }172 }
173 173
174 // Per-pane short-window deduplication by canonical URL.174 // Per-pane short-window deduplication by canonical URL.
175- if state.lastCanonicalURL == url && now.Sub(state.lastRecordedAt) < historyDeduplicationWindow {175+ if state.lastCanonicalURL == canonicalURL && now.Sub(state.lastRecordedAt) < historyDeduplicationWindow {
176 state.lastRawURL = rawURL176 state.lastRawURL = rawURL
177 uc.recentVisits[paneID] = state177 uc.recentVisits[paneID] = state
178 uc.recentMu.Unlock()178 uc.recentMu.Unlock()
@@ -180,17 +180,17 @@ func (uc *NavigateUseCase) RecordHistory(ctx context.Context, paneID, rawURL str
180 }180 }
181 181
182 state.lastRawURL = rawURL182 state.lastRawURL = rawURL
183- state.lastCanonicalURL = url183+ state.lastCanonicalURL = canonicalURL
184 state.lastRecordedAt = now184 state.lastRecordedAt = now
185 uc.recentVisits[paneID] = state185 uc.recentVisits[paneID] = state
186 uc.recentMu.Unlock()186 uc.recentMu.Unlock()
187 187
188 // Non-blocking send to async queue188 // Non-blocking send to async queue
189 select {189 select {
190- case uc.historyQueue <- historyRecord{url: url, visits: 1}:190+ case uc.historyQueue <- historyRecord{url: canonicalURL, visits: 1}:
191 default:191 default:
192 // Queue full - log warning but don't block navigation192 // Queue full - log warning but don't block navigation
193- log.Warn().Str("url", logging.TruncateURL(url, logURLMaxLen)).Msg("history queue full, dropping record")193+ log.Warn().Str("url", logging.TruncateURL(canonicalURL, logURLMaxLen)).Msg("history queue full, dropping record")
194 }194 }
195 }195 }
196 196
@@ -258,7 +258,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
258 }258 }
259 259
260 if existing != nil {260 if existing != nil {
261- for i := 0; i < max(1, record.visits); i++ {261+ for i := 0; i < maxInt(1, record.visits); i++ {
262 if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil {262 if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil {
263 log.Warn().Err(err).Str("url", record.url).Msg("failed to increment visit count")263 log.Warn().Err(err).Str("url", record.url).Msg("failed to increment visit count")
264 return264 return
@@ -267,7 +267,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
267 } else {267 } else {
268 // Create new entry268 // Create new entry
269 entry := entity.NewHistoryEntry(record.url, "")269 entry := entity.NewHistoryEntry(record.url, "")
270- entry.VisitCount = int64(max(1, record.visits))270+ entry.VisitCount = int64(maxInt(1, record.visits))
271 if err := uc.historyRepo.Save(ctx, entry); err != nil {271 if err := uc.historyRepo.Save(ctx, entry); err != nil {
272 log.Warn().Err(err).Str("url", record.url).Msg("failed to save history")272 log.Warn().Err(err).Str("url", record.url).Msg("failed to save history")
273 }273 }
@@ -275,16 +275,16 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
275 }275 }
276 276
277 // UpdateHistoryTitle updates the title of a history entry after page load.277 // UpdateHistoryTitle updates the title of a history entry after page load.
278-func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, url, title string) error {278+func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, historyURL, title string) error {
279 log := logging.FromContext(ctx)279 log := logging.FromContext(ctx)
280 280
281 // Canonicalize URL the same way history records are persisted.281 // Canonicalize URL the same way history records are persisted.
282- url = canonicalizeURLForHistory(url, historyCanonicalizationOptions{282+ historyURL = canonicalizeURLForHistory(historyURL, historyCanonicalizationOptions{
283 StripTrackingParams: stripTrackingParamsForHistoryDedup,283 StripTrackingParams: stripTrackingParamsForHistoryDedup,
284 })284 })
285- log.Debug().Str("url", logging.TruncateURL(url, logURLMaxLen)).Str("title", title).Msg("updating history title")285+ log.Debug().Str("url", logging.TruncateURL(historyURL, logURLMaxLen)).Str("title", title).Msg("updating history title")
286 286
287- entry, err := uc.historyRepo.FindByURL(ctx, url)287+ entry, err := uc.historyRepo.FindByURL(ctx, historyURL)
288 if err != nil {288 if err != nil {
289 return fmt.Errorf("failed to find history entry: %w", err)289 return fmt.Errorf("failed to find history entry: %w", err)
290 }290 }
@@ -293,7 +293,7 @@ func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, url, title st
293 // Entry doesn't exist - don't create it here293 // Entry doesn't exist - don't create it here
294 // Initial navigation should have already created the entry294 // Initial navigation should have already created the entry
295 // This can happen if URL changed during page load (SPA, redirect)295 // This can happen if URL changed during page load (SPA, redirect)
296- log.Debug().Str("url", url).Msg("no history entry found for URL, skipping title update")296+ log.Debug().Str("url", historyURL).Msg("no history entry found for URL, skipping title update")
297 return nil297 return nil
298 }298 }
299 299
@@ -324,14 +324,6 @@ func (uc *NavigateUseCase) Stop(ctx context.Context, webview port.WebView) error
324 return webview.Stop(ctx)324 return webview.Stop(ctx)
325 }325 }
326 326
327-// normalizeURLForHistory normalizes a URL for history storage.
328-// Strips trailing slash to avoid duplicates like github.com vs github.com/
329-func normalizeURLForHistory(raw string) string {
330- return canonicalizeURLForHistory(raw, historyCanonicalizationOptions{
331- StripTrackingParams: false,
332- })
333-}
334-
335 func canonicalizeURLForHistory(raw string, opts historyCanonicalizationOptions) string {327 func canonicalizeURLForHistory(raw string, opts historyCanonicalizationOptions) string {
336 raw = strings.TrimSpace(raw)328 raw = strings.TrimSpace(raw)
337 if raw == "" {329 if raw == "" {
@@ -412,7 +404,7 @@ func isHashOnlyTransition(previous, current string) bool {
412 return prevParsed.Fragment != currParsed.Fragment404 return prevParsed.Fragment != currParsed.Fragment
413 }405 }
414 406
415-func max(a, b int) int {407+func maxInt(a, b int) int {
416 if a > b {408 if a > b {
417 return a409 return a
418 }410 }
M internal/bootstrap/webkit_stack_test.go +4 -4
@@ -32,13 +32,13 @@ func TestBuildWebKitContextOptions_MapsPrivacyAndMemory(t *testing.T) {
3232
3333 require.NotNil(t, opts.WebProcessMemory)
3434 assert.Equal(t, 1024, opts.WebProcessMemory.MemoryLimitMB)
35- assert.Equal(t, 11.0, opts.WebProcessMemory.PollIntervalSec)
36- assert.Equal(t, 0.25, opts.WebProcessMemory.ConservativeThreshold)
37- assert.Equal(t, 0.5, opts.WebProcessMemory.StrictThreshold)
35+ assert.InDelta(t, 11.0, opts.WebProcessMemory.PollIntervalSec, 1e-9)
36+ assert.InDelta(t, 0.25, opts.WebProcessMemory.ConservativeThreshold, 1e-9)
37+ assert.InDelta(t, 0.5, opts.WebProcessMemory.StrictThreshold, 1e-9)
3838
3939 require.NotNil(t, opts.NetworkProcessMemory)
4040 assert.Equal(t, 512, opts.NetworkProcessMemory.MemoryLimitMB)
41- assert.Equal(t, 13.0, opts.NetworkProcessMemory.PollIntervalSec)
41+ assert.InDelta(t, 13.0, opts.NetworkProcessMemory.PollIntervalSec, 1e-9)
4242 }
4343
4444 func TestBuildWebKitContextOptions_DefaultPrivacyWhenConfigNil(t *testing.T) {
@@ -32,13 +32,13 @@ func TestBuildWebKitContextOptions_MapsPrivacyAndMemory(t *testing.T) {
32 32
33 require.NotNil(t, opts.WebProcessMemory)33 require.NotNil(t, opts.WebProcessMemory)
34 assert.Equal(t, 1024, opts.WebProcessMemory.MemoryLimitMB)34 assert.Equal(t, 1024, opts.WebProcessMemory.MemoryLimitMB)
35- assert.Equal(t, 11.0, opts.WebProcessMemory.PollIntervalSec)35+ assert.InDelta(t, 11.0, opts.WebProcessMemory.PollIntervalSec, 1e-9)
36- assert.Equal(t, 0.25, opts.WebProcessMemory.ConservativeThreshold)36+ assert.InDelta(t, 0.25, opts.WebProcessMemory.ConservativeThreshold, 1e-9)
37- assert.Equal(t, 0.5, opts.WebProcessMemory.StrictThreshold)37+ assert.InDelta(t, 0.5, opts.WebProcessMemory.StrictThreshold, 1e-9)
38 38
39 require.NotNil(t, opts.NetworkProcessMemory)39 require.NotNil(t, opts.NetworkProcessMemory)
40 assert.Equal(t, 512, opts.NetworkProcessMemory.MemoryLimitMB)40 assert.Equal(t, 512, opts.NetworkProcessMemory.MemoryLimitMB)
41- assert.Equal(t, 13.0, opts.NetworkProcessMemory.PollIntervalSec)41+ assert.InDelta(t, 13.0, opts.NetworkProcessMemory.PollIntervalSec, 1e-9)
42 }42 }
43 43
44 func TestBuildWebKitContextOptions_DefaultPrivacyWhenConfigNil(t *testing.T) {44 func TestBuildWebKitContextOptions_DefaultPrivacyWhenConfigNil(t *testing.T) {
M internal/infrastructure/config/loader.go +16 -3
@@ -177,6 +177,16 @@ func ensureDatabasePath(config *Config) error {
177177 }
178178
179179 func normalizeConfig(config *Config) {
180+ normalizeRendering(config)
181+ normalizePrivacy(config)
182+ normalizeAppearance(config)
183+ normalizeMedia(config)
184+
185+ config.Runtime.Prefix = strings.TrimSpace(config.Runtime.Prefix)
186+ normalizePerformanceProfile(config)
187+}
188+
189+func normalizeRendering(config *Config) {
180190 switch strings.ToLower(string(config.Rendering.Mode)) {
181191 case "", string(RenderingModeAuto):
182192 config.Rendering.Mode = RenderingModeAuto
@@ -200,7 +210,9 @@ func normalizeConfig(config *Config) {
200210 default:
201211 config.Rendering.GSKRenderer = GSKRendererAuto
202212 }
213+}
203214
215+func normalizePrivacy(config *Config) {
204216 switch strings.ToLower(string(config.Privacy.CookiePolicy)) {
205217 case "", string(CookiePolicyNoThirdParty):
206218 config.Privacy.CookiePolicy = CookiePolicyNoThirdParty
@@ -211,7 +223,9 @@ func normalizeConfig(config *Config) {
211223 default:
212224 config.Privacy.CookiePolicy = CookiePolicyNoThirdParty
213225 }
226+}
214227
228+func normalizeAppearance(config *Config) {
215229 switch config.Appearance.ColorScheme {
216230 case ThemePreferDark, ThemePreferLight, ThemeDefault:
217231 case "":
@@ -219,7 +233,9 @@ func normalizeConfig(config *Config) {
219233 default:
220234 config.Appearance.ColorScheme = ThemeDefault
221235 }
236+}
222237
238+func normalizeMedia(config *Config) {
223239 switch strings.ToLower(string(config.Media.HardwareDecodingMode)) {
224240 case "", string(HardwareDecodingAuto):
225241 config.Media.HardwareDecodingMode = HardwareDecodingAuto
@@ -243,9 +259,6 @@ func normalizeConfig(config *Config) {
243259 default:
244260 config.Media.GLRenderingMode = GLRenderingModeAuto
245261 }
246-
247- config.Runtime.Prefix = strings.TrimSpace(config.Runtime.Prefix)
248- normalizePerformanceProfile(config)
249262 }
250263
251264 func normalizePerformanceProfile(config *Config) {
@@ -177,6 +177,16 @@ func ensureDatabasePath(config *Config) error {
177 }177 }
178 178
179 func normalizeConfig(config *Config) {179 func normalizeConfig(config *Config) {
180+ normalizeRendering(config)
181+ normalizePrivacy(config)
182+ normalizeAppearance(config)
183+ normalizeMedia(config)
184+
185+ config.Runtime.Prefix = strings.TrimSpace(config.Runtime.Prefix)
186+ normalizePerformanceProfile(config)
187+}
188+
189+func normalizeRendering(config *Config) {
180 switch strings.ToLower(string(config.Rendering.Mode)) {190 switch strings.ToLower(string(config.Rendering.Mode)) {
181 case "", string(RenderingModeAuto):191 case "", string(RenderingModeAuto):
182 config.Rendering.Mode = RenderingModeAuto192 config.Rendering.Mode = RenderingModeAuto
@@ -200,7 +210,9 @@ func normalizeConfig(config *Config) {
200 default:210 default:
201 config.Rendering.GSKRenderer = GSKRendererAuto211 config.Rendering.GSKRenderer = GSKRendererAuto
202 }212 }
213+}
203 214
215+func normalizePrivacy(config *Config) {
204 switch strings.ToLower(string(config.Privacy.CookiePolicy)) {216 switch strings.ToLower(string(config.Privacy.CookiePolicy)) {
205 case "", string(CookiePolicyNoThirdParty):217 case "", string(CookiePolicyNoThirdParty):
206 config.Privacy.CookiePolicy = CookiePolicyNoThirdParty218 config.Privacy.CookiePolicy = CookiePolicyNoThirdParty
@@ -211,7 +223,9 @@ func normalizeConfig(config *Config) {
211 default:223 default:
212 config.Privacy.CookiePolicy = CookiePolicyNoThirdParty224 config.Privacy.CookiePolicy = CookiePolicyNoThirdParty
213 }225 }
226+}
214 227
228+func normalizeAppearance(config *Config) {
215 switch config.Appearance.ColorScheme {229 switch config.Appearance.ColorScheme {
216 case ThemePreferDark, ThemePreferLight, ThemeDefault:230 case ThemePreferDark, ThemePreferLight, ThemeDefault:
217 case "":231 case "":
@@ -219,7 +233,9 @@ func normalizeConfig(config *Config) {
219 default:233 default:
220 config.Appearance.ColorScheme = ThemeDefault234 config.Appearance.ColorScheme = ThemeDefault
221 }235 }
236+}
222 237
238+func normalizeMedia(config *Config) {
223 switch strings.ToLower(string(config.Media.HardwareDecodingMode)) {239 switch strings.ToLower(string(config.Media.HardwareDecodingMode)) {
224 case "", string(HardwareDecodingAuto):240 case "", string(HardwareDecodingAuto):
225 config.Media.HardwareDecodingMode = HardwareDecodingAuto241 config.Media.HardwareDecodingMode = HardwareDecodingAuto
@@ -243,9 +259,6 @@ func normalizeConfig(config *Config) {
243 default:259 default:
244 config.Media.GLRenderingMode = GLRenderingModeAuto260 config.Media.GLRenderingMode = GLRenderingModeAuto
245 }261 }
246-
247- config.Runtime.Prefix = strings.TrimSpace(config.Runtime.Prefix)
248- normalizePerformanceProfile(config)
249 }262 }
250 263
251 func normalizePerformanceProfile(config *Config) {264 func normalizePerformanceProfile(config *Config) {
M internal/infrastructure/filtering/manager.go +112 -101
@@ -141,115 +141,125 @@ func (m *Manager) LoadAsync(ctx context.Context) {
141141 return
142142 }
143143
144- go func() {
145- log := logging.FromContext(ctx).With().
146- Str("component", "filter-manager").
147- Logger()
148-
149- // If already loaded, skip
150- m.filterMu.RLock()
151- if m.filter != nil {
152- m.filterMu.RUnlock()
153- log.Debug().Msg("filters already loaded, skipping async load")
154- return
155- }
156- m.filterMu.RUnlock()
157-
158- // Try to load from cache first (fast path)
159- m.setStatus(FilterStatus{State: StateLoading, Message: "Loading filters..."})
160- if m.store.HasCompiledFilter(ctx, FilterIdentifier) {
161- log.Debug().Msg("found compiled filter, loading from cache")
162- filter, err := m.store.Load(ctx, FilterIdentifier)
163- if err == nil && filter != nil {
164- m.filterMu.Lock()
165- m.filter = filter
166- m.filterMu.Unlock()
167-
168- version := m.getCachedVersion()
169- m.setStatus(FilterStatus{
170- State: StateActive,
171- Message: "Filters active",
172- Version: version,
173- })
174- log.Info().Str("version", version).Msg("filters loaded from cache")
175-
176- m.checkStaleCacheAndUpdate(ctx)
177- return
178- }
179- log.Warn().Err(err).Msg("failed to load cached filter, will download")
180- }
144+ go m.loadAsyncWorker(ctx)
145+}
181146
182- // Download filters
183- m.setStatus(FilterStatus{State: StateLoading, Message: "Downloading filters..."})
184- paths, err := m.downloader.DownloadFilters(ctx, func(p DownloadProgress) {
185- msg := fmt.Sprintf("Downloading filters (%d/%d)...", p.Current, p.Total)
186- m.setStatus(FilterStatus{State: StateLoading, Message: msg})
187- })
188- if err != nil {
189- log.Error().Err(err).Msg("failed to download filters")
190- m.setStatus(FilterStatus{State: StateError, Message: "Download failed"})
191- return
192- }
147+func (m *Manager) loadAsyncWorker(ctx context.Context) {
148+ log := logging.FromContext(ctx).With().
149+ Str("component", "filter-manager").
150+ Logger()
193151
194- if err := m.validateDownloadedFiles(ctx, paths); err != nil {
195- log.Error().Err(err).Msg("downloaded filter validation failed")
196- m.setStatus(FilterStatus{State: StateError, Message: "Invalid downloaded filters"})
197- return
198- }
152+ if m.hasActiveFilter() {
153+ log.Debug().Msg("filters already loaded, skipping async load")
154+ return
155+ }
199156
200- m.setStatus(FilterStatus{State: StateLoading, Message: "Compiling filters..."})
157+ if m.loadFromCache(ctx) {
158+ m.checkStaleCacheAndUpdate(ctx)
159+ return
160+ }
201161
202- // Merge JSON files into one for compilation
203- mergedPath, err := m.mergeJSONFiles(ctx, paths)
204- if err != nil {
205- log.Error().Err(err).Msg("failed to merge filter files")
206- m.setStatus(FilterStatus{State: StateError, Message: "Filter merge failed"})
207- return
208- }
162+ m.downloadCompileAndActivate(ctx)
163+}
209164
210- // Compile the merged filter
211- filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath)
212- if err != nil {
213- log.Error().Err(err).Msg("failed to compile filters")
214- if m.hasActiveFilter() {
215- version := m.getCachedVersion()
216- m.setStatus(FilterStatus{
217- State: StateActive,
218- Message: "Filters active (update skipped)",
219- Version: version,
220- })
221- return
222- }
223- m.setStatus(FilterStatus{State: StateError, Message: "Compilation failed"})
224- return
225- }
226- if filter == nil {
227- log.Error().Msg("filter compilation returned nil filter")
228- if m.hasActiveFilter() {
229- version := m.getCachedVersion()
230- m.setStatus(FilterStatus{
231- State: StateActive,
232- Message: "Filters active (update skipped)",
233- Version: version,
234- })
235- return
236- }
237- m.setStatus(FilterStatus{State: StateError, Message: "Compilation failed"})
238- return
239- }
165+func (m *Manager) loadFromCache(ctx context.Context) bool {
166+ log := logging.FromContext(ctx).With().
167+ Str("component", "filter-manager").
168+ Logger()
169+
170+ m.setStatus(FilterStatus{State: StateLoading, Message: "Loading filters..."})
171+ if !m.store.HasCompiledFilter(ctx, FilterIdentifier) {
172+ return false
173+ }
174+
175+ log.Debug().Msg("found compiled filter, loading from cache")
176+ filter, err := m.store.Load(ctx, FilterIdentifier)
177+ if err != nil || filter == nil {
178+ log.Warn().Err(err).Msg("failed to load cached filter, will download")
179+ return false
180+ }
181+
182+ version := m.setActiveFilter(filter, "Filters active")
183+ log.Info().Str("version", version).Msg("filters loaded from cache")
184+ return true
185+}
186+
187+func (m *Manager) downloadCompileAndActivate(ctx context.Context) {
188+ log := logging.FromContext(ctx).With().
189+ Str("component", "filter-manager").
190+ Logger()
191+
192+ paths, err := m.downloadFiltersWithProgress(ctx)
193+ if err != nil {
194+ log.Error().Err(err).Msg("failed to download filters")
195+ m.setStatus(FilterStatus{State: StateError, Message: "Download failed"})
196+ return
197+ }
198+
199+ validateErr := m.validateDownloadedFiles(ctx, paths)
200+ if validateErr != nil {
201+ log.Error().Err(validateErr).Msg("downloaded filter validation failed")
202+ m.setStatus(FilterStatus{State: StateError, Message: "Invalid downloaded filters"})
203+ return
204+ }
205+
206+ m.setStatus(FilterStatus{State: StateLoading, Message: "Compiling filters..."})
207+ mergedPath, err := m.mergeJSONFiles(ctx, paths)
208+ if err != nil {
209+ log.Error().Err(err).Msg("failed to merge filter files")
210+ m.setStatus(FilterStatus{State: StateError, Message: "Filter merge failed"})
211+ return
212+ }
213+
214+ filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath)
215+ if err != nil {
216+ log.Error().Err(err).Msg("failed to compile filters")
217+ m.handleCompilationFailure()
218+ return
219+ }
220+ if filter == nil {
221+ log.Error().Msg("filter compilation returned nil filter")
222+ m.handleCompilationFailure()
223+ return
224+ }
240225
241- m.filterMu.Lock()
242- m.filter = filter
243- m.filterMu.Unlock()
226+ version := m.setActiveFilter(filter, "Filters active")
227+ log.Info().Str("version", version).Msg("filters compiled and active")
228+}
244229
230+func (m *Manager) downloadFiltersWithProgress(ctx context.Context) ([]string, error) {
231+ m.setStatus(FilterStatus{State: StateLoading, Message: "Downloading filters..."})
232+ return m.downloader.DownloadFilters(ctx, func(p DownloadProgress) {
233+ msg := fmt.Sprintf("Downloading filters (%d/%d)...", p.Current, p.Total)
234+ m.setStatus(FilterStatus{State: StateLoading, Message: msg})
235+ })
236+}
237+
238+func (m *Manager) handleCompilationFailure() {
239+ if m.hasActiveFilter() {
245240 version := m.getCachedVersion()
246241 m.setStatus(FilterStatus{
247242 State: StateActive,
248- Message: "Filters active",
243+ Message: "Filters active (update skipped)",
249244 Version: version,
250245 })
251- log.Info().Str("version", version).Msg("filters compiled and active")
252- }()
246+ return
247+ }
248+ m.setStatus(FilterStatus{State: StateError, Message: "Compilation failed"})
249+}
250+
251+func (m *Manager) setActiveFilter(filter *webkit.UserContentFilter, message string) string {
252+ m.filterMu.Lock()
253+ m.filter = filter
254+ m.filterMu.Unlock()
255+
256+ version := m.getCachedVersion()
257+ m.setStatus(FilterStatus{
258+ State: StateActive,
259+ Message: message,
260+ Version: version,
261+ })
262+ return version
253263 }
254264
255265 // mergeJSONFiles combines multiple JSON rule files into one.
@@ -375,8 +385,9 @@ func (m *Manager) CheckForUpdates(ctx context.Context) error {
375385 if err != nil {
376386 return fmt.Errorf("failed to download filter update: %w", err)
377387 }
378- if err := m.validateDownloadedFiles(ctx, paths); err != nil {
379- log.Warn().Err(err).Msg("invalid downloaded filters, keeping existing active filter")
388+ validateErr := m.validateDownloadedFiles(ctx, paths)
389+ if validateErr != nil {
390+ log.Warn().Err(validateErr).Msg("invalid downloaded filters, keeping existing active filter")
380391 return nil
381392 }
382393
@@ -422,7 +433,7 @@ func (m *Manager) validateDownloadedFiles(ctx context.Context, paths []string) e
422433 for _, path := range paths {
423434 if err := validateRuleFile(path); err != nil {
424435 if quarantineErr := m.quarantineInvalidFile(ctx, path, "rule_validation_failed"); quarantineErr != nil {
425- return fmt.Errorf("invalid filter file %s (%v), quarantine failed: %w", path, err, quarantineErr)
436+ return fmt.Errorf("invalid filter file %s: %w (quarantine failed: %s)", path, err, quarantineErr.Error())
426437 }
427438 return fmt.Errorf("invalid filter file %s: %w", path, err)
428439 }
@@ -467,7 +478,7 @@ func validateRuleFile(path string) error {
467478 return nil
468479 }
469480
470-func (m *Manager) quarantineInvalidFile(ctx context.Context, srcPath string, cause string) error {
481+func (m *Manager) quarantineInvalidFile(ctx context.Context, srcPath, cause string) error {
471482 log := logging.FromContext(ctx).With().
472483 Str("component", "filter-manager").
473484 Logger()
@@ -141,115 +141,125 @@ func (m *Manager) LoadAsync(ctx context.Context) {
141 return141 return
142 }142 }
143 143
144- go func() {144+ go m.loadAsyncWorker(ctx)
145- log := logging.FromContext(ctx).With().145+}
146- Str("component", "filter-manager").
147- Logger()
148-
149- // If already loaded, skip
150- m.filterMu.RLock()
151- if m.filter != nil {
152- m.filterMu.RUnlock()
153- log.Debug().Msg("filters already loaded, skipping async load")
154- return
155- }
156- m.filterMu.RUnlock()
157-
158- // Try to load from cache first (fast path)
159- m.setStatus(FilterStatus{State: StateLoading, Message: "Loading filters..."})
160- if m.store.HasCompiledFilter(ctx, FilterIdentifier) {
161- log.Debug().Msg("found compiled filter, loading from cache")
162- filter, err := m.store.Load(ctx, FilterIdentifier)
163- if err == nil && filter != nil {
164- m.filterMu.Lock()
165- m.filter = filter
166- m.filterMu.Unlock()
167-
168- version := m.getCachedVersion()
169- m.setStatus(FilterStatus{
170- State: StateActive,
171- Message: "Filters active",
172- Version: version,
173- })
174- log.Info().Str("version", version).Msg("filters loaded from cache")
175-
176- m.checkStaleCacheAndUpdate(ctx)
177- return
178- }
179- log.Warn().Err(err).Msg("failed to load cached filter, will download")
180- }
181 146
182- // Download filters147+func (m *Manager) loadAsyncWorker(ctx context.Context) {
183- m.setStatus(FilterStatus{State: StateLoading, Message: "Downloading filters..."})148+ log := logging.FromContext(ctx).With().
184- paths, err := m.downloader.DownloadFilters(ctx, func(p DownloadProgress) {149+ Str("component", "filter-manager").
185- msg := fmt.Sprintf("Downloading filters (%d/%d)...", p.Current, p.Total)150+ Logger()
186- m.setStatus(FilterStatus{State: StateLoading, Message: msg})
187- })
188- if err != nil {
189- log.Error().Err(err).Msg("failed to download filters")
190- m.setStatus(FilterStatus{State: StateError, Message: "Download failed"})
191- return
192- }
193 151
194- if err := m.validateDownloadedFiles(ctx, paths); err != nil {152+ if m.hasActiveFilter() {
195- log.Error().Err(err).Msg("downloaded filter validation failed")153+ log.Debug().Msg("filters already loaded, skipping async load")
196- m.setStatus(FilterStatus{State: StateError, Message: "Invalid downloaded filters"})154+ return
197- return155+ }
198- }
199 156
200- m.setStatus(FilterStatus{State: StateLoading, Message: "Compiling filters..."})157+ if m.loadFromCache(ctx) {
158+ m.checkStaleCacheAndUpdate(ctx)
159+ return
160+ }
201 161
202- // Merge JSON files into one for compilation162+ m.downloadCompileAndActivate(ctx)
203- mergedPath, err := m.mergeJSONFiles(ctx, paths)163+}
204- if err != nil {
205- log.Error().Err(err).Msg("failed to merge filter files")
206- m.setStatus(FilterStatus{State: StateError, Message: "Filter merge failed"})
207- return
208- }
209 164
210- // Compile the merged filter165+func (m *Manager) loadFromCache(ctx context.Context) bool {
211- filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath)166+ log := logging.FromContext(ctx).With().
212- if err != nil {167+ Str("component", "filter-manager").
213- log.Error().Err(err).Msg("failed to compile filters")168+ Logger()
214- if m.hasActiveFilter() {169+
215- version := m.getCachedVersion()170+ m.setStatus(FilterStatus{State: StateLoading, Message: "Loading filters..."})
216- m.setStatus(FilterStatus{171+ if !m.store.HasCompiledFilter(ctx, FilterIdentifier) {
217- State: StateActive,172+ return false
218- Message: "Filters active (update skipped)",173+ }
219- Version: version,174+
220- })175+ log.Debug().Msg("found compiled filter, loading from cache")
221- return176+ filter, err := m.store.Load(ctx, FilterIdentifier)
222- }177+ if err != nil || filter == nil {
223- m.setStatus(FilterStatus{State: StateError, Message: "Compilation failed"})178+ log.Warn().Err(err).Msg("failed to load cached filter, will download")
224- return179+ return false
225- }180+ }
226- if filter == nil {181+
227- log.Error().Msg("filter compilation returned nil filter")182+ version := m.setActiveFilter(filter, "Filters active")
228- if m.hasActiveFilter() {183+ log.Info().Str("version", version).Msg("filters loaded from cache")
229- version := m.getCachedVersion()184+ return true
230- m.setStatus(FilterStatus{185+}
231- State: StateActive,186+
232- Message: "Filters active (update skipped)",187+func (m *Manager) downloadCompileAndActivate(ctx context.Context) {
233- Version: version,188+ log := logging.FromContext(ctx).With().
234- })189+ Str("component", "filter-manager").
235- return190+ Logger()
236- }191+
237- m.setStatus(FilterStatus{State: StateError, Message: "Compilation failed"})192+ paths, err := m.downloadFiltersWithProgress(ctx)
238- return193+ if err != nil {
239- }194+ log.Error().Err(err).Msg("failed to download filters")
195+ m.setStatus(FilterStatus{State: StateError, Message: "Download failed"})
196+ return
197+ }
198+
199+ validateErr := m.validateDownloadedFiles(ctx, paths)
200+ if validateErr != nil {
201+ log.Error().Err(validateErr).Msg("downloaded filter validation failed")
202+ m.setStatus(FilterStatus{State: StateError, Message: "Invalid downloaded filters"})
203+ return
204+ }
205+
206+ m.setStatus(FilterStatus{State: StateLoading, Message: "Compiling filters..."})
207+ mergedPath, err := m.mergeJSONFiles(ctx, paths)
208+ if err != nil {
209+ log.Error().Err(err).Msg("failed to merge filter files")
210+ m.setStatus(FilterStatus{State: StateError, Message: "Filter merge failed"})
211+ return
212+ }
213+
214+ filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath)
215+ if err != nil {
216+ log.Error().Err(err).Msg("failed to compile filters")
217+ m.handleCompilationFailure()
218+ return
219+ }
220+ if filter == nil {
221+ log.Error().Msg("filter compilation returned nil filter")
222+ m.handleCompilationFailure()
223+ return
224+ }
240 225
241- m.filterMu.Lock()226+ version := m.setActiveFilter(filter, "Filters active")
242- m.filter = filter227+ log.Info().Str("version", version).Msg("filters compiled and active")
243- m.filterMu.Unlock()228+}
244 229
230+func (m *Manager) downloadFiltersWithProgress(ctx context.Context) ([]string, error) {
231+ m.setStatus(FilterStatus{State: StateLoading, Message: "Downloading filters..."})
232+ return m.downloader.DownloadFilters(ctx, func(p DownloadProgress) {
233+ msg := fmt.Sprintf("Downloading filters (%d/%d)...", p.Current, p.Total)
234+ m.setStatus(FilterStatus{State: StateLoading, Message: msg})
235+ })
236+}
237+
238+func (m *Manager) handleCompilationFailure() {
239+ if m.hasActiveFilter() {
245 version := m.getCachedVersion()240 version := m.getCachedVersion()
246 m.setStatus(FilterStatus{241 m.setStatus(FilterStatus{
247 State: StateActive,242 State: StateActive,
248- Message: "Filters active",243+ Message: "Filters active (update skipped)",
249 Version: version,244 Version: version,
250 })245 })
251- log.Info().Str("version", version).Msg("filters compiled and active")246+ return
252- }()247+ }
248+ m.setStatus(FilterStatus{State: StateError, Message: "Compilation failed"})
249+}
250+
251+func (m *Manager) setActiveFilter(filter *webkit.UserContentFilter, message string) string {
252+ m.filterMu.Lock()
253+ m.filter = filter
254+ m.filterMu.Unlock()
255+
256+ version := m.getCachedVersion()
257+ m.setStatus(FilterStatus{
258+ State: StateActive,
259+ Message: message,
260+ Version: version,
261+ })
262+ return version
253 }263 }
254 264
255 // mergeJSONFiles combines multiple JSON rule files into one.265 // mergeJSONFiles combines multiple JSON rule files into one.
@@ -375,8 +385,9 @@ func (m *Manager) CheckForUpdates(ctx context.Context) error {
375 if err != nil {385 if err != nil {
376 return fmt.Errorf("failed to download filter update: %w", err)386 return fmt.Errorf("failed to download filter update: %w", err)
377 }387 }
378- if err := m.validateDownloadedFiles(ctx, paths); err != nil {388+ validateErr := m.validateDownloadedFiles(ctx, paths)
379- log.Warn().Err(err).Msg("invalid downloaded filters, keeping existing active filter")389+ if validateErr != nil {
390+ log.Warn().Err(validateErr).Msg("invalid downloaded filters, keeping existing active filter")
380 return nil391 return nil
381 }392 }
382 393
@@ -422,7 +433,7 @@ func (m *Manager) validateDownloadedFiles(ctx context.Context, paths []string) e
422 for _, path := range paths {433 for _, path := range paths {
423 if err := validateRuleFile(path); err != nil {434 if err := validateRuleFile(path); err != nil {
424 if quarantineErr := m.quarantineInvalidFile(ctx, path, "rule_validation_failed"); quarantineErr != nil {435 if quarantineErr := m.quarantineInvalidFile(ctx, path, "rule_validation_failed"); quarantineErr != nil {
425- return fmt.Errorf("invalid filter file %s (%v), quarantine failed: %w", path, err, quarantineErr)436+ return fmt.Errorf("invalid filter file %s: %w (quarantine failed: %s)", path, err, quarantineErr.Error())
426 }437 }
427 return fmt.Errorf("invalid filter file %s: %w", path, err)438 return fmt.Errorf("invalid filter file %s: %w", path, err)
428 }439 }
@@ -467,7 +478,7 @@ func validateRuleFile(path string) error {
467 return nil478 return nil
468 }479 }
469 480
470-func (m *Manager) quarantineInvalidFile(ctx context.Context, srcPath string, cause string) error {481+func (m *Manager) quarantineInvalidFile(ctx context.Context, srcPath, cause string) error {
471 log := logging.FromContext(ctx).With().482 log := logging.FromContext(ctx).With().
472 Str("component", "filter-manager").483 Str("component", "filter-manager").
473 Logger()484 Logger()
M internal/infrastructure/filtering/manager_test.go +1 -1
@@ -538,7 +538,7 @@ func TestManager_LoadAsync_QuarantinesInvalidDownloadedPayload(t *testing.T) {
538538 assert.Equal(t, "Invalid downloaded filters", mgr.Status().Message)
539539
540540 _, statErr := os.Stat(invalidJSONFile)
541- assert.ErrorIs(t, statErr, os.ErrNotExist)
541+ require.ErrorIs(t, statErr, os.ErrNotExist)
542542
543543 quarantineDir := filepath.Join(jsonDir, "quarantine")
544544 entries, err := os.ReadDir(quarantineDir)
@@ -538,7 +538,7 @@ func TestManager_LoadAsync_QuarantinesInvalidDownloadedPayload(t *testing.T) {
538 assert.Equal(t, "Invalid downloaded filters", mgr.Status().Message)538 assert.Equal(t, "Invalid downloaded filters", mgr.Status().Message)
539 539
540 _, statErr := os.Stat(invalidJSONFile)540 _, statErr := os.Stat(invalidJSONFile)
541- assert.ErrorIs(t, statErr, os.ErrNotExist)541+ require.ErrorIs(t, statErr, os.ErrNotExist)
542 542
543 quarantineDir := filepath.Join(jsonDir, "quarantine")543 quarantineDir := filepath.Join(jsonDir, "quarantine")
544 entries, err := os.ReadDir(quarantineDir)544 entries, err := os.ReadDir(quarantineDir)
M internal/infrastructure/webkit/scheme_handler.go +4 -3
@@ -31,6 +31,7 @@ const (
3131 ErrorPath = "error"
3232 CrashPath = "crash"
3333 IndexHTML = "index.html"
34+ httpGET = "GET"
3435 )
3536
3637 // SchemeRequest represents a request to a custom URI scheme.
@@ -165,7 +166,7 @@ func (h *DumbSchemeHandler) registerDefaults() {
165166
166167 // Crash page (web process termination fallback)
167168 h.RegisterPage("/crash", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
168- if req.Method != "" && req.Method != "GET" {
169+ if req.Method != "" && req.Method != httpGET {
169170 return nil
170171 }
171172 originalURI := crashOriginalURI(req.URI)
@@ -178,7 +179,7 @@ func (h *DumbSchemeHandler) registerDefaults() {
178179
179180 // API: Get current config (used by dumb://config)
180181 h.RegisterPage("/api/config", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
181- if req.Method != "GET" {
182+ if req.Method != httpGET {
182183 return nil
183184 }
184185
@@ -187,7 +188,7 @@ func (h *DumbSchemeHandler) registerDefaults() {
187188
188189 // API: Get default config (used by Reset Defaults in dumb://config)
189190 h.RegisterPage("/api/config/default", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
190- if req.Method != "GET" {
191+ if req.Method != httpGET {
191192 return nil
192193 }
193194
@@ -31,6 +31,7 @@ const (
31 ErrorPath = "error"31 ErrorPath = "error"
32 CrashPath = "crash"32 CrashPath = "crash"
33 IndexHTML = "index.html"33 IndexHTML = "index.html"
34+ httpGET = "GET"
34 )35 )
35 36
36 // SchemeRequest represents a request to a custom URI scheme.37 // SchemeRequest represents a request to a custom URI scheme.
@@ -165,7 +166,7 @@ func (h *DumbSchemeHandler) registerDefaults() {
165 166
166 // Crash page (web process termination fallback)167 // Crash page (web process termination fallback)
167 h.RegisterPage("/crash", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {168 h.RegisterPage("/crash", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
168- if req.Method != "" && req.Method != "GET" {169+ if req.Method != "" && req.Method != httpGET {
169 return nil170 return nil
170 }171 }
171 originalURI := crashOriginalURI(req.URI)172 originalURI := crashOriginalURI(req.URI)
@@ -178,7 +179,7 @@ func (h *DumbSchemeHandler) registerDefaults() {
178 179
179 // API: Get current config (used by dumb://config)180 // API: Get current config (used by dumb://config)
180 h.RegisterPage("/api/config", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {181 h.RegisterPage("/api/config", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
181- if req.Method != "GET" {182+ if req.Method != httpGET {
182 return nil183 return nil
183 }184 }
184 185
@@ -187,7 +188,7 @@ func (h *DumbSchemeHandler) registerDefaults() {
187 188
188 // API: Get default config (used by Reset Defaults in dumb://config)189 // API: Get default config (used by Reset Defaults in dumb://config)
189 h.RegisterPage("/api/config/default", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {190 h.RegisterPage("/api/config/default", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
190- if req.Method != "GET" {191+ if req.Method != httpGET {
191 return nil192 return nil
192 }193 }
193 194
M internal/infrastructure/webkit/scheme_handler_test.go +2 -2
@@ -10,8 +10,8 @@ import (
1010
1111 func TestCrashOriginalURI(t *testing.T) {
1212 assert.Equal(t, "https://example.com/path?q=1", crashOriginalURI("dumb://home/crash?url=https%3A%2F%2Fexample.com%2Fpath%3Fq%3D1"))
13- assert.Equal(t, "", crashOriginalURI("dumb://home/crash"))
14- assert.Equal(t, "", crashOriginalURI("%%%"))
13+ assert.Empty(t, crashOriginalURI("dumb://home/crash"))
14+ assert.Empty(t, crashOriginalURI("%%%"))
1515 }
1616
1717 func TestBuildCrashPageHTMLIncludesReloadTarget(t *testing.T) {
@@ -10,8 +10,8 @@ import (
10 10
11 func TestCrashOriginalURI(t *testing.T) {11 func TestCrashOriginalURI(t *testing.T) {
12 assert.Equal(t, "https://example.com/path?q=1", crashOriginalURI("dumb://home/crash?url=https%3A%2F%2Fexample.com%2Fpath%3Fq%3D1"))12 assert.Equal(t, "https://example.com/path?q=1", crashOriginalURI("dumb://home/crash?url=https%3A%2F%2Fexample.com%2Fpath%3Fq%3D1"))
13- assert.Equal(t, "", crashOriginalURI("dumb://home/crash"))13+ assert.Empty(t, crashOriginalURI("dumb://home/crash"))
14- assert.Equal(t, "", crashOriginalURI("%%%"))14+ assert.Empty(t, crashOriginalURI("%%%"))
15 }15 }
16 16
17 func TestBuildCrashPageHTMLIncludesReloadTarget(t *testing.T) {17 func TestBuildCrashPageHTMLIncludesReloadTarget(t *testing.T) {
M internal/ui/coordinator/content.go +24 -25
@@ -802,9 +802,9 @@ func (c *ContentCoordinator) onTitleChanged(ctx context.Context, paneID entity.P
802802 // Notify history persistence (get URL from WebView)
803803 if c.onTitleUpdated != nil {
804804 if wv := c.webViews[paneID]; wv != nil {
805- url := wv.URI()
806- if url != "" && title != "" {
807- c.onTitleUpdated(ctx, paneID, url, title)
805+ currentURI := wv.URI()
806+ if currentURI != "" && title != "" {
807+ c.onTitleUpdated(ctx, paneID, currentURI, title)
808808 }
809809 }
810810 }
@@ -912,22 +912,22 @@ func (c *ContentCoordinator) FaviconAdapter() *adapter.FaviconAdapter {
912912 // SetNavigationOrigin records the original URL before navigation starts.
913913 // This allows caching favicons under both original and final domains
914914 // when cross-domain redirects occur (e.g., google.fr → google.com).
915-func (c *ContentCoordinator) SetNavigationOrigin(paneID entity.PaneID, url string) {
915+func (c *ContentCoordinator) SetNavigationOrigin(paneID entity.PaneID, uri string) {
916916 c.navOriginMu.Lock()
917- c.navOrigins[paneID] = url
917+ c.navOrigins[paneID] = uri
918918 c.navOriginMu.Unlock()
919919 }
920920
921921 // PreloadCachedFavicon checks the favicon cache and updates the stacked pane
922922 // title bar immediately if a cached favicon exists for the URL.
923923 // This provides instant favicon display without waiting for WebKit.
924-func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID entity.PaneID, url string) {
925- if c.faviconAdapter == nil || url == "" {
924+func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID entity.PaneID, uri string) {
925+ if c.faviconAdapter == nil || uri == "" {
926926 return
927927 }
928928
929929 // Check memory and disk cache (no external fetch)
930- texture := c.faviconAdapter.PreloadFromCache(url)
930+ texture := c.faviconAdapter.PreloadFromCache(uri)
931931 if texture == nil {
932932 return
933933 }
@@ -953,14 +953,14 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
953953 log := logging.FromContext(ctx)
954954 logging.Trace().Mark("load_committed")
955955
956- url := wv.URI()
957- if url == "" {
956+ uri := wv.URI()
957+ if uri == "" {
958958 return
959959 }
960960
961961 // Set appropriate background color based on page type to prevent dark background bleeding.
962962 switch {
963- case strings.HasPrefix(url, "dumb://"):
963+ case strings.HasPrefix(uri, "dumb://"):
964964 // Internal pages: apply themed background
965965 theme, ok := c.getCurrentTheme()
966966 if ok && theme.prefersDark {
@@ -968,7 +968,7 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
968968 } else {
969969 wv.ResetBackgroundToDefault()
970970 }
971- case strings.HasPrefix(url, "about:"):
971+ case strings.HasPrefix(uri, "about:"):
972972 // Keep pool background (no action)
973973 default:
974974 // External pages: white background
@@ -980,7 +980,7 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
980980 // Skip showing if this is about:blank but the pane is loading a different URL
981981 // This prevents the brief flash of about:blank during initial navigation
982982 shouldShow := true
983- if url == aboutBlankURI {
983+ if uri == aboutBlankURI {
984984 // Get the pane's intended URI from the workspace
985985 ws, _ := c.getActiveWS()
986986 if ws != nil {
@@ -1011,15 +1011,15 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
10111011
10121012 c.markPendingReveal(paneID)
10131013 if wv.EstimatedProgress() > 0 {
1014- c.revealIfPending(ctx, paneID, url, "progress-after-commit")
1014+ c.revealIfPending(ctx, paneID, uri, "progress-after-commit")
10151015 }
10161016
10171017 // Update domain model with current URI for session snapshots
1018- c.updatePaneURI(paneID, url)
1018+ c.updatePaneURI(paneID, uri)
10191019
10201020 // Record history - URI is guaranteed to be correct at LoadCommitted
10211021 if c.onHistoryRecord != nil {
1022- c.onHistoryRecord(ctx, paneID, url)
1022+ c.onHistoryRecord(ctx, paneID, uri)
10231023 }
10241024
10251025 // Apply zoom
@@ -1027,7 +1027,7 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
10271027 return
10281028 }
10291029
1030- domain, err := usecase.ExtractDomain(url)
1030+ domain, err := usecase.ExtractDomain(uri)
10311031 if err != nil {
10321032 return
10331033 }
@@ -1058,21 +1058,21 @@ func (c *ContentCoordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID
10581058
10591059 // onSPANavigation records history when URL changes via JavaScript (History API).
10601060 // This handles SPA navigation like YouTube search, where the URL changes without a page load.
1061-func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, url string) {
1061+func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, uri string) {
10621062 // Update domain model with current URI for session snapshots
1063- c.updatePaneURI(paneID, url)
1063+ c.updatePaneURI(paneID, uri)
10641064
10651065 // Record history for SPA navigation
10661066 if c.onHistoryRecord != nil {
1067- c.onHistoryRecord(ctx, paneID, url)
1067+ c.onHistoryRecord(ctx, paneID, uri)
10681068 }
10691069 }
10701070
10711071 // updatePaneURI updates the pane's URI in the domain model.
10721072 // This is called on navigation so that session snapshots capture the current URL.
1073-func (c *ContentCoordinator) updatePaneURI(paneID entity.PaneID, url string) {
1073+func (c *ContentCoordinator) updatePaneURI(paneID entity.PaneID, uri string) {
10741074 if c.onPaneURIUpdated != nil {
1075- c.onPaneURIUpdated(paneID, url)
1075+ c.onPaneURIUpdated(paneID, uri)
10761076 }
10771077 }
10781078
@@ -1148,7 +1148,7 @@ func (c *ContentCoordinator) clearPendingReveal(paneID entity.PaneID) {
11481148 c.revealMu.Unlock()
11491149 }
11501150
1151-func (c *ContentCoordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, url, reason string) {
1151+func (c *ContentCoordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, uri, reason string) {
11521152 c.revealMu.Lock()
11531153 pending := c.pendingReveal[paneID]
11541154 if pending {
@@ -1170,7 +1170,7 @@ func (c *ContentCoordinator) revealIfPending(ctx context.Context, paneID entity.
11701170 logging.FromContext(ctx).
11711171 Debug().
11721172 Str("pane_id", string(paneID)).
1173- Str("url", url).
1173+ Str("url", uri).
11741174 Str("reason", reason).
11751175 Msg("webview revealed")
11761176 }
@@ -1518,7 +1518,6 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
15181518
15191519 // SPA navigation and external scheme handling
15201520 wv.OnURIChanged = func(uri string) {
1521- log := logging.FromContext(ctx)
15221521 if uri == "" {
15231522 return
15241523 }
@@ -802,9 +802,9 @@ func (c *ContentCoordinator) onTitleChanged(ctx context.Context, paneID entity.P
802 // Notify history persistence (get URL from WebView)802 // Notify history persistence (get URL from WebView)
803 if c.onTitleUpdated != nil {803 if c.onTitleUpdated != nil {
804 if wv := c.webViews[paneID]; wv != nil {804 if wv := c.webViews[paneID]; wv != nil {
805- url := wv.URI()805+ currentURI := wv.URI()
806- if url != "" && title != "" {806+ if currentURI != "" && title != "" {
807- c.onTitleUpdated(ctx, paneID, url, title)807+ c.onTitleUpdated(ctx, paneID, currentURI, title)
808 }808 }
809 }809 }
810 }810 }
@@ -912,22 +912,22 @@ func (c *ContentCoordinator) FaviconAdapter() *adapter.FaviconAdapter {
912 // SetNavigationOrigin records the original URL before navigation starts.912 // SetNavigationOrigin records the original URL before navigation starts.
913 // This allows caching favicons under both original and final domains913 // This allows caching favicons under both original and final domains
914 // when cross-domain redirects occur (e.g., google.fr → google.com).914 // when cross-domain redirects occur (e.g., google.fr → google.com).
915-func (c *ContentCoordinator) SetNavigationOrigin(paneID entity.PaneID, url string) {915+func (c *ContentCoordinator) SetNavigationOrigin(paneID entity.PaneID, uri string) {
916 c.navOriginMu.Lock()916 c.navOriginMu.Lock()
917- c.navOrigins[paneID] = url917+ c.navOrigins[paneID] = uri
918 c.navOriginMu.Unlock()918 c.navOriginMu.Unlock()
919 }919 }
920 920
921 // PreloadCachedFavicon checks the favicon cache and updates the stacked pane921 // PreloadCachedFavicon checks the favicon cache and updates the stacked pane
922 // title bar immediately if a cached favicon exists for the URL.922 // title bar immediately if a cached favicon exists for the URL.
923 // This provides instant favicon display without waiting for WebKit.923 // This provides instant favicon display without waiting for WebKit.
924-func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID entity.PaneID, url string) {924+func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID entity.PaneID, uri string) {
925- if c.faviconAdapter == nil || url == "" {925+ if c.faviconAdapter == nil || uri == "" {
926 return926 return
927 }927 }
928 928
929 // Check memory and disk cache (no external fetch)929 // Check memory and disk cache (no external fetch)
930- texture := c.faviconAdapter.PreloadFromCache(url)930+ texture := c.faviconAdapter.PreloadFromCache(uri)
931 if texture == nil {931 if texture == nil {
932 return932 return
933 }933 }
@@ -953,14 +953,14 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
953 log := logging.FromContext(ctx)953 log := logging.FromContext(ctx)
954 logging.Trace().Mark("load_committed")954 logging.Trace().Mark("load_committed")
955 955
956- url := wv.URI()956+ uri := wv.URI()
957- if url == "" {957+ if uri == "" {
958 return958 return
959 }959 }
960 960
961 // Set appropriate background color based on page type to prevent dark background bleeding.961 // Set appropriate background color based on page type to prevent dark background bleeding.
962 switch {962 switch {
963- case strings.HasPrefix(url, "dumb://"):963+ case strings.HasPrefix(uri, "dumb://"):
964 // Internal pages: apply themed background964 // Internal pages: apply themed background
965 theme, ok := c.getCurrentTheme()965 theme, ok := c.getCurrentTheme()
966 if ok && theme.prefersDark {966 if ok && theme.prefersDark {
@@ -968,7 +968,7 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
968 } else {968 } else {
969 wv.ResetBackgroundToDefault()969 wv.ResetBackgroundToDefault()
970 }970 }
971- case strings.HasPrefix(url, "about:"):971+ case strings.HasPrefix(uri, "about:"):
972 // Keep pool background (no action)972 // Keep pool background (no action)
973 default:973 default:
974 // External pages: white background974 // External pages: white background
@@ -980,7 +980,7 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
980 // Skip showing if this is about:blank but the pane is loading a different URL980 // Skip showing if this is about:blank but the pane is loading a different URL
981 // This prevents the brief flash of about:blank during initial navigation981 // This prevents the brief flash of about:blank during initial navigation
982 shouldShow := true982 shouldShow := true
983- if url == aboutBlankURI {983+ if uri == aboutBlankURI {
984 // Get the pane's intended URI from the workspace984 // Get the pane's intended URI from the workspace
985 ws, _ := c.getActiveWS()985 ws, _ := c.getActiveWS()
986 if ws != nil {986 if ws != nil {
@@ -1011,15 +1011,15 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
1011 1011
1012 c.markPendingReveal(paneID)1012 c.markPendingReveal(paneID)
1013 if wv.EstimatedProgress() > 0 {1013 if wv.EstimatedProgress() > 0 {
1014- c.revealIfPending(ctx, paneID, url, "progress-after-commit")1014+ c.revealIfPending(ctx, paneID, uri, "progress-after-commit")
1015 }1015 }
1016 1016
1017 // Update domain model with current URI for session snapshots1017 // Update domain model with current URI for session snapshots
1018- c.updatePaneURI(paneID, url)1018+ c.updatePaneURI(paneID, uri)
1019 1019
1020 // Record history - URI is guaranteed to be correct at LoadCommitted1020 // Record history - URI is guaranteed to be correct at LoadCommitted
1021 if c.onHistoryRecord != nil {1021 if c.onHistoryRecord != nil {
1022- c.onHistoryRecord(ctx, paneID, url)1022+ c.onHistoryRecord(ctx, paneID, uri)
1023 }1023 }
1024 1024
1025 // Apply zoom1025 // Apply zoom
@@ -1027,7 +1027,7 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
1027 return1027 return
1028 }1028 }
1029 1029
1030- domain, err := usecase.ExtractDomain(url)1030+ domain, err := usecase.ExtractDomain(uri)
1031 if err != nil {1031 if err != nil {
1032 return1032 return
1033 }1033 }
@@ -1058,21 +1058,21 @@ func (c *ContentCoordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID
1058 1058
1059 // onSPANavigation records history when URL changes via JavaScript (History API).1059 // onSPANavigation records history when URL changes via JavaScript (History API).
1060 // This handles SPA navigation like YouTube search, where the URL changes without a page load.1060 // This handles SPA navigation like YouTube search, where the URL changes without a page load.
1061-func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, url string) {1061+func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, uri string) {
1062 // Update domain model with current URI for session snapshots1062 // Update domain model with current URI for session snapshots
1063- c.updatePaneURI(paneID, url)1063+ c.updatePaneURI(paneID, uri)
1064 1064
1065 // Record history for SPA navigation1065 // Record history for SPA navigation
1066 if c.onHistoryRecord != nil {1066 if c.onHistoryRecord != nil {
1067- c.onHistoryRecord(ctx, paneID, url)1067+ c.onHistoryRecord(ctx, paneID, uri)
1068 }1068 }
1069 }1069 }
1070 1070
1071 // updatePaneURI updates the pane's URI in the domain model.1071 // updatePaneURI updates the pane's URI in the domain model.
1072 // This is called on navigation so that session snapshots capture the current URL.1072 // This is called on navigation so that session snapshots capture the current URL.
1073-func (c *ContentCoordinator) updatePaneURI(paneID entity.PaneID, url string) {1073+func (c *ContentCoordinator) updatePaneURI(paneID entity.PaneID, uri string) {
1074 if c.onPaneURIUpdated != nil {1074 if c.onPaneURIUpdated != nil {
1075- c.onPaneURIUpdated(paneID, url)1075+ c.onPaneURIUpdated(paneID, uri)
1076 }1076 }
1077 }1077 }
1078 1078
@@ -1148,7 +1148,7 @@ func (c *ContentCoordinator) clearPendingReveal(paneID entity.PaneID) {
1148 c.revealMu.Unlock()1148 c.revealMu.Unlock()
1149 }1149 }
1150 1150
1151-func (c *ContentCoordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, url, reason string) {1151+func (c *ContentCoordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, uri, reason string) {
1152 c.revealMu.Lock()1152 c.revealMu.Lock()
1153 pending := c.pendingReveal[paneID]1153 pending := c.pendingReveal[paneID]
1154 if pending {1154 if pending {
@@ -1170,7 +1170,7 @@ func (c *ContentCoordinator) revealIfPending(ctx context.Context, paneID entity.
1170 logging.FromContext(ctx).1170 logging.FromContext(ctx).
1171 Debug().1171 Debug().
1172 Str("pane_id", string(paneID)).1172 Str("pane_id", string(paneID)).
1173- Str("url", url).1173+ Str("url", uri).
1174 Str("reason", reason).1174 Str("reason", reason).
1175 Msg("webview revealed")1175 Msg("webview revealed")
1176 }1176 }
@@ -1518,7 +1518,6 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
1518 1518
1519 // SPA navigation and external scheme handling1519 // SPA navigation and external scheme handling
1520 wv.OnURIChanged = func(uri string) {1520 wv.OnURIChanged = func(uri string) {
1521- log := logging.FromContext(ctx)
1522 if uri == "" {1521 if uri == "" {
1523 return1522 return
1524 }1523 }
M internal/ui/coordinator/content_termination_test.go +1 -1
@@ -16,7 +16,7 @@ func TestShouldRenderCrashPage(t *testing.T) {
1616
1717 func TestExtractOriginalURIFromCrashPage(t *testing.T) {
1818 assert.Equal(t, "https://example.com/path?q=1", extractOriginalURIFromCrashPage("dumb://home/crash?url=https%3A%2F%2Fexample.com%2Fpath%3Fq%3D1"))
19- assert.Equal(t, "", extractOriginalURIFromCrashPage("dumb://home/crash"))
19+ assert.Empty(t, extractOriginalURIFromCrashPage("dumb://home/crash"))
2020 assert.Equal(t, "https://example.com", extractOriginalURIFromCrashPage("https://example.com"))
2121 assert.Equal(t, "%%%bad", extractOriginalURIFromCrashPage("%%%bad"))
2222 }
@@ -16,7 +16,7 @@ func TestShouldRenderCrashPage(t *testing.T) {
16 16
17 func TestExtractOriginalURIFromCrashPage(t *testing.T) {17 func TestExtractOriginalURIFromCrashPage(t *testing.T) {
18 assert.Equal(t, "https://example.com/path?q=1", extractOriginalURIFromCrashPage("dumb://home/crash?url=https%3A%2F%2Fexample.com%2Fpath%3Fq%3D1"))18 assert.Equal(t, "https://example.com/path?q=1", extractOriginalURIFromCrashPage("dumb://home/crash?url=https%3A%2F%2Fexample.com%2Fpath%3Fq%3D1"))
19- assert.Equal(t, "", extractOriginalURIFromCrashPage("dumb://home/crash"))19+ assert.Empty(t, extractOriginalURIFromCrashPage("dumb://home/crash"))
20 assert.Equal(t, "https://example.com", extractOriginalURIFromCrashPage("https://example.com"))20 assert.Equal(t, "https://example.com", extractOriginalURIFromCrashPage("https://example.com"))
21 assert.Equal(t, "%%%bad", extractOriginalURIFromCrashPage("%%%bad"))21 assert.Equal(t, "%%%bad", extractOriginalURIFromCrashPage("%%%bad"))
22 }22 }
M internal/ui/coordinator/oauth.go +2 -2
@@ -141,7 +141,7 @@ func IsOAuthError(url string) bool {
141141 return strings.Contains(lower, "error=")
142142 }
143143
144-func composeOnClose(existing func(), next func()) func() {
144+func composeOnClose(existing, next func()) func() {
145145 if existing == nil {
146146 return next
147147 }
@@ -154,7 +154,7 @@ func composeOnClose(existing func(), next func()) func() {
154154 }
155155 }
156156
157-func composeOnURIChanged(existing func(string), next func(string)) func(string) {
157+func composeOnURIChanged(existing, next func(string)) func(string) {
158158 if existing == nil {
159159 return next
160160 }
@@ -141,7 +141,7 @@ func IsOAuthError(url string) bool {
141 return strings.Contains(lower, "error=")141 return strings.Contains(lower, "error=")
142 }142 }
143 143
144-func composeOnClose(existing func(), next func()) func() {144+func composeOnClose(existing, next func()) func() {
145 if existing == nil {145 if existing == nil {
146 return next146 return next
147 }147 }
@@ -154,7 +154,7 @@ func composeOnClose(existing func(), next func()) func() {
154 }154 }
155 }155 }
156 156
157-func composeOnURIChanged(existing func(string), next func(string)) func(string) {157+func composeOnURIChanged(existing, next func(string)) func(string) {
158 if existing == nil {158 if existing == nil {
159 return next159 return next
160 }160 }

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