// Everything the File menu does: opening, saving and closing files, and // leaving the editor. package app import ( "fmt" "path/filepath" "codeberg.org/turbo-editors/turbo-core/buffer" "codeberg.org/turbo-editors/turbo-core/editor" "codeberg.org/turbo-editors/turbo-core/ui" ) // untitledName is what a window with no file yet is called. const untitledName = "Untitled" // NewFile opens an empty window. func (a *App) NewFile() { a.openBuffer(buffer.New()) } // OpenFile asks for a file and opens it. func (a *App) OpenFile() { dialog := NewFileDialog("Open", a.currentDirectory(), a.screenRect()) a.pushModal(dialog.Dialog(), func(result ui.Result) { if result == ui.ResultOK { a.Open(dialog.Path()) } }) } // Open reads a file into a new window, or brings its window forward when it is // already open. func (a *App) Open(path string) { if path == "" { return } if window := a.windowFor(path); window != nil { a.desktop.Focus(window) return } buf, err := buffer.Open(path) if err != nil { a.ShowMessage("Cannot open", err.Error()) return } a.openBuffer(buf) } // windowFor returns the window already editing a path, if there is one. func (a *App) windowFor(path string) *ui.Window { wanted, err := filepath.Abs(path) if err != nil { wanted = path } for _, window := range a.desktop.Windows() { view, ok := editorViewOf(window) if !ok || view.Buffer().Path() == "" { continue } if existing, err := filepath.Abs(view.Buffer().Path()); err == nil && existing == wanted { return window } } return nil } // openBuffer puts a buffer in a new window and tells the language server. func (a *App) openBuffer(buf *buffer.Buffer) { view := editor.NewView(buf, a.clipboard) window := ui.NewWindow(windowTitle(buf), view) window.SetBounds(a.newWindowBounds()) window.OnClose = func() bool { a.closeWindow(window); return true } view.OnChange = func() { a.viewChanged(window, view) } view.OnCompletionRequest = a.RequestCompletion a.desktop.Add(window) a.windowsOpened++ a.language.DidOpen(buf.Path(), buf.Text()) } // windowTitle returns what a buffer's window is called: its file name, with a // star while there are unsaved changes. func windowTitle(buf *buffer.Buffer) string { name := untitledName if buf.Path() != "" { name = filepath.Base(buf.Path()) } if buf.Modified() { return name + " *" } return name } // viewChanged runs after every edit: it retitles the window and tells the // language server what the file now says. func (a *App) viewChanged(window *ui.Window, view *editor.View) { window.SetTitle(windowTitle(view.Buffer())) a.language.DidChange(view.Buffer().Path(), view.Buffer().Text()) a.refreshCompletionPrefix() a.noteEdit() } // newWindowBounds returns where the next window goes: the whole desktop, with // a cascade offset so a second window does not hide the first. func (a *App) newWindowBounds() ui.Rect { area := a.desktopRect() offset := (a.windowsOpened % maxWindowOffsets) * newWindowOffset return ui.Rect{ X: area.X + offset, Y: area.Y + offset, W: max(area.W-offset-ui.ShadowWidth, ui.MinWindowWidth), H: max(area.H-offset, ui.MinWindowHeight), } } // currentDirectory returns where a file dialog should open: beside the file // being edited, or the working directory when there is none. func (a *App) currentDirectory() string { if view := a.activeView(); view != nil && view.Buffer().Path() != "" { return view.Buffer().Path() } return "" } // SaveFile writes the front window's file, asking for a name if it has none. func (a *App) SaveFile() { view := a.activeView() if view == nil { return } if view.Buffer().Path() == "" { a.SaveFileAs() return } a.save(view, view.Buffer().Path()) } // SaveFileAs asks for a name and writes the front window's file to it. func (a *App) SaveFileAs() { view := a.activeView() if view == nil { return } dialog := NewFileDialog("Save as", a.currentDirectory(), a.screenRect()) a.pushModal(dialog.Dialog(), func(result ui.Result) { if result == ui.ResultOK { a.save(view, dialog.Path()) } }) } // save writes a view's buffer to a path and reports how it went. func (a *App) save(view *editor.View, path string) { if path == "" { return } // Remembered before the write, because SaveAs rewrites the buffer's path; // it is the only record of which document the server had open until now. previous := view.Buffer().Path() if err := view.Buffer().SaveAs(path); err != nil { a.ShowMessage("Cannot save", err.Error()) return } if renamed(previous, path) { a.language.DidClose(previous) } a.afterSave(view, path) } // renamed reports whether a save gave the buffer a genuinely different file, // rather than writing the one it already had. Compared absolute, because the // Save As dialog and the command line spell the same file differently. // // Without the close this decides on, a Save As under a new name leaves the // old document open on the server for as long as the editor runs — a ghost // that keeps its diagnostics and shadows the file if it is ever reopened. func renamed(previous, path string) bool { return previous != "" && pathKey(previous) != pathKey(path) } // afterSave brings everything that watches a file up to date once it has been // written, whichever of the two ways wrote it. // // The File menu's save and automatic saving differ only in how they report a // failure; everything after a successful write is the same. It is one function // so that a step added to one cannot go missing from the other — re-reading the // project's settings is exactly such a step, and would have been missing from // autosave. func (a *App) afterSave(view *editor.View, path string) { view.RefreshSyntax() if window := a.windowOf(view); window != nil { window.SetTitle(windowTitle(view.Buffer())) } a.announceSaved(path, view.Buffer().Text()) a.refreshTree() // The message comes before the settings are re-read, so that re-reading can // replace it: "the settings you just saved are now in force" is worth more // than "saved", and a settings file that no longer parses is worth much // more than either. a.Message("Saved " + filepath.Base(path)) a.reapplySettings(path) } // announceSaved tells the language server about a write — or about the // document itself, when this save is the first time there is a name to tell. // // A window that started Untitled was skipped by every didOpen so far: it had // no path to announce. A server ignores didChange and didSave for a document // it was never told is open, so sending only didSave here would leave that // window without completion, hover or diagnostics until the editor is // restarted and the file is opened with its name — which is exactly how the // defect was found. func (a *App) announceSaved(path, text string) { if a.language.Knows(path) { a.language.DidSave(path, text) return } a.language.DidOpen(path, text) } // windowOf returns the window holding a view. func (a *App) windowOf(view *editor.View) *ui.Window { for _, window := range a.desktop.Windows() { if window.Content() == ui.Widget(view) { return window } } return nil } // CloseFile closes the front window, offering to save it first. func (a *App) CloseFile() { window := a.desktop.Active() if window == nil { return } a.closeWindow(window) } // closeWindow closes a window, asking about unsaved changes first. func (a *App) closeWindow(window *ui.Window) { if view, isTerminal := a.terminalView(window); isTerminal { a.closeTerminal(window, view) return } if a.isTreeWindow(window) { a.closeTree() return } // An agent window holds a running conversation, not unsaved work, so // closing it asks nothing — the same bargain a terminal makes. if a.isAgentWindow(window) { a.closeAgent(window) return } view, ok := editorViewOf(window) if !ok || !view.Buffer().Modified() { a.discard(window) return } // With autosave on there is nothing to ask about: the file was going to be // written anyway, so closing writes it and gets out of the way. if a.savedByAutosave(window) { a.discard(window) return } question := fmt.Sprintf("%s has unsaved changes. Save them?", window.Title()) a.pushModal(NewConfirmDialog("Close", question, a.screenRect()), func(result ui.Result) { switch result { case ui.ResultOK: a.SaveFile() a.discard(window) case ui.ResultNo: a.discard(window) } }) } // discard removes a window without asking anything. func (a *App) discard(window *ui.Window) { if view, ok := editorViewOf(window); ok { a.language.DidClose(view.Buffer().Path()) } a.desktop.Remove(window) a.completion.Hide() } // Quit leaves the editor, offering to save each modified file on the way. func (a *App) Quit() { for _, window := range a.desktop.Windows() { view, ok := editorViewOf(window) if !ok || !view.Buffer().Modified() { continue } // Autosave writes what it can; a window it cannot write — one that has // never been named — is still a real question. if a.savedByAutosave(window) { continue } a.confirmQuit(window) return } // Every shell goes with the editor: a window is the only way back to one, // and leaving them running would strand the processes. a.closeTerminals() a.closeAgents() a.cancelAutosave() a.quitting = true } // confirmQuit asks about one modified window, then tries to quit again. func (a *App) confirmQuit(window *ui.Window) { question := fmt.Sprintf("%s has unsaved changes. Save them?", window.Title()) a.pushModal(NewConfirmDialog("Exit", question, a.screenRect()), func(result ui.Result) { switch result { case ui.ResultOK: a.desktop.Focus(window) a.SaveFile() a.discard(window) a.Quit() case ui.ResultNo: a.discard(window) a.Quit() } }) }