1package modes
2
3import (
4 "context"
5 "errors"
6 "os"
7 "time"
8
9 "github.com/bnema/zut/packages/tui"
10)
11
12const (
13 themeWatchInterval = 500 * time.Millisecond
14 themeMissingGrace = 2 * time.Second
15)
16
17type themeWatchEvent struct {
18 path string
19 source *tui.ThemeSource
20 err error
21 deleted bool
22}
23
24func (i *Interactive) restartThemeWatch() {
25 if i.themeWatchCancel != nil {
26 i.themeWatchCancel()
27 i.themeWatchCancel = nil
28 }
29 if i.runCtx == nil || i.activeThemeSource == nil {
30 return
31 }
32 ctx, cancel := context.WithCancel(i.runCtx)
33 i.themeWatchCancel = cancel
34 interval := i.cfg.ThemeWatchInterval
35 if interval <= 0 {
36 interval = themeWatchInterval
37 }
38 go watchThemeSource(ctx, i.cfg.ZutHome, i.cfg.EffectiveThemeName, i.activeThemeSource, interval, i.themeWatchEvents)
39}
40
41func (i *Interactive) stopThemeWatch() {
42 if i.themeWatchCancel != nil {
43 i.themeWatchCancel()
44 i.themeWatchCancel = nil
45 }
46}
47
48func watchThemeSource(ctx context.Context, home, preference string, accepted *tui.ThemeSource, interval time.Duration, events chan<- themeWatchEvent) {
49 if accepted == nil || accepted.Path == "" {
50 return
51 }
52 ticker := time.NewTicker(interval)
53 defer ticker.Stop()
54 watchThemeSourceTicks(ctx, home, preference, accepted, ticker.C, events)
55}
56
57// watchThemeSourceTicks is the test seam for polling: tests advance explicit
58// timestamps rather than synchronizing with sleeps.
59func watchThemeSourceTicks(ctx context.Context, home, preference string, accepted *tui.ThemeSource, ticks <-chan time.Time, events chan<- themeWatchEvent) {
60 if accepted == nil || accepted.Path == "" {
61 return
62 }
63 lastDigest := accepted.Digest
64 var missingSince time.Time
65 lastError := ""
66 for {
67 select {
68 case <-ctx.Done():
69 return
70 case now := <-ticks:
71 if _, statErr := os.Stat(accepted.Path); errors.Is(statErr, os.ErrNotExist) {
72 if missingSince.IsZero() {
73 missingSince = now
74 }
75 if now.Sub(missingSince) >= themeMissingGrace {
76 sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, deleted: true})
77 return
78 }
79 continue
80 }
81 // A present-but-invalid revision remains watchable and must restart
82 // the deletion grace period just like a valid replacement does.
83 missingSince = time.Time{}
84 source, err := tui.LoadThemeSource(home, preference)
85 if err == nil && source != nil {
86 lastError = ""
87 if source.Path != accepted.Path || source.Digest == lastDigest {
88 continue
89 }
90 lastDigest = source.Digest
91 sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, source: source})
92 continue
93 }
94 if err != nil && err.Error() != lastError {
95 lastError = err.Error()
96 sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, err: err})
97 }
98 }
99 }
100}
101
102func sendThemeWatchEvent(ctx context.Context, events chan<- themeWatchEvent, event themeWatchEvent) {
103 select {
104 case events <- event:
105 case <-ctx.Done():
106 }
107}