Files
EnhancedTerminal/MainWindowController.swift

903 lines
35 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
// MARK: - Hover Close Button
class HoverCloseButton: NSButton {
override func mouseEntered(with event: NSEvent) {
super.mouseEntered(with: event)
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.15
self.layer?.backgroundColor = NSColor(white: 0.3, alpha: 0.85).cgColor
self.contentTintColor = NSColor.white
}
}
override func mouseExited(with event: NSEvent) {
super.mouseExited(with: event)
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.15
self.layer?.backgroundColor = NSColor(white: 0.2, alpha: 0.6).cgColor
self.contentTintColor = NSColor(white: 0.8, alpha: 0.9)
}
}
override func updateTrackingAreas() {
super.updateTrackingAreas()
// Remove old tracking areas
for trackingArea in trackingAreas {
removeTrackingArea(trackingArea)
}
// Add new tracking area
let trackingArea = NSTrackingArea(
rect: bounds,
options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
owner: self,
userInfo: nil
)
addTrackingArea(trackingArea)
}
}
class MainWindowController: NSWindowController {
var tabManager = TabManager()
var tabBar: NSStackView!
var contentView: NSView!
var splitView: NSSplitView?
var activePaneIndex: Int = 0
var panes: [TerminalSession] = []
var historyPanel: HistorySearchPanel!
var snippetPanel: SnippetPanel!
var aiPanel: AIGeneratorPanel!
var settingsWindow: SettingsWindowController!
var zoomedPane: TerminalSession?
var preZoomSplitView: NSSplitView?
var lastTabClickTime: Date?
var lastTabClickIndex: Int = -1
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)
// Restore working directory if available
if let workingDir = sessionData.workingDirectory {
session.currentWorkingDirectory = workingDir
}
session.startShell()
// Change to saved directory
if let workingDir = sessionData.workingDirectory, workingDir != "~" {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
session.executeCommand("cd \(workingDir)")
}
}
}
// 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: "Split Pane Vertically", action: #selector(splitPaneVertically), keyEquivalent: "d"))
fileMenu.addItem(NSMenuItem(title: "Split Pane Horizontally", action: #selector(splitPaneHorizontally), keyEquivalent: "D"))
let closePaneItem = NSMenuItem(title: "Close Pane", action: #selector(closePane), keyEquivalent: "w")
closePaneItem.keyEquivalentModifierMask = [.command, .shift]
fileMenu.addItem(closePaneItem)
let zoomPaneItem = NSMenuItem(title: "Zoom Pane", action: #selector(toggleZoomPane), keyEquivalent: "\r")
zoomPaneItem.keyEquivalentModifierMask = [.command, .shift]
fileMenu.addItem(zoomPaneItem)
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: "Keyboard Shortcuts", action: #selector(showShortcuts), keyEquivalent: "?"))
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
// 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) {
let index = sender.tag
let now = Date()
// Check for double-click (within 0.3 seconds)
if let lastClick = lastTabClickTime,
lastTabClickIndex == index,
now.timeIntervalSince(lastClick) < 0.3 {
// Double-click detected
tabDoubleClicked(sender)
lastTabClickTime = nil
lastTabClickIndex = -1
} else {
// Single click
lastTabClickTime = now
lastTabClickIndex = index
switchToTab(index: index)
}
}
@objc func tabDoubleClicked(_ sender: NSButton) {
let index = sender.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 showShortcuts() {
let alert = NSAlert()
alert.messageText = "Keyboard Shortcuts"
alert.informativeText = """
File:
Cmd+T - New Tab
Cmd+W - Close Tab
Cmd+D - Split Pane Vertically (side-by-side)
Cmd+Shift+D - Split Pane Horizontally (top-bottom)
Cmd+Shift+Enter - Zoom/Unzoom Pane
Cmd+K - Clear Screen
Cmd+Shift+K - Clear Scrollback
Edit:
Cmd+C - Copy
Cmd+V - Paste
Cmd+A - Select All
Cmd+F - Find in Terminal
Cmd+R - Search History
Middle-click - Paste
Tools:
Cmd+K - AI Command Generator
Cmd+P - Command Snippets
Cmd+, - Settings
Cmd+? - This Help
View:
Cmd+Plus - Increase Font
Cmd+Minus - Decrease Font
Cmd+0 - Reset Font
Cmd+F - Toggle Fullscreen
Terminal:
Tab - Accept Auto-suggestion
Up/Down - Navigate Command History
Double-click Tab - Rename Tab
Click × button - Close split pane
Cmd+Click URL - Open in browser
Select text - Auto-copy to clipboard
"""
alert.alertStyle = .informational
alert.addButton(withTitle: "OK")
alert.runModal()
}
@objc func splitPaneVertically() {
guard let currentSession = tabManager.getCurrentSession() else { return }
// If split view exists with wrong orientation, recreate it
if let existing = splitView, !existing.isVertical {
// Remove and recreate with correct orientation
existing.removeFromSuperview()
splitView = nil
panes = []
}
// Create split view if it doesn't exist
if splitView == nil {
splitView = NSSplitView(frame: contentView.bounds)
splitView?.isVertical = true // Side-by-side (vertical divider)
splitView?.dividerStyle = .thin
splitView?.autoresizingMask = [.width, .height]
splitView?.delegate = self
// Move current session to split view with close button
currentSession.scrollView.removeFromSuperview()
let containerView = addCloseButtonToPane(currentSession.scrollView, index: 0)
splitView?.addArrangedSubview(containerView)
panes = [currentSession]
}
// Create new pane
let newSession = TerminalSession(title: "Pane \(panes.count + 1)")
newSession.setupTextView(frame: CGRect(x: 0, y: 0, width: contentView.bounds.width / 2, height: contentView.bounds.height))
newSession.startShell()
let newContainerView = addCloseButtonToPane(newSession.scrollView, index: panes.count)
splitView?.addArrangedSubview(newContainerView)
panes.append(newSession)
activePaneIndex = panes.count - 1
// Add split view to content if not already there
if splitView?.superview == nil {
contentView.subviews.forEach { $0.removeFromSuperview() }
contentView.addSubview(splitView!)
}
newSession.textView.window?.makeFirstResponder(newSession.textView)
}
@objc func splitPaneHorizontally() {
guard let currentSession = tabManager.getCurrentSession() else { return }
// If split view exists with wrong orientation, recreate it
if let existing = splitView, existing.isVertical {
// Remove and recreate with correct orientation
existing.removeFromSuperview()
splitView = nil
panes = []
}
// Create split view if it doesn't exist
if splitView == nil {
splitView = NSSplitView(frame: contentView.bounds)
splitView?.isVertical = false // Top-bottom (horizontal divider)
splitView?.dividerStyle = .thin
splitView?.autoresizingMask = [.width, .height]
splitView?.delegate = self
// Move current session to split view with close button
currentSession.scrollView.removeFromSuperview()
let containerView = addCloseButtonToPane(currentSession.scrollView, index: 0)
splitView?.addArrangedSubview(containerView)
panes = [currentSession]
}
// Create new pane
let newSession = TerminalSession(title: "Pane \(panes.count + 1)")
newSession.setupTextView(frame: CGRect(x: 0, y: 0, width: contentView.bounds.width, height: contentView.bounds.height / 2))
newSession.startShell()
let newContainerView = addCloseButtonToPane(newSession.scrollView, index: panes.count)
splitView?.addArrangedSubview(newContainerView)
panes.append(newSession)
activePaneIndex = panes.count - 1
// Add split view to content if not already there
if splitView?.superview == nil {
contentView.subviews.forEach { $0.removeFromSuperview() }
contentView.addSubview(splitView!)
}
newSession.textView.window?.makeFirstResponder(newSession.textView)
}
private func addCloseButtonToPane(_ scrollView: NSScrollView, index: Int) -> NSView {
// Create a container view that will hold both the scroll view and the close button
let containerView = NSView(frame: scrollView.frame)
containerView.autoresizingMask = [.width, .height]
containerView.wantsLayer = true
// Adjust scroll view frame to fill container
scrollView.frame = containerView.bounds
scrollView.autoresizingMask = [.width, .height]
containerView.addSubview(scrollView)
// Create close button with subtle styling
let closeButton = HoverCloseButton(frame: NSRect(x: containerView.bounds.width - 35, y: containerView.bounds.height - 35, width: 24, height: 24))
closeButton.title = "×"
closeButton.bezelStyle = .circular
closeButton.font = NSFont.systemFont(ofSize: 16, weight: .medium)
closeButton.tag = 9999
closeButton.target = self
closeButton.action = #selector(closePaneFromButton(_:))
closeButton.toolTip = "Close pane (Cmd+Shift+W)"
closeButton.autoresizingMask = [.minXMargin, .minYMargin]
closeButton.wantsLayer = true
closeButton.layer?.backgroundColor = NSColor(white: 0.2, alpha: 0.6).cgColor
closeButton.layer?.cornerRadius = 12
closeButton.contentTintColor = NSColor(white: 0.8, alpha: 0.9)
// Store the pane index
objc_setAssociatedObject(closeButton, "paneIndex", index, .OBJC_ASSOCIATION_RETAIN)
// Add button on top of scroll view
containerView.addSubview(closeButton)
return containerView
}
@objc func closePaneFromButton(_ sender: NSButton) {
guard let index = objc_getAssociatedObject(sender, "paneIndex") as? Int else { return }
// Find the actual current index (it might have changed)
guard index < panes.count else { return }
activePaneIndex = index
closePane()
}
@objc func closePane() {
// Only close panes if we have splits
guard panes.count > 1 else {
// No splits, just close the tab normally
closeCurrentTab()
return
}
// Find which pane has focus
var paneToClose = activePaneIndex
// If we can't determine active pane, close the last one
if paneToClose >= panes.count {
paneToClose = panes.count - 1
}
let pane = panes[paneToClose]
pane.cleanup()
pane.scrollView.removeFromSuperview()
panes.remove(at: paneToClose)
if panes.count == 1 {
// Only one pane left, remove split view
splitView?.removeFromSuperview()
splitView = nil
let remainingSession = panes[0]
remainingSession.scrollView.removeFromSuperview()
remainingSession.scrollView.frame = contentView.bounds
contentView.addSubview(remainingSession.scrollView)
remainingSession.textView.window?.makeFirstResponder(remainingSession.textView)
panes = []
} else {
// Still have multiple panes
activePaneIndex = min(paneToClose, panes.count - 1)
if activePaneIndex < panes.count {
panes[activePaneIndex].textView.window?.makeFirstResponder(panes[activePaneIndex].textView)
}
}
}
@objc func toggleZoomPane() {
if zoomedPane != nil {
// Unzoom - restore split view
unzoomPane()
} else {
// Zoom current pane
zoomPane()
}
}
private func zoomPane() {
guard panes.count > 1 else { return }
// Save current split view state
preZoomSplitView = splitView
zoomedPane = panes[activePaneIndex]
// Remove split view
splitView?.removeFromSuperview()
// Show only the active pane
if let zoomed = zoomedPane {
zoomed.scrollView.removeFromSuperview()
zoomed.scrollView.frame = contentView.bounds
contentView.addSubview(zoomed.scrollView)
zoomed.textView.window?.makeFirstResponder(zoomed.textView)
}
}
private func unzoomPane() {
guard let zoomed = zoomedPane, let savedSplit = preZoomSplitView else { return }
// Remove zoomed pane
zoomed.scrollView.removeFromSuperview()
// Restore split view
contentView.addSubview(savedSplit)
savedSplit.frame = contentView.bounds
// Clear zoom state
zoomedPane = nil
preZoomSplitView = nil
// Focus the previously zoomed pane
zoomed.textView.window?.makeFirstResponder(zoomed.textView)
}
@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)
}
}
// MARK: - NSSplitView Delegate
extension MainWindowController: NSSplitViewDelegate {
func splitView(_ splitView: NSSplitView, canCollapseSubview subview: NSView) -> Bool {
return false
}
func splitView(_ splitView: NSSplitView, constrainMinCoordinate proposedMinimumPosition: CGFloat, ofSubviewAt dividerIndex: Int) -> CGFloat {
return 100 // Minimum pane size
}
func splitView(_ splitView: NSSplitView, constrainMaxCoordinate proposedMaximumPosition: CGFloat, ofSubviewAt dividerIndex: Int) -> CGFloat {
let totalSize = splitView.isVertical ? splitView.bounds.width : splitView.bounds.height
return totalSize - 100 // Minimum pane size
}
}