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()
|
||||
private var history: [String] = []
|
||||
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() {
|
||||
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) {
|
||||
@@ -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] {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
140
Panels.swift
140
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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user