Add comprehensive features: snippets, export/import, tab renaming, session persistence, and enhanced auto-suggestions
- Snippets: Start empty, add via right-click context menu on selected text - Export/Import: Save and load snippets as JSON files with merge/replace options - Tab Renaming: Double-click tabs to rename them - Session Persistence: Tabs and their names are saved and restored on app restart - Delete Snippets: Remove unwanted snippets from the panel - Auto-suggestions: Comprehensive command database with 400+ commands - Includes Git, Docker, Kubernetes, NPM, Python, Ruby, Rust, Go, and more - Suggestions from command history, common commands, and system PATH - Updates in real-time as you type - Press Tab to accept suggestions - Fixed tab content persistence when switching between tabs - Fixed tab bar layout and positioning - All features fully functional and tested
This commit is contained in:
Binary file not shown.
@@ -5,9 +5,246 @@ class HistoryManager {
|
|||||||
static let shared = HistoryManager()
|
static let shared = HistoryManager()
|
||||||
private var history: [String] = []
|
private var history: [String] = []
|
||||||
private let maxHistory = 1000
|
private let maxHistory = 1000
|
||||||
|
private var systemCommands: Set<String> = []
|
||||||
|
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() {
|
init() {
|
||||||
loadHistory()
|
loadHistory()
|
||||||
|
loadSystemCommands()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadSystemCommands() {
|
||||||
|
// Load commands from PATH in background
|
||||||
|
DispatchQueue.global(qos: .background).async { [weak self] in
|
||||||
|
var commands = Set<String>()
|
||||||
|
|
||||||
|
// 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) {
|
func addCommand(_ command: String) {
|
||||||
@@ -45,10 +282,33 @@ class HistoryManager {
|
|||||||
func getSuggestion(for partial: String) -> String? {
|
func getSuggestion(for partial: String) -> String? {
|
||||||
guard !partial.isEmpty else { return nil }
|
guard !partial.isEmpty else { return nil }
|
||||||
|
|
||||||
// Find the most recent command that starts with the partial input
|
let trimmed = partial.trimmingCharacters(in: .whitespaces)
|
||||||
return history.reversed().first { command in
|
|
||||||
command.hasPrefix(partial) && command != partial
|
// 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] {
|
func getAllHistory() -> [String] {
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ class MainWindowController: NSWindowController {
|
|||||||
setupUI()
|
setupUI()
|
||||||
setupPanels()
|
setupPanels()
|
||||||
setupNotifications()
|
setupNotifications()
|
||||||
addNewTab()
|
|
||||||
|
// Restore previous session or create new tab
|
||||||
|
restoreSession()
|
||||||
|
|
||||||
// Force window to show
|
// Force window to show
|
||||||
window.makeKeyAndOrderFront(nil)
|
window.makeKeyAndOrderFront(nil)
|
||||||
@@ -38,6 +40,28 @@ class MainWindowController: NSWindowController {
|
|||||||
NSLog("Window content view: \(window.contentView?.frame ?? .zero)")
|
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() {
|
private func setupUI() {
|
||||||
guard let window = window else { return }
|
guard let window = window else { return }
|
||||||
|
|
||||||
@@ -203,7 +227,7 @@ class MainWindowController: NSWindowController {
|
|||||||
tabBar.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
tabBar.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
|
|
||||||
// Add tab buttons
|
// Add tab buttons
|
||||||
for (index, _) in tabManager.tabs.enumerated() {
|
for (index, session) in tabManager.tabs.enumerated() {
|
||||||
// Create container for tab button and close button
|
// Create container for tab button and close button
|
||||||
let tabContainer = NSView()
|
let tabContainer = NSView()
|
||||||
tabContainer.wantsLayer = true
|
tabContainer.wantsLayer = true
|
||||||
@@ -211,7 +235,7 @@ class MainWindowController: NSWindowController {
|
|||||||
|
|
||||||
// Tab button
|
// Tab button
|
||||||
let tabButton = NSButton()
|
let tabButton = NSButton()
|
||||||
tabButton.title = "Tab \(index + 1)"
|
tabButton.title = session.title
|
||||||
tabButton.bezelStyle = .rounded
|
tabButton.bezelStyle = .rounded
|
||||||
tabButton.tag = index
|
tabButton.tag = index
|
||||||
tabButton.target = self
|
tabButton.target = self
|
||||||
@@ -219,6 +243,11 @@ class MainWindowController: NSWindowController {
|
|||||||
tabButton.font = NSFont.systemFont(ofSize: 11)
|
tabButton.font = NSFont.systemFont(ofSize: 11)
|
||||||
tabButton.translatesAutoresizingMaskIntoConstraints = false
|
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
|
// Highlight current tab
|
||||||
if index == tabManager.currentIndex {
|
if index == tabManager.currentIndex {
|
||||||
tabButton.contentTintColor = .systemBlue
|
tabButton.contentTintColor = .systemBlue
|
||||||
@@ -321,6 +350,34 @@ class MainWindowController: NSWindowController {
|
|||||||
switchToTab(index: sender.tag)
|
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) {
|
private func switchToTab(index: Int) {
|
||||||
guard index < tabManager.tabs.count else { return }
|
guard index < tabManager.tabs.count else { return }
|
||||||
|
|
||||||
@@ -329,6 +386,7 @@ class MainWindowController: NSWindowController {
|
|||||||
|
|
||||||
// Select the new tab
|
// Select the new tab
|
||||||
tabManager.selectTab(at: index)
|
tabManager.selectTab(at: index)
|
||||||
|
tabManager.saveSession()
|
||||||
|
|
||||||
// Add the selected tab's scroll view to content view
|
// Add the selected tab's scroll view to content view
|
||||||
if let session = tabManager.getCurrentSession() {
|
if let session = tabManager.getCurrentSession() {
|
||||||
|
|||||||
140
Panels.swift
140
Panels.swift
@@ -1,4 +1,5 @@
|
|||||||
import Cocoa
|
import Cocoa
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
// MARK: - History Search Panel (Ctrl+R)
|
// MARK: - History Search Panel (Ctrl+R)
|
||||||
class HistorySearchPanel: NSPanel, NSTableViewDataSource, NSTableViewDelegate, NSTextFieldDelegate {
|
class HistorySearchPanel: NSPanel, NSTableViewDataSource, NSTableViewDelegate, NSTextFieldDelegate {
|
||||||
@@ -156,19 +157,35 @@ class SnippetPanel: NSPanel, NSTableViewDataSource, NSTableViewDelegate, NSTextF
|
|||||||
container.autoresizingMask = [.width, .height]
|
container.autoresizingMask = [.width, .height]
|
||||||
|
|
||||||
// Search field
|
// 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.placeholderString = "Search snippets..."
|
||||||
searchField.autoresizingMask = [.width, .minYMargin]
|
searchField.autoresizingMask = [.width, .minYMargin]
|
||||||
searchField.delegate = self
|
searchField.delegate = self
|
||||||
|
|
||||||
// Add button
|
// Add button
|
||||||
let addButton = NSButton(frame: NSRect(x: container.bounds.width - 110, y: container.bounds.height - 45, width: 90, height: 30))
|
let addButton = NSButton(frame: NSRect(x: container.bounds.width - 230, y: container.bounds.height - 45, width: 70, height: 30))
|
||||||
addButton.title = "Add New"
|
addButton.title = "Add"
|
||||||
addButton.bezelStyle = .rounded
|
addButton.bezelStyle = .rounded
|
||||||
addButton.target = self
|
addButton.target = self
|
||||||
addButton.action = #selector(addSnippet)
|
addButton.action = #selector(addSnippet)
|
||||||
addButton.autoresizingMask = [.minXMargin, .minYMargin]
|
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
|
// Table view
|
||||||
let scrollView = NSScrollView(frame: NSRect(x: 20, y: 50, width: container.bounds.width - 40, height: container.bounds.height - 110))
|
let scrollView = NSScrollView(frame: NSRect(x: 20, y: 50, width: container.bounds.width - 40, height: container.bounds.height - 110))
|
||||||
scrollView.autoresizingMask = [.width, .height]
|
scrollView.autoresizingMask = [.width, .height]
|
||||||
@@ -218,7 +235,14 @@ class SnippetPanel: NSPanel, NSTableViewDataSource, NSTableViewDelegate, NSTextF
|
|||||||
useButton.action = #selector(selectSnippet)
|
useButton.action = #selector(selectSnippet)
|
||||||
useButton.autoresizingMask = [.minXMargin, .maxYMargin]
|
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.title = "Cancel"
|
||||||
cancelButton.bezelStyle = .rounded
|
cancelButton.bezelStyle = .rounded
|
||||||
cancelButton.target = self
|
cancelButton.target = self
|
||||||
@@ -227,8 +251,11 @@ class SnippetPanel: NSPanel, NSTableViewDataSource, NSTableViewDelegate, NSTextF
|
|||||||
|
|
||||||
container.addSubview(searchField)
|
container.addSubview(searchField)
|
||||||
container.addSubview(addButton)
|
container.addSubview(addButton)
|
||||||
|
container.addSubview(exportButton)
|
||||||
|
container.addSubview(importButton)
|
||||||
container.addSubview(scrollView)
|
container.addSubview(scrollView)
|
||||||
container.addSubview(useButton)
|
container.addSubview(useButton)
|
||||||
|
container.addSubview(deleteButton)
|
||||||
container.addSubview(cancelButton)
|
container.addSubview(cancelButton)
|
||||||
|
|
||||||
contentView = container
|
contentView = container
|
||||||
@@ -300,6 +327,111 @@ class SnippetPanel: NSPanel, NSTableViewDataSource, NSTableViewDelegate, NSTextF
|
|||||||
close()
|
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() {
|
@objc private func cancel() {
|
||||||
completion?(nil)
|
completion?(nil)
|
||||||
close()
|
close()
|
||||||
|
|||||||
@@ -52,6 +52,28 @@ class SnippetManager {
|
|||||||
return snippets
|
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() {
|
private func addDefaultSnippets() {
|
||||||
// Removed - snippets now start empty
|
// Removed - snippets now start empty
|
||||||
// Users can add snippets via right-click context menu
|
// Users can add snippets via right-click context menu
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ class TerminalSession: NSObject, NSTextViewDelegate, NSMenuDelegate {
|
|||||||
var promptPosition = 0
|
var promptPosition = 0
|
||||||
var sessionID: UUID
|
var sessionID: UUID
|
||||||
var title: String
|
var title: String
|
||||||
private var suggestionLabel: NSTextField?
|
|
||||||
private var currentSuggestion: String?
|
private var currentSuggestion: String?
|
||||||
|
|
||||||
init(title: String = "Terminal") {
|
init(title: String = "Terminal") {
|
||||||
@@ -120,15 +119,8 @@ class TerminalSession: NSObject, NSTextViewDelegate, NSMenuDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func setupSuggestionLabel() {
|
private func setupSuggestionLabel() {
|
||||||
suggestionLabel = NSTextField(frame: .zero)
|
// We'll use a different approach - inline gray text
|
||||||
suggestionLabel?.isEditable = false
|
// No separate label needed
|
||||||
suggestionLabel?.isSelectable = false
|
|
||||||
suggestionLabel?.isBordered = false
|
|
||||||
suggestionLabel?.backgroundColor = .clear
|
|
||||||
suggestionLabel?.isHidden = true
|
|
||||||
if let label = suggestionLabel {
|
|
||||||
textView.addSubview(label)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyTheme() {
|
func applyTheme() {
|
||||||
@@ -140,9 +132,6 @@ class TerminalSession: NSObject, NSTextViewDelegate, NSMenuDelegate {
|
|||||||
.backgroundColor: theme.selection
|
.backgroundColor: theme.selection
|
||||||
]
|
]
|
||||||
textView?.font = NSFont(name: TerminalSettings.shared.fontName, size: TerminalSettings.shared.fontSize) ?? NSFont.monospacedSystemFont(ofSize: TerminalSettings.shared.fontSize, weight: .regular)
|
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() {
|
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
|
DispatchQueue.main.async { [weak self] in
|
||||||
self?.updateSuggestion()
|
self?.updateSuggestion()
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getCurrentCommand() -> String {
|
private func getCurrentCommand() -> String {
|
||||||
@@ -272,57 +273,120 @@ class TerminalSession: NSObject, NSTextViewDelegate, NSMenuDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func updateSuggestion() {
|
private func updateSuggestion() {
|
||||||
|
// First, always hide any existing suggestion
|
||||||
|
hideSuggestion()
|
||||||
|
|
||||||
guard TerminalSettings.shared.enableAutoSuggestions else {
|
guard TerminalSettings.shared.enableAutoSuggestions else {
|
||||||
hideSuggestion()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let partial = getCurrentCommand()
|
let partial = getCurrentCommand().trimmingCharacters(in: .whitespaces)
|
||||||
guard !partial.isEmpty else {
|
guard !partial.isEmpty else {
|
||||||
hideSuggestion()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if let suggestion = HistoryManager.shared.getSuggestion(for: partial) {
|
if let suggestion = HistoryManager.shared.getSuggestion(for: partial) {
|
||||||
showSuggestion(suggestion, for: partial)
|
showSuggestion(suggestion, for: partial)
|
||||||
} else {
|
|
||||||
hideSuggestion()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showSuggestion(_ fullCommand: String, for partial: String) {
|
private func showSuggestion(_ fullCommand: String, for partial: String) {
|
||||||
guard let label = suggestionLabel else { return }
|
|
||||||
|
|
||||||
currentSuggestion = fullCommand
|
currentSuggestion = fullCommand
|
||||||
|
|
||||||
// Get the remaining part of the suggestion
|
// Get the remaining part of the suggestion
|
||||||
let remaining = String(fullCommand.dropFirst(partial.count))
|
let remaining = String(fullCommand.dropFirst(partial.count))
|
||||||
label.stringValue = remaining
|
guard !remaining.isEmpty else { return }
|
||||||
|
|
||||||
// Position the label at the cursor
|
// Append the suggestion in gray at the current cursor position
|
||||||
if let layoutManager = textView.layoutManager,
|
let font = NSFont(name: TerminalSettings.shared.fontName, size: TerminalSettings.shared.fontSize) ?? NSFont.monospacedSystemFont(ofSize: TerminalSettings.shared.fontSize, weight: .regular)
|
||||||
let textContainer = textView.textContainer {
|
|
||||||
let glyphRange = layoutManager.glyphRange(for: textContainer)
|
let suggestionAttr = NSAttributedString(string: remaining, attributes: [
|
||||||
let lastGlyphIndex = layoutManager.glyphIndexForCharacter(at: textView.string.count)
|
.foregroundColor: NSColor.gray.withAlphaComponent(0.5),
|
||||||
|
.font: font
|
||||||
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)
|
let currentLength = textView.string.count
|
||||||
label.isHidden = false
|
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() {
|
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
|
currentSuggestion = nil
|
||||||
suggestionLabel?.isHidden = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func acceptSuggestion(_ suggestion: String) {
|
private func acceptSuggestion(_ suggestion: String) {
|
||||||
|
guard let suggestion = currentSuggestion else { return }
|
||||||
|
|
||||||
|
// Get the current command and calculate remaining
|
||||||
let partial = getCurrentCommand()
|
let partial = getCurrentCommand()
|
||||||
let remaining = String(suggestion.dropFirst(partial.count))
|
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) {
|
func executeCommand(_ command: String) {
|
||||||
@@ -417,10 +481,16 @@ class TabManager {
|
|||||||
var tabs: [TerminalSession] = []
|
var tabs: [TerminalSession] = []
|
||||||
var currentIndex: Int = 0
|
var currentIndex: Int = 0
|
||||||
|
|
||||||
|
struct SessionData: Codable {
|
||||||
|
let title: String
|
||||||
|
let sessionID: String
|
||||||
|
}
|
||||||
|
|
||||||
func addTab(title: String = "Terminal") -> TerminalSession {
|
func addTab(title: String = "Terminal") -> TerminalSession {
|
||||||
let session = TerminalSession(title: "\(title) \(tabs.count + 1)")
|
let session = TerminalSession(title: "\(title) \(tabs.count + 1)")
|
||||||
tabs.append(session)
|
tabs.append(session)
|
||||||
currentIndex = tabs.count - 1
|
currentIndex = tabs.count - 1
|
||||||
|
saveSession()
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,6 +501,7 @@ class TabManager {
|
|||||||
if currentIndex >= tabs.count {
|
if currentIndex >= tabs.count {
|
||||||
currentIndex = max(0, tabs.count - 1)
|
currentIndex = max(0, tabs.count - 1)
|
||||||
}
|
}
|
||||||
|
saveSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectTab(at index: Int) {
|
func selectTab(at index: Int) {
|
||||||
@@ -442,4 +513,24 @@ class TabManager {
|
|||||||
guard currentIndex < tabs.count else { return nil }
|
guard currentIndex < tabs.count else { return nil }
|
||||||
return tabs[currentIndex]
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user