Skip to content

Commit b5492ae

Browse files
committed
Move to CELocalShellTerminalView with cleaner shell setup
1 parent 170af9a commit b5492ae

6 files changed

Lines changed: 309 additions & 246 deletions

File tree

CodeEdit/Features/TerminalEmulator/Model/ShellIntegration.swift

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,12 @@ enum ShellIntegration {
5151
/// - Returns: An array of args to pass to the shell executable.
5252
/// - Throws: Errors involving filesystem operations. This function requires copying various files, which can
5353
/// throw. Can also throw ``ShellIntegration/Error`` errors if required files are not found in the bundle.
54-
static func setUpIntegration(for shell: Shell, environment: inout [String], useLogin: Bool) throws -> [String] {
54+
static func setUpIntegration(
55+
for shell: Shell,
56+
environment: inout [String],
57+
useLogin: Bool,
58+
interactive: Bool
59+
) throws -> [String] {
5560
do {
5661
logger.debug("Setting up shell: \(shell.rawValue)")
5762
var args: [String] = []
@@ -61,9 +66,9 @@ enum ShellIntegration {
6166

6267
switch shell {
6368
case .bash:
64-
try bash(&args)
69+
try bash(&args, interactive)
6570
case .zsh:
66-
try zsh(&args, &environment, useLogin)
71+
try zsh(&args, &environment, useLogin, interactive)
6772
}
6873

6974
if useLogin {
@@ -84,18 +89,23 @@ enum ShellIntegration {
8489
///
8590
/// Sets the bash `--init-file` option to point to CE's shell integration script. This script will source the
8691
/// user's "real" init file and then install our required functions.
87-
/// Also sets the `-i` option to initialize an interactive session.
92+
/// Also sets the `-i` option to initialize an interactive session if `interactive` is true.
8893
///
89-
/// - Parameter args: The args to use for shell exec, will be modified by this function.
90-
private static func bash(_ args: inout [String]) throws {
94+
/// - Parameters:
95+
/// - args: The args to use for shell exec, will be modified by this function.
96+
/// - interactive: Set to true to use an interactive shell.
97+
private static func bash(_ args: inout [String], _ interactive: Bool) throws {
9198
// Inject our own bash script that will execute the user's init files, then install our pre/post exec functions.
9299
guard let scriptURL = Bundle.main.url(
93100
forResource: "codeedit_shell_integration",
94101
withExtension: "bash"
95102
) else {
96103
throw Error.bashShellFileNotFound
97104
}
98-
args += ["--init-file", scriptURL.path(), "-i"]
105+
args.append(contentsOf: ["--init-file", scriptURL.path()])
106+
if interactive {
107+
args.append("-i")
108+
}
99109
}
100110

101111
/// Sets up the `zsh` shell integration.
@@ -111,12 +121,23 @@ enum ShellIntegration {
111121
/// - environment: Environment variables in an array. Formatted as `EnvVar=Value`. Will be modified by this
112122
/// function.
113123
/// - useLogin: Whether to use a login shell.
114-
private static func zsh(_ args: inout [String], _ environment: inout [String], _ useLogin: Bool) throws {
124+
/// - interactive: Whether to use an interactive shell.
125+
private static func zsh(
126+
_ args: inout [String],
127+
_ environment: inout [String],
128+
_ useLogin: Bool,
129+
_ interactive: Bool
130+
) throws {
115131
// Interactive, login shell.
116-
if useLogin {
132+
switch (useLogin, interactive) {
133+
case (true, true):
117134
args.append("-il")
118-
} else {
135+
case (false, true):
119136
args.append("-i")
137+
case (true, false):
138+
args.append("-l")
139+
default:
140+
break
120141
}
121142

122143
// All injection script URLs

CodeEdit/Features/TerminalEmulator/Model/TerminalCache.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ final class TerminalCache {
1414
static let shared: TerminalCache = TerminalCache()
1515

1616
/// The cache of terminal views.
17-
private var terminals: [UUID: CELocalProcessTerminalView]
17+
private var terminals: [UUID: CELocalShellTerminalView]
1818

1919
private init() {
2020
terminals = [:]
@@ -23,15 +23,15 @@ final class TerminalCache {
2323
/// Get a cached terminal view.
2424
/// - Parameter id: The ID of the terminal.
2525
/// - Returns: The existing terminal, if it exists.
26-
func getTerminalView(_ id: UUID) -> CELocalProcessTerminalView? {
26+
func getTerminalView(_ id: UUID) -> CELocalShellTerminalView? {
2727
terminals[id]
2828
}
2929

3030
/// Store a terminal view for reuse.
3131
/// - Parameters:
3232
/// - id: The ID of the terminal.
3333
/// - view: The view representing the terminal's contents.
34-
func cacheTerminalView(for id: UUID, view: CELocalProcessTerminalView) {
34+
func cacheTerminalView(for id: UUID, view: CELocalShellTerminalView) {
3535
terminals[id] = view
3636
}
3737

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
//
2+
// CELocalShellTerminalView.swift
3+
// CodeEdit
4+
//
5+
// Created by Khan Winter on 8/7/24.
6+
//
7+
8+
import AppKit
9+
import SwiftTerm
10+
import Foundation
11+
12+
/// # Dev Note (please read)
13+
///
14+
/// This entire file is a nearly 1:1 copy of SwiftTerm's `LocalProcessTerminalView`. The exception being the use of
15+
/// `CETerminalView` over `TerminalView`. This change was made to fix the terminal clearing when the view was given a
16+
/// frame of `0`. This enables terminals to keep running in the background, and allows them to be removed and added
17+
/// back into the hierarchy for use in the utility area.
18+
///
19+
/// If there is a bug here: **there probably isn't**. Look instead in ``TerminalEmulatorView``.
20+
21+
protocol CELocalShellTerminalViewDelegate: AnyObject {
22+
/// This method is invoked to notify that the terminal has been resized to the specified number of columns and rows
23+
/// the user interface code might try to adjust the containing scroll view, or if it is a top level window, the
24+
/// window itself
25+
/// - Parameter source: the sending instance
26+
/// - Parameter newCols: the new number of columns that should be shown
27+
/// - Parameter newRow: the new number of rows that should be shown
28+
func sizeChanged(source: CETerminalView, newCols: Int, newRows: Int)
29+
30+
/// This method is invoked when the title of the terminal window should be updated to the provided title
31+
/// - Parameter source: the sending instance
32+
/// - Parameter title: the desired title
33+
func setTerminalTitle(source: CETerminalView, title: String)
34+
35+
/// Invoked when the OSC command 7 for "current directory has changed" command is sent
36+
/// - Parameter source: the sending instance
37+
/// - Parameter directory: the new working directory
38+
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?)
39+
40+
/// This method will be invoked when the child process started by `startProcess` has terminated.
41+
/// - Parameter source: the local process that terminated
42+
/// - Parameter exitCode: the exit code returned by the process, or nil if this was an error caused during
43+
/// the IO reading/writing
44+
func processTerminated(source: TerminalView, exitCode: Int32?)
45+
46+
/// Invoked when the shell integration notifies the terminal that a user-executed command has finished with an exit
47+
/// code.
48+
func userProcessTerminated(exitCode: Int32)
49+
}
50+
51+
// MARK: - CELocalShellTerminalView
52+
53+
class CELocalShellTerminalView: CETerminalView, TerminalViewDelegate, LocalProcessDelegate {
54+
var process: LocalProcess!
55+
56+
override public init(frame: CGRect) {
57+
super.init(frame: frame)
58+
setup()
59+
}
60+
61+
public required init?(coder: NSCoder) {
62+
super.init(coder: coder)
63+
setup()
64+
}
65+
66+
/// The `processDelegate` is used to deliver messages and information relevant to the execution of the terminal.
67+
public weak var processDelegate: CELocalShellTerminalViewDelegate?
68+
69+
func setup() {
70+
terminal = Terminal(delegate: self, options: TerminalOptions(scrollback: 2000))
71+
terminalDelegate = self
72+
process = LocalProcess(delegate: self)
73+
}
74+
75+
/// Launches a child process inside a pseudo-terminal.
76+
/// - Parameters:
77+
/// - workspaceURL: The URL of the workspace to start at.
78+
/// - shell: The shell to use, leave as `nil` to
79+
public func startProcess(
80+
workspaceURL url: URL,
81+
shell: Shell? = nil,
82+
environment: [String] = [],
83+
interactive: Bool = true
84+
) {
85+
let terminalSettings = Settings.shared.preferences.terminal
86+
// changes working directory to project root
87+
// TODO: Get rid of FileManager shared instance to prevent problems
88+
// using shared instance of FileManager might lead to problems when using
89+
// multiple workspaces. This works for now but most probably will need
90+
// to be changed later on
91+
FileManager.default.changeCurrentDirectoryPath(url.path)
92+
93+
var terminalEnvironment: [String] = Terminal.getEnvironmentVariables()
94+
terminalEnvironment.append("TERM_PROGRAM=CodeEditApp_Terminal")
95+
96+
guard let (shell, shellPath) = getShell(shell, userSetting: terminalSettings.shell) else {
97+
return
98+
}
99+
100+
processDelegate?.setTerminalTitle(source: self, title: shell.rawValue)
101+
102+
do {
103+
let shellArgs: [String]
104+
if terminalSettings.useShellIntegration {
105+
shellArgs = try ShellIntegration.setUpIntegration(
106+
for: shell,
107+
environment: &terminalEnvironment,
108+
useLogin: terminalSettings.useLoginShell,
109+
interactive: interactive
110+
)
111+
} else {
112+
shellArgs = []
113+
}
114+
115+
terminalEnvironment.append(contentsOf: environment)
116+
117+
process.startProcess(
118+
executable: shellPath,
119+
args: shellArgs,
120+
environment: terminalEnvironment,
121+
execName: shell.rawValue
122+
)
123+
} catch {
124+
terminal.feed(text: "Failed to start a terminal session: \(error.localizedDescription)")
125+
}
126+
}
127+
128+
/// Returns a string of a shell path to use
129+
func getShell(_ shellType: Shell?, userSetting: SettingsData.TerminalShell) -> (Shell, String)? {
130+
if let shellType {
131+
return (shellType, shellType.defaultPath)
132+
}
133+
switch userSetting {
134+
case .system:
135+
let defaultShell = Shell.autoDetectDefaultShell()
136+
guard let type = Shell(rawValue: NSString(string: defaultShell).lastPathComponent) else { return nil }
137+
return (type, defaultShell)
138+
case .bash:
139+
return (.bash, "/bin/bash")
140+
case .zsh:
141+
return (.zsh, "/bin/zsh")
142+
}
143+
}
144+
145+
// MARK: - TerminalViewDelegate
146+
147+
/// This method is invoked to notify the client of the new columsn and rows that have been set by the UI
148+
public func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {
149+
guard process.running else {
150+
return
151+
}
152+
var size = getWindowSize()
153+
_ = PseudoTerminalHelpers.setWinSize(masterPtyDescriptor: process.childfd, windowSize: &size)
154+
155+
processDelegate?.sizeChanged(source: self, newCols: newCols, newRows: newRows)
156+
}
157+
158+
public func clipboardCopy(source: TerminalView, content: Data) {
159+
if let str = String(bytes: content, encoding: .utf8) {
160+
let pasteBoard = NSPasteboard.general
161+
pasteBoard.clearContents()
162+
pasteBoard.writeObjects([str as NSString])
163+
}
164+
}
165+
166+
public func rangeChanged(source: TerminalView, startY: Int, endY: Int) { }
167+
168+
/// Invoke this method to notify the processDelegate of the new title for the terminal window
169+
public func setTerminalTitle(source: TerminalView, title: String) {
170+
processDelegate?.setTerminalTitle(source: self, title: title)
171+
}
172+
173+
public func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {
174+
processDelegate?.hostCurrentDirectoryUpdate(source: source, directory: directory)
175+
}
176+
177+
/// Passes data from the terminal to the shell.
178+
/// Eg, the user types characters, this forwards the data to the shell.
179+
public func send(source: TerminalView, data: ArraySlice<UInt8>) {
180+
process.send(data: data)
181+
}
182+
183+
public func scrolled(source: TerminalView, position: Double) { }
184+
185+
// MARK: - LocalProcessDelegate
186+
187+
/// Implements the LocalProcessDelegate method.
188+
public func processTerminated(_ source: LocalProcess, exitCode: Int32?) {
189+
processDelegate?.processTerminated(source: self, exitCode: exitCode)
190+
}
191+
192+
/// Implements the LocalProcessDelegate.dataReceived method
193+
///
194+
/// Passes data from the shell to the terminal.
195+
public func dataReceived(slice: ArraySlice<UInt8>) {
196+
feed(byteArray: slice)
197+
}
198+
199+
/// Implements the LocalProcessDelegate.getWindowSize method
200+
public func getWindowSize() -> winsize {
201+
let frame: CGRect = self.frame
202+
return winsize(
203+
ws_row: UInt16(getTerminal().rows),
204+
ws_col: UInt16(getTerminal().cols),
205+
ws_xpixel: UInt16(frame.width),
206+
ws_ypixel: UInt16(frame.height)
207+
)
208+
}
209+
}

0 commit comments

Comments
 (0)