// The toolchain menu: the commands a project runs on itself — format, lint, // build, test, run — read from a TOML file and each shown where it asks for. // // The menu is named by the profile: "Go" in Turbo Go, "Rust" in Turbo Rust. package app import ( "fmt" "os" "strconv" "strings" "codeberg.org/turbo-editors/turbo-core/buffer" "codeberg.org/turbo-editors/turbo-core/terminal" "codeberg.org/turbo-editors/turbo-core/tools" "codeberg.org/turbo-editors/turbo-core/ui" ) // createToolsLabel is the item that writes a starter file, kept at the bottom // of the menu so the commands above it read as the content. const createToolsLabel = "~C~reate tools file" // openToolsLabel is its partner. Exactly one of the two is ever available: you // can create the file you have not got, and open the one you have. const openToolsLabel = "~O~pen tools file" // toolchainMenu returns the editor's own toolchain menu. // // Its items come from OnOpen for the same reason the Snippets menu's do: they // are read from a file that changes while the editor runs, so there is nothing // to decide at start-up. // // The menu is always on the bar, even with no tools file, or the item that // creates one would be unreachable. func (a *App) toolchainMenu() *ui.Menu { menu := &ui.Menu{Label: a.profile.ToolsMenu} menu.OnOpen = func() { menu.Items = a.toolItems() } return menu } // toolsMenuName is the plain name of that menu, which is what a tools file // writes in its menu key. func (a *App) toolsMenuName() string { return tools.DefaultMenuName(a.profile) } // toolItems builds the menu's contents: the project's commands, then the item // that writes a starter file. func (a *App) toolItems() []*ui.MenuItem { items := a.commandItems(a.toolsMenuName()) if len(items) > 0 { items = append(items, &ui.MenuItem{Separator: true}) } return append(items, &ui.MenuItem{Label: createToolsLabel, Action: a.CreateTools, Enabled: not(a.HasProjectTools)}, &ui.MenuItem{Label: openToolsLabel, Action: a.OpenTools, Enabled: a.HasProjectTools}, ) } // commandItems turns one menu's tools into menu lines. // // A file that cannot be read gives one disabled line saying so, rather than an // empty menu: a typo should be visible where the commands were expected. func (a *App) commandItems(menu string) []*ui.MenuItem { list, err := a.loadTools() if err != nil { return []*ui.MenuItem{{Label: "Cannot read tools", Enabled: func() bool { return false }}} } chosen := list.In(menu) items := make([]*ui.MenuItem, 0, len(chosen)) for _, tool := range chosen { chosen := tool // captured per iteration, not read back from the loop items = append(items, &ui.MenuItem{ Label: tool.Name, Action: func() { a.RunTool(chosen) }, }) } return items } // loadTools reads the project's tools file. func (a *App) loadTools() (tools.List, error) { working, err := os.Getwd() if err != nil { return tools.List{}, err } return tools.Load(a.profile, working) } // RunTool runs a command and shows its output where the tool asked for. // // A command with a `{{label}}` in it asks for that value first, in a dialog, // and does not run until the dialog is answered. Escape or Cancel means it does // not run at all. // // a.RunTool(tools.Tool{Name: "Test", Command: "go test ./...", Output: tools.OutputPopup}) func (a *App) RunTool(tool tools.Tool) { placeholders := tool.Placeholders() if len(placeholders) == 0 { a.runFilledTool(tool, tool.Command) return } a.askForParameters(tool, placeholders) } // askForParameters opens the box, and runs the command once it is answered. // // The values are remembered for the rest of the session, per tool, so running // the same parameterised command twice does not mean typing the same thing // twice. Nothing is written to disk: the project's own directory is for what // the project decided, not for what somebody typed into a box this afternoon. func (a *App) askForParameters(tool tools.Tool, placeholders []tools.Placeholder) { screen := a.screenRect() if fits := MaxParameterFields(screen.H); len(placeholders) > fits { a.ShowMessage(a.toolsMenuName(), fmt.Sprintf( "%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.", ui.PlainLabel(tool.Name), len(placeholders), fits)) return } labels := make([]string, len(placeholders)) for i, placeholder := range placeholders { labels[i] = placeholder.Label } box := NewParametersDialog(ui.PlainLabel(tool.Name), labels, a.toolValues[tool.Name], screen) a.pushModal(box.Dialog(), func(result ui.Result) { if result != ui.ResultOK { return } values := box.Values() a.rememberToolValues(tool.Name, values) a.runFilledTool(tool, tool.Fill(values)) }) } // rememberToolValues keeps what was typed for the rest of the session. func (a *App) rememberToolValues(tool string, values map[string]string) { if a.toolValues == nil { a.toolValues = map[string]map[string]string{} } a.toolValues[tool] = values } // runFilledTool runs a command whose placeholders, if it had any, are already // substituted, and shows its output where the tool asked for. func (a *App) runFilledTool(tool tools.Tool, command string) { working, err := os.Getwd() if err != nil { a.ShowMessage(a.toolsMenuName(), "Cannot tell which directory this is:\n"+err.Error()) return } switch tool.Where() { case tools.OutputTerminal: a.runInTerminal(command, working) default: a.runCaptured(tool, command, working) } } // runInTerminal runs a command in a terminal window of its own. // // A window rather than a captured pane is what a program that reads the // keyboard needs, and what lets Ctrl-C stop one that is taking too long. The // window stays after the command exits, so its output can be read. func (a *App) runInTerminal(command, dir string) { view, err := terminal.NewView(terminal.ViewOptions{ // The same shell the popup uses, told to run one command line and // exit; tools knows which shell that is on this platform. Options: terminal.Options{ Shell: tools.Shell(), Args: tools.ShellArgs(command), Dir: dir, }, Name: command, OnChange: a.wake, // A command that has finished may have rewritten the files on disk, // which is what Format does every time. The wake is what gets the // reload onto the main goroutine, where touching buffers is safe. OnExit: func() { a.toolFinished(); a.wake() }, }) if err != nil { a.reportTerminalFailure(err) return } window := ui.NewWindow(view.Title(), view) window.SetBounds(a.newWindowBounds()) window.OnClose = func() bool { a.closeTerminal(window, view); return true } a.desktop.Add(window) a.windowsOpened++ a.terminals[window] = view a.Message("Running " + command) } // runCaptured runs a command with its output collected, and shows it in a // dialog that fills in as it goes. // // The dialog opens straight away rather than when the command ends. A popup // appearing unbidden a few seconds later would swallow whatever was being // typed at that moment, and watching a build's output arrive is most of what // makes waiting for it bearable. func (a *App) runCaptured(tool tools.Tool, command, dir string) { run, err := tools.Start(command, dir, a.wake) if err != nil { a.ShowMessage(a.toolsMenuName(), err.Error()) return } dialog, list := NewOutputDialog(runningTitle(command), nil, a.screenRect()) a.running = &toolRun{run: run, dialog: dialog, list: list, editor: tool.Where() == tools.OutputEditor} // Closing the dialog stops the command: there is no other way to interrupt // one whose output is not in a terminal, and leaving it running with // nowhere to show itself would be worse than stopping it. a.pushModal(dialog, func(ui.Result) { a.finishRun() }) } // toolRun is the command whose output is showing, and where it goes. type toolRun struct { run *tools.Run dialog *ui.Dialog list *ui.ListBox editor bool } // refreshRunningTool copies what a command has printed into its dialog. // // It runs at the top of the event loop rather than from the reading goroutine, // which may not touch a dialog. The goroutine only wakes the loop — and if that // wake is dropped, the next keystroke brings the output in, which is late // rather than lost. func (a *App) refreshRunningTool() { if a.running == nil { return } lines := a.running.run.Lines() if dropped := a.running.run.Dropped(); dropped > 0 { lines = append([]string{fmt.Sprintf("… %d earlier lines dropped …", dropped)}, lines...) } atEnd := a.running.list.Selected() >= len(a.running.list.Items())-1 a.running.list.SetItems(lines) if atEnd { // Following the output is what a reader wants until they scroll back. a.running.list.Select(len(lines) - 1) } finished, code := a.running.run.Done() if !finished { return } // A command that succeeded silently — `go build ./...` — would otherwise // leave a blank dialog, which reads as "nothing happened" rather than as // "it worked". While it is still running, blank is the honest picture. if len(lines) == 0 { a.running.list.SetItems([]string{noOutputLine}) } a.running.dialog.SetTitle(finishedTitle(a.running.run.Command(), code)) a.toolFinished() } // noOutputLine is what a finished command that printed nothing shows. const noOutputLine = "(no output)" // finishRun clears the running command away once its dialog has been closed, // stopping it if it has not ended, and putting its output in a window when the // tool asked for one. func (a *App) finishRun() { running := a.running a.running = nil if running == nil { return } running.run.Stop() if running.editor { a.openOutputInEditor(running.run) } a.toolFinished() } // openOutputInEditor puts a command's output in an editing window. // // It is filled in once the command has ended rather than as it goes: this is // the mode for searching output with Ctrl-F, and a buffer growing under the // cursor while you search it would be the opposite of that. func (a *App) openOutputInEditor(run *tools.Run) { buf := buffer.New() buf.SetText(strings.Join(run.Lines(), "\n")) buf.SetCursor(buffer.Position{}) a.openBuffer(buf) if window := a.desktop.Active(); window != nil { window.SetTitle(run.Command()) } } // runningTitle and finishedTitle are what a command's dialog is called. // // The exit code is always shown, because a command that succeeded silently // would otherwise give a dialog with nothing in it and no way to tell that from // one that had not started. func runningTitle(command string) string { return command + " — running" } func finishedTitle(command string, exit int) string { if exit == 0 { return command + " — ok" } return fmt.Sprintf("%s — exit %d", command, exit) } // toolFinished notes that a command has ended, so the next turn of the event // loop picks the change up. // // It is called from the reading goroutine, so it may not touch a buffer or the // desktop; setting a flag is all it does. func (a *App) toolFinished() { a.toolsRan.Store(true) } // reloadAfterTools re-reads the files a finished command may have rewritten. // // It runs at the top of the event loop, like the other state-driven work, and // only when a command has actually ended. // // **Only unmodified buffers are reloaded.** One with unsaved changes has // something to lose, so it is left alone and named on the status bar — which // is the honest outcome: a formatter and an unsaved edit genuinely conflict, // and the editor is not the one that should decide which wins. func (a *App) reloadAfterTools() { if !a.toolsRan.Swap(false) { return } reloaded, skipped := a.reloadUnmodifiedBuffers() a.refreshTree() switch { case skipped > 0: a.Message(reloadReport(reloaded) + "; " + plural(skipped, "file") + " with unsaved changes left alone") case reloaded > 0: a.Message(reloadReport(reloaded)) } } // reloadUnmodifiedBuffers re-reads every open file that has no unsaved // changes, and reports how many actually changed and how many were skipped. func (a *App) reloadUnmodifiedBuffers() (reloaded, skipped int) { for _, window := range a.desktop.Windows() { view, ok := editorViewOf(window) if !ok || view.Buffer().Path() == "" { continue } if view.Buffer().Modified() { skipped++ continue } changed, err := view.Buffer().Reload() if err != nil || !changed { continue // a file that has gone, or one nothing touched } view.RefreshSyntax() window.SetTitle(windowTitle(view.Buffer())) reloaded++ } return reloaded, skipped } // reloadReport says how many files were re-read. func reloadReport(reloaded int) string { if reloaded == 0 { return "Command finished" } return "Reloaded " + plural(reloaded, "file") } // plural renders a count with its noun, pluralised the simple way. func plural(count int, noun string) string { if count == 1 { return "1 " + noun } return strconv.Itoa(count) + " " + noun + "s" } // CreateTools writes a starter tools file for the project and opens it. // // The file is opened rather than merely written because the commands in it are // how the format is learnt, and changing one is the first thing anybody does. // A project that already has one is opened unchanged. func (a *App) CreateTools() { create := func(project string) (string, error) { return tools.Create(a.profile, project) } a.createProjectFile(a.toolsMenuName(), create, tools.ErrExists) }