1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
|
// Everything the File menu does: opening, saving and closing files, and
// leaving the editor.
package app
import (
"fmt"
"path/filepath"
"rickub.com/turbo-editors/turbo-core/buffer"
"rickub.com/turbo-editors/turbo-core/editor"
"rickub.com/turbo-editors/turbo-core/lsp"
"rickub.com/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.
//
// Paths are compared canonically — absolute, links resolved — for the same
// reason diagnostics are keyed that way: a location the server sends back
// names the file as the server spells it, which for a server that resolves
// symbolic links is not how the window was opened. Compared as spelt, a jump
// to a definition in a file already on screen opened it a second time, and a
// list of references quoted the disk instead of the unsaved window.
func (a *App) windowFor(path string) *ui.Window {
wanted := lsp.CanonicalPath(path)
for _, window := range a.desktop.Windows() {
view, ok := editorViewOf(window)
if !ok || view.Buffer().Path() == "" {
continue
}
if lsp.CanonicalPath(view.Buffer().Path()) == 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()
}
})
}
|