v0.7.0 31c7fae

fuzzy_cache.go RawHistoryFolder

1package cache
2
3import (
4	"context"
5	"crypto/md5"
6	"fmt"
7	"os"
8	"path/filepath"
9	"sync"
10	"sync/atomic"
11	"time"
12
13	"github.com/bnema/dumber/internal/logging"
14)
15
16// Cache update constants
17const (
18	updateWaitIntervalMs = 10   // Milliseconds to wait during concurrent cache build
19	dbHashTimeoutSec     = 5    // Seconds timeout for database hash calculation
20	recentHistoryLimit   = 20   // Number of recent entries to check for changes
21	exactMatchBonus      = 0.95 // Score bonus for exact matches
22	dirPerm              = 0755 // Directory permissions
23)
24
25// CacheManager handles the lifecycle of the fuzzy cache with smart invalidation.
26type CacheManager struct {
27	cache      *DmenuFuzzyCache
28	config     *CacheConfig
29	queries    HistoryQuerier
30	mu         sync.RWMutex
31	lastDBHash string // Hash of database content to detect changes
32	updating   int32  // Atomic flag to prevent concurrent updates
33}
34
35// NewCacheManager creates a new cache manager.
36func NewCacheManager(queries HistoryQuerier, config *CacheConfig) *CacheManager {
37	if config == nil {
38		config = DefaultCacheConfig()
39	}
40
41	// Set default cache file path if not specified
42	if config.CacheFile == "" {
43		stateDir, err := config.GetStateDir()
44		if err != nil {
45			// Fallback to temp directory if state dir fails
46			config.CacheFile = filepath.Join(os.TempDir(), "dumber_fuzzy_cache.bin")
47		} else {
48			config.CacheFile = filepath.Join(stateDir, "dmenu_fuzzy_cache.bin")
49		}
50	}
51
52	cm := &CacheManager{
53		cache:   &DmenuFuzzyCache{},
54		config:  config,
55		queries: queries,
56	}
57
58	return cm
59}
60
61// GetCache returns the current cache, loading from filesystem or DB as needed.
62func (cm *CacheManager) GetCache(ctx context.Context) (*DmenuFuzzyCache, error) {
63	cm.mu.RLock()
64
65	// Check if we have a valid cache
66	if cm.cache.entryCount > 0 {
67		defer cm.mu.RUnlock()
68		return cm.cache, nil
69	}
70	cm.mu.RUnlock()
71
72	// Need to load cache
73	return cm.loadOrBuildCache(ctx)
74}
75
76// loadOrBuildCache loads cache from filesystem or builds from database.
77func (cm *CacheManager) loadOrBuildCache(ctx context.Context) (*DmenuFuzzyCache, error) {
78	cm.mu.Lock()
79	defer cm.mu.Unlock()
80
81	// Double-check after acquiring lock
82	if cm.cache.entryCount > 0 {
83		return cm.cache, nil
84	}
85
86	// Try loading from filesystem first
87	if cm.shouldLoadFromFile() {
88		if err := cm.cache.LoadFromBinary(cm.config.CacheFile); err == nil {
89			// Verify cache is still valid
90			if cm.isCacheValid(ctx) {
91				return cm.cache, nil
92			}
93		}
94	}
95
96	// Build fresh cache from database
97	return cm.buildCacheFromDB(ctx)
98}
99
100// shouldLoadFromFile determines if we should try loading from filesystem.
101func (cm *CacheManager) shouldLoadFromFile() bool {
102	if _, err := os.Stat(cm.config.CacheFile); os.IsNotExist(err) {
103		return false
104	}
105
106	// Check if file is valid and not too old
107	if !IsValidCacheFile(cm.config.CacheFile) {
108		return false
109	}
110
111	// Check file age
112	info, err := os.Stat(cm.config.CacheFile)
113	if err != nil {
114		return false
115	}
116
117	return time.Since(info.ModTime()) < cm.config.TTL
118}
119
120// isCacheValid checks if the cache is still valid by comparing DB hash.
121func (cm *CacheManager) isCacheValid(ctx context.Context) bool {
122	currentHash, err := cm.calculateDBHash(ctx)
123	if err != nil {
124		return false
125	}
126
127	return currentHash == cm.lastDBHash
128}
129
130// buildCacheFromDB builds a fresh cache from the database.
131func (cm *CacheManager) buildCacheFromDB(ctx context.Context) (*DmenuFuzzyCache, error) {
132	// Prevent concurrent builds
133	if !atomic.CompareAndSwapInt32(&cm.updating, 0, 1) {
134		// Another goroutine is building, wait for it
135		for atomic.LoadInt32(&cm.updating) == 1 {
136			time.Sleep(updateWaitIntervalMs * time.Millisecond)
137		}
138		return cm.cache, nil
139	}
140	defer atomic.StoreInt32(&cm.updating, 0)
141
142	startTime := time.Now()
143
144	// Get history from database
145	history, err := cm.queries.GetHistory(ctx, int64(cm.config.MaxEntries))
146	if err != nil {
147		return nil, fmt.Errorf("failed to get history: %w", err)
148	}
149
150	// Build new cache
151	newCache := &DmenuFuzzyCache{
152		version:      CacheVersion,
153		lastModified: time.Now().Unix(),
154		entryCount:   uint32(len(history)),
155	}
156
157	// Convert database entries to compact format
158	newCache.entries = make([]CompactEntry, len(history))
159	for i, entry := range history {
160		title := ""
161		if entry.Title.Valid {
162			title = entry.Title.String
163		}
164
165		visitCount := int64(0)
166		if entry.VisitCount.Valid {
167			visitCount = entry.VisitCount.Int64
168		}
169
170		lastVisited := time.Now()
171		if entry.LastVisited.Valid {
172			lastVisited = entry.LastVisited.Time
173		}
174
175		faviconURL := ""
176		if entry.FaviconUrl.Valid {
177			faviconURL = entry.FaviconUrl.String
178		}
179
180		newCache.entries[i] = NewCompactEntry(
181			entry.Url,
182			title,
183			faviconURL,
184			visitCount,
185			lastVisited,
186		)
187	}
188
189	// Build search indices
190	newCache.buildTrigramIndex()
191	newCache.buildPrefixTrie()
192	newCache.buildSortedIndex() // Pre-sort for O(1) getTopEntries
193
194	// Update current cache
195	cm.cache = newCache
196
197	// Update DB hash
198	cm.lastDBHash, _ = cm.calculateDBHash(ctx)
199
200	// Save to filesystem in background
201	go cm.saveToFilesystemAsync()
202
203	// Log cache timing to file only (not stdout) to avoid interfering with dmenu
204	if logger := logging.GetLogger(); logger != nil {
205		logger.WriteFileOnly(logging.LogLevelInfo(), fmt.Sprintf("built in %v with %d entries", time.Since(startTime), len(history)), "CACHE")
206	}
207	return cm.cache, nil
208}
209
210// InvalidateAndRefresh forces cache invalidation and refresh.
211// This should be called when we know the DB has changed (e.g., after adding history).
212func (cm *CacheManager) InvalidateAndRefresh(ctx context.Context) {
213	go func() {
214		cm.mu.Lock()
215		defer cm.mu.Unlock()
216
217		// Clear current cache to force rebuild
218		cm.cache = &DmenuFuzzyCache{}
219		cm.lastDBHash = ""
220
221		// Remove cache file
222		if err := os.Remove(cm.config.CacheFile); err != nil {
223			logging.Warn(fmt.Sprintf("failed to remove cache file %s: %v", cm.config.CacheFile, err))
224		}
225
226		// Build new cache
227		_, err := cm.buildCacheFromDB(ctx)
228		if err != nil {
229			logging.Warn(fmt.Sprintf("failed to refresh cache: %v", err))
230		}
231	}()
232}
233
234// OnApplicationExit should be called when the application is about to exit.
235// This triggers a background cache refresh for the next startup.
236func (cm *CacheManager) OnApplicationExit(ctx context.Context) {
237	// Check if cache needs updating
238	if !cm.isCacheValid(ctx) {
239		// Refresh cache in background for next startup
240		go func() {
241			ctx, cancel := context.WithTimeout(context.Background(), dbHashTimeoutSec*time.Second)
242			defer cancel()
243
244			_, err := cm.buildCacheFromDB(ctx)
245			if err != nil {
246				logging.Warn(fmt.Sprintf("failed to refresh cache on exit: %v", err))
247			}
248		}()
249	}
250}
251
252// saveToFilesystemAsync saves the cache to filesystem in a background goroutine.
253func (cm *CacheManager) saveToFilesystemAsync() {
254	// Create cache directory if it doesn't exist
255	cacheDir := filepath.Dir(cm.config.CacheFile)
256	if err := os.MkdirAll(cacheDir, dirPerm); err != nil {
257		logging.Warn(fmt.Sprintf("failed to create cache directory: %v", err))
258		return
259	}
260
261	// Save to temporary file first, then atomic rename
262	tempFile := cm.config.CacheFile + ".tmp"
263	if err := cm.cache.SaveToBinary(tempFile); err != nil {
264		logging.Warn(fmt.Sprintf("failed to save cache: %v", err))
265		if err := os.Remove(tempFile); err != nil {
266			logging.Warn(fmt.Sprintf("failed to remove temp file %s: %v", tempFile, err))
267		}
268		return
269	}
270
271	// Atomic rename
272	if err := os.Rename(tempFile, cm.config.CacheFile); err != nil {
273		logging.Warn(fmt.Sprintf("failed to rename cache file: %v", err))
274		if err := os.Remove(tempFile); err != nil {
275			logging.Warn(fmt.Sprintf("failed to remove temp file %s: %v", tempFile, err))
276		}
277	}
278}
279
280// calculateDBHash creates a hash of the current database state to detect changes.
281func (cm *CacheManager) calculateDBHash(ctx context.Context) (string, error) {
282	// Get a small sample of recent entries to create a fingerprint
283	recentHistory, err := cm.queries.GetHistory(ctx, recentHistoryLimit) // Just check recent entries
284	if err != nil {
285		return "", err
286	}
287
288	// Create hash from URLs, visit counts, and last visited times
289	hasher := md5.New()
290	for _, entry := range recentHistory {
291		hasher.Write([]byte(entry.Url))
292		if entry.VisitCount.Valid {
293			if _, err := fmt.Fprintf(hasher, "%d", entry.VisitCount.Int64); err != nil {
294				logging.Warn(fmt.Sprintf("failed to write visit count to hasher: %v", err))
295			}
296		}
297		if entry.LastVisited.Valid {
298			hasher.Write([]byte(entry.LastVisited.Time.Format(time.RFC3339)))
299		}
300	}
301
302	return fmt.Sprintf("%x", hasher.Sum(nil)), nil
303}
304
305// Search performs a fuzzy search using the cached data.
306func (cm *CacheManager) Search(ctx context.Context, query string) (*FuzzyResult, error) {
307	cache, err := cm.GetCache(ctx)
308	if err != nil {
309		return nil, err
310	}
311
312	return cache.Search(query, cm.config), nil
313}
314
315// GetTopEntries returns the top entries without a search query.
316func (cm *CacheManager) GetTopEntries(ctx context.Context) (*FuzzyResult, error) {
317	cache, err := cm.GetCache(ctx)
318	if err != nil {
319		return nil, err
320	}
321
322	return cache.getTopEntries(cm.config), nil
323}
324
325// Stats returns cache statistics.
326func (cm *CacheManager) Stats() CacheStats {
327	cm.mu.RLock()
328	defer cm.mu.RUnlock()
329
330	stats := CacheStats{
331		EntryCount:   int(cm.cache.entryCount),
332		TrigramCount: len(cm.cache.trigramIndex),
333		LastModified: time.Unix(cm.cache.lastModified, 0),
334	}
335
336	if info, err := os.Stat(cm.config.CacheFile); err == nil {
337		stats.FileSize = info.Size()
338		stats.FileModTime = info.ModTime()
339	}
340
341	return stats
342}
343
344// CacheStats provides information about the cache state.
345type CacheStats struct {
346	EntryCount   int       // Number of cached entries
347	TrigramCount int       // Number of trigrams in index
348	FileSize     int64     // Size of cache file on disk
349	LastModified time.Time // When cache was last built
350	FileModTime  time.Time // When cache file was last modified
351}
352
353// String returns a string representation of cache stats.
354func (cs CacheStats) String() string {
355	return fmt.Sprintf("Entries: %d, Trigrams: %d, File: %d bytes, Modified: %v",
356		cs.EntryCount, cs.TrigramCount, cs.FileSize, cs.FileModTime.Format("15:04:05"))
357}

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