v0.32.0 0617278

watcher.go RawHistoryFolder

1package noctalia
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"log/slog"
8	"os"
9	"path/filepath"
10	"sync"
11	"time"
12
13	"github.com/bnema/dumber/internal/domain/entity"
14	"github.com/fsnotify/fsnotify"
15)
16
17const defaultDebounceDelay = 75 * time.Millisecond
18
19// FileWatcher watches one Noctalia external theme file.
20//
21// It watches the file's parent directory so atomic writes and renames are
22// observed, then filters events to the configured file path.
23type FileWatcher struct {
24	mu       sync.Mutex
25	delay    time.Duration
26	watcher  *fsnotify.Watcher
27	path     string
28	stopCtx  context.Context
29	stopFunc context.CancelFunc
30	running  bool
31}
32
33// NewFileWatcher creates a file watcher with the default debounce delay.
34func NewFileWatcher() *FileWatcher {
35	return &FileWatcher{delay: defaultDebounceDelay}
36}
37
38// Start starts watching the configured external theme path.
39//
40// Calling Start with the same enabled path while already running is a no-op.
41// Calling Start with a different enabled path restarts the underlying watcher.
42// Disabled, unsupported, or empty-path config stops any existing watcher.
43func (w *FileWatcher) Start(ctx context.Context, cfg entity.ExternalThemeConfig, onChange func()) error {
44	path, enabled, err := watchPathFromConfig(cfg)
45	if err != nil {
46		if stopErr := w.Stop(); stopErr != nil {
47			return errors.Join(err, fmt.Errorf("stop noctalia theme watcher: %w", stopErr))
48		}
49		return err
50	}
51	if !enabled || path == "" {
52		return w.Stop()
53	}
54	if onChange == nil {
55		return errors.New("noctalia file watcher callback is nil")
56	}
57
58	w.mu.Lock()
59	defer w.mu.Unlock()
60	if w.running && w.path == path {
61		return nil
62	}
63	if stopErr := w.stopLocked(); stopErr != nil {
64		return fmt.Errorf("stop previous noctalia theme watcher: %w", stopErr)
65	}
66
67	parent := filepath.Dir(path)
68	info, statErr := os.Stat(parent)
69	if statErr != nil {
70		return fmt.Errorf("watch noctalia theme parent directory: %w", statErr)
71	} else if !info.IsDir() {
72		return fmt.Errorf("watch noctalia theme parent directory: %s is not a directory", parent)
73	}
74
75	watcher, err := fsnotify.NewWatcher()
76	if err != nil {
77		return fmt.Errorf("create noctalia theme watcher: %w", err)
78	}
79	if err := watcher.Add(parent); err != nil {
80		if closeErr := watcher.Close(); closeErr != nil {
81			return errors.Join(
82				fmt.Errorf("watch noctalia theme parent directory: %w", err),
83				fmt.Errorf("close noctalia theme watcher after add failure: %w", closeErr),
84			)
85		}
86		return fmt.Errorf("watch noctalia theme parent directory: %w", err)
87	}
88
89	runCtx, cancel := context.WithCancel(ctx)
90	w.watcher = watcher
91	w.path = path
92	w.stopCtx = runCtx
93	w.stopFunc = cancel
94	w.running = true
95
96	go w.run(runCtx, watcher, path, onChange)
97	return nil
98}
99
100// Stop stops the current watcher. It is safe to call repeatedly.
101func (w *FileWatcher) Stop() error {
102	w.mu.Lock()
103	defer w.mu.Unlock()
104	return w.stopLocked()
105}
106
107func (w *FileWatcher) stopLocked() error {
108	if !w.running {
109		return nil
110	}
111	if w.stopFunc != nil {
112		w.stopFunc()
113	}
114	err := w.watcher.Close()
115	w.watcher = nil
116	w.path = ""
117	w.stopCtx = nil
118	w.stopFunc = nil
119	w.running = false
120	return err
121}
122
123func (w *FileWatcher) run(ctx context.Context, watcher *fsnotify.Watcher, path string, onChange func()) {
124	var timer *time.Timer
125	var timerC <-chan time.Time
126	defer func() {
127		if timer != nil {
128			timer.Stop()
129		}
130		w.mu.Lock()
131		if w.watcher == watcher {
132			_ = w.stopLocked()
133		}
134		w.mu.Unlock()
135	}()
136
137	for {
138		select {
139		case <-ctx.Done():
140			return
141		case event, ok := <-watcher.Events:
142			if !ok {
143				return
144			}
145			if !isRelevantFileEvent(event, path) {
146				continue
147			}
148			if timer == nil {
149				timer = time.NewTimer(w.delay)
150				timerC = timer.C
151			} else {
152				if !timer.Stop() {
153					select {
154					case <-timer.C:
155					default:
156					}
157				}
158				timer.Reset(w.delay)
159				timerC = timer.C
160			}
161		case err, ok := <-watcher.Errors:
162			if !ok {
163				return
164			}
165			slog.Warn("noctalia theme watcher error", "error", err)
166		case <-timerC:
167			timerC = nil
168			onChange()
169		}
170	}
171}
172
173func watchPathFromConfig(cfg entity.ExternalThemeConfig) (string, bool, error) {
174	provider, format := normalizeProviderAndFormat(cfg.Provider, cfg.Format)
175	enabled := cfg.Enabled && provider == providerName && isSupportedFormat(format)
176	if !enabled {
177		return "", false, nil
178	}
179	path, err := ExpandPath(cfg.Path)
180	if err != nil {
181		return "", false, err
182	}
183	return path, path != "", nil
184}
185
186func isRelevantFileEvent(event fsnotify.Event, path string) bool {
187	if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename|fsnotify.Remove) == 0 {
188		return false
189	}
190	return filepath.Clean(event.Name) == path
191}
192
193func (w *FileWatcher) isRunningForTest() bool {
194	w.mu.Lock()
195	defer w.mu.Unlock()
196	return w.running
197}

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