diff --git a/.DS_Store b/.DS_Store index c29d5fe..ad6a20f 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/EnhancedTerminal.app/Contents/MacOS/EnhancedTerminal b/EnhancedTerminal.app/Contents/MacOS/EnhancedTerminal index 63a62e5..225f0f5 100755 Binary files a/EnhancedTerminal.app/Contents/MacOS/EnhancedTerminal and b/EnhancedTerminal.app/Contents/MacOS/EnhancedTerminal differ diff --git a/HistoryManager.swift b/HistoryManager.swift index 0b54893..46691f4 100644 --- a/HistoryManager.swift +++ b/HistoryManager.swift @@ -5,9 +5,246 @@ class HistoryManager { static let shared = HistoryManager() private var history: [String] = [] private let maxHistory = 1000 + private var systemCommands: Set = [] + private var commonCommands: [String] = [ + // Basic Unix/Linux commands + "ls", "ls -la", "ls -lah", "ls -ltr", "ls -lh", "ls -R", + "cd", "cd ..", "cd ~", "cd -", "cd /", + "pwd", "mkdir", "mkdir -p", "rmdir", + "rm", "rm -f", "rm -rf", "rm -i", + "cp", "cp -r", "cp -i", "cp -v", + "mv", "mv -i", "mv -v", + "cat", "less", "more", "head", "head -n", "tail", "tail -f", "tail -n", + "touch", "file", "stat", + + // Text processing + "grep", "grep -r", "grep -i", "grep -v", "grep -n", "grep -A", "grep -B", + "egrep", "fgrep", "sed", "awk", "cut", "sort", "uniq", "wc", "wc -l", + "diff", "diff -u", "patch", "tr", "fmt", "fold", "expand", "unexpand", + + // File search and find + "find", "find . -name", "find . -type f", "find . -type d", "find . -size", + "locate", "updatedb", "which", "whereis", "whatis", "apropos", + + // File permissions + "chmod", "chmod +x", "chmod 755", "chmod 644", "chmod -R", + "chown", "chown -R", "chgrp", "umask", + + // Process management + "ps", "ps aux", "ps -ef", "ps aux | grep", "pgrep", "pkill", + "top", "htop", "kill", "kill -9", "killall", "nice", "renice", + "bg", "fg", "jobs", "nohup", "screen", "tmux", + + // System info + "uname", "uname -a", "hostname", "uptime", "whoami", "who", "w", "last", + "date", "cal", "df", "df -h", "du", "du -sh", "du -h", "free", "free -h", + "lsblk", "mount", "umount", "fdisk", "lsusb", "lspci", "dmesg", + + // Network commands + "ping", "ping -c", "traceroute", "netstat", "netstat -an", "netstat -tulpn", + "ss", "ss -tulpn", "ifconfig", "ip", "ip addr", "ip route", + "curl", "curl -O", "curl -L", "wget", "wget -O", "wget -c", + "ssh", "ssh -i", "ssh -p", "scp", "scp -r", "sftp", "rsync", "rsync -av", + "nc", "nmap", "dig", "nslookup", "host", "whois", + + // Archive and compression + "tar", "tar -xvf", "tar -cvf", "tar -xzvf", "tar -czvf", "tar -xjvf", "tar -cjvf", + "zip", "zip -r", "unzip", "unzip -l", "gzip", "gunzip", "bzip2", "bunzip2", + "xz", "unxz", "7z", "rar", "unrar", + + // Git commands (comprehensive) + "git", "git init", "git clone", "git clone --depth 1", + "git status", "git status -s", + "git add", "git add .", "git add -A", "git add -p", "git add -u", + "git commit", "git commit -m", "git commit -am", "git commit --amend", + "git push", "git push origin", "git push -u origin", "git push --force", "git push --tags", + "git pull", "git pull origin", "git pull --rebase", + "git fetch", "git fetch origin", "git fetch --all", "git fetch --prune", + "git merge", "git merge --no-ff", "git merge --squash", + "git rebase", "git rebase -i", "git rebase --continue", "git rebase --abort", + "git branch", "git branch -a", "git branch -d", "git branch -D", "git branch -m", + "git checkout", "git checkout -b", "git checkout -", "git checkout --", + "git switch", "git switch -c", + "git log", "git log --oneline", "git log --graph", "git log --all --graph --oneline", + "git diff", "git diff --staged", "git diff HEAD", "git diff --cached", + "git stash", "git stash pop", "git stash list", "git stash apply", "git stash drop", + "git remote", "git remote -v", "git remote add origin", "git remote set-url origin", + "git tag", "git tag -a", "git tag -d", "git push --tags", + "git reset", "git reset --hard", "git reset --soft", "git reset HEAD", + "git revert", "git cherry-pick", "git bisect", + "git clean", "git clean -fd", "git rm", "git mv", + "git show", "git blame", "git reflog", + "git config", "git config --global user.name", "git config --global user.email", + + // GitHub CLI + "gh", "gh repo clone", "gh repo create", "gh pr create", "gh pr list", "gh issue create", + + // NPM/Node.js + "npm", "npm init", "npm init -y", + "npm install", "npm i", "npm install --save", "npm install --save-dev", + "npm uninstall", "npm update", "npm outdated", + "npm start", "npm run", "npm run build", "npm run dev", "npm run test", + "npm test", "npm run lint", "npm run format", + "npm list", "npm list -g", "npm search", "npm info", + "npm publish", "npm version", "npm audit", "npm audit fix", + "node", "node -v", "npx", "npx create-react-app", "npx create-next-app", + + // Yarn + "yarn", "yarn init", "yarn add", "yarn add --dev", "yarn remove", + "yarn install", "yarn upgrade", "yarn start", "yarn build", "yarn test", + "yarn run", "yarn global add", + + // pnpm + "pnpm", "pnpm install", "pnpm add", "pnpm remove", "pnpm run", + + // Python + "python", "python3", "python -m", "python -c", + "pip", "pip install", "pip install -r requirements.txt", "pip uninstall", + "pip list", "pip freeze", "pip freeze > requirements.txt", "pip show", + "pip3", "pip3 install", + "python -m venv", "python3 -m venv venv", + "source venv/bin/activate", "deactivate", + "python -m pip install --upgrade pip", + "pytest", "pytest -v", "python -m pytest", + + // Ruby/Rails + "ruby", "ruby -v", "gem", "gem install", "gem list", "gem update", + "bundle", "bundle install", "bundle update", "bundle exec", + "rails", "rails new", "rails server", "rails console", "rails generate", + "rake", "rake db:migrate", "rake db:seed", + + // Docker (comprehensive) + "docker", "docker --version", + "docker ps", "docker ps -a", "docker ps -q", + "docker images", "docker images -a", + "docker run", "docker run -it", "docker run -d", "docker run -p", "docker run --rm", + "docker start", "docker stop", "docker restart", "docker pause", "docker unpause", + "docker rm", "docker rm -f", "docker rmi", "docker rmi -f", + "docker exec", "docker exec -it", "docker exec -it bash", "docker exec -it sh", + "docker logs", "docker logs -f", "docker logs --tail", + "docker build", "docker build -t", "docker build --no-cache", + "docker pull", "docker push", "docker tag", + "docker inspect", "docker stats", "docker top", + "docker network", "docker network ls", "docker network create", "docker network rm", + "docker volume", "docker volume ls", "docker volume create", "docker volume rm", + "docker system prune", "docker system prune -a", + "docker-compose", "docker-compose up", "docker-compose up -d", "docker-compose down", + "docker-compose build", "docker-compose logs", "docker-compose ps", + + // Kubernetes + "kubectl", "kubectl version", "kubectl cluster-info", + "kubectl get", "kubectl get pods", "kubectl get services", "kubectl get deployments", + "kubectl get nodes", "kubectl get namespaces", "kubectl get all", + "kubectl describe", "kubectl describe pod", "kubectl describe service", + "kubectl logs", "kubectl logs -f", "kubectl logs --tail", + "kubectl exec", "kubectl exec -it", "kubectl exec -it -- /bin/bash", + "kubectl apply", "kubectl apply -f", "kubectl delete", "kubectl delete -f", + "kubectl create", "kubectl edit", "kubectl scale", "kubectl rollout", + "kubectl port-forward", "kubectl proxy", + + // Terraform + "terraform", "terraform init", "terraform plan", "terraform apply", "terraform destroy", + "terraform fmt", "terraform validate", "terraform output", + + // AWS CLI + "aws", "aws s3 ls", "aws s3 cp", "aws s3 sync", "aws ec2 describe-instances", + "aws configure", "aws sts get-caller-identity", + + // Package managers + "brew", "brew install", "brew update", "brew upgrade", "brew list", "brew search", + "brew uninstall", "brew info", "brew doctor", "brew cleanup", + "apt", "apt update", "apt upgrade", "apt install", "apt remove", "apt search", + "apt-get", "apt-get update", "apt-get upgrade", "apt-get install", + "yum", "yum install", "yum update", "yum remove", "yum search", + "dnf", "dnf install", "dnf update", "dnf remove", + + // Rust/Cargo + "cargo", "cargo new", "cargo init", "cargo build", "cargo run", "cargo test", + "cargo check", "cargo clean", "cargo update", "rustc", "rustup", + + // Go + "go", "go run", "go build", "go test", "go get", "go mod init", "go mod tidy", + "go fmt", "go vet", "go install", + + // Java/Maven/Gradle + "java", "javac", "jar", "mvn", "mvn clean", "mvn install", "mvn test", "mvn package", + "gradle", "gradle build", "gradle test", "gradle run", + + // Database + "mysql", "mysql -u root -p", "mysqldump", "psql", "mongo", "redis-cli", + "sqlite3", + + // Editors + "vim", "vi", "nano", "emacs", "code", "subl", "atom", + + // Shell/Environment + "echo", "echo $PATH", "export", "env", "printenv", "set", "unset", + "alias", "unalias", "source", ".", "exec", "eval", + "history", "clear", "reset", "exit", "logout", + + // Help/Documentation + "man", "help", "info", "tldr", "--help", "-h", + + // Misc utilities + "xargs", "tee", "yes", "seq", "bc", "expr", "factor", "sleep", + "watch", "time", "timeout", "at", "cron", "crontab", "crontab -e", + "make", "cmake", "gcc", "g++", "clang", + "ln", "ln -s", "readlink", "basename", "dirname", + "md5sum", "sha256sum", "shasum", "openssl", + "jq", "yq", "xmllint", "base64", + + // macOS specific + "open", "pbcopy", "pbpaste", "say", "caffeinate", "defaults", + "diskutil", "softwareupdate", "system_profiler", + + // Common patterns and combinations + "sudo !!", "sudo -i", "sudo su", "su -", + "ls -la | grep", "ps aux | grep", "history | grep", + "find . -name '*.log'", "find . -type f -name", + "tar -xzvf file.tar.gz", "tar -czvf archive.tar.gz", + "chmod +x script.sh", "./script.sh", + "cat file.txt | grep", "tail -f /var/log/syslog", + "df -h | grep", "du -sh *", "du -sh * | sort -h", + "netstat -tulpn | grep", "lsof -i :8080", + "kill -9 $(ps aux | grep process | awk '{print $2}')", + "export PATH=$PATH:/usr/local/bin", + "source ~/.bashrc", "source ~/.zshrc", + "git log --oneline --graph --all --decorate", + "docker run -it --rm ubuntu bash", + "kubectl get pods -A", "kubectl get pods -o wide", + "npm install && npm start", + "python -m http.server", "python3 -m http.server 8000" + ] init() { loadHistory() + loadSystemCommands() + } + + private func loadSystemCommands() { + // Load commands from PATH in background + DispatchQueue.global(qos: .background).async { [weak self] in + var commands = Set() + + // Get PATH directories + let pathString = ProcessInfo.processInfo.environment["PATH"] ?? "" + let paths = pathString.split(separator: ":").map(String.init) + + // Common directories to check + let commonPaths = ["/usr/bin", "/bin", "/usr/local/bin", "/opt/homebrew/bin"] + let allPaths = Set(paths + commonPaths) + + for path in allPaths { + if let contents = try? FileManager.default.contentsOfDirectory(atPath: path) { + commands.formUnion(contents) + } + } + + DispatchQueue.main.async { + self?.systemCommands = commands + NSLog("Loaded \(commands.count) system commands for suggestions") + } + } } func addCommand(_ command: String) { @@ -45,10 +282,33 @@ class HistoryManager { func getSuggestion(for partial: String) -> String? { guard !partial.isEmpty else { return nil } - // Find the most recent command that starts with the partial input - return history.reversed().first { command in - command.hasPrefix(partial) && command != partial + let trimmed = partial.trimmingCharacters(in: .whitespaces) + + // Priority 1: Recent history (most relevant) + if let historyMatch = history.reversed().first(where: { command in + command.hasPrefix(trimmed) && command != trimmed + }) { + return historyMatch } + + // Priority 2: Common commands with arguments + if let commonMatch = commonCommands.first(where: { command in + command.hasPrefix(trimmed) && command != trimmed + }) { + return commonMatch + } + + // Priority 3: System commands (just the command name) + // Only suggest if we're typing a single word (no spaces yet) + if !trimmed.contains(" ") { + if let systemMatch = systemCommands.sorted().first(where: { command in + command.hasPrefix(trimmed) && command != trimmed + }) { + return systemMatch + } + } + + return nil } func getAllHistory() -> [String] { diff --git a/MainWindowController.swift b/MainWindowController.swift index 42d51b6..2e53616 100644 --- a/MainWindowController.swift +++ b/MainWindowController.swift @@ -26,7 +26,9 @@ class MainWindowController: NSWindowController { setupUI() setupPanels() setupNotifications() - addNewTab() + + // Restore previous session or create new tab + restoreSession() // Force window to show window.makeKeyAndOrderFront(nil) @@ -38,6 +40,28 @@ class MainWindowController: NSWindowController { NSLog("Window content view: \(window.contentView?.frame ?? .zero)") } + private func restoreSession() { + let savedSessions = tabManager.loadSession() + + if savedSessions.isEmpty { + // No saved session, create default tab + addNewTab() + } else { + // Restore saved tabs + for sessionData in savedSessions { + let session = tabManager.addTab(title: sessionData.title) + session.title = sessionData.title // Override the auto-generated title + session.setupTextView(frame: contentView.bounds) + session.startShell() + } + + // Restore current tab index + let savedIndex = tabManager.getSavedCurrentIndex() + updateTabBar() + switchToTab(index: min(savedIndex, tabManager.tabs.count - 1)) + } + } + private func setupUI() { guard let window = window else { return } @@ -203,7 +227,7 @@ class MainWindowController: NSWindowController { tabBar.arrangedSubviews.forEach { $0.removeFromSuperview() } // Add tab buttons - for (index, _) in tabManager.tabs.enumerated() { + for (index, session) in tabManager.tabs.enumerated() { // Create container for tab button and close button let tabContainer = NSView() tabContainer.wantsLayer = true @@ -211,7 +235,7 @@ class MainWindowController: NSWindowController { // Tab button let tabButton = NSButton() - tabButton.title = "Tab \(index + 1)" + tabButton.title = session.title tabButton.bezelStyle = .rounded tabButton.tag = index tabButton.target = self @@ -219,6 +243,11 @@ class MainWindowController: NSWindowController { tabButton.font = NSFont.systemFont(ofSize: 11) tabButton.translatesAutoresizingMaskIntoConstraints = false + // Add double-click for rename + let doubleClickGesture = NSClickGestureRecognizer(target: self, action: #selector(tabDoubleClicked(_:))) + doubleClickGesture.numberOfClicksRequired = 2 + tabButton.addGestureRecognizer(doubleClickGesture) + // Highlight current tab if index == tabManager.currentIndex { tabButton.contentTintColor = .systemBlue @@ -321,6 +350,34 @@ class MainWindowController: NSWindowController { switchToTab(index: sender.tag) } + @objc func tabDoubleClicked(_ sender: NSClickGestureRecognizer) { + guard let button = sender.view as? NSButton else { return } + let index = button.tag + + let alert = NSAlert() + alert.messageText = "Rename Tab" + alert.informativeText = "Enter a new name for this tab:" + + let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 24)) + textField.stringValue = tabManager.tabs[index].title + textField.placeholderString = "Tab name" + + alert.accessoryView = textField + alert.addButton(withTitle: "Rename") + alert.addButton(withTitle: "Cancel") + + alert.window.initialFirstResponder = textField + + if alert.runModal() == .alertFirstButtonReturn { + let newName = textField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + if !newName.isEmpty { + tabManager.tabs[index].title = newName + updateTabBar() + tabManager.saveSession() + } + } + } + private func switchToTab(index: Int) { guard index < tabManager.tabs.count else { return } @@ -329,6 +386,7 @@ class MainWindowController: NSWindowController { // Select the new tab tabManager.selectTab(at: index) + tabManager.saveSession() // Add the selected tab's scroll view to content view if let session = tabManager.getCurrentSession() { diff --git a/Panels.swift b/Panels.swift index b36ac90..1541611 100644 --- a/Panels.swift +++ b/Panels.swift @@ -1,4 +1,5 @@ import Cocoa +import UniformTypeIdentifiers // MARK: - History Search Panel (Ctrl+R) class HistorySearchPanel: NSPanel, NSTableViewDataSource, NSTableViewDelegate, NSTextFieldDelegate { @@ -156,19 +157,35 @@ class SnippetPanel: NSPanel, NSTableViewDataSource, NSTableViewDelegate, NSTextF container.autoresizingMask = [.width, .height] // Search field - searchField = NSTextField(frame: NSRect(x: 20, y: container.bounds.height - 42, width: container.bounds.width - 140, height: 24)) + searchField = NSTextField(frame: NSRect(x: 20, y: container.bounds.height - 42, width: container.bounds.width - 260, height: 24)) searchField.placeholderString = "Search snippets..." searchField.autoresizingMask = [.width, .minYMargin] searchField.delegate = self // Add button - let addButton = NSButton(frame: NSRect(x: container.bounds.width - 110, y: container.bounds.height - 45, width: 90, height: 30)) - addButton.title = "Add New" + let addButton = NSButton(frame: NSRect(x: container.bounds.width - 230, y: container.bounds.height - 45, width: 70, height: 30)) + addButton.title = "Add" addButton.bezelStyle = .rounded addButton.target = self addButton.action = #selector(addSnippet) addButton.autoresizingMask = [.minXMargin, .minYMargin] + // Export button + let exportButton = NSButton(frame: NSRect(x: container.bounds.width - 155, y: container.bounds.height - 45, width: 70, height: 30)) + exportButton.title = "Export" + exportButton.bezelStyle = .rounded + exportButton.target = self + exportButton.action = #selector(exportSnippets) + exportButton.autoresizingMask = [.minXMargin, .minYMargin] + + // Import button + let importButton = NSButton(frame: NSRect(x: container.bounds.width - 80, y: container.bounds.height - 45, width: 70, height: 30)) + importButton.title = "Import" + importButton.bezelStyle = .rounded + importButton.target = self + importButton.action = #selector(importSnippets) + importButton.autoresizingMask = [.minXMargin, .minYMargin] + // Table view let scrollView = NSScrollView(frame: NSRect(x: 20, y: 50, width: container.bounds.width - 40, height: container.bounds.height - 110)) scrollView.autoresizingMask = [.width, .height] @@ -218,7 +235,14 @@ class SnippetPanel: NSPanel, NSTableViewDataSource, NSTableViewDelegate, NSTextF useButton.action = #selector(selectSnippet) useButton.autoresizingMask = [.minXMargin, .maxYMargin] - let cancelButton = NSButton(frame: NSRect(x: container.bounds.width - 230, y: 10, width: 100, height: 30)) + let deleteButton = NSButton(frame: NSRect(x: container.bounds.width - 230, y: 10, width: 100, height: 30)) + deleteButton.title = "Delete" + deleteButton.bezelStyle = .rounded + deleteButton.target = self + deleteButton.action = #selector(deleteSnippet) + deleteButton.autoresizingMask = [.minXMargin, .maxYMargin] + + let cancelButton = NSButton(frame: NSRect(x: container.bounds.width - 340, y: 10, width: 100, height: 30)) cancelButton.title = "Cancel" cancelButton.bezelStyle = .rounded cancelButton.target = self @@ -227,8 +251,11 @@ class SnippetPanel: NSPanel, NSTableViewDataSource, NSTableViewDelegate, NSTextF container.addSubview(searchField) container.addSubview(addButton) + container.addSubview(exportButton) + container.addSubview(importButton) container.addSubview(scrollView) container.addSubview(useButton) + container.addSubview(deleteButton) container.addSubview(cancelButton) contentView = container @@ -300,6 +327,111 @@ class SnippetPanel: NSPanel, NSTableViewDataSource, NSTableViewDelegate, NSTextF close() } + @objc private func deleteSnippet() { + let row = tableView.selectedRow + guard row >= 0 && row < filteredSnippets.count else { return } + + let snippet = filteredSnippets[row] + let alert = NSAlert() + alert.messageText = "Delete Snippet?" + alert.informativeText = "Are you sure you want to delete '\(snippet.name)'?" + alert.addButton(withTitle: "Delete") + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + + if alert.runModal() == .alertFirstButtonReturn { + SnippetManager.shared.removeSnippet(snippet.id) + filteredSnippets = SnippetManager.shared.getAllSnippets() + tableView.reloadData() + updateEmptyState() + } + } + + @objc private func exportSnippets() { + guard let data = SnippetManager.shared.exportSnippets() else { + let alert = NSAlert() + alert.messageText = "Export Failed" + alert.informativeText = "Could not export snippets." + alert.alertStyle = .warning + alert.runModal() + return + } + + let savePanel = NSSavePanel() + savePanel.allowedContentTypes = [.json] + savePanel.nameFieldStringValue = "snippets.json" + savePanel.title = "Export Snippets" + + if savePanel.runModal() == .OK, let url = savePanel.url { + do { + try data.write(to: url) + let alert = NSAlert() + alert.messageText = "Export Successful" + alert.informativeText = "Snippets exported to \(url.lastPathComponent)" + alert.alertStyle = .informational + alert.runModal() + } catch { + let alert = NSAlert() + alert.messageText = "Export Failed" + alert.informativeText = error.localizedDescription + alert.alertStyle = .warning + alert.runModal() + } + } + } + + @objc private func importSnippets() { + let openPanel = NSOpenPanel() + openPanel.allowedContentTypes = [.json] + openPanel.allowsMultipleSelection = false + openPanel.title = "Import Snippets" + + if openPanel.runModal() == .OK, let url = openPanel.url { + do { + let data = try Data(contentsOf: url) + + // Ask if user wants to replace or merge + let alert = NSAlert() + alert.messageText = "Import Snippets" + alert.informativeText = "Do you want to replace existing snippets or merge with them?" + alert.addButton(withTitle: "Merge") + alert.addButton(withTitle: "Replace") + alert.addButton(withTitle: "Cancel") + + let response = alert.runModal() + if response == .alertThirdButtonReturn { + return + } + + let replace = (response == .alertSecondButtonReturn) + + if SnippetManager.shared.importSnippets(from: data, replace: replace) { + filteredSnippets = SnippetManager.shared.getAllSnippets() + tableView.reloadData() + updateEmptyState() + + let success = NSAlert() + success.messageText = "Import Successful" + success.informativeText = "Snippets imported successfully." + success.alertStyle = .informational + success.runModal() + } else { + let error = NSAlert() + error.messageText = "Import Failed" + error.informativeText = "Could not parse snippets file." + error.alertStyle = .warning + error.runModal() + } + } catch { + let alert = NSAlert() + alert.messageText = "Import Failed" + alert.informativeText = error.localizedDescription + alert.alertStyle = .warning + alert.runModal() + } + } + } + @objc private func cancel() { completion?(nil) close() diff --git a/SnippetManager.swift b/SnippetManager.swift index f05d4e0..b19fe98 100644 --- a/SnippetManager.swift +++ b/SnippetManager.swift @@ -52,6 +52,28 @@ class SnippetManager { return snippets } + func exportSnippets() -> Data? { + return try? JSONEncoder().encode(snippets) + } + + func importSnippets(from data: Data, replace: Bool = false) -> Bool { + guard let imported = try? JSONDecoder().decode([Snippet].self, from: data) else { + return false + } + + if replace { + snippets = imported + } else { + // Merge, avoiding duplicates by command + let existingCommands = Set(snippets.map { $0.command }) + let newSnippets = imported.filter { !existingCommands.contains($0.command) } + snippets.append(contentsOf: newSnippets) + } + + saveSnippets() + return true + } + private func addDefaultSnippets() { // Removed - snippets now start empty // Users can add snippets via right-click context menu diff --git a/TerminalSession.swift b/TerminalSession.swift index 2ef3501..4fe6194 100644 --- a/TerminalSession.swift +++ b/TerminalSession.swift @@ -10,7 +10,6 @@ class TerminalSession: NSObject, NSTextViewDelegate, NSMenuDelegate { var promptPosition = 0 var sessionID: UUID var title: String - private var suggestionLabel: NSTextField? private var currentSuggestion: String? init(title: String = "Terminal") { @@ -120,15 +119,8 @@ class TerminalSession: NSObject, NSTextViewDelegate, NSMenuDelegate { } 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) - } + // We'll use a different approach - inline gray text + // No separate label needed } func applyTheme() { @@ -140,9 +132,6 @@ class TerminalSession: NSObject, NSTextViewDelegate, NSMenuDelegate { .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() { @@ -257,12 +246,24 @@ class TerminalSession: NSObject, NSTextViewDelegate, NSMenuDelegate { } } - // Update suggestions after text changes + // Handle backspace/delete - need to update suggestions + if replacementString == "" { + // Allow the deletion + let result = true + // Update suggestions after deletion + DispatchQueue.main.async { [weak self] in + self?.updateSuggestion() + } + return result + } + + // For regular character input, update suggestions after the character is added + let result = true DispatchQueue.main.async { [weak self] in self?.updateSuggestion() } - return true + return result } private func getCurrentCommand() -> String { @@ -272,57 +273,120 @@ class TerminalSession: NSObject, NSTextViewDelegate, NSMenuDelegate { } private func updateSuggestion() { + // First, always hide any existing suggestion + hideSuggestion() + guard TerminalSettings.shared.enableAutoSuggestions else { - hideSuggestion() return } - let partial = getCurrentCommand() + let partial = getCurrentCommand().trimmingCharacters(in: .whitespaces) 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 + guard !remaining.isEmpty else { return } - // 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 - } - } + // Append the suggestion in gray at the current cursor position + let font = NSFont(name: TerminalSettings.shared.fontName, size: TerminalSettings.shared.fontSize) ?? NSFont.monospacedSystemFont(ofSize: TerminalSettings.shared.fontSize, weight: .regular) + + let suggestionAttr = NSAttributedString(string: remaining, attributes: [ + .foregroundColor: NSColor.gray.withAlphaComponent(0.5), + .font: font + ]) + + let currentLength = textView.string.count + textView.textStorage?.append(suggestionAttr) + + // Move cursor back to before the suggestion + textView.setSelectedRange(NSRange(location: currentLength, length: 0)) + + NSLog("Showing suggestion: '\(remaining)' for partial: '\(partial)'") } private func hideSuggestion() { + guard let suggestion = currentSuggestion else { return } + + // Find and remove any gray text at the end + let textLength = textView.string.count + guard textLength > 0, let storage = textView.textStorage else { + currentSuggestion = nil + return + } + + // Scan backwards from the end to find gray text + var removeLength = 0 + for i in stride(from: textLength - 1, through: 0, by: -1) { + let attributes = storage.attributes(at: i, effectiveRange: nil) + if let color = attributes[.foregroundColor] as? NSColor, + color.alphaComponent < 1.0 { + removeLength += 1 + } else { + break + } + } + + if removeLength > 0 { + let removeRange = NSRange(location: textLength - removeLength, length: removeLength) + storage.deleteCharacters(in: removeRange) + NSLog("Removed \(removeLength) chars of gray suggestion") + } + currentSuggestion = nil - suggestionLabel?.isHidden = true } private func acceptSuggestion(_ suggestion: String) { + guard let suggestion = currentSuggestion else { return } + + // Get the current command and calculate remaining let partial = getCurrentCommand() let remaining = String(suggestion.dropFirst(partial.count)) - textView.insertText(remaining, replacementRange: NSRange(location: textView.string.count, length: 0)) - hideSuggestion() + + // Remove the gray suggestion first by scanning for gray text + let textLength = textView.string.count + var removeLength = 0 + if let storage = textView.textStorage { + for i in stride(from: textLength - 1, through: 0, by: -1) { + let attributes = storage.attributes(at: i, effectiveRange: nil) + if let color = attributes[.foregroundColor] as? NSColor, + color.alphaComponent < 1.0 { + removeLength += 1 + } else { + break + } + } + + if removeLength > 0 { + let removeRange = NSRange(location: textLength - removeLength, length: removeLength) + storage.deleteCharacters(in: removeRange) + } + } + + // Add it back in normal color + let theme = TerminalSettings.shared.theme + let font = NSFont(name: TerminalSettings.shared.fontName, size: TerminalSettings.shared.fontSize) ?? NSFont.monospacedSystemFont(ofSize: TerminalSettings.shared.fontSize, weight: .regular) + + let normalAttr = NSAttributedString(string: remaining, attributes: [ + .foregroundColor: theme.foreground, + .font: font + ]) + + textView.textStorage?.append(normalAttr) + textView.setSelectedRange(NSRange(location: textView.string.count, length: 0)) + + currentSuggestion = nil + + NSLog("Accepted suggestion: '\(suggestion)'") } func executeCommand(_ command: String) { @@ -417,10 +481,16 @@ class TabManager { var tabs: [TerminalSession] = [] var currentIndex: Int = 0 + struct SessionData: Codable { + let title: String + let sessionID: String + } + func addTab(title: String = "Terminal") -> TerminalSession { let session = TerminalSession(title: "\(title) \(tabs.count + 1)") tabs.append(session) currentIndex = tabs.count - 1 + saveSession() return session } @@ -431,6 +501,7 @@ class TabManager { if currentIndex >= tabs.count { currentIndex = max(0, tabs.count - 1) } + saveSession() } func selectTab(at index: Int) { @@ -442,4 +513,24 @@ class TabManager { guard currentIndex < tabs.count else { return nil } return tabs[currentIndex] } + + func saveSession() { + let sessionData = tabs.map { SessionData(title: $0.title, sessionID: $0.sessionID.uuidString) } + if let data = try? JSONEncoder().encode(sessionData) { + UserDefaults.standard.set(data, forKey: "terminal_session") + UserDefaults.standard.set(currentIndex, forKey: "terminal_current_tab") + } + } + + func loadSession() -> [SessionData] { + guard let data = UserDefaults.standard.data(forKey: "terminal_session"), + let sessions = try? JSONDecoder().decode([SessionData].self, from: data) else { + return [] + } + return sessions + } + + func getSavedCurrentIndex() -> Int { + return UserDefaults.standard.integer(forKey: "terminal_current_tab") + } }