| 🛟 Updated. 28d5985 k33g 18h ago | 1 | // Everything the File menu does: opening, saving and closing files, and |
| 2 | // leaving the editor. |
| 3 | package app |
| 4 | |
| 5 | import ( |
| 6 | "fmt" |
| 📦 Turbo Core — a save that creates a file tells the server (workspace/didChangeWatchedFiles), so moon-lsp diagnoses a new .mbt from its first save 3561e52 k33g 8h ago | 7 | "os" |
| 🛟 Updated. 28d5985 k33g 18h ago | 8 | "path/filepath" |
| 9 | |
| 📦 Turbo Core f3ade8d k33g 10h ago | 10 | "rickub.com/turbo-editors/turbo-core/buffer" |
| 11 | "rickub.com/turbo-editors/turbo-core/editor" |
| 📦 Turbo Core — canonical paths: symbolic links resolved in URIs and document keys (moon-lsp on macOS) b91316e k33g 9h ago | 12 | "rickub.com/turbo-editors/turbo-core/lsp" |
| 📦 Turbo Core f3ade8d k33g 10h ago | 13 | "rickub.com/turbo-editors/turbo-core/ui" |
| 🛟 Updated. 28d5985 k33g 18h ago | 14 | ) |
| 15 | |
| 16 | // untitledName is what a window with no file yet is called. |
| 17 | const untitledName = "Untitled" |
| 18 | |
| 19 | // NewFile opens an empty window. |
| 20 | func (a *App) NewFile() { |
| 21 | a.openBuffer(buffer.New()) |
| 22 | } |
| 23 | |
| 24 | // OpenFile asks for a file and opens it. |
| 25 | func (a *App) OpenFile() { |
| 26 | dialog := NewFileDialog("Open", a.currentDirectory(), a.screenRect()) |
| 27 | |
| 28 | a.pushModal(dialog.Dialog(), func(result ui.Result) { |
| 29 | if result == ui.ResultOK { |
| 30 | a.Open(dialog.Path()) |
| 31 | } |
| 32 | }) |
| 33 | } |
| 34 | |
| 35 | // Open reads a file into a new window, or brings its window forward when it is |
| 36 | // already open. |
| 37 | func (a *App) Open(path string) { |
| 38 | if path == "" { |
| 39 | return |
| 40 | } |
| 41 | if window := a.windowFor(path); window != nil { |
| 42 | a.desktop.Focus(window) |
| 43 | return |
| 44 | } |
| 45 | |
| 46 | buf, err := buffer.Open(path) |
| 47 | if err != nil { |
| 48 | a.ShowMessage("Cannot open", err.Error()) |
| 49 | return |
| 50 | } |
| 51 | a.openBuffer(buf) |
| 52 | } |
| 53 | |
| 54 | // windowFor returns the window already editing a path, if there is one. |
| 📦 Turbo Core — canonical paths: symbolic links resolved in URIs and document keys (moon-lsp on macOS) b91316e k33g 9h ago | 55 | // |
| 56 | // Paths are compared canonically — absolute, links resolved — for the same |
| 57 | // reason diagnostics are keyed that way: a location the server sends back |
| 58 | // names the file as the server spells it, which for a server that resolves |
| 59 | // symbolic links is not how the window was opened. Compared as spelt, a jump |
| 60 | // to a definition in a file already on screen opened it a second time, and a |
| 61 | // list of references quoted the disk instead of the unsaved window. |
| 🛟 Updated. 28d5985 k33g 18h ago | 62 | func (a *App) windowFor(path string) *ui.Window { |
| 📦 Turbo Core — canonical paths: symbolic links resolved in URIs and document keys (moon-lsp on macOS) b91316e k33g 9h ago | 63 | wanted := lsp.CanonicalPath(path) |
| 🛟 Updated. 28d5985 k33g 18h ago | 64 | |
| 65 | for _, window := range a.desktop.Windows() { |
| 66 | view, ok := editorViewOf(window) |
| 67 | if !ok || view.Buffer().Path() == "" { |
| 68 | continue |
| 69 | } |
| 📦 Turbo Core — canonical paths: symbolic links resolved in URIs and document keys (moon-lsp on macOS) b91316e k33g 9h ago | 70 | if lsp.CanonicalPath(view.Buffer().Path()) == wanted { |
| 🛟 Updated. 28d5985 k33g 18h ago | 71 | return window |
| 72 | } |
| 73 | } |
| 74 | return nil |
| 75 | } |
| 76 | |
| 77 | // openBuffer puts a buffer in a new window and tells the language server. |
| 78 | func (a *App) openBuffer(buf *buffer.Buffer) { |
| 79 | view := editor.NewView(buf, a.clipboard) |
| 80 | window := ui.NewWindow(windowTitle(buf), view) |
| 81 | window.SetBounds(a.newWindowBounds()) |
| 82 | window.OnClose = func() bool { a.closeWindow(window); return true } |
| 83 | |
| 84 | view.OnChange = func() { a.viewChanged(window, view) } |
| 85 | view.OnCompletionRequest = a.RequestCompletion |
| 86 | |
| 87 | a.desktop.Add(window) |
| 88 | a.windowsOpened++ |
| 89 | a.language.DidOpen(buf.Path(), buf.Text()) |
| 90 | } |
| 91 | |
| 92 | // windowTitle returns what a buffer's window is called: its file name, with a |
| 93 | // star while there are unsaved changes. |
| 94 | func windowTitle(buf *buffer.Buffer) string { |
| 95 | name := untitledName |
| 96 | if buf.Path() != "" { |
| 97 | name = filepath.Base(buf.Path()) |
| 98 | } |
| 99 | if buf.Modified() { |
| 100 | return name + " *" |
| 101 | } |
| 102 | return name |
| 103 | } |
| 104 | |
| 105 | // viewChanged runs after every edit: it retitles the window and tells the |
| 106 | // language server what the file now says. |
| 107 | func (a *App) viewChanged(window *ui.Window, view *editor.View) { |
| 108 | window.SetTitle(windowTitle(view.Buffer())) |
| 109 | a.language.DidChange(view.Buffer().Path(), view.Buffer().Text()) |
| 110 | a.refreshCompletionPrefix() |
| 111 | a.noteEdit() |
| 112 | } |
| 113 | |
| 114 | // newWindowBounds returns where the next window goes: the whole desktop, with |
| 115 | // a cascade offset so a second window does not hide the first. |
| 116 | func (a *App) newWindowBounds() ui.Rect { |
| 117 | area := a.desktopRect() |
| 118 | offset := (a.windowsOpened % maxWindowOffsets) * newWindowOffset |
| 119 | |
| 120 | return ui.Rect{ |
| 121 | X: area.X + offset, |
| 122 | Y: area.Y + offset, |
| 123 | W: max(area.W-offset-ui.ShadowWidth, ui.MinWindowWidth), |
| 124 | H: max(area.H-offset, ui.MinWindowHeight), |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | // currentDirectory returns where a file dialog should open: beside the file |
| 129 | // being edited, or the working directory when there is none. |
| 130 | func (a *App) currentDirectory() string { |
| 131 | if view := a.activeView(); view != nil && view.Buffer().Path() != "" { |
| 132 | return view.Buffer().Path() |
| 133 | } |
| 134 | return "" |
| 135 | } |
| 136 | |
| 137 | // SaveFile writes the front window's file, asking for a name if it has none. |
| 138 | func (a *App) SaveFile() { |
| 139 | view := a.activeView() |
| 140 | if view == nil { |
| 141 | return |
| 142 | } |
| 143 | if view.Buffer().Path() == "" { |
| 144 | a.SaveFileAs() |
| 145 | return |
| 146 | } |
| 147 | a.save(view, view.Buffer().Path()) |
| 148 | } |
| 149 | |
| 150 | // SaveFileAs asks for a name and writes the front window's file to it. |
| 151 | func (a *App) SaveFileAs() { |
| 152 | view := a.activeView() |
| 153 | if view == nil { |
| 154 | return |
| 155 | } |
| 156 | |
| 157 | dialog := NewFileDialog("Save as", a.currentDirectory(), a.screenRect()) |
| 158 | a.pushModal(dialog.Dialog(), func(result ui.Result) { |
| 159 | if result == ui.ResultOK { |
| 160 | a.save(view, dialog.Path()) |
| 161 | } |
| 162 | }) |
| 163 | } |
| 164 | |
| 165 | // save writes a view's buffer to a path and reports how it went. |
| 166 | func (a *App) save(view *editor.View, path string) { |
| 167 | if path == "" { |
| 168 | return |
| 169 | } |
| 170 | // Remembered before the write, because SaveAs rewrites the buffer's path; |
| 171 | // it is the only record of which document the server had open until now. |
| 172 | previous := view.Buffer().Path() |
| 📦 Turbo Core — a save that creates a file tells the server (workspace/didChangeWatchedFiles), so moon-lsp diagnoses a new .mbt from its first save 3561e52 k33g 8h ago | 173 | created := !fileExists(path) |
| 🛟 Updated. 28d5985 k33g 18h ago | 174 | if err := view.Buffer().SaveAs(path); err != nil { |
| 175 | a.ShowMessage("Cannot save", err.Error()) |
| 176 | return |
| 177 | } |
| 178 | if renamed(previous, path) { |
| 179 | a.language.DidClose(previous) |
| 180 | } |
| 📦 Turbo Core — a save that creates a file tells the server (workspace/didChangeWatchedFiles), so moon-lsp diagnoses a new .mbt from its first save 3561e52 k33g 8h ago | 181 | a.afterSave(view, path, created) |
| 182 | } |
| 183 | |
| 184 | // fileExists reports whether there is already a file at a path — asked before |
| 185 | // a write, so that afterwards the save knows whether it created the file. |
| 186 | func fileExists(path string) bool { |
| 187 | _, err := os.Stat(path) |
| 188 | return err == nil |
| 🛟 Updated. 28d5985 k33g 18h ago | 189 | } |
| 190 | |
| 191 | // renamed reports whether a save gave the buffer a genuinely different file, |
| 192 | // rather than writing the one it already had. Compared absolute, because the |
| 193 | // Save As dialog and the command line spell the same file differently. |
| 194 | // |
| 195 | // Without the close this decides on, a Save As under a new name leaves the |
| 196 | // old document open on the server for as long as the editor runs — a ghost |
| 197 | // that keeps its diagnostics and shadows the file if it is ever reopened. |
| 198 | func renamed(previous, path string) bool { |
| 199 | return previous != "" && pathKey(previous) != pathKey(path) |
| 200 | } |
| 201 | |
| 202 | // afterSave brings everything that watches a file up to date once it has been |
| 203 | // written, whichever of the two ways wrote it. |
| 204 | // |
| 205 | // The File menu's save and automatic saving differ only in how they report a |
| 206 | // failure; everything after a successful write is the same. It is one function |
| 207 | // so that a step added to one cannot go missing from the other — re-reading the |
| 208 | // project's settings is exactly such a step, and would have been missing from |
| 209 | // autosave. |
| 📦 Turbo Core — a save that creates a file tells the server (workspace/didChangeWatchedFiles), so moon-lsp diagnoses a new .mbt from its first save 3561e52 k33g 8h ago | 210 | func (a *App) afterSave(view *editor.View, path string, created bool) { |
| 🛟 Updated. 28d5985 k33g 18h ago | 211 | view.RefreshSyntax() |
| 212 | if window := a.windowOf(view); window != nil { |
| 213 | window.SetTitle(windowTitle(view.Buffer())) |
| 214 | } |
| 📦 Turbo Core — a save that creates a file tells the server (workspace/didChangeWatchedFiles), so moon-lsp diagnoses a new .mbt from its first save 3561e52 k33g 8h ago | 215 | a.announceSaved(path, view.Buffer().Text(), created) |
| 🛟 Updated. 28d5985 k33g 18h ago | 216 | a.refreshTree() |
| 217 | |
| 218 | // The message comes before the settings are re-read, so that re-reading can |
| 219 | // replace it: "the settings you just saved are now in force" is worth more |
| 220 | // than "saved", and a settings file that no longer parses is worth much |
| 221 | // more than either. |
| 222 | a.Message("Saved " + filepath.Base(path)) |
| 223 | a.reapplySettings(path) |
| 224 | } |
| 225 | |
| 226 | // announceSaved tells the language server about a write — or about the |
| 227 | // document itself, when this save is the first time there is a name to tell. |
| 228 | // |
| 229 | // A window that started Untitled was skipped by every didOpen so far: it had |
| 230 | // no path to announce. A server ignores didChange and didSave for a document |
| 231 | // it was never told is open, so sending only didSave here would leave that |
| 232 | // window without completion, hover or diagnostics until the editor is |
| 233 | // restarted and the file is opened with its name — which is exactly how the |
| 234 | // defect was found. |
| 📦 Turbo Core — a save that creates a file tells the server (workspace/didChangeWatchedFiles), so moon-lsp diagnoses a new .mbt from its first save 3561e52 k33g 8h ago | 235 | // |
| 236 | // A save that created the file also says so, after the document has been |
| 237 | // announced: to a server that works a package's files out from the directory |
| 238 | // — moon-lsp — a document being open and a file existing are different facts, |
| 239 | // and it diagnoses a file saved for the first time only once told the file is |
| 240 | // there. Whether the document was already known does not enter into it: a |
| 241 | // buffer opened as `turbo-moonbit new.mbt` was announced long before anything |
| 242 | // was on disk. |
| 243 | func (a *App) announceSaved(path, text string, created bool) { |
| 🛟 Updated. 28d5985 k33g 18h ago | 244 | if a.language.Knows(path) { |
| 245 | a.language.DidSave(path, text) |
| 📦 Turbo Core — a save that creates a file tells the server (workspace/didChangeWatchedFiles), so moon-lsp diagnoses a new .mbt from its first save 3561e52 k33g 8h ago | 246 | } else { |
| 247 | a.language.DidOpen(path, text) |
| 248 | } |
| 249 | if created { |
| 250 | a.language.FileCreated(path) |
| 🛟 Updated. 28d5985 k33g 18h ago | 251 | } |
| 252 | } |
| 253 | |
| 254 | // windowOf returns the window holding a view. |
| 255 | func (a *App) windowOf(view *editor.View) *ui.Window { |
| 256 | for _, window := range a.desktop.Windows() { |
| 257 | if window.Content() == ui.Widget(view) { |
| 258 | return window |
| 259 | } |
| 260 | } |
| 261 | return nil |
| 262 | } |
| 263 | |
| 264 | // CloseFile closes the front window, offering to save it first. |
| 265 | func (a *App) CloseFile() { |
| 266 | window := a.desktop.Active() |
| 267 | if window == nil { |
| 268 | return |
| 269 | } |
| 270 | a.closeWindow(window) |
| 271 | } |
| 272 | |
| 273 | // closeWindow closes a window, asking about unsaved changes first. |
| 274 | func (a *App) closeWindow(window *ui.Window) { |
| 275 | if view, isTerminal := a.terminalView(window); isTerminal { |
| 276 | a.closeTerminal(window, view) |
| 277 | return |
| 278 | } |
| 279 | if a.isTreeWindow(window) { |
| 280 | a.closeTree() |
| 281 | return |
| 282 | } |
| 283 | // An agent window holds a running conversation, not unsaved work, so |
| 284 | // closing it asks nothing — the same bargain a terminal makes. |
| 285 | if a.isAgentWindow(window) { |
| 286 | a.closeAgent(window) |
| 287 | return |
| 288 | } |
| 289 | |
| 290 | view, ok := editorViewOf(window) |
| 291 | if !ok || !view.Buffer().Modified() { |
| 292 | a.discard(window) |
| 293 | return |
| 294 | } |
| 295 | // With autosave on there is nothing to ask about: the file was going to be |
| 296 | // written anyway, so closing writes it and gets out of the way. |
| 297 | if a.savedByAutosave(window) { |
| 298 | a.discard(window) |
| 299 | return |
| 300 | } |
| 301 | |
| 302 | question := fmt.Sprintf("%s has unsaved changes. Save them?", window.Title()) |
| 303 | a.pushModal(NewConfirmDialog("Close", question, a.screenRect()), func(result ui.Result) { |
| 304 | switch result { |
| 305 | case ui.ResultOK: |
| 306 | a.SaveFile() |
| 307 | a.discard(window) |
| 308 | case ui.ResultNo: |
| 309 | a.discard(window) |
| 310 | } |
| 311 | }) |
| 312 | } |
| 313 | |
| 314 | // discard removes a window without asking anything. |
| 315 | func (a *App) discard(window *ui.Window) { |
| 316 | if view, ok := editorViewOf(window); ok { |
| 317 | a.language.DidClose(view.Buffer().Path()) |
| 318 | } |
| 319 | a.desktop.Remove(window) |
| 320 | a.completion.Hide() |
| 321 | } |
| 322 | |
| 323 | // Quit leaves the editor, offering to save each modified file on the way. |
| 324 | func (a *App) Quit() { |
| 325 | for _, window := range a.desktop.Windows() { |
| 326 | view, ok := editorViewOf(window) |
| 327 | if !ok || !view.Buffer().Modified() { |
| 328 | continue |
| 329 | } |
| 330 | // Autosave writes what it can; a window it cannot write — one that has |
| 331 | // never been named — is still a real question. |
| 332 | if a.savedByAutosave(window) { |
| 333 | continue |
| 334 | } |
| 335 | a.confirmQuit(window) |
| 336 | return |
| 337 | } |
| 338 | |
| 339 | // Every shell goes with the editor: a window is the only way back to one, |
| 340 | // and leaving them running would strand the processes. |
| 341 | a.closeTerminals() |
| 342 | a.closeAgents() |
| 343 | a.cancelAutosave() |
| 344 | a.quitting = true |
| 345 | } |
| 346 | |
| 347 | // confirmQuit asks about one modified window, then tries to quit again. |
| 348 | func (a *App) confirmQuit(window *ui.Window) { |
| 349 | question := fmt.Sprintf("%s has unsaved changes. Save them?", window.Title()) |
| 350 | |
| 351 | a.pushModal(NewConfirmDialog("Exit", question, a.screenRect()), func(result ui.Result) { |
| 352 | switch result { |
| 353 | case ui.ResultOK: |
| 354 | a.desktop.Focus(window) |
| 355 | a.SaveFile() |
| 356 | a.discard(window) |
| 357 | a.Quit() |
| 358 | case ui.ResultNo: |
| 359 | a.discard(window) |
| 360 | a.Quit() |
| 361 | } |
| 362 | }) |
| 363 | } |