166 lines
5.3 KiB
Swift
166 lines
5.3 KiB
Swift
import AppKit
|
|
import SwiftTerm
|
|
import Combine
|
|
|
|
/// Wraps a single SwiftTerm TerminalView + LocalProcess pair.
|
|
@MainActor
|
|
class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @preconcurrency TerminalViewDelegate {
|
|
|
|
let id = UUID()
|
|
let terminalView: TerminalView
|
|
private var process: LocalProcess?
|
|
private var keyEventMonitor: Any?
|
|
private let backgroundColor = NSColor.black
|
|
private let configuredShellPath: String
|
|
|
|
@Published var title: String = "shell"
|
|
@Published var isRunning: Bool = true
|
|
@Published var currentDirectory: String?
|
|
|
|
init(fontSize: CGFloat, theme: TerminalTheme, shellPath: String) {
|
|
terminalView = TerminalView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
|
|
configuredShellPath = shellPath
|
|
super.init()
|
|
|
|
terminalView.terminalDelegate = self
|
|
|
|
let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
|
|
terminalView.font = font
|
|
applyTheme(theme)
|
|
installCommandArrowMonitor()
|
|
|
|
startShell()
|
|
}
|
|
|
|
deinit {
|
|
if let keyEventMonitor {
|
|
NSEvent.removeMonitor(keyEventMonitor)
|
|
}
|
|
}
|
|
|
|
// MARK: - Shell management
|
|
|
|
private func startShell() {
|
|
let shellPath = resolveShell()
|
|
let shellName = (shellPath as NSString).lastPathComponent
|
|
let loginExecName = "-\(shellName)"
|
|
|
|
let proc = LocalProcess(delegate: self)
|
|
// Launch as a login shell so user startup files initialize PATH/tools.
|
|
proc.startProcess(
|
|
executable: shellPath,
|
|
args: ["-l"],
|
|
environment: nil,
|
|
execName: loginExecName,
|
|
currentDirectory: NSHomeDirectory()
|
|
)
|
|
process = proc
|
|
title = shellName
|
|
}
|
|
|
|
private func resolveShell() -> String {
|
|
let custom = configuredShellPath.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !custom.isEmpty && FileManager.default.isExecutableFile(atPath: custom) {
|
|
return custom
|
|
}
|
|
return ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh"
|
|
}
|
|
|
|
private func installCommandArrowMonitor() {
|
|
keyEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
|
guard let self else { return event }
|
|
guard let window = self.terminalView.window else { return event }
|
|
guard event.window === window else { return event }
|
|
guard window.firstResponder === self.terminalView else { return event }
|
|
|
|
guard let sequence = TerminalCommandArrowBehavior.sequence(
|
|
for: event.modifierFlags,
|
|
keyCode: event.keyCode,
|
|
applicationCursor: self.terminalView.getTerminal().applicationCursor
|
|
) else {
|
|
return event
|
|
}
|
|
|
|
self.terminalView.send(data: sequence[...])
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func updateFontSize(_ size: CGFloat) {
|
|
terminalView.font = NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
|
}
|
|
|
|
func applyTheme(_ theme: TerminalTheme) {
|
|
// Keep the notch visually consistent while swapping the terminal's
|
|
// default foreground color and ANSI palette for command output.
|
|
terminalView.nativeBackgroundColor = backgroundColor
|
|
terminalView.nativeForegroundColor = theme.foregroundColor
|
|
terminalView.installColors(theme.ansiColors)
|
|
}
|
|
|
|
func terminate() {
|
|
process?.terminate()
|
|
process = nil
|
|
isRunning = false
|
|
}
|
|
|
|
// MARK: - LocalProcessDelegate
|
|
|
|
nonisolated func processTerminated(_ source: LocalProcess, exitCode: Int32?) {
|
|
Task { @MainActor in self.isRunning = false }
|
|
}
|
|
|
|
nonisolated func dataReceived(slice: ArraySlice<UInt8>) {
|
|
let data = slice
|
|
Task { @MainActor in self.terminalView.feed(byteArray: data) }
|
|
}
|
|
|
|
nonisolated func getWindowSize() -> winsize {
|
|
var ws = winsize()
|
|
ws.ws_col = 80
|
|
ws.ws_row = 24
|
|
return ws
|
|
}
|
|
|
|
// MARK: - TerminalViewDelegate
|
|
|
|
func send(source: TerminalView, data: ArraySlice<UInt8>) {
|
|
process?.send(data: data)
|
|
}
|
|
|
|
func setTerminalTitle(source: TerminalView, title: String) {
|
|
self.title = title.isEmpty ? "shell" : title
|
|
}
|
|
|
|
func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {
|
|
guard newCols > 0, newRows > 0 else { return }
|
|
guard let proc = process else { return }
|
|
let fd = proc.childfd
|
|
guard fd >= 0 else { return }
|
|
|
|
var ws = winsize()
|
|
ws.ws_col = UInt16(newCols)
|
|
ws.ws_row = UInt16(newRows)
|
|
_ = ioctl(fd, TIOCSWINSZ, &ws)
|
|
}
|
|
|
|
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {
|
|
currentDirectory = directory
|
|
}
|
|
|
|
func scrolled(source: TerminalView, position: Double) {}
|
|
func rangeChanged(source: TerminalView, startY: Int, endY: Int) {}
|
|
|
|
func clipboardCopy(source: TerminalView, content: Data) {
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setData(content, forType: .string)
|
|
}
|
|
|
|
func requestOpenLink(source: TerminalView, link: String, params: [String : String]) {
|
|
if let url = URL(string: link) { NSWorkspace.shared.open(url) }
|
|
}
|
|
|
|
func bell(source: TerminalView) { NSSound.beep() }
|
|
func iTermContent(source: TerminalView, content: ArraySlice<UInt8>) {}
|
|
}
|