forked from fabriqaai/claude-code-logs
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathwatcher.go
More file actions
344 lines (287 loc) · 9.5 KB
/
watcher.go
File metadata and controls
344 lines (287 loc) · 9.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
)
// WatchConfig configures the file watcher
type WatchConfig struct {
SourceDir string // Claude projects directory
OutputDir string // HTML output directory
PollInterval time.Duration // Interval for scanning new directories
DebounceDelay time.Duration // Delay before regenerating after changes
SelectedProjects []string // Project folder names to watch (nil = all projects)
}
// DefaultWatchConfig returns the default watcher configuration
func DefaultWatchConfig() WatchConfig {
return WatchConfig{
PollInterval: 30 * time.Second,
DebounceDelay: 2 * time.Second,
}
}
// Watcher monitors for file changes and triggers regeneration
type Watcher struct {
config WatchConfig
fsWatcher *fsnotify.Watcher
// Debouncing state
mu sync.Mutex
pendingTimers map[string]*time.Timer // project folder -> debounce timer
// Callback for regeneration
onRegenerate func(projectFolder string) error
}
// NewWatcher creates a new file watcher
func NewWatcher(config WatchConfig) (*Watcher, error) {
fsWatcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("creating fsnotify watcher: %w", err)
}
return &Watcher{
config: config,
fsWatcher: fsWatcher,
pendingTimers: make(map[string]*time.Timer),
}, nil
}
// Close closes the watcher and releases resources
func (w *Watcher) Close() error {
w.mu.Lock()
// Cancel all pending timers
for _, timer := range w.pendingTimers {
timer.Stop()
}
w.pendingTimers = make(map[string]*time.Timer)
w.mu.Unlock()
return w.fsWatcher.Close()
}
// Watch starts watching for changes (blocking until context is cancelled)
func (w *Watcher) Watch(ctx context.Context) error {
// Add watches for source directory and all project subdirectories
if err := w.addWatches(); err != nil {
return fmt.Errorf("adding watches: %w", err)
}
if w.config.SelectedProjects != nil {
fmt.Printf("Watching %d selected projects for changes\n", len(w.config.SelectedProjects))
} else {
fmt.Printf("Watching for changes in %s\n", w.config.SourceDir)
}
logVerbose("Poll interval: %v, Debounce delay: %v", w.config.PollInterval, w.config.DebounceDelay)
// Start directory scanner for new project folders
scanTicker := time.NewTicker(w.config.PollInterval)
defer scanTicker.Stop()
for {
select {
case <-ctx.Done():
fmt.Println("\nShutting down watcher...")
return nil
case event, ok := <-w.fsWatcher.Events:
if !ok {
return nil
}
w.handleEvent(event)
case err, ok := <-w.fsWatcher.Errors:
if !ok {
return nil
}
fmt.Fprintf(os.Stderr, "Watcher error: %v\n", err)
case <-scanTicker.C:
// Periodically scan for new project directories
w.scanForNewDirectories()
}
}
}
// addWatches adds watches for the source directory and all project subdirectories
func (w *Watcher) addWatches() error {
// Watch the root projects directory (for new projects)
if err := w.fsWatcher.Add(w.config.SourceDir); err != nil {
return fmt.Errorf("watching source directory: %w", err)
}
// Watch each project subdirectory
entries, err := os.ReadDir(w.config.SourceDir)
if err != nil {
return fmt.Errorf("reading source directory: %w", err)
}
for _, entry := range entries {
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
// Skip if we have a filter and this project isn't in it
if !w.isProjectSelected(entry.Name()) {
continue
}
projectPath := filepath.Join(w.config.SourceDir, entry.Name())
if err := w.fsWatcher.Add(projectPath); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to watch %s: %v\n", projectPath, err)
continue
}
}
return nil
}
// isProjectSelected returns true if the project should be watched
// Returns true if no filter is set (SelectedProjects is nil) or if the project is in the filter
func (w *Watcher) isProjectSelected(projectFolder string) bool {
if w.config.SelectedProjects == nil {
return true // No filter, watch all
}
for _, p := range w.config.SelectedProjects {
if p == projectFolder {
return true
}
}
return false
}
// scanForNewDirectories checks for new project directories and adds watches
func (w *Watcher) scanForNewDirectories() {
// Skip scanning for new directories if we have a specific project filter
if w.config.SelectedProjects != nil {
return
}
entries, err := os.ReadDir(w.config.SourceDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to scan for new directories: %v\n", err)
return
}
watchList := w.fsWatcher.WatchList()
watchSet := make(map[string]bool)
for _, path := range watchList {
watchSet[path] = true
}
for _, entry := range entries {
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
projectPath := filepath.Join(w.config.SourceDir, entry.Name())
if !watchSet[projectPath] {
if err := w.fsWatcher.Add(projectPath); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to watch new directory %s: %v\n", projectPath, err)
continue
}
fmt.Printf("Now watching new project: %s\n", entry.Name())
// Trigger regeneration for the new project
w.scheduleRegeneration(entry.Name())
}
}
}
// handleEvent processes a single fsnotify event
func (w *Watcher) handleEvent(event fsnotify.Event) {
// Only care about .jsonl files
if !strings.HasSuffix(event.Name, ".jsonl") {
// Check if it's a new directory being created (only if no filter)
if event.Op&fsnotify.Create != 0 && w.config.SelectedProjects == nil {
info, err := os.Stat(event.Name)
if err == nil && info.IsDir() {
// New project directory - add watch
if err := w.fsWatcher.Add(event.Name); err == nil {
projectFolder := filepath.Base(event.Name)
fmt.Printf("Now watching new project: %s\n", projectFolder)
w.scheduleRegeneration(projectFolder)
}
}
}
return
}
// Extract project folder from path
// Path format: /path/to/projects/-Project-Name/session.jsonl
dir := filepath.Dir(event.Name)
projectFolder := filepath.Base(dir)
// Skip if project is not in our selection
if !w.isProjectSelected(projectFolder) {
return
}
// Handle different event types
switch {
case event.Op&fsnotify.Write != 0:
logVerbose("Modified: %s", filepath.Base(event.Name))
w.scheduleRegeneration(projectFolder)
case event.Op&fsnotify.Create != 0:
logVerbose("Created: %s", filepath.Base(event.Name))
w.scheduleRegeneration(projectFolder)
case event.Op&fsnotify.Remove != 0:
logVerbose("Removed: %s", filepath.Base(event.Name))
w.scheduleRegeneration(projectFolder)
case event.Op&fsnotify.Rename != 0:
logVerbose("Renamed: %s", filepath.Base(event.Name))
w.scheduleRegeneration(projectFolder)
}
}
// scheduleRegeneration schedules a regeneration for a project with debouncing
func (w *Watcher) scheduleRegeneration(projectFolder string) {
w.mu.Lock()
defer w.mu.Unlock()
// Cancel existing timer for this project
if timer, exists := w.pendingTimers[projectFolder]; exists {
timer.Stop()
}
// Create new timer
w.pendingTimers[projectFolder] = time.AfterFunc(w.config.DebounceDelay, func() {
w.regenerateProject(projectFolder)
})
}
// regenerateProject regenerates HTML for a single project
func (w *Watcher) regenerateProject(projectFolder string) {
w.mu.Lock()
delete(w.pendingTimers, projectFolder)
w.mu.Unlock()
fmt.Printf("Regenerating: %s\n", DecodeProjectPath(projectFolder))
if w.onRegenerate != nil {
if err := w.onRegenerate(projectFolder); err != nil {
fmt.Fprintf(os.Stderr, "Error regenerating %s: %v\n", projectFolder, err)
}
}
}
// SetRegenerateCallback sets the callback function for regeneration
func (w *Watcher) SetRegenerateCallback(fn func(projectFolder string) error) {
w.onRegenerate = fn
}
// StartWatcher starts watching with default regeneration behavior
func StartWatcher(ctx context.Context, config WatchConfig) error {
watcher, err := NewWatcher(config)
if err != nil {
return err
}
defer watcher.Close()
// Set up regeneration callback
watcher.SetRegenerateCallback(func(projectFolder string) error {
return regenerateProject(config.SourceDir, config.OutputDir, projectFolder)
})
return watcher.Watch(ctx)
}
// regenerateProject reloads a project and regenerates its Markdown files
func regenerateProject(sourceDir, outputDir, projectFolder string) error {
_ = projectFolder // Currently regenerates all projects; mtime check handles efficiency
// Load all projects (needed for index files)
allProjects, err := LoadAllProjects(sourceDir)
if err != nil {
return fmt.Errorf("loading all projects: %w", err)
}
// Generate markdown (with force=false for incremental updates)
result, err := GenerateAllMarkdown(allProjects, outputDir, sourceDir, false)
if err != nil {
return fmt.Errorf("generating markdown: %w", err)
}
fmt.Printf("Regenerated: %d generated, %d skipped\n", result.Generated, result.Skipped)
return nil
}
// WatchInBackground starts the watcher in a background goroutine
// Returns a cancel function to stop the watcher
func WatchInBackground(config WatchConfig) (context.CancelFunc, error) {
watcher, err := NewWatcher(config)
if err != nil {
return nil, err
}
// Set up regeneration callback
watcher.SetRegenerateCallback(func(projectFolder string) error {
return regenerateProject(config.SourceDir, config.OutputDir, projectFolder)
})
ctx, cancel := context.WithCancel(context.Background())
go func() {
if err := watcher.Watch(ctx); err != nil {
fmt.Fprintf(os.Stderr, "Watcher error: %v\n", err)
}
watcher.Close()
}()
return cancel, nil
}