| 📦 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 9h ago | 1 | // 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 | |
| 4 | package app |
| 5 | |
| 6 | import ( |
| 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. |
| 20 | const 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. |
| 33 | type 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. |
| 41 | func (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. |
| 49 | func (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. |
| 66 | func (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. |
| 91 | func (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. |
| 110 | func (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. |
| 143 | func (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. |
| 163 | func (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. |
| 178 | func 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 | } |