Files
CxIDE/Views/TerminalView.swift
T

926 lines
32 KiB
Swift

import SwiftUI
import AppKit
// MARK: - Terminal View
struct TerminalView: View {
@ObservedObject var viewModel: EditorViewModel
@FocusState private var inputFocused: Bool
@State private var showSearch: Bool = false
@State private var searchQuery: String = ""
@State private var searchResultCount: Int = 0
private var terminal: TerminalSession { viewModel.terminalSession }
var body: some View {
VStack(spacing: 0) {
terminalHeader
if showSearch {
terminalSearchBar
}
Divider()
TerminalOutputView(terminal: terminal, highlightQuery: showSearch ? searchQuery : nil)
terminalInput
}
.background(Color(red: 0.05, green: 0.05, blue: 0.07))
.onAppear {
if !terminal.isRunning {
if let dir = viewModel.workspaceService.currentWorkspace?.rootURL {
terminal.start(in: dir)
} else {
terminal.start(in: FileManager.default.homeDirectoryForCurrentUser)
}
}
inputFocused = true
}
.onChange(of: viewModel.workspaceService.currentWorkspace?.rootURL) { _, newURL in
if let url = newURL, terminal.workingDirectory != url {
terminal.sendInput("cd \(url.path.replacingOccurrences(of: " ", with: "\\ "))")
}
}
}
// MARK: - Header
private var terminalHeader: some View {
HStack(spacing: 6) {
Image(systemName: "terminal")
.font(.caption)
.foregroundColor(.green)
Text(terminal.title)
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(.gray)
.lineLimit(1)
Spacer()
// Process indicator
if terminal.isRunning {
Circle()
.fill(.green)
.frame(width: 6, height: 6)
} else {
Circle()
.fill(.red)
.frame(width: 6, height: 6)
}
Button(action: terminal.clear) {
Image(systemName: "trash")
.font(.caption)
.foregroundColor(.gray)
}
.buttonStyle(.plain)
.help("Clear output")
Button(action: { showSearch.toggle() }) {
Image(systemName: "magnifyingglass")
.font(.caption)
.foregroundColor(showSearch ? .accentColor : .gray)
}
.buttonStyle(.plain)
.help("Search output (⌘F)")
Button(action: {
terminal.kill()
if let dir = viewModel.workspaceService.currentWorkspace?.rootURL {
terminal.start(in: dir)
} else {
terminal.start(in: FileManager.default.homeDirectoryForCurrentUser)
}
}) {
Image(systemName: "arrow.counterclockwise")
.font(.caption)
.foregroundColor(.gray)
}
.buttonStyle(.plain)
.help("Restart terminal")
}
.padding(.horizontal, 10)
.padding(.vertical, 4)
}
// MARK: - Search Bar
private var terminalSearchBar: some View {
HStack(spacing: 6) {
Image(systemName: "magnifyingglass")
.font(.caption)
.foregroundColor(.gray)
TextField("Search output...", text: $searchQuery)
.font(.system(size: 11, design: .monospaced))
.textFieldStyle(.plain)
.foregroundColor(.white)
.onChange(of: searchQuery) { _, newValue in
searchResultCount = terminal.searchOutput(newValue).count
}
if !searchQuery.isEmpty {
Text("\(searchResultCount) matches")
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.gray)
}
Button(action: {
searchQuery = ""
showSearch = false
}) {
Image(systemName: "xmark.circle.fill")
.font(.caption)
.foregroundColor(.gray)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 10)
.padding(.vertical, 3)
.background(Color(white: 0.12))
}
// MARK: - Input
private var terminalInput: some View {
HStack(spacing: 6) {
Text(terminal.prompt)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.green.opacity(0.7))
TerminalInputField(
text: $viewModel.terminalSession.inputBuffer,
onSubmit: {
let cmd = terminal.inputBuffer
terminal.sendInput(cmd)
terminal.inputBuffer = ""
},
onHistoryUp: { terminal.historyUp() },
onHistoryDown: { terminal.historyDown() },
onTab: { terminal.handleTab() },
onInterrupt: { terminal.sendInterrupt() }
)
.focused($inputFocused)
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(Color(white: 0.08))
}
}
// MARK: - Terminal Input Field (NSViewRepresentable for key handling)
struct TerminalInputField: NSViewRepresentable {
@Binding var text: String
let onSubmit: () -> Void
let onHistoryUp: () -> Void
let onHistoryDown: () -> Void
let onTab: () -> Void
let onInterrupt: () -> Void
func makeNSView(context: Context) -> NSTextField {
let field = NSTextField()
field.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
field.textColor = .white
field.backgroundColor = .clear
field.isBordered = false
field.focusRingType = .none
field.delegate = context.coordinator
field.placeholderString = ""
field.cell?.sendsActionOnEndEditing = false
return field
}
func updateNSView(_ nsView: NSTextField, context: Context) {
if nsView.stringValue != text {
nsView.stringValue = text
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, NSTextFieldDelegate {
var parent: TerminalInputField
init(_ parent: TerminalInputField) {
self.parent = parent
}
func controlTextDidChange(_ obj: Notification) {
if let field = obj.object as? NSTextField {
parent.text = field.stringValue
}
}
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
switch commandSelector {
case #selector(NSResponder.insertNewline(_:)):
parent.onSubmit()
return true
case #selector(NSResponder.moveUp(_:)):
parent.onHistoryUp()
return true
case #selector(NSResponder.moveDown(_:)):
parent.onHistoryDown()
return true
case #selector(NSResponder.insertTab(_:)):
parent.onTab()
return true
case #selector(NSResponder.cancelOperation(_:)):
parent.onInterrupt()
return true
default:
return false
}
}
}
}
// MARK: - Terminal Output View (NSViewRepresentable for performance)
struct TerminalOutputView: NSViewRepresentable {
@ObservedObject var terminal: TerminalSession
var highlightQuery: String? = nil
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSScrollView()
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
scrollView.autohidesScrollers = true
scrollView.borderType = .noBorder
scrollView.backgroundColor = NSColor(red: 0.05, green: 0.05, blue: 0.07, alpha: 1.0)
let textView = NSTextView()
textView.isEditable = false
textView.isSelectable = true
textView.isRichText = true
textView.backgroundColor = NSColor(red: 0.05, green: 0.05, blue: 0.07, alpha: 1.0)
textView.textContainerInset = NSSize(width: 8, height: 4)
textView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
textView.textColor = NSColor(calibratedRed: 0.85, green: 0.85, blue: 0.85, alpha: 1.0)
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticDashSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
textView.allowsUndo = false
let maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
textView.maxSize = maxSize
textView.isVerticallyResizable = true
textView.isHorizontallyResizable = false
textView.textContainer?.widthTracksTextView = true
textView.textContainer?.containerSize = NSSize(width: scrollView.contentSize.width, height: CGFloat.greatestFiniteMagnitude)
scrollView.documentView = textView
context.coordinator.textView = textView
context.coordinator.scrollView = scrollView
return scrollView
}
func updateNSView(_ nsView: NSScrollView, context: Context) {
context.coordinator.updateContent(terminal.lines)
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator {
weak var textView: NSTextView?
weak var scrollView: NSScrollView?
private var lastLineCount = 0
func updateContent(_ lines: [TerminalSession.TerminalLine]) {
guard let textView = textView else { return }
if lines.isEmpty {
textView.string = ""
lastLineCount = 0
return
}
// Incremental append for performance
if lines.count > lastLineCount {
let newLines = lines[lastLineCount...]
let storage = textView.textStorage!
storage.beginEditing()
for line in newLines {
let attrs = lineAttributes(for: line)
let str = NSAttributedString(string: (storage.length > 0 ? "\n" : "") + line.text, attributes: attrs)
storage.append(str)
}
storage.endEditing()
lastLineCount = lines.count
} else if lines.count < lastLineCount {
// Cleared rebuild
let storage = textView.textStorage!
storage.beginEditing()
let fullText = NSMutableAttributedString()
for (i, line) in lines.enumerated() {
let attrs = lineAttributes(for: line)
let prefix = i > 0 ? "\n" : ""
fullText.append(NSAttributedString(string: prefix + line.text, attributes: attrs))
}
storage.setAttributedString(fullText)
storage.endEditing()
lastLineCount = lines.count
}
// Auto-scroll to bottom
DispatchQueue.main.async {
textView.scrollToEndOfDocument(nil)
}
}
private func lineAttributes(for line: TerminalSession.TerminalLine) -> [NSAttributedString.Key: Any] {
let font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
let color: NSColor
switch line.kind {
case .stdout:
color = NSColor(calibratedRed: 0.85, green: 0.85, blue: 0.85, alpha: 1.0)
case .stderr:
color = NSColor(calibratedRed: 1.0, green: 0.45, blue: 0.4, alpha: 1.0)
case .stdin:
color = NSColor(calibratedRed: 0.4, green: 0.9, blue: 0.4, alpha: 1.0)
case .system:
color = NSColor(calibratedRed: 0.5, green: 0.6, blue: 0.8, alpha: 1.0)
}
return [.font: font, .foregroundColor: color]
}
}
}
// MARK: - Terminal Session (PTY-backed persistent bash process)
@MainActor
final class TerminalSession: ObservableObject {
@Published var lines: [TerminalLine] = []
@Published var inputBuffer: String = ""
@Published var title: String = "bash"
@Published var isRunning: Bool = false
@Published var currentDirectory: String = ""
let id = UUID()
var prompt: String { isRunning ? "$ " : "[exited] $ " }
private var process: Process?
private var inputPipe: Pipe?
private var outputPipe: Pipe?
private var errorPipe: Pipe?
var workingDirectory: URL?
// Command history
private(set) var commandHistory: [String] = []
private(set) var historyIndex: Int = -1
private var savedInput: String = ""
let maxHistory = 500
// Ring buffer for agent reads
private(set) var recentOutput: [String] = []
let maxRecentLines = 200
// Shell aliases
private(set) var aliases: [String: String] = [
"ll": "ls -la",
"la": "ls -A",
"l": "ls -CF",
"gs": "git status",
"gd": "git diff",
"gl": "git log --oneline -20",
"gb": "git branch",
"..": "cd ..",
"...": "cd ../..",
]
// Blocked commands (security)
static let blockedPatterns: [String] = [
"rm -rf /",
"rm -rf /*",
"mkfs",
":(){:|:&};:", // fork bomb
"dd if=/dev/",
"chmod -R 777 /",
"curl.*|.*bash",
"wget.*|.*bash",
]
enum LineKind: String, CaseIterable {
case stdout, stderr, stdin, system
}
struct TerminalLine: Identifiable {
let id = UUID()
let text: String
let kind: LineKind
let timestamp: Date = Date()
// Legacy compat
var isError: Bool { kind == .stderr }
}
// MARK: - Lifecycle
func start(in directory: URL) {
kill()
workingDirectory = directory
currentDirectory = directory.path
title = "bash — \(directory.lastPathComponent)"
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/bin/bash")
proc.arguments = ["--login"]
proc.currentDirectoryURL = directory
var env = ProcessInfo.processInfo.environment
env["TERM"] = "dumb"
env["CLICOLOR"] = "0"
env["NO_COLOR"] = "1"
// Custom prompt that's recognizable and clean
env["PS1"] = "\\W $ "
env["PS2"] = "> "
// Strip secrets
for key in env.keys where key.lowercased().contains("api_key") || key.lowercased().contains("secret") || key.lowercased().contains("token") || key.lowercased().contains("password") {
env.removeValue(forKey: key)
}
proc.environment = env
let inPipe = Pipe()
let outPipe = Pipe()
let errPipe = Pipe()
proc.standardInput = inPipe
proc.standardOutput = outPipe
proc.standardError = errPipe
// Read stdout
outPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return }
let cleaned = Self.stripANSI(text)
Task { @MainActor [weak self] in
self?.handleOutput(cleaned, kind: .stdout)
}
}
// Read stderr
errPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return }
let cleaned = Self.stripANSI(text)
Task { @MainActor [weak self] in
self?.handleOutput(cleaned, kind: .stderr)
}
}
// Termination
proc.terminationHandler = { [weak self] proc in
Task { @MainActor [weak self] in
self?.isRunning = false
self?.title = "bash [exited: \(proc.terminationStatus)]"
self?.appendLine("[Process exited with code \(proc.terminationStatus)]", kind: .system)
}
}
do {
try proc.run()
process = proc
inputPipe = inPipe
outputPipe = outPipe
errorPipe = errPipe
isRunning = true
appendLine("Terminal ready — \(directory.path)", kind: .system)
} catch {
appendLine("Failed to start: \(error.localizedDescription)", kind: .system)
}
}
// MARK: - Input
func sendInput(_ input: String) {
guard !input.isEmpty else { return }
// Check for blocked commands
if Self.isBlocked(input) {
appendLine("$ \(input)", kind: .stdin)
appendLine("⛔ Blocked: this command is not allowed for safety reasons.", kind: .system)
return
}
// Handle built-in commands
if handleBuiltIn(input) { return }
// Expand aliases
let expanded = expandAlias(input)
// Add to history (skip duplicates of last entry)
if commandHistory.last != input {
commandHistory.append(input)
if commandHistory.count > maxHistory {
commandHistory.removeFirst(commandHistory.count - maxHistory)
}
}
historyIndex = -1
savedInput = ""
if isRunning, let pipe = inputPipe {
appendLine("$ \(input)", kind: .stdin)
let data = (expanded + "\n").data(using: .utf8)!
pipe.fileHandleForWriting.write(data)
} else {
// No running process execute one-shot
appendLine("$ \(input)", kind: .stdin)
Task { await executeOneShot(expanded) }
}
}
/// Check if a command is blocked for security
nonisolated static func isBlocked(_ command: String) -> Bool {
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
for pattern in blockedPatterns {
if trimmed.contains(pattern.lowercased()) { return true }
}
// Block sudo rm -rf with broad path
if trimmed.hasPrefix("sudo") && trimmed.contains("rm") && trimmed.contains("-rf") {
return true
}
return false
}
/// Expand aliases in the command
func expandAlias(_ input: String) -> String {
let parts = input.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: false)
guard let firstWord = parts.first else { return input }
let key = String(firstWord)
if let expanded = aliases[key] {
if parts.count > 1 {
return expanded + " " + String(parts[1])
}
return expanded
}
return input
}
/// Handle built-in terminal commands (return true if handled)
func handleBuiltIn(_ input: String) -> Bool {
let trimmed = input.trimmingCharacters(in: .whitespaces)
let parts = trimmed.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
guard let cmd = parts.first else { return false }
switch String(cmd) {
case "clear":
clear()
return true
case "history":
appendLine("$ history", kind: .stdin)
for (i, entry) in commandHistory.enumerated() {
appendLine(" \(i + 1) \(entry)", kind: .stdout)
}
return true
case "alias":
if parts.count == 1 {
// List all aliases
appendLine("$ alias", kind: .stdin)
for (key, val) in aliases.sorted(by: { $0.key < $1.key }) {
appendLine("alias \(key)='\(val)'", kind: .stdout)
}
return true
} else {
// Set alias: alias name='command'
let rest = String(parts[1])
if let eqIdx = rest.firstIndex(of: "=") {
let name = String(rest[rest.startIndex..<eqIdx]).trimmingCharacters(in: .whitespaces)
var value = String(rest[rest.index(after: eqIdx)...]).trimmingCharacters(in: .whitespaces)
// Strip surrounding quotes
if (value.hasPrefix("'") && value.hasSuffix("'")) ||
(value.hasPrefix("\"") && value.hasSuffix("\"")) {
value = String(value.dropFirst().dropLast())
}
if !name.isEmpty && !value.isEmpty {
aliases[name] = value
appendLine("$ alias \(name)='\(value)'", kind: .stdin)
return true
}
}
return false
}
case "unalias":
if let name = parts.dropFirst().first {
let key = String(name)
aliases.removeValue(forKey: key)
appendLine("$ unalias \(key)", kind: .stdin)
return true
}
return false
case "export":
// Show environment info
appendLine("$ export", kind: .stdin)
appendLine("CWD: \(currentDirectory)", kind: .stdout)
appendLine("SHELL: /bin/bash", kind: .stdout)
appendLine("TERM: dumb", kind: .stdout)
return true
default:
return false
}
}
func sendInterrupt() {
guard let proc = process, proc.isRunning else { return }
proc.interrupt() // sends SIGINT
appendLine("^C", kind: .system)
}
// MARK: - History Navigation
func historyUp() {
guard !commandHistory.isEmpty else { return }
if historyIndex == -1 {
savedInput = inputBuffer
historyIndex = commandHistory.count - 1
} else if historyIndex > 0 {
historyIndex -= 1
}
inputBuffer = commandHistory[historyIndex]
}
func historyDown() {
guard historyIndex >= 0 else { return }
if historyIndex < commandHistory.count - 1 {
historyIndex += 1
inputBuffer = commandHistory[historyIndex]
} else {
historyIndex = -1
inputBuffer = savedInput
}
}
// MARK: - Tab Completion
func handleTab() {
guard !inputBuffer.isEmpty else { return }
let partial = inputBuffer
// Find the last path component to complete
let parts = partial.split(separator: " ", omittingEmptySubsequences: false)
guard let lastPart = parts.last, !lastPart.isEmpty else { return }
let pathStr = String(lastPart)
let baseDir: String
let prefix: String
if pathStr.contains("/") {
let url = URL(fileURLWithPath: pathStr, relativeTo: workingDirectory)
baseDir = url.deletingLastPathComponent().path
prefix = url.lastPathComponent
} else {
baseDir = workingDirectory?.path ?? FileManager.default.currentDirectoryPath
prefix = pathStr
}
let fm = FileManager.default
guard let contents = try? fm.contentsOfDirectory(atPath: baseDir) else { return }
let matches = contents.filter { $0.hasPrefix(prefix) }.sorted()
if matches.count == 1 {
var completion = matches[0]
let fullPath = (baseDir as NSString).appendingPathComponent(completion)
var isDir: ObjCBool = false
if fm.fileExists(atPath: fullPath, isDirectory: &isDir), isDir.boolValue {
completion += "/"
}
// Replace the last part
var newParts = Array(parts.dropLast())
if pathStr.contains("/") {
let dirPart = (pathStr as NSString).deletingLastPathComponent
newParts.append(Substring(dirPart + "/" + completion))
} else {
newParts.append(Substring(completion))
}
inputBuffer = newParts.joined(separator: " ")
} else if matches.count > 1 {
// Show options
appendLine(matches.joined(separator: " "), kind: .system)
// Complete common prefix
let common = Self.commonPrefix(matches)
if common.count > prefix.count {
var newParts = Array(parts.dropLast())
if pathStr.contains("/") {
let dirPart = (pathStr as NSString).deletingLastPathComponent
newParts.append(Substring(dirPart + "/" + common))
} else {
newParts.append(Substring(common))
}
inputBuffer = newParts.joined(separator: " ")
}
}
}
// MARK: - Agent API
/// Execute a command and return the output (for agent tool calls)
func executeAndCapture(_ command: String, timeout: Int = 30) async -> (output: String, exitCode: Int32, isError: Bool) {
let cwd = workingDirectory ?? FileManager.default.homeDirectoryForCurrentUser
return await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/bin/bash")
proc.arguments = ["-c", command]
proc.currentDirectoryURL = cwd
var env = ProcessInfo.processInfo.environment
env["TERM"] = "dumb"
for key in env.keys where key.lowercased().contains("api_key") || key.lowercased().contains("secret") || key.lowercased().contains("token") || key.lowercased().contains("password") {
env.removeValue(forKey: key)
}
proc.environment = env
let outPipe = Pipe()
let errPipe = Pipe()
proc.standardOutput = outPipe
proc.standardError = errPipe
do {
try proc.run()
let deadline = DispatchTime.now() + .seconds(timeout)
let group = DispatchGroup()
group.enter()
DispatchQueue.global().async {
proc.waitUntilExit()
group.leave()
}
let result = group.wait(timeout: deadline)
if result == .timedOut {
proc.terminate()
continuation.resume(returning: ("Command timed out after \(timeout)s", -1, true))
return
}
let outData = outPipe.fileHandleForReading.readDataToEndOfFile()
let errData = errPipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: outData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let errOutput = String(data: errData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let exitCode = proc.terminationStatus
var combined = output
if !errOutput.isEmpty {
combined += combined.isEmpty ? errOutput : "\n\(errOutput)"
}
continuation.resume(returning: (combined, exitCode, exitCode != 0))
} catch {
continuation.resume(returning: ("Error: \(error.localizedDescription)", -1, true))
}
}
}
}
/// Get recent output lines (for agent to read terminal state)
func getRecentOutput(lineCount: Int = 50) -> String {
let count = min(lineCount, recentOutput.count)
return recentOutput.suffix(count).joined(separator: "\n")
}
/// Append agent-executed command output to the terminal UI
func appendAgentCommand(_ command: String, output: String) {
appendLine("[agent] $ \(command)", kind: .system)
if !output.isEmpty {
for line in output.components(separatedBy: "\n") where !line.isEmpty {
appendLine(line, kind: .stdout)
}
}
}
// MARK: - Clear/Kill
func clear() {
lines.removeAll()
recentOutput.removeAll()
}
func kill() {
outputPipe?.fileHandleForReading.readabilityHandler = nil
errorPipe?.fileHandleForReading.readabilityHandler = nil
if let proc = process, proc.isRunning {
proc.terminate()
}
process = nil
inputPipe = nil
outputPipe = nil
errorPipe = nil
isRunning = false
}
func setCwd(_ path: String) {
workingDirectory = URL(fileURLWithPath: path)
currentDirectory = path
}
// MARK: - Search & Filtering
/// Search terminal output for a pattern (case-insensitive)
func searchOutput(_ query: String) -> [TerminalLine] {
guard !query.isEmpty else { return [] }
let lower = query.lowercased()
return lines.filter { $0.text.lowercased().contains(lower) }
}
/// Get line counts by kind
func lineCounts() -> [LineKind: Int] {
var counts: [LineKind: Int] = [:]
for kind in LineKind.allCases {
counts[kind] = lines.filter { $0.kind == kind }.count
}
return counts
}
/// Total output size in characters
var totalOutputSize: Int {
lines.reduce(0) { $0 + $1.text.count }
}
// MARK: - Internal (exposed for testing)
func handleOutput(_ text: String, kind: LineKind) {
let outputLines = text.components(separatedBy: "\n")
for line in outputLines where !line.isEmpty {
// Skip prompt echo lines
if line.hasSuffix(" $ ") && line.count < 60 { continue }
appendLine(line, kind: kind)
recentOutput.append(line)
if recentOutput.count > maxRecentLines {
recentOutput.removeFirst(recentOutput.count - maxRecentLines)
}
}
}
func appendLine(_ text: String, kind: LineKind) {
lines.append(TerminalLine(text: text, kind: kind))
if lines.count > 5000 {
lines.removeFirst(lines.count - 5000)
}
}
private func executeOneShot(_ command: String) async {
let result = await executeAndCapture(command)
if !result.output.isEmpty {
for line in result.output.components(separatedBy: "\n") {
appendLine(line, kind: result.isError ? .stderr : .stdout)
}
}
if result.exitCode != 0 {
appendLine("[exit code: \(result.exitCode)]", kind: .system)
}
}
// MARK: - Static Helpers
/// Strip ANSI escape sequences from terminal output
nonisolated static func stripANSI(_ text: String) -> String {
// Match ESC[ ... m (SGR), ESC[ ... H (cursor), ESC[ ... J (erase), etc.
guard let regex = try? NSRegularExpression(pattern: "\\x1B(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~])", options: []) else {
return text
}
return regex.stringByReplacingMatches(in: text, options: [], range: NSRange(text.startIndex..., in: text), withTemplate: "")
}
/// Find the common prefix of an array of strings
nonisolated static func commonPrefix(_ strings: [String]) -> String {
guard let first = strings.first else { return "" }
var prefix = first
for s in strings.dropFirst() {
while !s.hasPrefix(prefix) {
prefix = String(prefix.dropLast())
if prefix.isEmpty { return "" }
}
}
return prefix
}
}
// MARK: - Split Editor View
struct SplitEditorView: View {
@ObservedObject var viewModel: EditorViewModel
let orientation: SplitOrientation
var body: some View {
Group {
if orientation == .horizontal {
HSplitView {
CodeEditorView(viewModel: viewModel)
if viewModel.splitEditorEnabled, let splitVM = viewModel.splitViewModel {
Divider()
CodeEditorView(viewModel: splitVM)
}
}
} else {
VSplitView {
CodeEditorView(viewModel: viewModel)
if viewModel.splitEditorEnabled, let splitVM = viewModel.splitViewModel {
Divider()
CodeEditorView(viewModel: splitVM)
}
}
}
}
}
}