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, nil)
55}
56
57// watchThemeSourceTicks is the test seam for polling: tests advance explicit
58// timestamps and may receive a processed acknowledgement instead of sleeping.
59func watchThemeSourceTicks(ctx context.Context, home, preference string, accepted *tui.ThemeSource, ticks <-chan time.Time, events chan<- themeWatchEvent, processed chan<- struct{}) {
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 stop := false
72 if _, statErr := os.Stat(accepted.Path); errors.Is(statErr, os.ErrNotExist) {
73 if missingSince.IsZero() {
74 missingSince = now
75 }
76 if now.Sub(missingSince) >= themeMissingGrace {
77 sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, deleted: true})
78 stop = true
79 }
80 } else {
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 lastDigest = source.Digest
89 sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, source: source})
90 }
91 } else if err != nil && err.Error() != lastError {
92 lastError = err.Error()
93 sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, err: err})
94 }
95 }
96 acknowledgeThemeWatchTick(ctx, processed)
97 if stop {
98 return
99 }
100 }
101 }
102}
103
104func acknowledgeThemeWatchTick(ctx context.Context, processed chan<- struct{}) {
105 if processed == nil {
106 return
107 }
108 select {
109 case processed <- struct{}{}:
110 case <-ctx.Done():
111 }
112}
113
114func sendThemeWatchEvent(ctx context.Context, events chan<- themeWatchEvent, event themeWatchEvent) {
115 select {
116 case events <- event:
117 case <-ctx.Done():
118 }
119}