turbo-editors/turbo-corepublic Fork 0
v1.0.1
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

actions_file.go · 343 lines · 10.0 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 18h ago1// Everything the File menu does: opening, saving and closing files, and
2// leaving the editor.
3package app
4
5import (
6 "fmt"
7 "path/filepath"
8
📦 Turbo Core f3ade8d k33g 11h ago9 "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 ago11 "rickub.com/turbo-editors/turbo-core/lsp"
📦 Turbo Core f3ade8d k33g 11h ago12 "rickub.com/turbo-editors/turbo-core/ui"
🛟 Updated. 28d5985 k33g 18h ago13)
14
15// untitledName is what a window with no file yet is called.
16const untitledName = "Untitled"
17
18// NewFile opens an empty window.
19func (a *App) NewFile() {
20 a.openBuffer(buffer.New())
21}
22
23// OpenFile asks for a file and opens it.
24func (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.
36func (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 ago54//
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 ago61func (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 ago62 wanted := lsp.CanonicalPath(path)
🛟 Updated. 28d5985 k33g 18h ago63
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 ago69 if lsp.CanonicalPath(view.Buffer().Path()) == wanted {
🛟 Updated. 28d5985 k33g 18h ago70 return window
71 }
72 }
73 return nil
74}
75
76// openBuffer puts a buffer in a new window and tells the language server.
77func (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.
93func 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.
106func (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.
115func (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.
129func (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.
137func (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.
150func (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.
165func (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.
189func 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.
201func (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.
226func (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.
235func (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.
245func (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.
254func (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.
295func (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.
304func (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.
328func (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}