Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix Terminal State Loss #1843

Merged
merged 5 commits into from
Aug 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 63 additions & 33 deletions CodeEdit.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"originHash" : "a33fcca819dee4c816b1474e19017510b1d62b170c921187042e0675d3f4b0b3",
"originHash" : "c1c6a3fce844bb0e9fb04272ffab26747869319dec6715e2d5d6ab10df59932a",
"pins" : [
{
"identity" : "anycodable",
Expand All @@ -13,7 +13,7 @@
{
"identity" : "codeeditkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/CodeEditApp/CodeEditKit",
"location" : "https://github.com/CodeEditApp/CodeEditKit.git",
"state" : {
"revision" : "ad28213a968586abb0cb21a8a56a3587227895f1",
"version" : "0.1.2"
Expand Down Expand Up @@ -220,10 +220,9 @@
{
"identity" : "swiftterm",
"kind" : "remoteSourceControl",
"location" : "https://github.com/migueldeicaza/SwiftTerm.git",
"location" : "https://github.com/migueldeicaza/SwiftTerm",
"state" : {
"revision" : "55e7cdbeb3f41c80cce7b8a29ce9d17e214b2e77",
"version" : "1.2.0"
"revision" : "384776a4e24d08833ac7c6b8c6f6c7490323c845"
}
},
{
Expand Down
19 changes: 19 additions & 0 deletions CodeEdit/Features/TerminalEmulator/Model/Shell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,23 @@ enum Shell: String, CaseIterable {
// Run the process
try process.run()
}

var defaultPath: String {
switch self {
case .bash:
"/bin/bash"
case .zsh:
"/bin/zsh"
}
}

/// Gets the default shell from the current user and returns the string of the shell path.
///
/// If getting the user's shell does not work, defaults to `zsh`,
static func autoDetectDefaultShell() -> String {
guard let currentUser = CurrentUser.getCurrentUser() else {
return Self.zsh.rawValue // macOS defaults to zsh
}
return currentUser.shell
}
}
43 changes: 43 additions & 0 deletions CodeEdit/Features/TerminalEmulator/Model/TerminalCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//
// TerminalCache.swift
// CodeEdit
//
// Created by Khan Winter on 7/27/24.
//

import Foundation
import SwiftTerm

/// Stores a mapping of ID -> terminal view for reusing terminal views.
/// This allows terminal views to continue to receive data even when not in the view hierarchy.
final class TerminalCache {
austincondiff marked this conversation as resolved.
Show resolved Hide resolved
static let shared: TerminalCache = TerminalCache()

/// The cache of terminal views.
private var terminals: [UUID: CELocalProcessTerminalView]

private init() {
terminals = [:]
}

/// Get a cached terminal view.
/// - Parameter id: The ID of the terminal.
/// - Returns: The existing terminal, if it exists.
func getTerminalView(_ id: UUID) -> CELocalProcessTerminalView? {
terminals[id]
}

/// Store a terminal view for reuse.
/// - Parameters:
/// - id: The ID of the terminal.
/// - view: The view representing the terminal's contents.
func cacheTerminalView(for id: UUID, view: CELocalProcessTerminalView) {
terminals[id] = view
}

/// Remove any view associated with the terminal id.
/// - Parameter id: The ID of the terminal.
func removeCachedView(_ id: UUID) {
terminals[id] = nil
}
}
160 changes: 160 additions & 0 deletions CodeEdit/Features/TerminalEmulator/Views/CETerminalView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
//
// CETerminalView.swift
// CodeEdit
//
// Created by Khan Winter on 8/7/24.
//

import AppKit
import SwiftTerm
import Foundation

/// # Dev Note (please read)
///
/// This entire file is a nearly 1:1 copy of SwiftTerm's `LocalProcessTerminalView`. The exception being the use of
/// `CETerminalView` over `TerminalView`. This change was made to fix the terminal clearing when the view was given a
/// frame of `0`. This enables terminals to keep running in the background, and allows them to be removed and added
/// back into the hierarchy for use in the utility area.
///
/// If there is a bug here: **there probably isn't**. Look instead in ``TerminalEmulatorView``.

class CETerminalView: TerminalView {
override var frame: NSRect {
get {
return super.frame
}
set(newValue) {
if newValue != .zero {
super.frame = newValue
}
}
}
}

protocol CELocalProcessTerminalViewDelegate: AnyObject {
/// This method is invoked to notify that the terminal has been resized to the specified number of columns and rows
/// the user interface code might try to adjust the containing scroll view, or if it is a top level window, the
/// window itself
/// - Parameter source: the sending instance
/// - Parameter newCols: the new number of columns that should be shown
/// - Parameter newRow: the new number of rows that should be shown
func sizeChanged(source: CETerminalView, newCols: Int, newRows: Int)

/// This method is invoked when the title of the terminal window should be updated to the provided title
/// - Parameter source: the sending instance
/// - Parameter title: the desired title
func setTerminalTitle(source: CETerminalView, title: String)

/// Invoked when the OSC command 7 for "current directory has changed" command is sent
/// - Parameter source: the sending instance
/// - Parameter directory: the new working directory
func hostCurrentDirectoryUpdate (source: TerminalView, directory: String?)

/// This method will be invoked when the child process started by `startProcess` has terminated.
/// - Parameter source: the local process that terminated
/// - Parameter exitCode: the exit code returned by the process, or nil if this was an error caused during
/// the IO reading/writing
func processTerminated (source: TerminalView, exitCode: Int32?)
}

class CELocalProcessTerminalView: CETerminalView, TerminalViewDelegate, LocalProcessDelegate {
var process: LocalProcess!

override public init (frame: CGRect) {
super.init(frame: frame)
setup()
}

public required init? (coder: NSCoder) {
super.init(coder: coder)
setup()
}

func setup () {
terminalDelegate = self
process = LocalProcess(delegate: self)
}

/// The `processDelegate` is used to deliver messages and information relevant to the execution of the terminal.
public weak var processDelegate: CELocalProcessTerminalViewDelegate?

/// This method is invoked to notify the client of the new columsn and rows that have been set by the UI
public func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {
guard process.running else {
return
}
var size = getWindowSize()
_ = PseudoTerminalHelpers.setWinSize(masterPtyDescriptor: process.childfd, windowSize: &size)

processDelegate?.sizeChanged(source: self, newCols: newCols, newRows: newRows)
}

public func clipboardCopy(source: TerminalView, content: Data) {
if let str = String(bytes: content, encoding: .utf8) {
let pasteBoard = NSPasteboard.general
pasteBoard.clearContents()
pasteBoard.writeObjects([str as NSString])
}
}

public func rangeChanged(source: TerminalView, startY: Int, endY: Int) { }

/// Invoke this method to notify the processDelegate of the new title for the terminal window
public func setTerminalTitle(source: TerminalView, title: String) {
processDelegate?.setTerminalTitle(source: self, title: title)
}

public func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {
processDelegate?.hostCurrentDirectoryUpdate(source: source, directory: directory)
}

/// This method is invoked when input from the user needs to be sent to the client
public func send(source: TerminalView, data: ArraySlice<UInt8>) {
process.send(data: data)
}

/// Use this method to toggle the logging of data coming from the host, or pass nil to stop
public func setHostLogging (directory: String?) {
process.setHostLogging(directory: directory)
}

public func scrolled(source: TerminalView, position: Double) { }

/// Launches a child process inside a pseudo-terminal.
/// - Parameter executable: The executable to launch inside the pseudo terminal, defaults to /bin/bash
/// - Parameter args: an array of strings that is passed as the arguments to the underlying process
/// - Parameter environment: an array of environment variables to pass to the child process, if this is null,
/// this picks a good set of defaults from `Terminal.getEnvironmentVariables`.
/// - Parameter execName: If provided, this is used as the Unix argv[0] parameter,
/// otherwise, the executable is used as the args [0], this is used when
/// the intent is to set a different process name than the file that backs it.
public func startProcess(
executable: String = "/bin/bash",
args: [String] = [],
environment: [String]? = nil,
execName: String? = nil
) {
process.startProcess(executable: executable, args: args, environment: environment, execName: execName)
}

/// Implements the LocalProcessDelegate method.
public func processTerminated(_ source: LocalProcess, exitCode: Int32?) {
processDelegate?.processTerminated(source: self, exitCode: exitCode)
}

/// Implements the LocalProcessDelegate.dataReceived method
public func dataReceived(slice: ArraySlice<UInt8>) {
feed(byteArray: slice)
}

/// Implements the LocalProcessDelegate.getWindowSize method
public func getWindowSize() -> winsize {
let frame: CGRect = self.frame
return winsize(
ws_row: UInt16(getTerminal().rows),
ws_col: UInt16(getTerminal().cols),
ws_xpixel: UInt16(frame.width),
ws_ypixel: UInt16(frame.height)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,21 @@ import SwiftUI
import SwiftTerm

extension TerminalEmulatorView {
final class Coordinator: NSObject, LocalProcessTerminalViewDelegate {

@State private var url: URL

final class Coordinator: NSObject, CELocalProcessTerminalViewDelegate {
private let terminalID: UUID
public var onTitleChange: (_ title: String) -> Void

init(url: URL, onTitleChange: @escaping (_ title: String) -> Void) {
self._url = .init(wrappedValue: url)
init(terminalID: UUID, onTitleChange: @escaping (_ title: String) -> Void) {
self.terminalID = terminalID
self.onTitleChange = onTitleChange
super.init()
}

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

func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) {}
func sizeChanged(source: CETerminalView, newCols: Int, newRows: Int) {}

func setTerminalTitle(source: LocalProcessTerminalView, title: String) {
func setTerminalTitle(source: CETerminalView, title: String) {
onTitleChange(title)
}

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