Skip to content

Commit 0bf177f

Browse files
Fix Terminal State Loss (#1843)
* Fix Terminal State Loss * Use Transparent Terminal Caret * Fix Resizing Bug, Refactor Changes From Main, Pin SwiftTerm
1 parent df1f3b2 commit 0bf177f

File tree

12 files changed

+632
-291
lines changed

12 files changed

+632
-291
lines changed

CodeEdit.xcodeproj/project.pbxproj

Lines changed: 63 additions & 33 deletions
Large diffs are not rendered by default.

CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 4 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

CodeEdit/Features/TerminalEmulator/Model/Shell.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,23 @@ enum Shell: String, CaseIterable {
7777
// Run the process
7878
try process.run()
7979
}
80+
81+
var defaultPath: String {
82+
switch self {
83+
case .bash:
84+
"/bin/bash"
85+
case .zsh:
86+
"/bin/zsh"
87+
}
88+
}
89+
90+
/// Gets the default shell from the current user and returns the string of the shell path.
91+
///
92+
/// If getting the user's shell does not work, defaults to `zsh`,
93+
static func autoDetectDefaultShell() -> String {
94+
guard let currentUser = CurrentUser.getCurrentUser() else {
95+
return Self.zsh.rawValue // macOS defaults to zsh
96+
}
97+
return currentUser.shell
98+
}
8099
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
//
2+
// TerminalCache.swift
3+
// CodeEdit
4+
//
5+
// Created by Khan Winter on 7/27/24.
6+
//
7+
8+
import Foundation
9+
import SwiftTerm
10+
11+
/// Stores a mapping of ID -> terminal view for reusing terminal views.
12+
/// This allows terminal views to continue to receive data even when not in the view hierarchy.
13+
final class TerminalCache {
14+
static let shared: TerminalCache = TerminalCache()
15+
16+
/// The cache of terminal views.
17+
private var terminals: [UUID: CELocalProcessTerminalView]
18+
19+
private init() {
20+
terminals = [:]
21+
}
22+
23+
/// Get a cached terminal view.
24+
/// - Parameter id: The ID of the terminal.
25+
/// - Returns: The existing terminal, if it exists.
26+
func getTerminalView(_ id: UUID) -> CELocalProcessTerminalView? {
27+
terminals[id]
28+
}
29+
30+
/// Store a terminal view for reuse.
31+
/// - Parameters:
32+
/// - id: The ID of the terminal.
33+
/// - view: The view representing the terminal's contents.
34+
func cacheTerminalView(for id: UUID, view: CELocalProcessTerminalView) {
35+
terminals[id] = view
36+
}
37+
38+
/// Remove any view associated with the terminal id.
39+
/// - Parameter id: The ID of the terminal.
40+
func removeCachedView(_ id: UUID) {
41+
terminals[id] = nil
42+
}
43+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
//
2+
// CETerminalView.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+
class CETerminalView: TerminalView {
22+
override var frame: NSRect {
23+
get {
24+
return super.frame
25+
}
26+
set(newValue) {
27+
if newValue != .zero {
28+
super.frame = newValue
29+
}
30+
}
31+
}
32+
}
33+
34+
protocol CELocalProcessTerminalViewDelegate: AnyObject {
35+
/// This method is invoked to notify that the terminal has been resized to the specified number of columns and rows
36+
/// the user interface code might try to adjust the containing scroll view, or if it is a top level window, the
37+
/// window itself
38+
/// - Parameter source: the sending instance
39+
/// - Parameter newCols: the new number of columns that should be shown
40+
/// - Parameter newRow: the new number of rows that should be shown
41+
func sizeChanged(source: CETerminalView, newCols: Int, newRows: Int)
42+
43+
/// This method is invoked when the title of the terminal window should be updated to the provided title
44+
/// - Parameter source: the sending instance
45+
/// - Parameter title: the desired title
46+
func setTerminalTitle(source: CETerminalView, title: String)
47+
48+
/// Invoked when the OSC command 7 for "current directory has changed" command is sent
49+
/// - Parameter source: the sending instance
50+
/// - Parameter directory: the new working directory
51+
func hostCurrentDirectoryUpdate (source: TerminalView, directory: String?)
52+
53+
/// This method will be invoked when the child process started by `startProcess` has terminated.
54+
/// - Parameter source: the local process that terminated
55+
/// - Parameter exitCode: the exit code returned by the process, or nil if this was an error caused during
56+
/// the IO reading/writing
57+
func processTerminated (source: TerminalView, exitCode: Int32?)
58+
}
59+
60+
class CELocalProcessTerminalView: CETerminalView, TerminalViewDelegate, LocalProcessDelegate {
61+
var process: LocalProcess!
62+
63+
override public init (frame: CGRect) {
64+
super.init(frame: frame)
65+
setup()
66+
}
67+
68+
public required init? (coder: NSCoder) {
69+
super.init(coder: coder)
70+
setup()
71+
}
72+
73+
func setup () {
74+
terminalDelegate = self
75+
process = LocalProcess(delegate: self)
76+
}
77+
78+
/// The `processDelegate` is used to deliver messages and information relevant to the execution of the terminal.
79+
public weak var processDelegate: CELocalProcessTerminalViewDelegate?
80+
81+
/// This method is invoked to notify the client of the new columsn and rows that have been set by the UI
82+
public func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {
83+
guard process.running else {
84+
return
85+
}
86+
var size = getWindowSize()
87+
_ = PseudoTerminalHelpers.setWinSize(masterPtyDescriptor: process.childfd, windowSize: &size)
88+
89+
processDelegate?.sizeChanged(source: self, newCols: newCols, newRows: newRows)
90+
}
91+
92+
public func clipboardCopy(source: TerminalView, content: Data) {
93+
if let str = String(bytes: content, encoding: .utf8) {
94+
let pasteBoard = NSPasteboard.general
95+
pasteBoard.clearContents()
96+
pasteBoard.writeObjects([str as NSString])
97+
}
98+
}
99+
100+
public func rangeChanged(source: TerminalView, startY: Int, endY: Int) { }
101+
102+
/// Invoke this method to notify the processDelegate of the new title for the terminal window
103+
public func setTerminalTitle(source: TerminalView, title: String) {
104+
processDelegate?.setTerminalTitle(source: self, title: title)
105+
}
106+
107+
public func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {
108+
processDelegate?.hostCurrentDirectoryUpdate(source: source, directory: directory)
109+
}
110+
111+
/// This method is invoked when input from the user needs to be sent to the client
112+
public func send(source: TerminalView, data: ArraySlice<UInt8>) {
113+
process.send(data: data)
114+
}
115+
116+
/// Use this method to toggle the logging of data coming from the host, or pass nil to stop
117+
public func setHostLogging (directory: String?) {
118+
process.setHostLogging(directory: directory)
119+
}
120+
121+
public func scrolled(source: TerminalView, position: Double) { }
122+
123+
/// Launches a child process inside a pseudo-terminal.
124+
/// - Parameter executable: The executable to launch inside the pseudo terminal, defaults to /bin/bash
125+
/// - Parameter args: an array of strings that is passed as the arguments to the underlying process
126+
/// - Parameter environment: an array of environment variables to pass to the child process, if this is null,
127+
/// this picks a good set of defaults from `Terminal.getEnvironmentVariables`.
128+
/// - Parameter execName: If provided, this is used as the Unix argv[0] parameter,
129+
/// otherwise, the executable is used as the args [0], this is used when
130+
/// the intent is to set a different process name than the file that backs it.
131+
public func startProcess(
132+
executable: String = "/bin/bash",
133+
args: [String] = [],
134+
environment: [String]? = nil,
135+
execName: String? = nil
136+
) {
137+
process.startProcess(executable: executable, args: args, environment: environment, execName: execName)
138+
}
139+
140+
/// Implements the LocalProcessDelegate method.
141+
public func processTerminated(_ source: LocalProcess, exitCode: Int32?) {
142+
processDelegate?.processTerminated(source: self, exitCode: exitCode)
143+
}
144+
145+
/// Implements the LocalProcessDelegate.dataReceived method
146+
public func dataReceived(slice: ArraySlice<UInt8>) {
147+
feed(byteArray: slice)
148+
}
149+
150+
/// Implements the LocalProcessDelegate.getWindowSize method
151+
public func getWindowSize() -> winsize {
152+
let frame: CGRect = self.frame
153+
return winsize(
154+
ws_row: UInt16(getTerminal().rows),
155+
ws_col: UInt16(getTerminal().cols),
156+
ws_xpixel: UInt16(frame.width),
157+
ws_ypixel: UInt16(frame.height)
158+
)
159+
}
160+
}

CodeEdit/Features/TerminalEmulator/Views/TerminalEmulatorView+Coordinator.swift

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,21 @@ import SwiftUI
99
import SwiftTerm
1010

1111
extension TerminalEmulatorView {
12-
final class Coordinator: NSObject, LocalProcessTerminalViewDelegate {
13-
14-
@State private var url: URL
15-
12+
final class Coordinator: NSObject, CELocalProcessTerminalViewDelegate {
13+
private let terminalID: UUID
1614
public var onTitleChange: (_ title: String) -> Void
1715

18-
init(url: URL, onTitleChange: @escaping (_ title: String) -> Void) {
19-
self._url = .init(wrappedValue: url)
16+
init(terminalID: UUID, onTitleChange: @escaping (_ title: String) -> Void) {
17+
self.terminalID = terminalID
2018
self.onTitleChange = onTitleChange
2119
super.init()
2220
}
2321

2422
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {}
2523

26-
func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) {}
24+
func sizeChanged(source: CETerminalView, newCols: Int, newRows: Int) {}
2725

28-
func setTerminalTitle(source: LocalProcessTerminalView, title: String) {
26+
func setTerminalTitle(source: CETerminalView, title: String) {
2927
onTitleChange(title)
3028
}
3129

@@ -35,7 +33,7 @@ extension TerminalEmulatorView {
3533
}
3634
source.feed(text: "Exit code: \(exitCode)\n\r\n")
3735
source.feed(text: "To open a new session, create a new terminal tab.")
38-
TerminalEmulatorView.lastTerminal[url.path] = nil
36+
TerminalCache.shared.removeCachedView(terminalID)
3937
}
4038
}
4139
}

0 commit comments

Comments
 (0)