| 🛟 Updated. 28d5985 k33g 18h ago | 1 | // Terminal windows: a shell running in a pseudo-terminal, in a window of the |
| 2 | // editor's own. |
| 3 | |
| 4 | package app |
| 5 | |
| 6 | import ( |
| 7 | "errors" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | |
| 11 | "codeberg.org/turbo-editors/turbo-core/editor" |
| 12 | "codeberg.org/turbo-editors/turbo-core/terminal" |
| 13 | "codeberg.org/turbo-editors/turbo-core/ui" |
| 14 | ) |
| 15 | |
| 16 | // NewTerminal opens a window running a shell. |
| 17 | // |
| 18 | // The shell starts in the directory of the file being edited, which is where |
| 19 | // you want to be for `go build` or `git`. With no file open it starts wherever |
| 20 | // the editor was started. |
| 21 | // |
| 22 | // On a platform without pseudo-terminal support this reports so and changes |
| 23 | // nothing: the rest of the editor is unaffected. |
| 24 | func (a *App) NewTerminal() { |
| 25 | // The shell writes from a goroutine of its own, so neither callback may |
| 26 | // touch the desktop: they only ask the event loop to come round again. They |
| 27 | // are options rather than fields because NewView starts that goroutine. |
| 28 | view, err := terminal.NewView(terminal.ViewOptions{ |
| 29 | Options: terminal.Options{Dir: a.terminalDirectory()}, |
| 30 | OnChange: a.wake, |
| 31 | OnExit: a.wake, |
| 32 | }) |
| 33 | if err != nil { |
| 34 | a.reportTerminalFailure(err) |
| 35 | return |
| 36 | } |
| 37 | |
| 38 | window := ui.NewWindow(view.Title(), view) |
| 39 | window.SetBounds(a.newWindowBounds()) |
| 40 | window.OnClose = func() bool { a.closeTerminal(window, view); return true } |
| 41 | |
| 42 | a.desktop.Add(window) |
| 43 | a.windowsOpened++ |
| 44 | a.terminals[window] = view |
| 45 | } |
| 46 | |
| 47 | // reportTerminalFailure explains why a terminal could not be opened. |
| 48 | func (a *App) reportTerminalFailure(err error) { |
| 49 | if errors.Is(err, terminal.ErrUnsupported) { |
| 50 | 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.") |
| 51 | return |
| 52 | } |
| 53 | a.ShowMessage("Terminal", err.Error()) |
| 54 | } |
| 55 | |
| 56 | // terminalDirectory returns where a new shell should start: beside the file |
| 57 | // being edited, or the editor's own working directory. |
| 58 | func (a *App) terminalDirectory() string { |
| 59 | if view := a.activeView(); view != nil && view.Buffer().Path() != "" { |
| 60 | if absolute, err := filepath.Abs(view.Buffer().Path()); err == nil { |
| 61 | return filepath.Dir(absolute) |
| 62 | } |
| 63 | } |
| 64 | if working, err := os.Getwd(); err == nil { |
| 65 | return working |
| 66 | } |
| 67 | return "" |
| 68 | } |
| 69 | |
| 70 | // closeTerminal ends a shell and takes its window away. |
| 71 | // |
| 72 | // A terminal is not asked about unsaved work: there is none. What it holds is |
| 73 | // a running process, and closing the window is how you say you have finished |
| 74 | // with it. |
| 75 | func (a *App) closeTerminal(window *ui.Window, view *terminal.View) { |
| 76 | _ = view.Close() |
| 77 | delete(a.terminals, window) |
| 78 | a.desktop.Remove(window) |
| 79 | a.completion.Hide() |
| 80 | } |
| 81 | |
| 82 | // terminalView returns the terminal in a window, if that is what it holds. |
| 83 | func (a *App) terminalView(window *ui.Window) (*terminal.View, bool) { |
| 84 | view, ok := a.terminals[window] |
| 85 | return view, ok |
| 86 | } |
| 87 | |
| 88 | // activeTerminal returns the terminal in the front window, or nil when the |
| 89 | // front window holds a file. |
| 90 | func (a *App) activeTerminal() *terminal.View { |
| 91 | window := a.desktop.Active() |
| 92 | if window == nil { |
| 93 | return nil |
| 94 | } |
| 95 | if view, ok := a.terminals[window]; ok { |
| 96 | return view |
| 97 | } |
| 98 | return nil |
| 99 | } |
| 100 | |
| 101 | // refreshTerminalTitles keeps each terminal window's title in step with what |
| 102 | // the program inside it calls itself. |
| 103 | // |
| 104 | // It runs on every turn of the event loop rather than on a notification, |
| 105 | // because the title changes on the shell's goroutine and nothing else would |
| 106 | // notice. |
| 107 | func (a *App) refreshTerminalTitles() { |
| 108 | for window, view := range a.terminals { |
| 109 | if title := view.Title(); title != window.Title() { |
| 110 | window.SetTitle(title) |
| 111 | } |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | // closeTerminals ends every shell, which is what leaving the editor does. |
| 116 | func (a *App) closeTerminals() { |
| 117 | for window, view := range a.terminals { |
| 118 | _ = view.Close() |
| 119 | delete(a.terminals, window) |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | // isTerminalWindow reports whether a window holds a terminal rather than a |
| 124 | // file. The file actions use it to leave terminals alone. |
| 125 | func (a *App) isTerminalWindow(window *ui.Window) bool { |
| 126 | _, ok := a.terminals[window] |
| 127 | return ok |
| 128 | } |
| 129 | |
| 130 | // editorViewOf returns the editor in a window, if that is what it holds. |
| 131 | func editorViewOf(window *ui.Window) (*editor.View, bool) { |
| 132 | view, ok := window.Content().(*editor.View) |
| 133 | return view, ok |
| 134 | } |