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
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
// 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
}