turbo-editors/turbo-corepublic Fork 0
b1c5e36e1d1a12e805a39c391e69028b649e6a87
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

watch.go · 184 lines · 6.1 KBGo Blame HistoryRaw
📦 Turbo Core — files rewritten outside reload into their windows; the agent window wraps what you type and keeps a long paste aside as a token (#28); configrepo fetches a shared .turbo-<slug> from a forge URL (#25) fe23288 k33g 11h ago1// Files changing on disk while they are open: noticing it, and re-reading them
2// when nothing typed in the window would be lost.
3
4package app
5
6import (
7 "path/filepath"
8 "sync"
9 "time"
10
11 "rickub.com/turbo-editors/turbo-core/editor"
12 "rickub.com/turbo-editors/turbo-core/ui"
13)
14
15// defaultFileWatchInterval is how often the disk is looked at for a change to
16// a file that is open, while the editor is otherwise idle.
17//
18// One second is what a person notices as "straight away" and what a machine
19// does not notice at all: one stat per open window per second.
20const defaultFileWatchInterval = time.Second
21
22// fileWatch is what the event loop knows about the files on disk behind its
23// windows, shared with the goroutine that watches them while the loop sleeps.
24//
25// The loop is the only thing that may touch a buffer, and it sits blocked in
26// PollEvent until something happens — so a file rewritten by a formatter, a
27// generator or a coding agent working in another terminal changed nothing on
28// screen until the next keystroke, and then only because the tools reload
29// happened to run. The goroutine here exists solely to wake the loop; the loop
30// then decides for itself, by stat, what actually changed. That split is the
31// same one every other state-driven step in tick makes: a wake-up may be
32// dropped, so it must never be the only thing carrying a fact.
33type fileWatch struct {
34 mu sync.Mutex
35 // stamps is what the loop last saw of each open file, by canonical path.
36 // Written by the loop, read by the watching goroutine.
37 stamps map[string]fileStamp
38}
39
40// publish hands the goroutine what the loop has just seen on disk.
41func (w *fileWatch) publish(stamps map[string]fileStamp) {
42 w.mu.Lock()
43 defer w.mu.Unlock()
44 w.stamps = stamps
45}
46
47// changed reports whether any file the loop last saw is now different on
48// disk. It reads the disk and nothing else, so it is safe from any goroutine.
49func (w *fileWatch) changed() bool {
50 w.mu.Lock()
51 defer w.mu.Unlock()
52 for path, stamp := range w.stamps {
53 if stampOf(path) != stamp {
54 return true
55 }
56 }
57 return false
58}
59
60// watchFiles starts looking at the disk behind the open windows, and returns
61// what stops it. Only Run calls it: a test drives the loop by hand and has no
62// need of a wake-up.
63//
64// A dropped wake costs nothing here — the file is still different next time
65// round, so the goroutine wakes the loop again a second later.
66func (a *App) watchFiles() (stop func()) {
67 done := make(chan struct{})
68 go func() {
69 ticker := time.NewTicker(a.fileWatchInterval)
70 defer ticker.Stop()
71 for {
72 select {
73 case <-done:
74 return
75 case <-ticker.C:
76 if a.fileWatch.changed() {
77 a.wake()
78 }
79 }
80 }
81 }()
82 return func() { close(done) }
83}
84
85// stampFile records what a file looks like on disk now, so that a later turn
86// of the loop compares against this and not against something older.
87//
88// It is called wherever the editor itself is the reason the file changed —
89// opening it, saving it, re-reading it — because a change the editor made is
90// not one it needs to be told about.
91func (a *App) stampFile(path string) {
92 if path == "" {
93 return
94 }
95 a.fileStamps[pathKey(path)] = stampOf(path)
96}
97
98// reloadChangedFiles re-reads every open file that something else rewrote.
99//
100// It runs at the top of every turn of the event loop, like the other
101// state-driven work, and costs one stat per open window. A file whose stamp —
102// size and modification time — is what it was last turn is not read.
103//
104// **A buffer with unsaved changes is never reloaded**, whatever happened on
105// disk. The window says so on the status bar, once per change, and keeps the
106// user's work; the next save writes the buffer over the file, which is the
107// only answer the editor is entitled to give. A file that has gone from disk
108// is left as it is too, silently: it is still what the window shows, and
109// saving it puts it back.
110func (a *App) reloadChangedFiles() {
111 open := map[string]bool{}
112 for _, window := range a.desktop.Windows() {
113 view, ok := editorViewOf(window)
114 if !ok || view.Buffer().Path() == "" {
115 continue
116 }
117 path := view.Buffer().Path()
118 key := pathKey(path)
119 open[key] = true
120
121 stamp := stampOf(path)
122 last, known := a.fileStamps[key]
123 a.fileStamps[key] = stamp
124 if !known || stamp == last {
125 continue
126 }
127 a.fileChangedOnDisk(window, view)
128 }
129
130 // Stamps outlive their windows otherwise — and a closed window's file
131 // would still wake the loop every time something touched it.
132 for key := range a.fileStamps {
133 if !open[key] {
134 delete(a.fileStamps, key)
135 }
136 }
137 a.fileWatch.publish(copyStamps(a.fileStamps))
138}
139
140// fileChangedOnDisk is what one window does about its file having changed
141// under it: re-read it when nothing would be lost, say so when something
142// would.
143func (a *App) fileChangedOnDisk(window *ui.Window, view *editor.View) {
144 name := filepath.Base(view.Buffer().Path())
145 if view.Buffer().Modified() {
146 a.Message(name + " changed on disk; your unsaved changes are kept")
147 return
148 }
149 if a.reloadFromDisk(window, view) {
150 a.Message("Reloaded " + name)
151 }
152}
153
154// reloadFromDisk re-reads one unmodified window's file and brings everything
155// that depends on its text up to date, reporting whether the text changed.
156//
157// It is one function for the two callers — a command from the tools menu
158// finishing, and a change noticed on disk — so that a step added for one
159// cannot go missing from the other. Telling the language server is exactly
160// such a step: a server answering completions from the text the window
161// showed before a formatter ran is the same stale-copy problem in a different
162// place.
163func (a *App) reloadFromDisk(window *ui.Window, view *editor.View) bool {
164 buf := view.Buffer()
165 changed, err := buf.Reload()
166 a.stampFile(buf.Path())
167 if err != nil || !changed {
168 return false // a file that has gone, or one nothing touched
169 }
170 view.RefreshSyntax()
171 window.SetTitle(windowTitle(buf))
172 a.language.DidChange(buf.Path(), buf.Text())
173 return true
174}
175
176// copyStamps returns a map the goroutine can read while the loop goes on
177// writing its own.
178func copyStamps(stamps map[string]fileStamp) map[string]fileStamp {
179 out := make(map[string]fileStamp, len(stamps))
180 for key, stamp := range stamps {
181 out[key] = stamp
182 }
183 return out
184}