turbo-editors/turbo-corepublic Fork 0
v1.0.2
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.

toolchain.go · 398 lines · 13.2 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 18h ago1// The toolchain menu: the commands a project runs on itself — format, lint,
2// build, test, run — read from a TOML file and each shown where it asks for.
3//
4// The menu is named by the profile: "Go" in Turbo Go, "Rust" in Turbo Rust.
5
6package app
7
8import (
9 "fmt"
10 "os"
11 "strconv"
12 "strings"
13
📦 Turbo Core f3ade8d k33g 11h ago14 "rickub.com/turbo-editors/turbo-core/buffer"
🛟 Updated. 28d5985 k33g 18h ago15
📦 Turbo Core f3ade8d k33g 11h ago16 "rickub.com/turbo-editors/turbo-core/terminal"
17 "rickub.com/turbo-editors/turbo-core/tools"
18 "rickub.com/turbo-editors/turbo-core/ui"
🛟 Updated. 28d5985 k33g 18h ago19)
20
21// createToolsLabel is the item that writes a starter file, kept at the bottom
22// of the menu so the commands above it read as the content.
23const createToolsLabel = "~C~reate tools file"
24
25// openToolsLabel is its partner. Exactly one of the two is ever available: you
26// can create the file you have not got, and open the one you have.
27const openToolsLabel = "~O~pen tools file"
28
29// toolchainMenu returns the editor's own toolchain menu.
30//
31// Its items come from OnOpen for the same reason the Snippets menu's do: they
32// are read from a file that changes while the editor runs, so there is nothing
33// to decide at start-up.
34//
35// The menu is always on the bar, even with no tools file, or the item that
36// creates one would be unreachable.
37func (a *App) toolchainMenu() *ui.Menu {
38 menu := &ui.Menu{Label: a.profile.ToolsMenu}
39 menu.OnOpen = func() { menu.Items = a.toolItems() }
40 return menu
41}
42
43// toolsMenuName is the plain name of that menu, which is what a tools file
44// writes in its menu key.
45func (a *App) toolsMenuName() string { return tools.DefaultMenuName(a.profile) }
46
47// toolItems builds the menu's contents: the project's commands, then the item
48// that writes a starter file.
49func (a *App) toolItems() []*ui.MenuItem {
50 items := a.commandItems(a.toolsMenuName())
51 if len(items) > 0 {
52 items = append(items, &ui.MenuItem{Separator: true})
53 }
54 return append(items,
55 &ui.MenuItem{Label: createToolsLabel, Action: a.CreateTools, Enabled: not(a.HasProjectTools)},
56 &ui.MenuItem{Label: openToolsLabel, Action: a.OpenTools, Enabled: a.HasProjectTools},
57 )
58}
59
60// commandItems turns one menu's tools into menu lines.
61//
62// A file that cannot be read gives one disabled line saying so, rather than an
63// empty menu: a typo should be visible where the commands were expected.
64func (a *App) commandItems(menu string) []*ui.MenuItem {
65 list, err := a.loadTools()
66 if err != nil {
67 return []*ui.MenuItem{{Label: "Cannot read tools", Enabled: func() bool { return false }}}
68 }
69
70 chosen := list.In(menu)
71 items := make([]*ui.MenuItem, 0, len(chosen))
72 for _, tool := range chosen {
73 chosen := tool // captured per iteration, not read back from the loop
74 items = append(items, &ui.MenuItem{
75 Label: tool.Name,
76 Action: func() { a.RunTool(chosen) },
77 })
78 }
79 return items
80}
81
82// loadTools reads the project's tools file.
83func (a *App) loadTools() (tools.List, error) {
84 working, err := os.Getwd()
85 if err != nil {
86 return tools.List{}, err
87 }
88 return tools.Load(a.profile, working)
89}
90
91// RunTool runs a command and shows its output where the tool asked for.
92//
93// A command with a `{{label}}` in it asks for that value first, in a dialog,
94// and does not run until the dialog is answered. Escape or Cancel means it does
95// not run at all.
96//
97// a.RunTool(tools.Tool{Name: "Test", Command: "go test ./...", Output: tools.OutputPopup})
98func (a *App) RunTool(tool tools.Tool) {
99 placeholders := tool.Placeholders()
100 if len(placeholders) == 0 {
101 a.runFilledTool(tool, tool.Command)
102 return
103 }
104 a.askForParameters(tool, placeholders)
105}
106
107// askForParameters opens the box, and runs the command once it is answered.
108//
109// The values are remembered for the rest of the session, per tool, so running
110// the same parameterised command twice does not mean typing the same thing
111// twice. Nothing is written to disk: the project's own directory is for what
112// the project decided, not for what somebody typed into a box this afternoon.
113func (a *App) askForParameters(tool tools.Tool, placeholders []tools.Placeholder) {
114 screen := a.screenRect()
115 if fits := MaxParameterFields(screen.H); len(placeholders) > fits {
116 a.ShowMessage(a.toolsMenuName(), fmt.Sprintf(
117 "%s asks for %d values, and only %d fit on a screen this tall.\n\nMake the terminal taller, or split the command into two tools.",
118 ui.PlainLabel(tool.Name), len(placeholders), fits))
119 return
120 }
121
122 labels := make([]string, len(placeholders))
123 for i, placeholder := range placeholders {
124 labels[i] = placeholder.Label
125 }
126
127 box := NewParametersDialog(ui.PlainLabel(tool.Name), labels, a.toolValues[tool.Name], screen)
128 a.pushModal(box.Dialog(), func(result ui.Result) {
129 if result != ui.ResultOK {
130 return
131 }
132 values := box.Values()
133 a.rememberToolValues(tool.Name, values)
134 a.runFilledTool(tool, tool.Fill(values))
135 })
136}
137
138// rememberToolValues keeps what was typed for the rest of the session.
139func (a *App) rememberToolValues(tool string, values map[string]string) {
140 if a.toolValues == nil {
141 a.toolValues = map[string]map[string]string{}
142 }
143 a.toolValues[tool] = values
144}
145
146// runFilledTool runs a command whose placeholders, if it had any, are already
147// substituted, and shows its output where the tool asked for.
148func (a *App) runFilledTool(tool tools.Tool, command string) {
149 working, err := os.Getwd()
150 if err != nil {
151 a.ShowMessage(a.toolsMenuName(), "Cannot tell which directory this is:\n"+err.Error())
152 return
153 }
154
155 switch tool.Where() {
156 case tools.OutputTerminal:
157 a.runInTerminal(command, working)
158 default:
159 a.runCaptured(tool, command, working)
160 }
161}
162
163// runInTerminal runs a command in a terminal window of its own.
164//
165// A window rather than a captured pane is what a program that reads the
166// keyboard needs, and what lets Ctrl-C stop one that is taking too long. The
167// window stays after the command exits, so its output can be read.
168func (a *App) runInTerminal(command, dir string) {
169 view, err := terminal.NewView(terminal.ViewOptions{
170 // The same shell the popup uses, told to run one command line and
171 // exit; tools knows which shell that is on this platform.
172 Options: terminal.Options{
173 Shell: tools.Shell(),
174 Args: tools.ShellArgs(command),
175 Dir: dir,
176 },
177 Name: command,
178 OnChange: a.wake,
179 // A command that has finished may have rewritten the files on disk,
180 // which is what Format does every time. The wake is what gets the
181 // reload onto the main goroutine, where touching buffers is safe.
182 OnExit: func() { a.toolFinished(); a.wake() },
183 })
184 if err != nil {
185 a.reportTerminalFailure(err)
186 return
187 }
188
189 window := ui.NewWindow(view.Title(), view)
190 window.SetBounds(a.newWindowBounds())
191 window.OnClose = func() bool { a.closeTerminal(window, view); return true }
192
193 a.desktop.Add(window)
194 a.windowsOpened++
195 a.terminals[window] = view
196 a.Message("Running " + command)
197}
198
199// runCaptured runs a command with its output collected, and shows it in a
200// dialog that fills in as it goes.
201//
202// The dialog opens straight away rather than when the command ends. A popup
203// appearing unbidden a few seconds later would swallow whatever was being
204// typed at that moment, and watching a build's output arrive is most of what
205// makes waiting for it bearable.
206func (a *App) runCaptured(tool tools.Tool, command, dir string) {
207 run, err := tools.Start(command, dir, a.wake)
208 if err != nil {
209 a.ShowMessage(a.toolsMenuName(), err.Error())
210 return
211 }
212
213 dialog, list := NewOutputDialog(runningTitle(command), nil, a.screenRect())
214 a.running = &toolRun{run: run, dialog: dialog, list: list, editor: tool.Where() == tools.OutputEditor}
215
216 // Closing the dialog stops the command: there is no other way to interrupt
217 // one whose output is not in a terminal, and leaving it running with
218 // nowhere to show itself would be worse than stopping it.
219 a.pushModal(dialog, func(ui.Result) { a.finishRun() })
220}
221
222// toolRun is the command whose output is showing, and where it goes.
223type toolRun struct {
224 run *tools.Run
225 dialog *ui.Dialog
226 list *ui.ListBox
227 editor bool
228}
229
230// refreshRunningTool copies what a command has printed into its dialog.
231//
232// It runs at the top of the event loop rather than from the reading goroutine,
233// which may not touch a dialog. The goroutine only wakes the loop — and if that
234// wake is dropped, the next keystroke brings the output in, which is late
235// rather than lost.
236func (a *App) refreshRunningTool() {
237 if a.running == nil {
238 return
239 }
240
241 lines := a.running.run.Lines()
242 if dropped := a.running.run.Dropped(); dropped > 0 {
243 lines = append([]string{fmt.Sprintf("… %d earlier lines dropped …", dropped)}, lines...)
244 }
245
246 atEnd := a.running.list.Selected() >= len(a.running.list.Items())-1
247 a.running.list.SetItems(lines)
248 if atEnd {
249 // Following the output is what a reader wants until they scroll back.
250 a.running.list.Select(len(lines) - 1)
251 }
252
253 finished, code := a.running.run.Done()
254 if !finished {
255 return
256 }
257
258 // A command that succeeded silently — `go build ./...` — would otherwise
259 // leave a blank dialog, which reads as "nothing happened" rather than as
260 // "it worked". While it is still running, blank is the honest picture.
261 if len(lines) == 0 {
262 a.running.list.SetItems([]string{noOutputLine})
263 }
264 a.running.dialog.SetTitle(finishedTitle(a.running.run.Command(), code))
265 a.toolFinished()
266}
267
268// noOutputLine is what a finished command that printed nothing shows.
269const noOutputLine = "(no output)"
270
271// finishRun clears the running command away once its dialog has been closed,
272// stopping it if it has not ended, and putting its output in a window when the
273// tool asked for one.
274func (a *App) finishRun() {
275 running := a.running
276 a.running = nil
277 if running == nil {
278 return
279 }
280
281 running.run.Stop()
282 if running.editor {
283 a.openOutputInEditor(running.run)
284 }
285 a.toolFinished()
286}
287
288// openOutputInEditor puts a command's output in an editing window.
289//
290// It is filled in once the command has ended rather than as it goes: this is
291// the mode for searching output with Ctrl-F, and a buffer growing under the
292// cursor while you search it would be the opposite of that.
293func (a *App) openOutputInEditor(run *tools.Run) {
294 buf := buffer.New()
295 buf.SetText(strings.Join(run.Lines(), "\n"))
296 buf.SetCursor(buffer.Position{})
297 a.openBuffer(buf)
298
299 if window := a.desktop.Active(); window != nil {
300 window.SetTitle(run.Command())
301 }
302}
303
304// runningTitle and finishedTitle are what a command's dialog is called.
305//
306// The exit code is always shown, because a command that succeeded silently
307// would otherwise give a dialog with nothing in it and no way to tell that from
308// one that had not started.
309func runningTitle(command string) string { return command + " — running" }
310
311func finishedTitle(command string, exit int) string {
312 if exit == 0 {
313 return command + " — ok"
314 }
315 return fmt.Sprintf("%s — exit %d", command, exit)
316}
317
318// toolFinished notes that a command has ended, so the next turn of the event
319// loop picks the change up.
320//
321// It is called from the reading goroutine, so it may not touch a buffer or the
322// desktop; setting a flag is all it does.
323func (a *App) toolFinished() { a.toolsRan.Store(true) }
324
325// reloadAfterTools re-reads the files a finished command may have rewritten.
326//
327// It runs at the top of the event loop, like the other state-driven work, and
328// only when a command has actually ended.
329//
330// **Only unmodified buffers are reloaded.** One with unsaved changes has
331// something to lose, so it is left alone and named on the status bar — which
332// is the honest outcome: a formatter and an unsaved edit genuinely conflict,
333// and the editor is not the one that should decide which wins.
334func (a *App) reloadAfterTools() {
335 if !a.toolsRan.Swap(false) {
336 return
337 }
338
339 reloaded, skipped := a.reloadUnmodifiedBuffers()
340 a.refreshTree()
341
342 switch {
343 case skipped > 0:
344 a.Message(reloadReport(reloaded) + "; " + plural(skipped, "file") + " with unsaved changes left alone")
345 case reloaded > 0:
346 a.Message(reloadReport(reloaded))
347 }
348}
349
350// reloadUnmodifiedBuffers re-reads every open file that has no unsaved
351// changes, and reports how many actually changed and how many were skipped.
352func (a *App) reloadUnmodifiedBuffers() (reloaded, skipped int) {
353 for _, window := range a.desktop.Windows() {
354 view, ok := editorViewOf(window)
355 if !ok || view.Buffer().Path() == "" {
356 continue
357 }
358 if view.Buffer().Modified() {
359 skipped++
360 continue
361 }
362
363 changed, err := view.Buffer().Reload()
364 if err != nil || !changed {
365 continue // a file that has gone, or one nothing touched
366 }
367 view.RefreshSyntax()
368 window.SetTitle(windowTitle(view.Buffer()))
369 reloaded++
370 }
371 return reloaded, skipped
372}
373
374// reloadReport says how many files were re-read.
375func reloadReport(reloaded int) string {
376 if reloaded == 0 {
377 return "Command finished"
378 }
379 return "Reloaded " + plural(reloaded, "file")
380}
381
382// plural renders a count with its noun, pluralised the simple way.
383func plural(count int, noun string) string {
384 if count == 1 {
385 return "1 " + noun
386 }
387 return strconv.Itoa(count) + " " + noun + "s"
388}
389
390// CreateTools writes a starter tools file for the project and opens it.
391//
392// The file is opened rather than merely written because the commands in it are
393// how the format is learnt, and changing one is the first thing anybody does.
394// A project that already has one is opened unchanged.
395func (a *App) CreateTools() {
396 create := func(project string) (string, error) { return tools.Create(a.profile, project) }
397 a.createProjectFile(a.toolsMenuName(), create, tools.ErrExists)
398}