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