Files
EnhancedTerminal/MainWindowController.swift
Paul Bailey 9561e229aa Add 5 new features: edit snippets, clear scrollback, category filter, find in terminal, startup commands
1. Edit Snippets: Edit button to modify existing snippets
2. Clear Scrollback: Cmd+Shift+K to clear all terminal output
3. Category Filter: Dropdown to filter snippets by category
4. Find in Terminal: Cmd+F to search terminal output (built-in)
5. Startup Commands: Configure commands to run when opening new tabs

All features tested and working
2026-01-31 23:21:39 +00:00

535 lines
21 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Cocoa
class MainWindowController: NSWindowController {
var tabManager = TabManager()
var tabBar: NSStackView!
var contentView: NSView!
var historyPanel: HistorySearchPanel!
var snippetPanel: SnippetPanel!
var aiPanel: AIGeneratorPanel!
var settingsWindow: SettingsWindowController!
convenience init() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 1000, height: 700),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false
)
window.titlebarAppearsTransparent = false
window.titleVisibility = .visible
window.center()
window.title = "Enhanced Terminal"
window.isReleasedWhenClosed = false
self.init(window: window)
setupUI()
setupPanels()
setupNotifications()
// Restore previous session or create new tab
restoreSession()
// Force window to show
window.makeKeyAndOrderFront(nil)
window.orderFrontRegardless()
// Debug: Print tab bar info
NSLog("Tab bar frame: \(tabBar.frame)")
NSLog("Tab bar subviews: \(tabBar.arrangedSubviews.count)")
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 }
let mainContainer = NSView(frame: window.contentView!.bounds)
mainContainer.autoresizingMask = [.width, .height]
// Tab bar - positioned below the title bar
let tabBarHeight: CGFloat = 28
let topMargin: CGFloat = 10 // Space below title bar
tabBar = NSStackView(frame: NSRect(x: 0, y: mainContainer.bounds.height - tabBarHeight - topMargin, width: mainContainer.bounds.width, height: tabBarHeight))
tabBar.orientation = .horizontal
tabBar.distribution = .fill
tabBar.alignment = .centerY
tabBar.spacing = 5
tabBar.autoresizingMask = [.width, .minYMargin]
tabBar.wantsLayer = true
tabBar.layer?.backgroundColor = NSColor(white: 0.15, alpha: 1.0).cgColor // Dark gray background
tabBar.edgeInsets = NSEdgeInsets(top: 2, left: 10, bottom: 2, right: 10)
NSLog("Creating tab bar at: \(tabBar.frame)")
// Content view below tab bar
contentView = NSView(frame: NSRect(x: 0, y: 0, width: mainContainer.bounds.width, height: mainContainer.bounds.height - tabBarHeight - topMargin))
contentView.autoresizingMask = [.width, .height]
contentView.wantsLayer = true
contentView.layer?.backgroundColor = NSColor.black.cgColor
NSLog("Creating content view at: \(contentView.frame)")
mainContainer.addSubview(contentView)
mainContainer.addSubview(tabBar)
window.contentView = mainContainer
NSLog("Main container bounds: \(mainContainer.bounds)")
setupMenuBar()
}
private func setupPanels() {
historyPanel = HistorySearchPanel()
snippetPanel = SnippetPanel()
aiPanel = AIGeneratorPanel()
settingsWindow = SettingsWindowController()
}
private func setupNotifications() {
NotificationCenter.default.addObserver(self, selector: #selector(themeChanged), name: NSNotification.Name("ThemeChanged"), object: nil)
}
@objc private func themeChanged() {
tabManager.tabs.forEach { $0.applyTheme() }
}
private func setupMenuBar() {
let mainMenu = NSMenu()
// App menu
let appMenuItem = NSMenuItem()
mainMenu.addItem(appMenuItem)
let appMenu = NSMenu()
appMenuItem.submenu = appMenu
appMenu.addItem(NSMenuItem(title: "About Enhanced Terminal", action: #selector(showAbout), keyEquivalent: ""))
appMenu.addItem(NSMenuItem.separator())
appMenu.addItem(NSMenuItem(title: "Settings...", action: #selector(openSettings), keyEquivalent: ","))
appMenu.addItem(NSMenuItem.separator())
appMenu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
// File menu
let fileMenuItem = NSMenuItem(title: "File", action: nil, keyEquivalent: "")
mainMenu.addItem(fileMenuItem)
let fileMenu = NSMenu(title: "File")
fileMenuItem.submenu = fileMenu
fileMenu.addItem(NSMenuItem(title: "New Tab", action: #selector(addNewTab), keyEquivalent: "t"))
fileMenu.addItem(NSMenuItem(title: "Close Tab", action: #selector(closeCurrentTab), keyEquivalent: "w"))
fileMenu.addItem(NSMenuItem.separator())
fileMenu.addItem(NSMenuItem(title: "Clear Screen", action: #selector(clearScreen), keyEquivalent: "k"))
fileMenu.addItem(NSMenuItem(title: "Clear Scrollback", action: #selector(clearScrollback), keyEquivalent: "K"))
// Edit menu
let editMenuItem = NSMenuItem(title: "Edit", action: nil, keyEquivalent: "")
mainMenu.addItem(editMenuItem)
let editMenu = NSMenu(title: "Edit")
editMenuItem.submenu = editMenu
editMenu.addItem(NSMenuItem(title: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c"))
editMenu.addItem(NSMenuItem(title: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v"))
editMenu.addItem(NSMenuItem(title: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a"))
editMenu.addItem(NSMenuItem.separator())
editMenu.addItem(NSMenuItem(title: "Find...", action: #selector(showFindPanel), keyEquivalent: "f"))
editMenu.addItem(NSMenuItem.separator())
let searchHistoryItem = NSMenuItem(title: "Search History", action: #selector(searchHistory), keyEquivalent: "r")
searchHistoryItem.keyEquivalentModifierMask = [.command]
editMenu.addItem(searchHistoryItem)
// View menu
let viewMenuItem = NSMenuItem(title: "View", action: nil, keyEquivalent: "")
mainMenu.addItem(viewMenuItem)
let viewMenu = NSMenu(title: "View")
viewMenuItem.submenu = viewMenu
viewMenu.addItem(NSMenuItem(title: "Toggle Fullscreen", action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f"))
viewMenu.addItem(NSMenuItem.separator())
viewMenu.addItem(NSMenuItem(title: "Increase Font Size", action: #selector(increaseFontSize), keyEquivalent: "+"))
viewMenu.addItem(NSMenuItem(title: "Decrease Font Size", action: #selector(decreaseFontSize), keyEquivalent: "-"))
viewMenu.addItem(NSMenuItem(title: "Reset Font Size", action: #selector(resetFontSize), keyEquivalent: "0"))
// Tools menu
let toolsMenuItem = NSMenuItem(title: "Tools", action: nil, keyEquivalent: "")
mainMenu.addItem(toolsMenuItem)
let toolsMenu = NSMenu(title: "Tools")
toolsMenuItem.submenu = toolsMenu
toolsMenu.addItem(NSMenuItem(title: "AI Command Generator", action: #selector(showAIGenerator), keyEquivalent: "k"))
toolsMenu.addItem(NSMenuItem(title: "Command Snippets", action: #selector(showSnippets), keyEquivalent: "p"))
toolsMenu.addItem(NSMenuItem.separator())
toolsMenu.addItem(NSMenuItem(title: "Clear History", action: #selector(clearHistory), keyEquivalent: ""))
// Window menu
let windowMenuItem = NSMenuItem(title: "Window", action: nil, keyEquivalent: "")
mainMenu.addItem(windowMenuItem)
let windowMenu = NSMenu(title: "Window")
windowMenuItem.submenu = windowMenu
windowMenu.addItem(NSMenuItem(title: "Minimize", action: #selector(NSWindow.miniaturize(_:)), keyEquivalent: "m"))
windowMenu.addItem(NSMenuItem(title: "Zoom", action: #selector(NSWindow.zoom(_:)), keyEquivalent: ""))
NSApp.mainMenu = mainMenu
}
@objc func addNewTab() {
let session = tabManager.addTab()
session.setupTextView(frame: contentView.bounds)
session.startShell()
updateTabBar()
switchToTab(index: tabManager.tabs.count - 1)
}
@objc func closeCurrentTab() {
guard tabManager.tabs.count > 1 else {
// If it's the last tab, quit the app
NSApp.terminate(nil)
return
}
let alert = NSAlert()
alert.messageText = "Close Tab?"
alert.informativeText = "Are you sure you want to close this terminal session?"
alert.addButton(withTitle: "Close")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
if alert.runModal() == .alertFirstButtonReturn {
tabManager.removeTab(at: tabManager.currentIndex)
updateTabBar()
switchToTab(index: min(tabManager.currentIndex, tabManager.tabs.count - 1))
}
}
private func updateTabBar() {
// Remove all existing views
tabBar.arrangedSubviews.forEach { $0.removeFromSuperview() }
// Add tab buttons
for (index, session) in tabManager.tabs.enumerated() {
// Create container for tab button and close button
let tabContainer = NSView()
tabContainer.wantsLayer = true
tabContainer.translatesAutoresizingMaskIntoConstraints = false
// Tab button
let tabButton = NSButton()
tabButton.title = session.title
tabButton.bezelStyle = .rounded
tabButton.tag = index
tabButton.target = self
tabButton.action = #selector(tabClicked(_:))
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
tabButton.isBordered = true
} else {
tabButton.contentTintColor = .systemGray
tabButton.isBordered = false
}
// Close button (X)
let closeButton = NSButton()
closeButton.title = "×"
closeButton.bezelStyle = .rounded
closeButton.tag = index
closeButton.target = self
closeButton.action = #selector(closeTabAtIndex(_:))
closeButton.font = NSFont.systemFont(ofSize: 13, weight: .bold)
closeButton.translatesAutoresizingMaskIntoConstraints = false
tabContainer.addSubview(tabButton)
tabContainer.addSubview(closeButton)
// Layout constraints
NSLayoutConstraint.activate([
tabButton.leadingAnchor.constraint(equalTo: tabContainer.leadingAnchor),
tabButton.centerYAnchor.constraint(equalTo: tabContainer.centerYAnchor),
tabButton.widthAnchor.constraint(equalToConstant: 60),
tabButton.heightAnchor.constraint(equalToConstant: 20),
closeButton.leadingAnchor.constraint(equalTo: tabButton.trailingAnchor, constant: 2),
closeButton.centerYAnchor.constraint(equalTo: tabContainer.centerYAnchor),
closeButton.widthAnchor.constraint(equalToConstant: 18),
closeButton.heightAnchor.constraint(equalToConstant: 20),
closeButton.trailingAnchor.constraint(equalTo: tabContainer.trailingAnchor),
tabContainer.heightAnchor.constraint(equalToConstant: 20)
])
// Prevent container from stretching
tabContainer.setContentHuggingPriority(.init(1000), for: .horizontal)
tabContainer.setContentCompressionResistancePriority(.init(1000), for: .horizontal)
tabBar.addArrangedSubview(tabContainer)
}
// Add new tab button
let newTabButton = NSButton()
newTabButton.title = "+"
newTabButton.bezelStyle = .rounded
newTabButton.target = self
newTabButton.action = #selector(addNewTab)
newTabButton.font = NSFont.systemFont(ofSize: 13)
newTabButton.translatesAutoresizingMaskIntoConstraints = false
newTabButton.setContentHuggingPriority(.init(1000), for: .horizontal)
NSLayoutConstraint.activate([
newTabButton.widthAnchor.constraint(equalToConstant: 22),
newTabButton.heightAnchor.constraint(equalToConstant: 20)
])
tabBar.addArrangedSubview(newTabButton)
// Add spacer at the end to push everything to the left
let spacer = NSView()
spacer.setContentHuggingPriority(.init(1), for: .horizontal)
tabBar.addArrangedSubview(spacer)
// Force layout update
tabBar.needsLayout = true
tabBar.layoutSubtreeIfNeeded()
}
@objc func closeTabAtIndex(_ sender: NSButton) {
let index = sender.tag
// Don't close if it's the last tab
guard tabManager.tabs.count > 1 else {
NSApp.terminate(nil)
return
}
// Confirm if there's content
let alert = NSAlert()
alert.messageText = "Close Tab?"
alert.informativeText = "Are you sure you want to close this tab?"
alert.addButton(withTitle: "Close")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
if alert.runModal() == .alertFirstButtonReturn {
tabManager.removeTab(at: index)
// Switch to previous tab or first tab
let newIndex = min(index, tabManager.tabs.count - 1)
switchToTab(index: newIndex)
}
}
@objc func tabClicked(_ sender: NSButton) {
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 }
// Remove current view from content view (but don't destroy it)
contentView.subviews.forEach { $0.removeFromSuperview() }
// 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() {
session.scrollView.frame = contentView.bounds
session.scrollView.autoresizingMask = [.width, .height]
contentView.addSubview(session.scrollView)
// Make the text view first responder
window?.makeFirstResponder(session.textView)
}
updateTabBar()
}
@objc func searchHistory() {
guard let window = window else { return }
historyPanel.show(relativeTo: window) { [weak self] command in
if let command = command, let session = self?.tabManager.getCurrentSession() {
session.insertCommand(command)
}
}
}
@objc func showSnippets() {
guard let window = window else { return }
snippetPanel.show(relativeTo: window) { [weak self] snippet in
if let snippet = snippet, let session = self?.tabManager.getCurrentSession() {
session.insertCommand(snippet.command)
}
}
}
@objc func showAIGenerator() {
guard AIManager.shared.isAvailable() else {
let alert = NSAlert()
alert.messageText = "AI Not Configured"
alert.informativeText = "Please configure an AI provider in Settings (Cmd+,) first.\n\nYou can use:\n• OpenAI (requires API key)\n• Ollama (free, local)"
alert.alertStyle = .informational
alert.addButton(withTitle: "Open Settings")
alert.addButton(withTitle: "Cancel")
if alert.runModal() == .alertFirstButtonReturn {
openSettings()
}
return
}
guard let window = window else { return }
aiPanel.show(relativeTo: window) { [weak self] command in
if let command = command, let session = self?.tabManager.getCurrentSession() {
session.insertCommand(command)
}
}
}
@objc func openSettings() {
settingsWindow.showWindow(nil)
}
@objc func clearScreen() {
if let session = tabManager.getCurrentSession() {
session.textView.string = ""
session.showPrompt()
}
}
@objc func clearScrollback() {
if let session = tabManager.getCurrentSession() {
let alert = NSAlert()
alert.messageText = "Clear Scrollback?"
alert.informativeText = "This will clear all terminal output history."
alert.addButton(withTitle: "Clear")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
if alert.runModal() == .alertFirstButtonReturn {
session.textView.string = ""
session.showPrompt()
}
}
}
@objc func showFindPanel() {
if let session = tabManager.getCurrentSession() {
session.textView.performTextFinderAction(nil)
}
}
@objc func clearHistory() {
let alert = NSAlert()
alert.messageText = "Clear Command History?"
alert.informativeText = "This will permanently delete all command history."
alert.addButton(withTitle: "Clear")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
if alert.runModal() == .alertFirstButtonReturn {
HistoryManager.shared.clearHistory()
}
}
@objc func increaseFontSize() {
TerminalSettings.shared.fontSize += 1
tabManager.tabs.forEach { $0.applyTheme() }
}
@objc func decreaseFontSize() {
TerminalSettings.shared.fontSize = max(8, TerminalSettings.shared.fontSize - 1)
tabManager.tabs.forEach { $0.applyTheme() }
}
@objc func resetFontSize() {
TerminalSettings.shared.fontSize = 14
tabManager.tabs.forEach { $0.applyTheme() }
}
@objc func showAbout() {
let alert = NSAlert()
alert.messageText = "Enhanced Terminal v3.0"
alert.informativeText = """
A feature-rich macOS terminal emulator
Features:
• Multiple tabs
• Command history search (Cmd+R)
• Auto-suggestions
• AI command generation
• Command snippets
• Multiple themes
• Customizable settings
Built with Swift and Cocoa
"""
alert.alertStyle = .informational
alert.addButton(withTitle: "OK")
alert.runModal()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}