// Terminal windows: a shell running in a pseudo-terminal, in a window of the // editor's own. package app import ( "errors" "os" "path/filepath" "rickub.com/turbo-editors/turbo-core/editor" "rickub.com/turbo-editors/turbo-core/terminal" "rickub.com/turbo-editors/turbo-core/ui" ) // NewTerminal opens a window running a shell. // // The shell starts in the directory of the file being edited, which is where // you want to be for `go build` or `git`. With no file open it starts wherever // the editor was started. // // On a platform without pseudo-terminal support this reports so and changes // nothing: the rest of the editor is unaffected. func (a *App) NewTerminal() { // The shell writes from a goroutine of its own, so neither callback may // touch the desktop: they only ask the event loop to come round again. They // are options rather than fields because NewView starts that goroutine. view, err := terminal.NewView(terminal.ViewOptions{ Options: terminal.Options{Dir: a.terminalDirectory()}, OnChange: a.wake, OnExit: 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 } // reportTerminalFailure explains why a terminal could not be opened. func (a *App) reportTerminalFailure(err error) { if errors.Is(err, terminal.ErrUnsupported) { a.ShowMessage("Terminal", "Terminal windows are not supported on this\nplatform yet — Linux, macOS and Windows have them.\n\nEverything else in the editor works as usual.") return } a.ShowMessage("Terminal", err.Error()) } // terminalDirectory returns where a new shell should start: beside the file // being edited, or the editor's own working directory. func (a *App) terminalDirectory() string { if view := a.activeView(); view != nil && view.Buffer().Path() != "" { if absolute, err := filepath.Abs(view.Buffer().Path()); err == nil { return filepath.Dir(absolute) } } if working, err := os.Getwd(); err == nil { return working } return "" } // closeTerminal ends a shell and takes its window away. // // A terminal is not asked about unsaved work: there is none. What it holds is // a running process, and closing the window is how you say you have finished // with it. func (a *App) closeTerminal(window *ui.Window, view *terminal.View) { _ = view.Close() delete(a.terminals, window) a.desktop.Remove(window) a.completion.Hide() } // terminalView returns the terminal in a window, if that is what it holds. func (a *App) terminalView(window *ui.Window) (*terminal.View, bool) { view, ok := a.terminals[window] return view, ok } // activeTerminal returns the terminal in the front window, or nil when the // front window holds a file. func (a *App) activeTerminal() *terminal.View { window := a.desktop.Active() if window == nil { return nil } if view, ok := a.terminals[window]; ok { return view } return nil } // refreshTerminalTitles keeps each terminal window's title in step with what // the program inside it calls itself. // // It runs on every turn of the event loop rather than on a notification, // because the title changes on the shell's goroutine and nothing else would // notice. func (a *App) refreshTerminalTitles() { for window, view := range a.terminals { if title := view.Title(); title != window.Title() { window.SetTitle(title) } } } // closeTerminals ends every shell, which is what leaving the editor does. func (a *App) closeTerminals() { for window, view := range a.terminals { _ = view.Close() delete(a.terminals, window) } } // isTerminalWindow reports whether a window holds a terminal rather than a // file. The file actions use it to leave terminals alone. func (a *App) isTerminalWindow(window *ui.Window) bool { _, ok := a.terminals[window] return ok } // editorViewOf returns the editor in a window, if that is what it holds. func editorViewOf(window *ui.Window) (*editor.View, bool) { view, ok := window.Content().(*editor.View) return view, ok }