v0.4.0 6203a22

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

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