| 🛟 Updated. 28d5985 k33g 18h ago | 1 | // 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 | |
| 6 | package app |
| 7 | |
| 8 | import ( |
| 9 | "fmt" |
| 10 | "os" |
| 11 | "strconv" |
| 12 | "strings" |
| 13 | |
| 📦 Turbo Core f3ade8d k33g 11h ago | 14 | "rickub.com/turbo-editors/turbo-core/buffer" |
| 🛟 Updated. 28d5985 k33g 18h ago | 15 | |
| 📦 Turbo Core f3ade8d k33g 11h ago | 16 | "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 ago | 19 | ) |
| 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. |
| 23 | const 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. |
| 27 | const 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. |
| 37 | func (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. |
| 45 | func (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. |
| 49 | func (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. |
| 64 | func (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. |
| 83 | func (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}) |
| 98 | func (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. |
| 113 | func (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. |
| 139 | func (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. |
| 148 | func (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. |
| 168 | func (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. |
| 206 | func (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. |
| 223 | type 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. |
| 236 | func (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. |
| 269 | const 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. |
| 274 | func (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. |
| 293 | func (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. |
| 309 | func runningTitle(command string) string { return command + " — running" } |
| 310 | |
| 311 | func 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. |
| 323 | func (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. |
| 334 | func (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. |
| 352 | func (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. |
| 375 | func 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. |
| 383 | func 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. |
| 395 | func (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 | } |