// Files changing on disk while they are open: noticing it, and re-reading them // when nothing typed in the window would be lost. package app import ( "path/filepath" "sync" "time" "rickub.com/turbo-editors/turbo-core/editor" "rickub.com/turbo-editors/turbo-core/ui" ) // defaultFileWatchInterval is how often the disk is looked at for a change to // a file that is open, while the editor is otherwise idle. // // One second is what a person notices as "straight away" and what a machine // does not notice at all: one stat per open window per second. const defaultFileWatchInterval = time.Second // fileWatch is what the event loop knows about the files on disk behind its // windows, shared with the goroutine that watches them while the loop sleeps. // // The loop is the only thing that may touch a buffer, and it sits blocked in // PollEvent until something happens — so a file rewritten by a formatter, a // generator or a coding agent working in another terminal changed nothing on // screen until the next keystroke, and then only because the tools reload // happened to run. The goroutine here exists solely to wake the loop; the loop // then decides for itself, by stat, what actually changed. That split is the // same one every other state-driven step in tick makes: a wake-up may be // dropped, so it must never be the only thing carrying a fact. type fileWatch struct { mu sync.Mutex // stamps is what the loop last saw of each open file, by canonical path. // Written by the loop, read by the watching goroutine. stamps map[string]fileStamp } // publish hands the goroutine what the loop has just seen on disk. func (w *fileWatch) publish(stamps map[string]fileStamp) { w.mu.Lock() defer w.mu.Unlock() w.stamps = stamps } // changed reports whether any file the loop last saw is now different on // disk. It reads the disk and nothing else, so it is safe from any goroutine. func (w *fileWatch) changed() bool { w.mu.Lock() defer w.mu.Unlock() for path, stamp := range w.stamps { if stampOf(path) != stamp { return true } } return false } // watchFiles starts looking at the disk behind the open windows, and returns // what stops it. Only Run calls it: a test drives the loop by hand and has no // need of a wake-up. // // A dropped wake costs nothing here — the file is still different next time // round, so the goroutine wakes the loop again a second later. func (a *App) watchFiles() (stop func()) { done := make(chan struct{}) go func() { ticker := time.NewTicker(a.fileWatchInterval) defer ticker.Stop() for { select { case <-done: return case <-ticker.C: if a.fileWatch.changed() { a.wake() } } } }() return func() { close(done) } } // stampFile records what a file looks like on disk now, so that a later turn // of the loop compares against this and not against something older. // // It is called wherever the editor itself is the reason the file changed — // opening it, saving it, re-reading it — because a change the editor made is // not one it needs to be told about. func (a *App) stampFile(path string) { if path == "" { return } a.fileStamps[pathKey(path)] = stampOf(path) } // reloadChangedFiles re-reads every open file that something else rewrote. // // It runs at the top of every turn of the event loop, like the other // state-driven work, and costs one stat per open window. A file whose stamp — // size and modification time — is what it was last turn is not read. // // **A buffer with unsaved changes is never reloaded**, whatever happened on // disk. The window says so on the status bar, once per change, and keeps the // user's work; the next save writes the buffer over the file, which is the // only answer the editor is entitled to give. A file that has gone from disk // is left as it is too, silently: it is still what the window shows, and // saving it puts it back. func (a *App) reloadChangedFiles() { open := map[string]bool{} for _, window := range a.desktop.Windows() { view, ok := editorViewOf(window) if !ok || view.Buffer().Path() == "" { continue } path := view.Buffer().Path() key := pathKey(path) open[key] = true stamp := stampOf(path) last, known := a.fileStamps[key] a.fileStamps[key] = stamp if !known || stamp == last { continue } a.fileChangedOnDisk(window, view) } // Stamps outlive their windows otherwise — and a closed window's file // would still wake the loop every time something touched it. for key := range a.fileStamps { if !open[key] { delete(a.fileStamps, key) } } a.fileWatch.publish(copyStamps(a.fileStamps)) } // fileChangedOnDisk is what one window does about its file having changed // under it: re-read it when nothing would be lost, say so when something // would. func (a *App) fileChangedOnDisk(window *ui.Window, view *editor.View) { name := filepath.Base(view.Buffer().Path()) if view.Buffer().Modified() { a.Message(name + " changed on disk; your unsaved changes are kept") return } if a.reloadFromDisk(window, view) { a.Message("Reloaded " + name) } } // reloadFromDisk re-reads one unmodified window's file and brings everything // that depends on its text up to date, reporting whether the text changed. // // It is one function for the two callers — a command from the tools menu // finishing, and a change noticed on disk — so that a step added for one // cannot go missing from the other. Telling the language server is exactly // such a step: a server answering completions from the text the window // showed before a formatter ran is the same stale-copy problem in a different // place. func (a *App) reloadFromDisk(window *ui.Window, view *editor.View) bool { buf := view.Buffer() changed, err := buf.Reload() a.stampFile(buf.Path()) if err != nil || !changed { return false // a file that has gone, or one nothing touched } view.RefreshSyntax() window.SetTitle(windowTitle(buf)) a.language.DidChange(buf.Path(), buf.Text()) return true } // copyStamps returns a map the goroutine can read while the loop goes on // writing its own. func copyStamps(stamps map[string]fileStamp) map[string]fileStamp { out := make(map[string]fileStamp, len(stamps)) for key, stamp := range stamps { out[key] = stamp } return out }