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
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
|
package app
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gdamore/tcell/v2"
)
// newWatchedFile returns an editor with a file open in the front window, and
// that file's path. The contents written later are deliberately of a different
// length: on a filesystem that stamps modification times by the second, two
// writes in the same second are told apart by size or not at all.
func newWatchedFile(t *testing.T) (*App, tcell.SimulationScreen, string) {
t.Helper()
a, screen := newTestApp(t)
path := filepath.Join(t.TempDir(), "main.go")
writeTestFile(t, path, "package main\n")
a.Open(path)
a.tick() // the loop has seen the file as it was opened
return a, screen, path
}
func TestAFileRewrittenByAnotherProgramIsReloaded(t *testing.T) {
// This is the whole feature: a formatter, a generator or an agent in
// another terminal rewrites the file, and the window shows it without
// closing and reopening.
a, _, path := newWatchedFile(t)
writeTestFile(t, path, "package main\n\n// rewritten outside the editor\n")
a.tick()
if got := activeEditorText(t, a, path); !strings.Contains(got, "rewritten outside") {
t.Errorf("the window still shows %q after the file changed on disk", got)
}
if got := a.status.Message(); got != "Reloaded main.go" {
t.Errorf("status message = %q, want %q", got, "Reloaded main.go")
}
if title := a.desktop.Active().Title(); title != "main.go" {
t.Errorf("window title = %q after a reload, want %q", title, "main.go")
}
}
func TestAFileWithUnsavedChangesIsKeptWhenTheDiskChanges(t *testing.T) {
// The user's work and the other program's genuinely conflict, and the
// editor is not the one to decide — so it keeps the work and says so.
a, _, path := newWatchedFile(t)
typeText(a, "// mine")
edited := activeEditorText(t, a, path)
writeTestFile(t, path, "package main\n\n// rewritten outside the editor\n")
a.tick()
if got := activeEditorText(t, a, path); got != edited {
t.Errorf("the modified buffer was reloaded: %q, want the unsaved edit %q", got, edited)
}
if got := a.status.Message(); !strings.Contains(got, "changed on disk") {
t.Errorf("status message = %q, want it to say the file changed on disk", got)
}
}
func TestAChangeOnDiskIsReportedOncePerChange(t *testing.T) {
// The message would otherwise be re-issued on every turn of the loop for as
// long as the window stays modified, hiding everything else the status bar
// has to say.
a, _, path := newWatchedFile(t)
typeText(a, "// mine")
writeTestFile(t, path, "package main\n\n// rewritten outside the editor\n")
a.tick()
a.Message("something else")
a.tick()
if got := a.status.Message(); got != "something else" {
t.Errorf("status message = %q; the change on disk was reported a second time", got)
}
}
func TestTheEditorsOwnSaveIsNotMistakenForAChangeOnDisk(t *testing.T) {
// A save rewrites the file — through a rename, so with a new inode and
// stamp — and must not come round as a reload of the text just written.
a, _, path := newWatchedFile(t)
typeText(a, "// mine")
a.SaveFile()
a.tick()
if got := a.status.Message(); got != "Saved main.go" {
t.Errorf("status message = %q after a save, want %q", got, "Saved main.go")
}
if got := activeEditorText(t, a, path); !strings.Contains(got, "// mine") {
t.Errorf("the saved text was lost: %q", got)
}
}
func TestAFileThatDisappearsIsLeftAsItWasUntilItComesBack(t *testing.T) {
// Some programs replace a file by deleting it and writing a new one; a
// stat between the two sees nothing. The window keeps what it has, and
// picks the file up when it is there again.
a, _, path := newWatchedFile(t)
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
a.tick()
if got := activeEditorText(t, a, path); got != "package main\n" {
t.Errorf("the window lost its text when the file went: %q", got)
}
writeTestFile(t, path, "package main\n\n// back again, longer\n")
a.tick()
if got := activeEditorText(t, a, path); !strings.Contains(got, "back again") {
t.Errorf("the window did not pick the file up when it came back: %q", got)
}
}
func TestAReloadTellsTheLanguageServerWhatTheFileNowSays(t *testing.T) {
// A server answering completions from the text before the formatter ran
// is the same stale-copy problem in another place.
a, server, project := newCodeApp(t)
path := filepath.Join(project, "main.go")
waitForMethod(t, server, "textDocument/didOpen")
a.tick()
before := server.methodCount("textDocument/didChange")
writeTestFile(t, path, "package main\n\nfunc main() { println() }\n")
a.tick()
deadline := time.After(2 * time.Second)
for server.methodCount("textDocument/didChange") == before {
select {
case <-deadline:
t.Fatal("the server was never told the reloaded text")
case <-time.After(time.Millisecond):
}
}
}
func TestAClosedWindowsFileIsNoLongerWatched(t *testing.T) {
a, _, path := newWatchedFile(t)
a.CloseFile()
a.tick()
writeTestFile(t, path, "package main\n\n// nobody is looking\n")
if len(a.fileStamps) != 0 {
t.Errorf("%d file(s) still stamped after the only window closed", len(a.fileStamps))
}
if a.fileWatch.changed() {
t.Error("the watcher would wake the loop for a file no window shows")
}
}
func TestTheWatcherWakesTheLoopWhenAnOpenFileChanges(t *testing.T) {
// Run sits in PollEvent; without this wake the reload would wait for the
// next keystroke, which is the defect being fixed.
a, screen, path := newWatchedFile(t)
a.fileWatchInterval = 5 * time.Millisecond
stop := a.watchFiles()
defer stop()
writeTestFile(t, path, "package main\n\n// rewritten outside the editor\n")
if !wokeWithin(screen, time.Second) {
t.Fatal("the event loop was never woken for a file that changed on disk")
}
}
func TestTheWatcherLeavesAnIdleEditorAlone(t *testing.T) {
// Waking the loop every second regardless would make the editor redraw
// forever with nothing to show; the wake is for a change, not for time.
a, screen, _ := newWatchedFile(t)
a.fileWatchInterval = 5 * time.Millisecond
stop := a.watchFiles()
defer stop()
if wokeWithin(screen, 100*time.Millisecond) {
t.Fatal("the event loop was woken though no file changed")
}
}
// wokeWithin reports whether the watcher posted its wake-up to the screen
// before the deadline.
func wokeWithin(screen tcell.SimulationScreen, within time.Duration) bool {
deadline := time.After(within)
for {
for screen.HasPendingEvent() {
if _, ok := screen.PollEvent().(*tcell.EventInterrupt); ok {
return true
}
}
select {
case <-deadline:
return false
case <-time.After(2 * time.Millisecond):
}
}
}
|