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