detached 92431e4

refactor(filtering): update manager to use interfaces instead of concrete types

commit
92431e4d5835d18cdfcec5fd0152c1012e4ab4dd
author
brice · · 2025-12-18 21:27 UTC
committer
brice · · 2025-12-18 19:06 UTC
parent
a373232
signature
unsigned
1 file · +410 -0
A internal/infrastructure/filtering/manager.go +410 -0
@@ -0,0 +1,410 @@
1+package filtering
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "sync"
10+ "sync/atomic"
11+
12+ "github.com/bnema/dumber/internal/logging"
13+ "github.com/bnema/puregotk-webkit/webkit"
14+)
15+
16+// Manager orchestrates the content filter lifecycle.
17+// It handles downloading, compiling, loading, and applying filters to WebViews.
18+type Manager struct {
19+ store FilterStore
20+ downloader FilterDownloader
21+
22+ storeDir string
23+ jsonDir string
24+
25+ filter *webkit.UserContentFilter
26+ filterMu sync.RWMutex
27+ status atomic.Value // FilterStatus
28+ enabled bool
29+ autoUpdate bool
30+
31+ // Callbacks for status updates (e.g., toast notifications)
32+ onStatusChange func(FilterStatus)
33+}
34+
35+// ManagerConfig holds configuration for the filter manager.
36+type ManagerConfig struct {
37+ StoreDir string // Where to store compiled filter bytecode
38+ JSONDir string // Where to cache downloaded JSON files
39+ Enabled bool // Whether filtering is enabled
40+ AutoUpdate bool // Whether to auto-update filters
41+
42+ // Optional: custom implementations for testing
43+ Store FilterStore // If nil, creates default Store
44+ Downloader FilterDownloader // If nil, creates default Downloader
45+}
46+
47+// NewManager creates a new filter Manager.
48+func NewManager(cfg ManagerConfig) (*Manager, error) {
49+ // Ensure directories exist
50+ if err := os.MkdirAll(cfg.StoreDir, 0o755); err != nil {
51+ return nil, fmt.Errorf("failed to create store dir: %w", err)
52+ }
53+ if err := os.MkdirAll(cfg.JSONDir, 0o755); err != nil {
54+ return nil, fmt.Errorf("failed to create json dir: %w", err)
55+ }
56+
57+ // Use provided implementations or create defaults
58+ store := cfg.Store
59+ if store == nil {
60+ store = NewStore(cfg.StoreDir)
61+ if store == nil {
62+ return nil, fmt.Errorf("failed to create filter store at %s", cfg.StoreDir)
63+ }
64+ }
65+
66+ downloader := cfg.Downloader
67+ if downloader == nil {
68+ downloader = NewDownloader(cfg.JSONDir)
69+ }
70+
71+ m := &Manager{
72+ store: store,
73+ downloader: downloader,
74+ storeDir: cfg.StoreDir,
75+ jsonDir: cfg.JSONDir,
76+ enabled: cfg.Enabled,
77+ autoUpdate: cfg.AutoUpdate,
78+ }
79+
80+ m.setStatus(FilterStatus{State: StateUninitialized})
81+ return m, nil
82+}
83+
84+// SetStatusCallback sets a callback for status changes (e.g., for toast notifications).
85+func (m *Manager) SetStatusCallback(cb func(FilterStatus)) {
86+ m.onStatusChange = cb
87+}
88+
89+// setStatus updates the current status and notifies callback.
90+func (m *Manager) setStatus(status FilterStatus) {
91+ m.status.Store(status)
92+ if m.onStatusChange != nil {
93+ m.onStatusChange(status)
94+ }
95+}
96+
97+// Status returns the current filter status.
98+func (m *Manager) Status() FilterStatus {
99+ if s, ok := m.status.Load().(FilterStatus); ok {
100+ return s
101+ }
102+ return FilterStatus{State: StateUninitialized}
103+}
104+
105+// Initialize performs fast initialization.
106+// This is called BEFORE GTK starts, so we cannot use GIO async operations.
107+// Just sets up state - actual filter loading happens in LoadAsync after GTK starts.
108+func (m *Manager) Initialize(ctx context.Context) error {
109+ log := logging.FromContext(ctx).With().
110+ Str("component", "filter-manager").
111+ Logger()
112+
113+ if !m.enabled {
114+ log.Info().Msg("content filtering disabled")
115+ m.setStatus(FilterStatus{State: StateDisabled})
116+ return nil
117+ }
118+
119+ log.Debug().Msg("filter manager initialized (filters will load after GTK starts)")
120+ m.setStatus(FilterStatus{State: StateUninitialized, Message: "Filters pending"})
121+ return nil
122+}
123+
124+// LoadAsync loads or downloads filters in the background.
125+// Call this after the browser window is shown (GTK main loop running).
126+// First tries to load from cache, then downloads if needed.
127+func (m *Manager) LoadAsync(ctx context.Context) {
128+ if !m.enabled {
129+ return
130+ }
131+
132+ go func() {
133+ log := logging.FromContext(ctx).With().
134+ Str("component", "filter-manager").
135+ Logger()
136+
137+ // If already loaded, skip
138+ m.filterMu.RLock()
139+ if m.filter != nil {
140+ m.filterMu.RUnlock()
141+ log.Debug().Msg("filters already loaded, skipping async load")
142+ return
143+ }
144+ m.filterMu.RUnlock()
145+
146+ // Try to load from cache first (fast path)
147+ m.setStatus(FilterStatus{State: StateLoading, Message: "Loading filters..."})
148+ if m.store.HasCompiledFilter(ctx, FilterIdentifier) {
149+ log.Debug().Msg("found compiled filter, loading from cache")
150+ filter, err := m.store.Load(ctx, FilterIdentifier)
151+ if err == nil && filter != nil {
152+ m.filterMu.Lock()
153+ m.filter = filter
154+ m.filterMu.Unlock()
155+
156+ version := m.getCachedVersion()
157+ m.setStatus(FilterStatus{
158+ State: StateActive,
159+ Message: "Filters active",
160+ Version: version,
161+ })
162+ log.Info().Str("version", version).Msg("filters loaded from cache")
163+ return
164+ }
165+ log.Warn().Err(err).Msg("failed to load cached filter, will download")
166+ }
167+
168+ // Download filters
169+ m.setStatus(FilterStatus{State: StateLoading, Message: "Downloading filters..."})
170+ paths, err := m.downloader.DownloadFilters(ctx, func(p DownloadProgress) {
171+ msg := fmt.Sprintf("Downloading filters (%d/%d)...", p.Current, p.Total)
172+ m.setStatus(FilterStatus{State: StateLoading, Message: msg})
173+ })
174+ if err != nil {
175+ log.Error().Err(err).Msg("failed to download filters")
176+ m.setStatus(FilterStatus{State: StateError, Message: "Download failed"})
177+ return
178+ }
179+
180+ m.setStatus(FilterStatus{State: StateLoading, Message: "Compiling filters..."})
181+
182+ // Merge JSON files into one for compilation
183+ mergedPath, err := m.mergeJSONFiles(ctx, paths)
184+ if err != nil {
185+ log.Error().Err(err).Msg("failed to merge filter files")
186+ m.setStatus(FilterStatus{State: StateError, Message: "Filter merge failed"})
187+ return
188+ }
189+
190+ // Compile the merged filter
191+ filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath)
192+ if err != nil {
193+ log.Error().Err(err).Msg("failed to compile filters")
194+ m.setStatus(FilterStatus{State: StateError, Message: "Compilation failed"})
195+ return
196+ }
197+
198+ m.filterMu.Lock()
199+ m.filter = filter
200+ m.filterMu.Unlock()
201+
202+ version := m.getCachedVersion()
203+ m.setStatus(FilterStatus{
204+ State: StateActive,
205+ Message: "Filters active",
206+ Version: version,
207+ })
208+ log.Info().Str("version", version).Msg("filters compiled and active")
209+ }()
210+}
211+
212+// mergeJSONFiles combines multiple JSON rule files into one.
213+// WebKit's UserContentFilterStore expects a single JSON array.
214+func (m *Manager) mergeJSONFiles(ctx context.Context, paths []string) (string, error) {
215+ log := logging.FromContext(ctx).With().
216+ Str("component", "filter-manager").
217+ Logger()
218+
219+ var allRules []json.RawMessage
220+
221+ for _, path := range paths {
222+ log.Debug().Str("path", path).Msg("reading filter file")
223+
224+ data, err := os.ReadFile(path)
225+ if err != nil {
226+ return "", fmt.Errorf("failed to read %s: %w", path, err)
227+ }
228+
229+ var rules []json.RawMessage
230+ if err := json.Unmarshal(data, &rules); err != nil {
231+ return "", fmt.Errorf("failed to parse %s: %w", path, err)
232+ }
233+
234+ allRules = append(allRules, rules...)
235+ }
236+
237+ log.Debug().Int("total_rules", len(allRules)).Msg("merging filter rules")
238+
239+ merged, err := json.Marshal(allRules)
240+ if err != nil {
241+ return "", fmt.Errorf("failed to marshal merged rules: %w", err)
242+ }
243+
244+ // Write merged file
245+ mergedPath := filepath.Join(m.jsonDir, "merged.json")
246+ if err := os.WriteFile(mergedPath, merged, 0o644); err != nil {
247+ return "", fmt.Errorf("failed to write merged file: %w", err)
248+ }
249+
250+ return mergedPath, nil
251+}
252+
253+// getCachedVersion returns the version from the cached manifest.
254+func (m *Manager) getCachedVersion() string {
255+ manifest, err := m.downloader.GetCachedManifest()
256+ if err != nil || manifest == nil {
257+ return "unknown"
258+ }
259+ return manifest.Version
260+}
261+
262+// ApplyTo adds the active filter to a WebView's UserContentManager.
263+// Safe to call even if filters are not yet loaded (no-op in that case).
264+func (m *Manager) ApplyTo(ctx context.Context, ucm *webkit.UserContentManager) {
265+ log := logging.FromContext(ctx).With().
266+ Str("component", "filter-manager").
267+ Logger()
268+
269+ if ucm == nil {
270+ return
271+ }
272+
273+ m.filterMu.RLock()
274+ filter := m.filter
275+ m.filterMu.RUnlock()
276+
277+ if filter != nil {
278+ ucm.AddFilter(filter)
279+ log.Debug().Msg("content filter applied to webview")
280+ } else {
281+ log.Debug().Msg("no filter available to apply (filters still loading?)")
282+ }
283+}
284+
285+// GetFilter returns the currently loaded filter, or nil if none.
286+func (m *Manager) GetFilter() *webkit.UserContentFilter {
287+ m.filterMu.RLock()
288+ defer m.filterMu.RUnlock()
289+ return m.filter
290+}
291+
292+// CheckForUpdates checks if newer filters are available and downloads them.
293+// This should be called periodically in the background.
294+func (m *Manager) CheckForUpdates(ctx context.Context) error {
295+ if !m.enabled || !m.autoUpdate {
296+ return nil
297+ }
298+
299+ log := logging.FromContext(ctx).With().
300+ Str("component", "filter-manager").
301+ Logger()
302+
303+ needsUpdate, err := m.downloader.NeedsUpdate(ctx)
304+ if err != nil {
305+ log.Warn().Err(err).Msg("failed to check for filter updates")
306+ return err
307+ }
308+
309+ if !needsUpdate {
310+ log.Debug().Msg("filters are up to date")
311+ return nil
312+ }
313+
314+ log.Info().Msg("filter update available, downloading")
315+
316+ // Download new filters
317+ paths, err := m.downloader.DownloadFilters(ctx, nil)
318+ if err != nil {
319+ return fmt.Errorf("failed to download filter update: %w", err)
320+ }
321+
322+ // Merge and compile
323+ mergedPath, err := m.mergeJSONFiles(ctx, paths)
324+ if err != nil {
325+ return fmt.Errorf("failed to merge updated filters: %w", err)
326+ }
327+
328+ filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath)
329+ if err != nil {
330+ return fmt.Errorf("failed to compile updated filters: %w", err)
331+ }
332+
333+ m.filterMu.Lock()
334+ m.filter = filter
335+ m.filterMu.Unlock()
336+
337+ version := m.getCachedVersion()
338+ m.setStatus(FilterStatus{
339+ State: StateActive,
340+ Message: fmt.Sprintf("Filters updated to %s", version),
341+ Version: version,
342+ })
343+ log.Info().Str("version", version).Msg("filters updated successfully")
344+
345+ return nil
346+}
347+
348+// Enable enables content filtering.
349+func (m *Manager) Enable(ctx context.Context) {
350+ log := logging.FromContext(ctx).With().
351+ Str("component", "filter-manager").
352+ Logger()
353+
354+ m.enabled = true
355+ log.Info().Msg("content filtering enabled")
356+
357+ // Try to load filters if not already loaded
358+ m.filterMu.RLock()
359+ hasFilter := m.filter != nil
360+ m.filterMu.RUnlock()
361+
362+ if !hasFilter {
363+ m.LoadAsync(ctx)
364+ } else {
365+ m.setStatus(FilterStatus{
366+ State: StateActive,
367+ Message: "Filters active",
368+ Version: m.getCachedVersion(),
369+ })
370+ }
371+}
372+
373+// Disable disables content filtering.
374+func (m *Manager) Disable(ctx context.Context) {
375+ log := logging.FromContext(ctx).With().
376+ Str("component", "filter-manager").
377+ Logger()
378+
379+ m.enabled = false
380+ m.setStatus(FilterStatus{State: StateDisabled})
381+ log.Info().Msg("content filtering disabled")
382+}
383+
384+// IsEnabled returns whether content filtering is enabled.
385+func (m *Manager) IsEnabled() bool {
386+ return m.enabled
387+}
388+
389+// Clear removes all compiled and cached filters.
390+func (m *Manager) Clear(ctx context.Context) error {
391+ log := logging.FromContext(ctx).With().
392+ Str("component", "filter-manager").
393+ Logger()
394+
395+ m.filterMu.Lock()
396+ m.filter = nil
397+ m.filterMu.Unlock()
398+
399+ if err := m.store.Remove(ctx, FilterIdentifier); err != nil {
400+ log.Warn().Err(err).Msg("failed to remove compiled filter")
401+ }
402+
403+ if err := m.downloader.ClearCache(); err != nil {
404+ log.Warn().Err(err).Msg("failed to clear download cache")
405+ }
406+
407+ m.setStatus(FilterStatus{State: StateUninitialized})
408+ log.Info().Msg("filters cleared")
409+ return nil
410+}
@@ -0,0 +1,410 @@
1+package filtering
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "sync"
10+ "sync/atomic"
11+
12+ "github.com/bnema/dumber/internal/logging"
13+ "github.com/bnema/puregotk-webkit/webkit"
14+)
15+
16+// Manager orchestrates the content filter lifecycle.
17+// It handles downloading, compiling, loading, and applying filters to WebViews.
18+type Manager struct {
19+ store FilterStore
20+ downloader FilterDownloader
21+
22+ storeDir string
23+ jsonDir string
24+
25+ filter *webkit.UserContentFilter
26+ filterMu sync.RWMutex
27+ status atomic.Value // FilterStatus
28+ enabled bool
29+ autoUpdate bool
30+
31+ // Callbacks for status updates (e.g., toast notifications)
32+ onStatusChange func(FilterStatus)
33+}
34+
35+// ManagerConfig holds configuration for the filter manager.
36+type ManagerConfig struct {
37+ StoreDir string // Where to store compiled filter bytecode
38+ JSONDir string // Where to cache downloaded JSON files
39+ Enabled bool // Whether filtering is enabled
40+ AutoUpdate bool // Whether to auto-update filters
41+
42+ // Optional: custom implementations for testing
43+ Store FilterStore // If nil, creates default Store
44+ Downloader FilterDownloader // If nil, creates default Downloader
45+}
46+
47+// NewManager creates a new filter Manager.
48+func NewManager(cfg ManagerConfig) (*Manager, error) {
49+ // Ensure directories exist
50+ if err := os.MkdirAll(cfg.StoreDir, 0o755); err != nil {
51+ return nil, fmt.Errorf("failed to create store dir: %w", err)
52+ }
53+ if err := os.MkdirAll(cfg.JSONDir, 0o755); err != nil {
54+ return nil, fmt.Errorf("failed to create json dir: %w", err)
55+ }
56+
57+ // Use provided implementations or create defaults
58+ store := cfg.Store
59+ if store == nil {
60+ store = NewStore(cfg.StoreDir)
61+ if store == nil {
62+ return nil, fmt.Errorf("failed to create filter store at %s", cfg.StoreDir)
63+ }
64+ }
65+
66+ downloader := cfg.Downloader
67+ if downloader == nil {
68+ downloader = NewDownloader(cfg.JSONDir)
69+ }
70+
71+ m := &Manager{
72+ store: store,
73+ downloader: downloader,
74+ storeDir: cfg.StoreDir,
75+ jsonDir: cfg.JSONDir,
76+ enabled: cfg.Enabled,
77+ autoUpdate: cfg.AutoUpdate,
78+ }
79+
80+ m.setStatus(FilterStatus{State: StateUninitialized})
81+ return m, nil
82+}
83+
84+// SetStatusCallback sets a callback for status changes (e.g., for toast notifications).
85+func (m *Manager) SetStatusCallback(cb func(FilterStatus)) {
86+ m.onStatusChange = cb
87+}
88+
89+// setStatus updates the current status and notifies callback.
90+func (m *Manager) setStatus(status FilterStatus) {
91+ m.status.Store(status)
92+ if m.onStatusChange != nil {
93+ m.onStatusChange(status)
94+ }
95+}
96+
97+// Status returns the current filter status.
98+func (m *Manager) Status() FilterStatus {
99+ if s, ok := m.status.Load().(FilterStatus); ok {
100+ return s
101+ }
102+ return FilterStatus{State: StateUninitialized}
103+}
104+
105+// Initialize performs fast initialization.
106+// This is called BEFORE GTK starts, so we cannot use GIO async operations.
107+// Just sets up state - actual filter loading happens in LoadAsync after GTK starts.
108+func (m *Manager) Initialize(ctx context.Context) error {
109+ log := logging.FromContext(ctx).With().
110+ Str("component", "filter-manager").
111+ Logger()
112+
113+ if !m.enabled {
114+ log.Info().Msg("content filtering disabled")
115+ m.setStatus(FilterStatus{State: StateDisabled})
116+ return nil
117+ }
118+
119+ log.Debug().Msg("filter manager initialized (filters will load after GTK starts)")
120+ m.setStatus(FilterStatus{State: StateUninitialized, Message: "Filters pending"})
121+ return nil
122+}
123+
124+// LoadAsync loads or downloads filters in the background.
125+// Call this after the browser window is shown (GTK main loop running).
126+// First tries to load from cache, then downloads if needed.
127+func (m *Manager) LoadAsync(ctx context.Context) {
128+ if !m.enabled {
129+ return
130+ }
131+
132+ go func() {
133+ log := logging.FromContext(ctx).With().
134+ Str("component", "filter-manager").
135+ Logger()
136+
137+ // If already loaded, skip
138+ m.filterMu.RLock()
139+ if m.filter != nil {
140+ m.filterMu.RUnlock()
141+ log.Debug().Msg("filters already loaded, skipping async load")
142+ return
143+ }
144+ m.filterMu.RUnlock()
145+
146+ // Try to load from cache first (fast path)
147+ m.setStatus(FilterStatus{State: StateLoading, Message: "Loading filters..."})
148+ if m.store.HasCompiledFilter(ctx, FilterIdentifier) {
149+ log.Debug().Msg("found compiled filter, loading from cache")
150+ filter, err := m.store.Load(ctx, FilterIdentifier)
151+ if err == nil && filter != nil {
152+ m.filterMu.Lock()
153+ m.filter = filter
154+ m.filterMu.Unlock()
155+
156+ version := m.getCachedVersion()
157+ m.setStatus(FilterStatus{
158+ State: StateActive,
159+ Message: "Filters active",
160+ Version: version,
161+ })
162+ log.Info().Str("version", version).Msg("filters loaded from cache")
163+ return
164+ }
165+ log.Warn().Err(err).Msg("failed to load cached filter, will download")
166+ }
167+
168+ // Download filters
169+ m.setStatus(FilterStatus{State: StateLoading, Message: "Downloading filters..."})
170+ paths, err := m.downloader.DownloadFilters(ctx, func(p DownloadProgress) {
171+ msg := fmt.Sprintf("Downloading filters (%d/%d)...", p.Current, p.Total)
172+ m.setStatus(FilterStatus{State: StateLoading, Message: msg})
173+ })
174+ if err != nil {
175+ log.Error().Err(err).Msg("failed to download filters")
176+ m.setStatus(FilterStatus{State: StateError, Message: "Download failed"})
177+ return
178+ }
179+
180+ m.setStatus(FilterStatus{State: StateLoading, Message: "Compiling filters..."})
181+
182+ // Merge JSON files into one for compilation
183+ mergedPath, err := m.mergeJSONFiles(ctx, paths)
184+ if err != nil {
185+ log.Error().Err(err).Msg("failed to merge filter files")
186+ m.setStatus(FilterStatus{State: StateError, Message: "Filter merge failed"})
187+ return
188+ }
189+
190+ // Compile the merged filter
191+ filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath)
192+ if err != nil {
193+ log.Error().Err(err).Msg("failed to compile filters")
194+ m.setStatus(FilterStatus{State: StateError, Message: "Compilation failed"})
195+ return
196+ }
197+
198+ m.filterMu.Lock()
199+ m.filter = filter
200+ m.filterMu.Unlock()
201+
202+ version := m.getCachedVersion()
203+ m.setStatus(FilterStatus{
204+ State: StateActive,
205+ Message: "Filters active",
206+ Version: version,
207+ })
208+ log.Info().Str("version", version).Msg("filters compiled and active")
209+ }()
210+}
211+
212+// mergeJSONFiles combines multiple JSON rule files into one.
213+// WebKit's UserContentFilterStore expects a single JSON array.
214+func (m *Manager) mergeJSONFiles(ctx context.Context, paths []string) (string, error) {
215+ log := logging.FromContext(ctx).With().
216+ Str("component", "filter-manager").
217+ Logger()
218+
219+ var allRules []json.RawMessage
220+
221+ for _, path := range paths {
222+ log.Debug().Str("path", path).Msg("reading filter file")
223+
224+ data, err := os.ReadFile(path)
225+ if err != nil {
226+ return "", fmt.Errorf("failed to read %s: %w", path, err)
227+ }
228+
229+ var rules []json.RawMessage
230+ if err := json.Unmarshal(data, &rules); err != nil {
231+ return "", fmt.Errorf("failed to parse %s: %w", path, err)
232+ }
233+
234+ allRules = append(allRules, rules...)
235+ }
236+
237+ log.Debug().Int("total_rules", len(allRules)).Msg("merging filter rules")
238+
239+ merged, err := json.Marshal(allRules)
240+ if err != nil {
241+ return "", fmt.Errorf("failed to marshal merged rules: %w", err)
242+ }
243+
244+ // Write merged file
245+ mergedPath := filepath.Join(m.jsonDir, "merged.json")
246+ if err := os.WriteFile(mergedPath, merged, 0o644); err != nil {
247+ return "", fmt.Errorf("failed to write merged file: %w", err)
248+ }
249+
250+ return mergedPath, nil
251+}
252+
253+// getCachedVersion returns the version from the cached manifest.
254+func (m *Manager) getCachedVersion() string {
255+ manifest, err := m.downloader.GetCachedManifest()
256+ if err != nil || manifest == nil {
257+ return "unknown"
258+ }
259+ return manifest.Version
260+}
261+
262+// ApplyTo adds the active filter to a WebView's UserContentManager.
263+// Safe to call even if filters are not yet loaded (no-op in that case).
264+func (m *Manager) ApplyTo(ctx context.Context, ucm *webkit.UserContentManager) {
265+ log := logging.FromContext(ctx).With().
266+ Str("component", "filter-manager").
267+ Logger()
268+
269+ if ucm == nil {
270+ return
271+ }
272+
273+ m.filterMu.RLock()
274+ filter := m.filter
275+ m.filterMu.RUnlock()
276+
277+ if filter != nil {
278+ ucm.AddFilter(filter)
279+ log.Debug().Msg("content filter applied to webview")
280+ } else {
281+ log.Debug().Msg("no filter available to apply (filters still loading?)")
282+ }
283+}
284+
285+// GetFilter returns the currently loaded filter, or nil if none.
286+func (m *Manager) GetFilter() *webkit.UserContentFilter {
287+ m.filterMu.RLock()
288+ defer m.filterMu.RUnlock()
289+ return m.filter
290+}
291+
292+// CheckForUpdates checks if newer filters are available and downloads them.
293+// This should be called periodically in the background.
294+func (m *Manager) CheckForUpdates(ctx context.Context) error {
295+ if !m.enabled || !m.autoUpdate {
296+ return nil
297+ }
298+
299+ log := logging.FromContext(ctx).With().
300+ Str("component", "filter-manager").
301+ Logger()
302+
303+ needsUpdate, err := m.downloader.NeedsUpdate(ctx)
304+ if err != nil {
305+ log.Warn().Err(err).Msg("failed to check for filter updates")
306+ return err
307+ }
308+
309+ if !needsUpdate {
310+ log.Debug().Msg("filters are up to date")
311+ return nil
312+ }
313+
314+ log.Info().Msg("filter update available, downloading")
315+
316+ // Download new filters
317+ paths, err := m.downloader.DownloadFilters(ctx, nil)
318+ if err != nil {
319+ return fmt.Errorf("failed to download filter update: %w", err)
320+ }
321+
322+ // Merge and compile
323+ mergedPath, err := m.mergeJSONFiles(ctx, paths)
324+ if err != nil {
325+ return fmt.Errorf("failed to merge updated filters: %w", err)
326+ }
327+
328+ filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath)
329+ if err != nil {
330+ return fmt.Errorf("failed to compile updated filters: %w", err)
331+ }
332+
333+ m.filterMu.Lock()
334+ m.filter = filter
335+ m.filterMu.Unlock()
336+
337+ version := m.getCachedVersion()
338+ m.setStatus(FilterStatus{
339+ State: StateActive,
340+ Message: fmt.Sprintf("Filters updated to %s", version),
341+ Version: version,
342+ })
343+ log.Info().Str("version", version).Msg("filters updated successfully")
344+
345+ return nil
346+}
347+
348+// Enable enables content filtering.
349+func (m *Manager) Enable(ctx context.Context) {
350+ log := logging.FromContext(ctx).With().
351+ Str("component", "filter-manager").
352+ Logger()
353+
354+ m.enabled = true
355+ log.Info().Msg("content filtering enabled")
356+
357+ // Try to load filters if not already loaded
358+ m.filterMu.RLock()
359+ hasFilter := m.filter != nil
360+ m.filterMu.RUnlock()
361+
362+ if !hasFilter {
363+ m.LoadAsync(ctx)
364+ } else {
365+ m.setStatus(FilterStatus{
366+ State: StateActive,
367+ Message: "Filters active",
368+ Version: m.getCachedVersion(),
369+ })
370+ }
371+}
372+
373+// Disable disables content filtering.
374+func (m *Manager) Disable(ctx context.Context) {
375+ log := logging.FromContext(ctx).With().
376+ Str("component", "filter-manager").
377+ Logger()
378+
379+ m.enabled = false
380+ m.setStatus(FilterStatus{State: StateDisabled})
381+ log.Info().Msg("content filtering disabled")
382+}
383+
384+// IsEnabled returns whether content filtering is enabled.
385+func (m *Manager) IsEnabled() bool {
386+ return m.enabled
387+}
388+
389+// Clear removes all compiled and cached filters.
390+func (m *Manager) Clear(ctx context.Context) error {
391+ log := logging.FromContext(ctx).With().
392+ Str("component", "filter-manager").
393+ Logger()
394+
395+ m.filterMu.Lock()
396+ m.filter = nil
397+ m.filterMu.Unlock()
398+
399+ if err := m.store.Remove(ctx, FilterIdentifier); err != nil {
400+ log.Warn().Err(err).Msg("failed to remove compiled filter")
401+ }
402+
403+ if err := m.downloader.ClearCache(); err != nil {
404+ log.Warn().Err(err).Msg("failed to clear download cache")
405+ }
406+
407+ m.setStatus(FilterStatus{State: StateUninitialized})
408+ log.Info().Msg("filters cleared")
409+ return nil
410+}

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