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:
@@ -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