| 📦 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 | package app |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | "github.com/gdamore/tcell/v2" |
| 11 | ) |
| 12 | |
| 13 | // newWatchedFile returns an editor with a file open in the front window, and |
| 14 | // that file's path. The contents written later are deliberately of a different |
| 15 | // length: on a filesystem that stamps modification times by the second, two |
| 16 | // writes in the same second are told apart by size or not at all. |
| 17 | func newWatchedFile(t *testing.T) (*App, tcell.SimulationScreen, string) { |
| 18 | t.Helper() |
| 19 | |
| 20 | a, screen := newTestApp(t) |
| 21 | path := filepath.Join(t.TempDir(), "main.go") |
| 22 | writeTestFile(t, path, "package main\n") |
| 23 | a.Open(path) |
| 24 | a.tick() // the loop has seen the file as it was opened |
| 25 | return a, screen, path |
| 26 | } |
| 27 | |
| 28 | func TestAFileRewrittenByAnotherProgramIsReloaded(t *testing.T) { |
| 29 | // This is the whole feature: a formatter, a generator or an agent in |
| 30 | // another terminal rewrites the file, and the window shows it without |
| 31 | // closing and reopening. |
| 32 | a, _, path := newWatchedFile(t) |
| 33 | |
| 34 | writeTestFile(t, path, "package main\n\n// rewritten outside the editor\n") |
| 35 | a.tick() |
| 36 | |
| 37 | if got := activeEditorText(t, a, path); !strings.Contains(got, "rewritten outside") { |
| 38 | t.Errorf("the window still shows %q after the file changed on disk", got) |
| 39 | } |
| 40 | if got := a.status.Message(); got != "Reloaded main.go" { |
| 41 | t.Errorf("status message = %q, want %q", got, "Reloaded main.go") |
| 42 | } |
| 43 | if title := a.desktop.Active().Title(); title != "main.go" { |
| 44 | t.Errorf("window title = %q after a reload, want %q", title, "main.go") |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | func TestAFileWithUnsavedChangesIsKeptWhenTheDiskChanges(t *testing.T) { |
| 49 | // The user's work and the other program's genuinely conflict, and the |
| 50 | // editor is not the one to decide — so it keeps the work and says so. |
| 51 | a, _, path := newWatchedFile(t) |
| 52 | typeText(a, "// mine") |
| 53 | edited := activeEditorText(t, a, path) |
| 54 | |
| 55 | writeTestFile(t, path, "package main\n\n// rewritten outside the editor\n") |
| 56 | a.tick() |
| 57 | |
| 58 | if got := activeEditorText(t, a, path); got != edited { |
| 59 | t.Errorf("the modified buffer was reloaded: %q, want the unsaved edit %q", got, edited) |
| 60 | } |
| 61 | if got := a.status.Message(); !strings.Contains(got, "changed on disk") { |
| 62 | t.Errorf("status message = %q, want it to say the file changed on disk", got) |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | func TestAChangeOnDiskIsReportedOncePerChange(t *testing.T) { |
| 67 | // The message would otherwise be re-issued on every turn of the loop for as |
| 68 | // long as the window stays modified, hiding everything else the status bar |
| 69 | // has to say. |
| 70 | a, _, path := newWatchedFile(t) |
| 71 | typeText(a, "// mine") |
| 72 | writeTestFile(t, path, "package main\n\n// rewritten outside the editor\n") |
| 73 | a.tick() |
| 74 | |
| 75 | a.Message("something else") |
| 76 | a.tick() |
| 77 | |
| 78 | if got := a.status.Message(); got != "something else" { |
| 79 | t.Errorf("status message = %q; the change on disk was reported a second time", got) |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | func TestTheEditorsOwnSaveIsNotMistakenForAChangeOnDisk(t *testing.T) { |
| 84 | // A save rewrites the file — through a rename, so with a new inode and |
| 85 | // stamp — and must not come round as a reload of the text just written. |
| 86 | a, _, path := newWatchedFile(t) |
| 87 | typeText(a, "// mine") |
| 88 | a.SaveFile() |
| 89 | a.tick() |
| 90 | |
| 91 | if got := a.status.Message(); got != "Saved main.go" { |
| 92 | t.Errorf("status message = %q after a save, want %q", got, "Saved main.go") |
| 93 | } |
| 94 | if got := activeEditorText(t, a, path); !strings.Contains(got, "// mine") { |
| 95 | t.Errorf("the saved text was lost: %q", got) |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | func TestAFileThatDisappearsIsLeftAsItWasUntilItComesBack(t *testing.T) { |
| 100 | // Some programs replace a file by deleting it and writing a new one; a |
| 101 | // stat between the two sees nothing. The window keeps what it has, and |
| 102 | // picks the file up when it is there again. |
| 103 | a, _, path := newWatchedFile(t) |
| 104 | |
| 105 | if err := os.Remove(path); err != nil { |
| 106 | t.Fatal(err) |
| 107 | } |
| 108 | a.tick() |
| 109 | if got := activeEditorText(t, a, path); got != "package main\n" { |
| 110 | t.Errorf("the window lost its text when the file went: %q", got) |
| 111 | } |
| 112 | |
| 113 | writeTestFile(t, path, "package main\n\n// back again, longer\n") |
| 114 | a.tick() |
| 115 | if got := activeEditorText(t, a, path); !strings.Contains(got, "back again") { |
| 116 | t.Errorf("the window did not pick the file up when it came back: %q", got) |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | func TestAReloadTellsTheLanguageServerWhatTheFileNowSays(t *testing.T) { |
| 121 | // A server answering completions from the text before the formatter ran |
| 122 | // is the same stale-copy problem in another place. |
| 123 | a, server, project := newCodeApp(t) |
| 124 | path := filepath.Join(project, "main.go") |
| 125 | waitForMethod(t, server, "textDocument/didOpen") |
| 126 | a.tick() |
| 127 | before := server.methodCount("textDocument/didChange") |
| 128 | |
| 129 | writeTestFile(t, path, "package main\n\nfunc main() { println() }\n") |
| 130 | a.tick() |
| 131 | |
| 132 | deadline := time.After(2 * time.Second) |
| 133 | for server.methodCount("textDocument/didChange") == before { |
| 134 | select { |
| 135 | case <-deadline: |
| 136 | t.Fatal("the server was never told the reloaded text") |
| 137 | case <-time.After(time.Millisecond): |
| 138 | } |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | func TestAClosedWindowsFileIsNoLongerWatched(t *testing.T) { |
| 143 | a, _, path := newWatchedFile(t) |
| 144 | |
| 145 | a.CloseFile() |
| 146 | a.tick() |
| 147 | writeTestFile(t, path, "package main\n\n// nobody is looking\n") |
| 148 | |
| 149 | if len(a.fileStamps) != 0 { |
| 150 | t.Errorf("%d file(s) still stamped after the only window closed", len(a.fileStamps)) |
| 151 | } |
| 152 | if a.fileWatch.changed() { |
| 153 | t.Error("the watcher would wake the loop for a file no window shows") |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | func TestTheWatcherWakesTheLoopWhenAnOpenFileChanges(t *testing.T) { |
| 158 | // Run sits in PollEvent; without this wake the reload would wait for the |
| 159 | // next keystroke, which is the defect being fixed. |
| 160 | a, screen, path := newWatchedFile(t) |
| 161 | a.fileWatchInterval = 5 * time.Millisecond |
| 162 | stop := a.watchFiles() |
| 163 | defer stop() |
| 164 | |
| 165 | writeTestFile(t, path, "package main\n\n// rewritten outside the editor\n") |
| 166 | |
| 167 | if !wokeWithin(screen, time.Second) { |
| 168 | t.Fatal("the event loop was never woken for a file that changed on disk") |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | func TestTheWatcherLeavesAnIdleEditorAlone(t *testing.T) { |
| 173 | // Waking the loop every second regardless would make the editor redraw |
| 174 | // forever with nothing to show; the wake is for a change, not for time. |
| 175 | a, screen, _ := newWatchedFile(t) |
| 176 | a.fileWatchInterval = 5 * time.Millisecond |
| 177 | stop := a.watchFiles() |
| 178 | defer stop() |
| 179 | |
| 180 | if wokeWithin(screen, 100*time.Millisecond) { |
| 181 | t.Fatal("the event loop was woken though no file changed") |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | // wokeWithin reports whether the watcher posted its wake-up to the screen |
| 186 | // before the deadline. |
| 187 | func wokeWithin(screen tcell.SimulationScreen, within time.Duration) bool { |
| 188 | deadline := time.After(within) |
| 189 | for { |
| 190 | for screen.HasPendingEvent() { |
| 191 | if _, ok := screen.PollEvent().(*tcell.EventInterrupt); ok { |
| 192 | return true |
| 193 | } |
| 194 | } |
| 195 | select { |
| 196 | case <-deadline: |
| 197 | return false |
| 198 | case <-time.After(2 * time.Millisecond): |
| 199 | } |
| 200 | } |
| 201 | } |