Initial Push
This commit is contained in:
445
TerminalSession.swift
Normal file
445
TerminalSession.swift
Normal file
@@ -0,0 +1,445 @@
|
||||
import Cocoa
|
||||
|
||||
// MARK: - Terminal Session
|
||||
class TerminalSession: NSObject, NSTextViewDelegate, NSMenuDelegate {
|
||||
var textView: NSTextView!
|
||||
var scrollView: NSScrollView! // Store scroll view as property
|
||||
var shellTask: Process?
|
||||
var inputPipe: Pipe?
|
||||
var outputPipe: Pipe?
|
||||
var promptPosition = 0
|
||||
var sessionID: UUID
|
||||
var title: String
|
||||
private var suggestionLabel: NSTextField?
|
||||
private var currentSuggestion: String?
|
||||
|
||||
init(title: String = "Terminal") {
|
||||
self.sessionID = UUID()
|
||||
self.title = title
|
||||
super.init()
|
||||
}
|
||||
|
||||
func setupTextView(frame: NSRect) {
|
||||
scrollView = NSScrollView(frame: frame)
|
||||
scrollView.autoresizingMask = [.width, .height]
|
||||
scrollView.hasVerticalScroller = true
|
||||
scrollView.hasHorizontalScroller = false
|
||||
|
||||
textView = NSTextView(frame: scrollView.bounds)
|
||||
textView.autoresizingMask = [.width, .height]
|
||||
textView.isEditable = true
|
||||
textView.delegate = self
|
||||
textView.isRichText = true
|
||||
textView.allowsUndo = true
|
||||
textView.usesFindBar = true
|
||||
|
||||
applyTheme()
|
||||
|
||||
scrollView.documentView = textView
|
||||
|
||||
// Setup context menu
|
||||
setupContextMenu()
|
||||
|
||||
// Setup suggestion label
|
||||
setupSuggestionLabel()
|
||||
}
|
||||
|
||||
private func setupContextMenu() {
|
||||
let menu = NSMenu()
|
||||
menu.delegate = self
|
||||
|
||||
let copyItem = NSMenuItem(title: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "")
|
||||
let pasteItem = NSMenuItem(title: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "")
|
||||
let selectAllItem = NSMenuItem(title: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "")
|
||||
|
||||
menu.addItem(copyItem)
|
||||
menu.addItem(pasteItem)
|
||||
menu.addItem(selectAllItem)
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
|
||||
let saveSnippetItem = NSMenuItem(title: "Save as Snippet", action: #selector(saveSelectionAsSnippet), keyEquivalent: "")
|
||||
saveSnippetItem.target = self
|
||||
menu.addItem(saveSnippetItem)
|
||||
|
||||
textView.menu = menu
|
||||
}
|
||||
|
||||
@objc private func saveSelectionAsSnippet() {
|
||||
let selectedRange = textView.selectedRange()
|
||||
guard selectedRange.length > 0 else { return }
|
||||
|
||||
let selectedText = (textView.string as NSString).substring(with: selectedRange).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !selectedText.isEmpty else { return }
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Save as Snippet"
|
||||
alert.informativeText = "Enter details for this snippet:"
|
||||
|
||||
let nameField = NSTextField(frame: NSRect(x: 0, y: 60, width: 300, height: 24))
|
||||
nameField.placeholderString = "Snippet name"
|
||||
|
||||
let categoryField = NSTextField(frame: NSRect(x: 0, y: 30, width: 300, height: 24))
|
||||
categoryField.placeholderString = "Category (optional)"
|
||||
|
||||
let descField = NSTextField(frame: NSRect(x: 0, y: 0, width: 300, height: 24))
|
||||
descField.placeholderString = "Description (optional)"
|
||||
|
||||
let commandLabel = NSTextField(labelWithString: "Command: \(selectedText)")
|
||||
commandLabel.frame = NSRect(x: 0, y: 90, width: 300, height: 20)
|
||||
commandLabel.font = NSFont.systemFont(ofSize: 11)
|
||||
commandLabel.textColor = .secondaryLabelColor
|
||||
commandLabel.lineBreakMode = .byTruncatingTail
|
||||
|
||||
let view = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 115))
|
||||
view.addSubview(commandLabel)
|
||||
view.addSubview(nameField)
|
||||
view.addSubview(categoryField)
|
||||
view.addSubview(descField)
|
||||
|
||||
alert.accessoryView = view
|
||||
alert.addButton(withTitle: "Save")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
|
||||
if alert.runModal() == .alertFirstButtonReturn {
|
||||
let snippet = SnippetManager.Snippet(
|
||||
name: nameField.stringValue.isEmpty ? "Untitled" : nameField.stringValue,
|
||||
command: selectedText,
|
||||
description: descField.stringValue,
|
||||
category: categoryField.stringValue.isEmpty ? "General" : categoryField.stringValue
|
||||
)
|
||||
SnippetManager.shared.addSnippet(snippet)
|
||||
|
||||
// Show confirmation
|
||||
let confirmation = NSAlert()
|
||||
confirmation.messageText = "Snippet Saved"
|
||||
confirmation.informativeText = "Your snippet has been saved. Access it with Cmd+P."
|
||||
confirmation.alertStyle = .informational
|
||||
confirmation.addButton(withTitle: "OK")
|
||||
confirmation.runModal()
|
||||
}
|
||||
}
|
||||
|
||||
private func setupSuggestionLabel() {
|
||||
suggestionLabel = NSTextField(frame: .zero)
|
||||
suggestionLabel?.isEditable = false
|
||||
suggestionLabel?.isSelectable = false
|
||||
suggestionLabel?.isBordered = false
|
||||
suggestionLabel?.backgroundColor = .clear
|
||||
suggestionLabel?.isHidden = true
|
||||
if let label = suggestionLabel {
|
||||
textView.addSubview(label)
|
||||
}
|
||||
}
|
||||
|
||||
func applyTheme() {
|
||||
let theme = TerminalSettings.shared.theme
|
||||
textView?.backgroundColor = theme.background
|
||||
textView?.textColor = theme.foreground
|
||||
textView?.insertionPointColor = theme.cursor
|
||||
textView?.selectedTextAttributes = [
|
||||
.backgroundColor: theme.selection
|
||||
]
|
||||
textView?.font = NSFont(name: TerminalSettings.shared.fontName, size: TerminalSettings.shared.fontSize) ?? NSFont.monospacedSystemFont(ofSize: TerminalSettings.shared.fontSize, weight: .regular)
|
||||
|
||||
suggestionLabel?.textColor = theme.suggestion
|
||||
suggestionLabel?.font = textView?.font
|
||||
}
|
||||
|
||||
func startShell() {
|
||||
shellTask = Process()
|
||||
inputPipe = Pipe()
|
||||
outputPipe = Pipe()
|
||||
|
||||
shellTask?.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
||||
shellTask?.arguments = ["-l"] // Login shell for proper initialization
|
||||
|
||||
// Better environment setup
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["TERM"] = "xterm-256color"
|
||||
env["PS1"] = "$ "
|
||||
env["PROMPT"] = "$ "
|
||||
env["DISABLE_AUTO_TITLE"] = "true"
|
||||
|
||||
shellTask?.environment = env
|
||||
shellTask?.standardInput = inputPipe
|
||||
shellTask?.standardOutput = outputPipe
|
||||
shellTask?.standardError = outputPipe
|
||||
|
||||
outputPipe?.fileHandleForReading.readabilityHandler = { [weak self] handle in
|
||||
let data = handle.availableData
|
||||
if data.count > 0, let output = String(data: data, encoding: .utf8) {
|
||||
DispatchQueue.main.async {
|
||||
self?.appendText(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try shellTask?.run()
|
||||
appendText("Enhanced Terminal v3.0\n")
|
||||
appendText("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
|
||||
appendText("Features:\n")
|
||||
appendText(" • Cmd+T: New tab\n")
|
||||
appendText(" • Cmd+R: Search history (fuzzy search)\n")
|
||||
appendText(" • Cmd+K: AI command generator\n")
|
||||
appendText(" • Cmd+P: Command snippets\n")
|
||||
appendText(" • Tab: Accept auto-suggestion\n")
|
||||
appendText(" • Type 'help' for more commands\n")
|
||||
appendText("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n")
|
||||
|
||||
// Wait a bit for shell to initialize, then show prompt
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
||||
self?.showPrompt()
|
||||
}
|
||||
} catch {
|
||||
appendText("Error starting shell: \(error)\n")
|
||||
}
|
||||
}
|
||||
|
||||
func showPrompt() {
|
||||
appendText("$ ")
|
||||
promptPosition = textView.string.count
|
||||
}
|
||||
|
||||
func appendText(_ text: String) {
|
||||
let cleaned = cleanANSI(text)
|
||||
let theme = TerminalSettings.shared.theme
|
||||
let attr = NSAttributedString(string: cleaned, attributes: [
|
||||
.foregroundColor: theme.foreground,
|
||||
.font: NSFont(name: TerminalSettings.shared.fontName, size: TerminalSettings.shared.fontSize) ?? NSFont.monospacedSystemFont(ofSize: TerminalSettings.shared.fontSize, weight: .regular)
|
||||
])
|
||||
textView.textStorage?.append(attr)
|
||||
textView.scrollToEndOfDocument(nil)
|
||||
}
|
||||
|
||||
private func cleanANSI(_ text: String) -> String {
|
||||
var cleaned = text
|
||||
|
||||
// Remove ANSI escape sequences
|
||||
let patterns = [
|
||||
"\u{1B}\\[[0-9;]*[a-zA-Z]",
|
||||
"\u{1B}\\][0-9];[^\u{07}]*\u{07}",
|
||||
"\\]633;[^\u{07}]*\u{07}",
|
||||
"\\]697;[^\u{07}]*\u{07}",
|
||||
"\\]1337;[^\u{07}]*\u{07}",
|
||||
"\\]1;[^\u{07}]*\u{07}",
|
||||
"\\]2;[^\u{07}]*\u{07}",
|
||||
"\\]7;[^\u{07}]*\\\\",
|
||||
"\u{1B}\\]0;[^\u{07}]*\u{07}"
|
||||
]
|
||||
|
||||
for pattern in patterns {
|
||||
cleaned = cleaned.replacingOccurrences(of: pattern, with: "", options: .regularExpression)
|
||||
}
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func textView(_ textView: NSTextView, shouldChangeTextIn range: NSRange, replacementString: String?) -> Bool {
|
||||
// Don't allow editing before the prompt
|
||||
if range.location < promptPosition {
|
||||
return false
|
||||
}
|
||||
|
||||
// Handle Enter key
|
||||
if replacementString == "\n" {
|
||||
let command = getCurrentCommand()
|
||||
executeCommand(command)
|
||||
hideSuggestion()
|
||||
return false
|
||||
}
|
||||
|
||||
// Handle Tab key for accepting suggestions
|
||||
if replacementString == "\t" {
|
||||
if let suggestion = currentSuggestion {
|
||||
acceptSuggestion(suggestion)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Update suggestions after text changes
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.updateSuggestion()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private func getCurrentCommand() -> String {
|
||||
guard promptPosition <= textView.string.count else { return "" }
|
||||
let startIndex = textView.string.index(textView.string.startIndex, offsetBy: promptPosition)
|
||||
return String(textView.string[startIndex...])
|
||||
}
|
||||
|
||||
private func updateSuggestion() {
|
||||
guard TerminalSettings.shared.enableAutoSuggestions else {
|
||||
hideSuggestion()
|
||||
return
|
||||
}
|
||||
|
||||
let partial = getCurrentCommand()
|
||||
guard !partial.isEmpty else {
|
||||
hideSuggestion()
|
||||
return
|
||||
}
|
||||
|
||||
if let suggestion = HistoryManager.shared.getSuggestion(for: partial) {
|
||||
showSuggestion(suggestion, for: partial)
|
||||
} else {
|
||||
hideSuggestion()
|
||||
}
|
||||
}
|
||||
|
||||
private func showSuggestion(_ fullCommand: String, for partial: String) {
|
||||
guard let label = suggestionLabel else { return }
|
||||
|
||||
currentSuggestion = fullCommand
|
||||
|
||||
// Get the remaining part of the suggestion
|
||||
let remaining = String(fullCommand.dropFirst(partial.count))
|
||||
label.stringValue = remaining
|
||||
|
||||
// Position the label at the cursor
|
||||
if let layoutManager = textView.layoutManager,
|
||||
let textContainer = textView.textContainer {
|
||||
let glyphRange = layoutManager.glyphRange(for: textContainer)
|
||||
let lastGlyphIndex = layoutManager.glyphIndexForCharacter(at: textView.string.count)
|
||||
|
||||
if lastGlyphIndex < glyphRange.upperBound {
|
||||
let rect = layoutManager.boundingRect(forGlyphRange: NSRange(location: lastGlyphIndex, length: 0), in: textContainer)
|
||||
label.frame = NSRect(x: rect.maxX, y: rect.minY, width: 400, height: rect.height)
|
||||
label.isHidden = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func hideSuggestion() {
|
||||
currentSuggestion = nil
|
||||
suggestionLabel?.isHidden = true
|
||||
}
|
||||
|
||||
private func acceptSuggestion(_ suggestion: String) {
|
||||
let partial = getCurrentCommand()
|
||||
let remaining = String(suggestion.dropFirst(partial.count))
|
||||
textView.insertText(remaining, replacementRange: NSRange(location: textView.string.count, length: 0))
|
||||
hideSuggestion()
|
||||
}
|
||||
|
||||
func executeCommand(_ command: String) {
|
||||
appendText("\n")
|
||||
|
||||
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// Handle built-in commands
|
||||
if trimmed == "exit" {
|
||||
NSApp.terminate(nil)
|
||||
return
|
||||
}
|
||||
|
||||
if trimmed == "clear" {
|
||||
textView.string = ""
|
||||
showPrompt()
|
||||
return
|
||||
}
|
||||
|
||||
if trimmed == "help" {
|
||||
showHelp()
|
||||
showPrompt()
|
||||
return
|
||||
}
|
||||
|
||||
// Add to history
|
||||
if !trimmed.isEmpty {
|
||||
HistoryManager.shared.addCommand(trimmed)
|
||||
|
||||
// Send to shell
|
||||
if let data = (trimmed + "\n").data(using: .utf8) {
|
||||
inputPipe?.fileHandleForWriting.write(data)
|
||||
}
|
||||
}
|
||||
|
||||
// Show prompt after a delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||
self?.showPrompt()
|
||||
}
|
||||
}
|
||||
|
||||
private func showHelp() {
|
||||
appendText("""
|
||||
Enhanced Terminal Commands:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Keyboard Shortcuts:
|
||||
• Cmd+T - New tab
|
||||
• Cmd+W - Close tab
|
||||
• Cmd+R - Search command history (fuzzy search)
|
||||
• Cmd+K - AI command generator
|
||||
• Cmd+P - Command snippets
|
||||
• Cmd+, - Settings
|
||||
• Tab - Accept auto-suggestion
|
||||
• Cmd+Plus - Increase font size
|
||||
• Cmd+Minus - Decrease font size
|
||||
|
||||
Built-in Commands:
|
||||
• clear - Clear screen
|
||||
• help - Show this help
|
||||
• exit - Quit application
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
""")
|
||||
}
|
||||
|
||||
func insertCommand(_ command: String) {
|
||||
textView.insertText(command, replacementRange: NSRange(location: textView.string.count, length: 0))
|
||||
textView.window?.makeFirstResponder(textView)
|
||||
}
|
||||
|
||||
func cleanup() {
|
||||
shellTask?.terminate()
|
||||
outputPipe?.fileHandleForReading.readabilityHandler = nil
|
||||
}
|
||||
|
||||
// MARK: - NSMenuDelegate
|
||||
func menuNeedsUpdate(_ menu: NSMenu) {
|
||||
// Enable "Save as Snippet" only if there's selected text
|
||||
if let saveSnippetItem = menu.items.first(where: { $0.action == #selector(saveSelectionAsSnippet) }) {
|
||||
let hasSelection = textView.selectedRange().length > 0
|
||||
saveSnippetItem.isEnabled = hasSelection
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tab Manager
|
||||
class TabManager {
|
||||
var tabs: [TerminalSession] = []
|
||||
var currentIndex: Int = 0
|
||||
|
||||
func addTab(title: String = "Terminal") -> TerminalSession {
|
||||
let session = TerminalSession(title: "\(title) \(tabs.count + 1)")
|
||||
tabs.append(session)
|
||||
currentIndex = tabs.count - 1
|
||||
return session
|
||||
}
|
||||
|
||||
func removeTab(at index: Int) {
|
||||
guard index < tabs.count else { return }
|
||||
tabs[index].cleanup()
|
||||
tabs.remove(at: index)
|
||||
if currentIndex >= tabs.count {
|
||||
currentIndex = max(0, tabs.count - 1)
|
||||
}
|
||||
}
|
||||
|
||||
func selectTab(at index: Int) {
|
||||
guard index < tabs.count else { return }
|
||||
currentIndex = index
|
||||
}
|
||||
|
||||
func getCurrentSession() -> TerminalSession? {
|
||||
guard currentIndex < tabs.count else { return nil }
|
||||
return tabs[currentIndex]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user