diff --git a/iTerm2.xcodeproj/project.pbxproj b/iTerm2.xcodeproj/project.pbxproj index 4439853b49..8f3902df84 100644 --- a/iTerm2.xcodeproj/project.pbxproj +++ b/iTerm2.xcodeproj/project.pbxproj @@ -946,6 +946,7 @@ 759637A52593AE0700E278CC /* iTerm2SandboxedWorker.xpc in Embed XPC Services */ = {isa = PBXBuildFile; fileRef = 7596379A2593AE0700E278CC /* iTerm2SandboxedWorker.xpc */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 759637C82593AFB500E278CC /* iTerm2SandboxedWorkerProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 7596379C2593AE0700E278CC /* iTerm2SandboxedWorkerProtocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; 77E49C95CA97F58D042F688E /* iTermScreenshotRedaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97539C440B63BAC6011E9208 /* iTermScreenshotRedaction.swift */; }; + 7959689433DB95D5BCFC387F /* iTermProjectsPanelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2343E2DF433BD7B9647283A3 /* iTermProjectsPanelController.swift */; }; 82FAE5CD376B4D65172ECCF2 /* NSStringQuotedStringForPasteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78783C5E320CF711E81627F /* NSStringQuotedStringForPasteTests.swift */; }; 84914ABAD880FC4B174DFAF5 /* iTermTouchIDHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 207C83644F1E850C9F1E918A /* iTermTouchIDHelper.swift */; }; 874206490564169600CFC3F1 /* iTermApplicationDelegate.h in Headers */ = {isa = PBXBuildFile; fileRef = 20D5CC6304E7AA0500000106 /* iTermApplicationDelegate.h */; }; @@ -3860,6 +3861,7 @@ A6D1784521BC5A1500FE499C /* iTermSavePanelFileFormatAccessory.h in Headers */ = {isa = PBXBuildFile; fileRef = A6D1784121BC5A1500FE499C /* iTermSavePanelFileFormatAccessory.h */; }; A6D1784721BC5A1500FE499C /* iTermSavePanelFileFormatAccessory.m in Sources */ = {isa = PBXBuildFile; fileRef = A6D1784221BC5A1500FE499C /* iTermSavePanelFileFormatAccessory.m */; }; A6D1784821BC5A1500FE499C /* iTermSavePanelFileFormatAccessory.xib in Resources */ = {isa = PBXBuildFile; fileRef = A6D1784321BC5A1500FE499C /* iTermSavePanelFileFormatAccessory.xib */; }; + A6D181293461545E1B15EE6D /* iTermWindowProjectsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C3C91DF2BBB018FD38146E7 /* iTermWindowProjectsModel.swift */; }; A6D1A3E52C7E994B00FECDF2 /* VT100GridTypes+Swift.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6D1A3E42C7E994B00FECDF2 /* VT100GridTypes+Swift.swift */; }; A6D1A3E72C7E996400FECDF2 /* NSRect+iTerm.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6D1A3E62C7E996400FECDF2 /* NSRect+iTerm.swift */; }; A6D1A3E92C7E99A100FECDF2 /* NSPoint+iTerm.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6D1A3E82C7E99A100FECDF2 /* NSPoint+iTerm.swift */; }; @@ -5953,6 +5955,7 @@ 20E74F4804E9089700000106 /* ITAddressBookMgr.h */ = {isa = PBXFileReference; fileEncoding = 4; indentWidth = 4; lastKnownFileType = sourcecode.c.h; path = ITAddressBookMgr.h; sourceTree = ""; tabWidth = 4; }; 20E74F4904E9089700000106 /* ITAddressBookMgr.m */ = {isa = PBXFileReference; fileEncoding = 4; indentWidth = 4; lastKnownFileType = sourcecode.c.objc; path = ITAddressBookMgr.m; sourceTree = ""; tabWidth = 4; }; 2205C1FAE857001EC8A263E4 /* PerformanceTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PerformanceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 2343E2DF433BD7B9647283A3 /* iTermProjectsPanelController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = iTermProjectsPanelController.swift; sourceTree = ""; }; 2491A846A993798FA4963536 /* iTermCharacterSourceTestHelper.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = iTermCharacterSourceTestHelper.h; sourceTree = ""; }; 28AF992AB875E6786421227B /* iTermScreenshotOnDemandPreview.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = iTermScreenshotOnDemandPreview.swift; sourceTree = ""; }; 2B9A9D7BB06953CE9297F1FC /* iTermStreamingScreenshotEncoder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = iTermStreamingScreenshotEncoder.swift; sourceTree = ""; }; @@ -6249,6 +6252,7 @@ 86F85D24451C27760B7C75BC /* PrivateIPCheckerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PrivateIPCheckerTests.swift; sourceTree = ""; }; 8742065A0564169600CFC3F1 /* iTerm2.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iTerm2.app; sourceTree = BUILT_PRODUCTS_DIR; }; 889A9128E944B618CDE75E18 /* iTermCursorSlideAnimator.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = iTermCursorSlideAnimator.h; path = sources/iTermCursorSlideAnimator.h; sourceTree = SOURCE_ROOT; }; + 8C3C91DF2BBB018FD38146E7 /* iTermWindowProjectsModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = iTermWindowProjectsModel.swift; sourceTree = ""; }; 904AE312A0E0489899D9ECB8 /* MenuTips.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = MenuTips.xcassets; sourceTree = ""; }; 90A1E139186F9EA4003EC3E8 /* AppleScriptTest.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppleScriptTest.h; sourceTree = ""; }; 90A1E13A186F9EA4003EC3E8 /* AppleScriptTest.m */ = {isa = PBXFileReference; fileEncoding = 4; indentWidth = 4; lastKnownFileType = sourcecode.c.objc; path = AppleScriptTest.m; sourceTree = ""; }; @@ -9512,6 +9516,8 @@ A621F10A26F650DF001DD3A6 /* ScreenCharArray.m */, A6E1524D27613EEC00D0F41C /* iTerm2XCTests-Bridging-Header.h */, 3335AD96138BA74CC79D25A8 /* iTermUnicodeNormalization.h */, + 8C3C91DF2BBB018FD38146E7 /* iTermWindowProjectsModel.swift */, + 2343E2DF433BD7B9647283A3 /* iTermProjectsPanelController.swift */, ); name = Classes; path = sources/; @@ -20076,6 +20082,8 @@ 564EA2FD15A05A60A95ABBB9 /* PrivateIPChecker.swift in Sources */, 0E917DB125F460B221026B56 /* iTermCharacterSourceTestHelper.h in Sources */, 8E87A30F7C195E310321C9D9 /* iTermCharacterSourceTestHelper.m in Sources */, + A6D181293461545E1B15EE6D /* iTermWindowProjectsModel.swift in Sources */, + 7959689433DB95D5BCFC387F /* iTermProjectsPanelController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/sources/iTermApplicationDelegate.m b/sources/iTermApplicationDelegate.m index 673d9d6835..d0fc5183b6 100644 --- a/sources/iTermApplicationDelegate.m +++ b/sources/iTermApplicationDelegate.m @@ -336,6 +336,20 @@ - (void)dealloc { - (void)awakeFromNib { [ArchivesMenuBuilder setShared:[[ArchivesMenuBuilder alloc] initWithMenuItem:_archivesMenuItem]]; + // Add "Window Projects…" to the Window menu near the archive items. + NSMenu *windowMenu = [self topLevelViewNamed:@"Window"]; + if (windowMenu) { + NSMenuItem *projectsItem = [[[NSMenuItem alloc] + initWithTitle:@"Window Projects\u2026" + action:@selector(showWindowProjectsPanel:) + keyEquivalent:@""] autorelease]; + [projectsItem setTarget:self]; + // Insert after the first item so it appears near the top of the Window menu. + NSUInteger insertIndex = MIN(1u, (NSUInteger)windowMenu.numberOfItems); + [windowMenu insertItem:projectsItem atIndex:insertIndex]; + [windowMenu insertItem:[NSMenuItem separatorItem] atIndex:insertIndex + 1]; + } + NSMenu *viewMenu = [self topLevelViewNamed:@"View"]; [viewMenu addItem:[NSMenuItem separatorItem]]; @@ -2978,6 +2992,10 @@ - (IBAction)importWindowArrangement:(id)sender { }]; } +- (IBAction)showWindowProjectsPanel:(id)sender { + [[iTermProjectsPanelController shared] showPanel]; +} + - (IBAction)saveWindowArrangement:(id)sender { [[iTermController sharedInstance] saveWindowArrangement:YES]; } diff --git a/sources/iTermProjectsPanelController.swift b/sources/iTermProjectsPanelController.swift new file mode 100644 index 0000000000..297869aca9 --- /dev/null +++ b/sources/iTermProjectsPanelController.swift @@ -0,0 +1,1402 @@ +// iTermProjectsPanelController.swift +// iTerm2 +// +// Panel UI for per-window project archives. +// +// Left pane — project tree. Each project shows both live (open, bold) and +// archived (closed, grey) windows. Drag source & drop target. +// Right pane — open windows only, grouped by their associated project. +// "Unassociated" section for windows with no project. +// Drag source & drop target. +// +// Drag-and-drop semantics +// right live window → left project archive + close +// right live window → right project group reassign association (keep open) +// right live window → right root / Unassoc disassociate (keep open) +// right project group → left pane close-all (archive + close all) +// left archived window → right pane restore (remove archive entry) +// left project → right pane restore all windows in project + +import AppKit + +// MARK: - Drag Pasteboard Types + +private let kLiveWindowDragType = NSPasteboard.PasteboardType("com.iterm2.projects.live-window") +private let kArchivedWindowDragType = NSPasteboard.PasteboardType("com.iterm2.projects.archived-window") +private let kProjectDragType = NSPasteboard.PasteboardType("com.iterm2.projects.project") +private let kProjectGroupDragType = NSPasteboard.PasteboardType("com.iterm2.projects.project-group") + +// MARK: - Sort Order + +enum ProjectSortOrder { case name, recent } + +// MARK: - Item Wrappers + +/// An archived (closed) window shown as a leaf in the left pane. +final class iTermArchivedWindowBox: NSObject { + let window: iTermArchivedWindow + let project: iTermWindowProject + init(_ window: iTermArchivedWindow, project: iTermWindowProject) { + self.window = window + self.project = project + } +} + +/// A live (open) window shown as a leaf in the left pane under its associated project. +final class iTermLiveWindowBox: NSObject { + let terminal: PseudoTerminal + let project: iTermWindowProject + init(_ terminal: PseudoTerminal, project: iTermWindowProject) { + self.terminal = terminal + self.project = project + } +} + +/// One group row in the right pane. nil project = "Unassociated". +final class iTermOpenProjectGroup: NSObject { + let project: iTermWindowProject? + var terminals: [PseudoTerminal] + init(project: iTermWindowProject?, terminals: [PseudoTerminal]) { + self.project = project + self.terminals = terminals + } +} + +// MARK: - Panel Window Controller + +@objc final class iTermProjectsPanelController: NSWindowController, NSWindowDelegate { + + @objc static let shared: iTermProjectsPanelController = { + iTermProjectsPanelController() + }() + + private var splitVC: iTermProjectsSplitViewController! + + private convenience init() { + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 900, height: 540), + styleMask: [.titled, .closable, .resizable, .miniaturizable, + .nonactivatingPanel, .utilityWindow], + backing: .buffered, + defer: true) + panel.title = "Window Projects" + panel.minSize = NSSize(width: 620, height: 380) + panel.isFloatingPanel = false + panel.hidesOnDeactivate = false + self.init(window: panel) + panel.delegate = self + let svc = iTermProjectsSplitViewController() + splitVC = svc + panel.contentViewController = svc + } + + @objc func showPanel() { + if !(window?.isVisible ?? false) { + window?.center() + } + window?.makeKeyAndOrderFront(nil) + splitVC.reloadAll() + } +} + +// MARK: - Split View Controller + +final class iTermProjectsSplitViewController: NSSplitViewController { + let projectsVC = iTermProjectsOutlineController() + let windowsVC = iTermOpenWindowsController() + + override func viewDidLoad() { + super.viewDidLoad() + splitView.isVertical = true + splitView.dividerStyle = .paneSplitter + + projectsVC.onSelectionChange = { [weak self] in + self?.windowsVC.updateActionButtons() + } + windowsVC.projectsController = projectsVC + + let left = NSSplitViewItem(viewController: projectsVC) + left.minimumThickness = 220 + left.maximumThickness = 480 + left.preferredThicknessFraction = 0.44 + + let right = NSSplitViewItem(viewController: windowsVC) + right.minimumThickness = 240 + + addSplitViewItem(left) + addSplitViewItem(right) + } + + override func viewWillAppear() { + super.viewWillAppear() + splitView.setPosition(380, ofDividerAt: 0) + } + + func reloadAll() { + projectsVC.reload() + windowsVC.reload() + } +} + +// MARK: - Left Pane: Project Tree (open + archived windows) + +final class iTermProjectsOutlineController: NSViewController, + NSOutlineViewDataSource, + NSOutlineViewDelegate { + var onSelectionChange: (() -> Void)? + + private(set) var outlineView = NSOutlineView() + private var scrollView = NSScrollView() + private var addProjectButton = NSButton() + private var addSubprojectButton = NSButton() + private var deleteButton = NSButton() + private var restoreButton = NSButton() + private var restoreAllButton = NSButton() + private var closeProjectButton = NSButton() + + private var sortOrder = ProjectSortOrder.recent + private var sortSegment = NSSegmentedControl() + + // Hover preview + private var previewPopover = NSPopover() + private var previewTimer: Timer? + private var previewRow = -1 + + // MARK: Selection helpers + + var selectedProject: iTermWindowProject? { + outlineView.item(atRow: outlineView.selectedRow) as? iTermWindowProject + } + var selectedArchivedBox: iTermArchivedWindowBox? { + outlineView.item(atRow: outlineView.selectedRow) as? iTermArchivedWindowBox + } + var selectedLiveBox: iTermLiveWindowBox? { + outlineView.item(atRow: outlineView.selectedRow) as? iTermLiveWindowBox + } + + override func loadView() { view = NSView() } + + override func viewDidLoad() { + super.viewDidLoad() + setupOutlineView() + setupBottomBar() + setupPreviewPopover() + setupObservers() + } + + // MARK: Setup + + private func setupOutlineView() { + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.borderType = .noBorder + scrollView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(scrollView) + + let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("main")) + col.title = "" + outlineView.addTableColumn(col) + outlineView.outlineTableColumn = col + outlineView.headerView = nil + outlineView.rowHeight = 22 + outlineView.dataSource = self + outlineView.delegate = self + outlineView.allowsEmptySelection = true + outlineView.allowsMultipleSelection = false + outlineView.focusRingType = .none + outlineView.doubleAction = #selector(doubleClicked(_:)) + outlineView.target = self + + // Drag source + destination + outlineView.setDraggingSourceOperationMask(.every, forLocal: true) + outlineView.setDraggingSourceOperationMask(.every, forLocal: false) + outlineView.registerForDraggedTypes([kLiveWindowDragType, kProjectGroupDragType]) + + scrollView.documentView = outlineView + + let sep = NSBox() + sep.boxType = .separator + sep.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(sep) + + let header = makeProjectsHeader() + header.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(header) + + NSLayoutConstraint.activate([ + header.topAnchor.constraint(equalTo: view.topAnchor), + header.leadingAnchor.constraint(equalTo: view.leadingAnchor), + header.trailingAnchor.constraint(equalTo: view.trailingAnchor), + header.heightAnchor.constraint(equalToConstant: 24), + + scrollView.topAnchor.constraint(equalTo: header.bottomAnchor), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: sep.topAnchor), + + sep.leadingAnchor.constraint(equalTo: view.leadingAnchor), + sep.trailingAnchor.constraint(equalTo: view.trailingAnchor), + sep.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -32), + sep.heightAnchor.constraint(equalToConstant: 1), + ]) + + let tracking = NSTrackingArea(rect: .zero, + options: [.mouseMoved, .mouseEnteredAndExited, + .activeInKeyWindow, .inVisibleRect], + owner: self, + userInfo: nil) + outlineView.addTrackingArea(tracking) + } + + private func makeProjectsHeader() -> NSView { + let box = NSView() + let tf = NSTextField(labelWithString: "PROJECTS") + tf.font = NSFont.systemFont(ofSize: 10, weight: .semibold) + tf.textColor = .secondaryLabelColor + tf.translatesAutoresizingMaskIntoConstraints = false + + sortSegment = NSSegmentedControl(labels: ["Name", "Recent"], + trackingMode: .selectOne, + target: self, + action: #selector(sortOrderChanged(_:))) + sortSegment.selectedSegment = 1 + sortSegment.controlSize = .mini + sortSegment.translatesAutoresizingMaskIntoConstraints = false + + box.addSubview(tf) + box.addSubview(sortSegment) + NSLayoutConstraint.activate([ + tf.leadingAnchor.constraint(equalTo: box.leadingAnchor, constant: 8), + tf.centerYAnchor.constraint(equalTo: box.centerYAnchor), + sortSegment.trailingAnchor.constraint(equalTo: box.trailingAnchor, constant: -6), + sortSegment.centerYAnchor.constraint(equalTo: box.centerYAnchor), + ]) + return box + } + + @objc private func sortOrderChanged(_ sender: NSSegmentedControl) { + sortOrder = sender.selectedSegment == 0 ? .name : .recent + outlineView.reloadData() + } + + private func sortedProjects(_ projects: [iTermWindowProject]) -> [iTermWindowProject] { + switch sortOrder { + case .name: return projects.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + case .recent: return projects.sorted { $0.lastUsed > $1.lastUsed } + } + } + + private func setupBottomBar() { + let bar = NSView() + bar.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(bar) + NSLayoutConstraint.activate([ + bar.leadingAnchor.constraint(equalTo: view.leadingAnchor), + bar.trailingAnchor.constraint(equalTo: view.trailingAnchor), + bar.bottomAnchor.constraint(equalTo: view.bottomAnchor), + bar.heightAnchor.constraint(equalToConstant: 32), + ]) + + configure(&addProjectButton, label: "+", tip: "New project", + action: #selector(addProject(_:))) + configure(&addSubprojectButton, label: "+sub", tip: "New sub-project under selection", + action: #selector(addSubproject(_:))) + configure(&deleteButton, label: "−", tip: "Delete selection", + action: #selector(deleteSelected(_:))) + configure(&restoreButton, label: "Restore", tip: "Restore selected archived window", + action: #selector(restoreSelectedWindow(_:))) + configure(&restoreAllButton, label: "Restore All", tip: "Restore all archived windows in selected project", + action: #selector(restoreAllInProject(_:))) + configure(&closeProjectButton, label: "Close All", tip: "Close and archive all open windows in selected project", + action: #selector(closeSelectedProject(_:))) + + let spacer = NSView() + let stack = NSStackView(views: [addProjectButton, addSubprojectButton, deleteButton, + spacer, + restoreButton, restoreAllButton, closeProjectButton]) + stack.orientation = .horizontal + stack.spacing = 4 + stack.edgeInsets = NSEdgeInsets(top: 0, left: 6, bottom: 0, right: 6) + stack.distribution = .fill + stack.translatesAutoresizingMaskIntoConstraints = false + bar.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: bar.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: bar.trailingAnchor), + stack.topAnchor.constraint(equalTo: bar.topAnchor), + stack.bottomAnchor.constraint(equalTo: bar.bottomAnchor), + ]) + } + + private func setupPreviewPopover() { + previewPopover.behavior = .transient + previewPopover.animates = false + } + + private func setupObservers() { + NotificationCenter.default.addObserver( + self, + selector: #selector(modelChanged(_:)), + name: iTermWindowProjectsModel.didChangeNotification, + object: nil) + } + + func reload() { outlineView.reloadData() } + + // MARK: NSOutlineViewDataSource + + func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { + if item == nil { + return sortedProjects(iTermWindowProjectsModel.shared.rootProjects).count + } + if let project = item as? iTermWindowProject { + let liveCount = iTermWindowProjectsModel.shared.liveWindows(for: project).count + return project.children.count + liveCount + project.windows.count + } + return 0 + } + + func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { + if item == nil { + return sortedProjects(iTermWindowProjectsModel.shared.rootProjects)[index] + } + let project = item as! iTermWindowProject + let sortedKids = sortedProjects(project.children) + if index < sortedKids.count { + return sortedKids[index] + } + let afterKids = index - sortedKids.count + let liveWins = iTermWindowProjectsModel.shared.liveWindows(for: project) + if afterKids < liveWins.count { + return iTermLiveWindowBox(liveWins[afterKids], project: project) + } + let archivedIdx = afterKids - liveWins.count + return iTermArchivedWindowBox(project.windows[archivedIdx], project: project) + } + + func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { + if let p = item as? iTermWindowProject { + let liveCount = iTermWindowProjectsModel.shared.liveWindows(for: p).count + return p.children.count + liveCount + p.windows.count > 0 + } + return false + } + + // MARK: NSOutlineViewDelegate + + func outlineView(_ outlineView: NSOutlineView, + viewFor tableColumn: NSTableColumn?, + item: Any) -> NSView? { + let id = NSUserInterfaceItemIdentifier("ProjectCell") + let cell: NSTableCellView + if let existing = outlineView.makeView(withIdentifier: id, owner: nil) as? NSTableCellView { + cell = existing + } else { + cell = makeProjectCell(identifier: id) + } + + if let project = item as? iTermWindowProject { + let liveCount = iTermWindowProjectsModel.shared.liveWindows(for: project).count + let archCount = project.totalWindowCount + var suffix = "" + if liveCount > 0 || archCount > 0 { + let parts = [liveCount > 0 ? "\(liveCount) open" : nil, + archCount > 0 ? "\(archCount) archived" : nil].compactMap { $0 } + suffix = " (\(parts.joined(separator: ", ")))" + } + cell.textField?.stringValue = project.name + suffix + cell.textField?.font = .systemFont(ofSize: NSFont.systemFontSize) + cell.textField?.textColor = .labelColor + cell.imageView?.image = NSImage(systemSymbolName: "folder", + accessibilityDescription: nil) + + } else if let liveBox = item as? iTermLiveWindowBox { + let title = liveBox.terminal.window()?.title ?? "Window" + cell.textField?.stringValue = title + cell.textField?.font = .boldSystemFont(ofSize: NSFont.systemFontSize) + cell.textField?.textColor = .labelColor + cell.imageView?.image = NSImage(systemSymbolName: "terminal.fill", + accessibilityDescription: nil) + + } else if let box = item as? iTermArchivedWindowBox { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + let age = formatter.localizedString(for: box.window.timestamp, relativeTo: Date()) + cell.textField?.stringValue = "\(box.window.name) \(age)" + cell.textField?.font = .systemFont(ofSize: NSFont.systemFontSize) + cell.textField?.textColor = .secondaryLabelColor + cell.imageView?.image = NSImage(systemSymbolName: "terminal", + accessibilityDescription: nil) + } + return cell + } + + func outlineViewSelectionDidChange(_ notification: Notification) { + updateButtons() + onSelectionChange?() + } + + // MARK: Double-click + + @objc private func doubleClicked(_ sender: Any?) { + let row = outlineView.clickedRow + guard row >= 0 else { return } + let item = outlineView.item(atRow: row) + if let box = item as? iTermArchivedWindowBox { + iTermWindowProjectsModel.shared.restoreWindow(box.window) + } else if let liveBox = item as? iTermLiveWindowBox { + liveBox.terminal.window()?.makeKeyAndOrderFront(nil) + } + } + + // MARK: Button Actions + + @objc private func addProject(_ sender: Any?) { + promptForName(title: "New Project", prompt: "Project name:") { [weak self] name in + iTermWindowProjectsModel.shared.createProject(named: name) + self?.reload() + } + } + + @objc private func addSubproject(_ sender: Any?) { + guard let parent = selectedProject else { + showAlert("Select a project first to add a sub-project.") + return + } + promptForName(title: "New Sub-Project", prompt: "Sub-project name:") { [weak self] name in + iTermWindowProjectsModel.shared.createProject(named: name, parent: parent) + self?.reload() + self?.outlineView.expandItem(parent) + } + } + + @objc private func deleteSelected(_ sender: Any?) { + if let box = selectedArchivedBox { + iTermWindowProjectsModel.shared.removeWindow(box.window, from: box.project) + reload() + } else if let project = selectedProject { + let alert = NSAlert() + alert.messageText = "Delete "\(project.name)"?" + alert.informativeText = "This removes the project and all its archived windows. Open windows are unaffected." + alert.addButton(withTitle: "Delete") + alert.addButton(withTitle: "Cancel") + alert.buttons[0].hasDestructiveAction = true + if alert.runModal() == .alertFirstButtonReturn { + iTermWindowProjectsModel.shared.deleteProject(project) + reload() + } + } + } + + @objc func restoreSelectedWindow(_ sender: Any?) { + guard let box = selectedArchivedBox else { return } + iTermWindowProjectsModel.shared.restoreWindow(box.window) + } + + @objc func restoreAllInProject(_ sender: Any?) { + guard let project = selectedProject else { return } + iTermWindowProjectsModel.shared.restoreAllWindows(in: project) + } + + @objc private func closeSelectedProject(_ sender: Any?) { + guard let project = selectedProject else { return } + guard iTermWindowProjectsModel.shared.hasLiveWindows(for: project) else { + showAlert("No open windows are associated with "\(project.name)".") + return + } + iTermWindowProjectsModel.shared.closeProject(project) + reload() + } + + // MARK: Context Menu + + override func rightMouseDown(with event: NSEvent) { + let point = outlineView.convert(event.locationInWindow, from: nil) + let row = outlineView.row(at: point) + guard row >= 0 else { super.rightMouseDown(with: event); return } + outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + + let menu = NSMenu() + if let box = selectedArchivedBox { + menu.addItem(NSMenuItem(title: "Restore Window", + action: #selector(restoreSelectedWindow(_:)), + keyEquivalent: "")) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Remove from Project", + action: #selector(deleteSelected(_:)), + keyEquivalent: "")) + _ = box + } else if let liveBox = selectedLiveBox { + let bringItem = NSMenuItem(title: "Bring to Front", + action: #selector(bringLiveWindowToFront(_:)), + keyEquivalent: "") + menu.addItem(bringItem) + menu.addItem(.separator()) + let archiveItem = NSMenuItem(title: "Close & Archive Now", + action: #selector(closeAndArchiveLiveWindow(_:)), + keyEquivalent: "") + menu.addItem(archiveItem) + let disItem = NSMenuItem(title: "Disassociate from Project", + action: #selector(disassociateLiveWindow(_:)), + keyEquivalent: "") + menu.addItem(disItem) + _ = liveBox + } else if let project = selectedProject { + menu.addItem(NSMenuItem(title: "Restore All Windows", + action: #selector(restoreAllInProject(_:)), + keyEquivalent: "")) + if iTermWindowProjectsModel.shared.hasLiveWindows(for: project) { + menu.addItem(NSMenuItem(title: "Close All Open Windows", + action: #selector(closeSelectedProject(_:)), + keyEquivalent: "")) + } + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Rename…", + action: #selector(renameProject(_:)), + keyEquivalent: "")) + menu.addItem(NSMenuItem(title: "New Sub-Project…", + action: #selector(addSubproject(_:)), + keyEquivalent: "")) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Delete Project", + action: #selector(deleteSelected(_:)), + keyEquivalent: "")) + _ = project + } + menu.items.forEach { $0.target = self } + NSMenu.popUpContextMenu(menu, with: event, for: outlineView) + } + + @objc private func renameProject(_ sender: Any?) { + guard let project = selectedProject else { return } + promptForName(title: "Rename Project", prompt: "New name:", initial: project.name) { [weak self] name in + iTermWindowProjectsModel.shared.renameProject(project, to: name) + self?.reload() + } + } + + @objc private func bringLiveWindowToFront(_ sender: Any?) { + selectedLiveBox?.terminal.window()?.makeKeyAndOrderFront(nil) + } + + @objc private func closeAndArchiveLiveWindow(_ sender: Any?) { + guard let liveBox = selectedLiveBox else { return } + iTermWindowProjectsModel.shared.archiveWindow(liveBox.terminal, + to: liveBox.project, + andClose: true) + reload() + } + + @objc private func disassociateLiveWindow(_ sender: Any?) { + guard let liveBox = selectedLiveBox else { return } + iTermWindowProjectsModel.shared.disassociateWindow(liveBox.terminal) + reload() + } + + // MARK: Drag Source (left → right: restore) + + func outlineView(_ outlineView: NSOutlineView, + pasteboardWriterForItem item: Any) -> NSPasteboardWriting? { + if let box = item as? iTermArchivedWindowBox { + let pb = NSPasteboardItem() + pb.setString(box.window.id.uuidString, forType: kArchivedWindowDragType) + return pb + } + if let project = item as? iTermWindowProject { + let pb = NSPasteboardItem() + pb.setString(project.id.uuidString, forType: kProjectDragType) + return pb + } + // Live window boxes are not draggable from the left pane — use right pane for that + return nil + } + + // MARK: Drop Destination (right → left: archive or close-all) + + func outlineView(_ outlineView: NSOutlineView, + validateDrop info: NSDraggingInfo, + proposedItem item: Any?, + proposedChildIndex index: Int) -> NSDragOperation { + let pb = info.draggingPasteboard + // Live window dropped onto a project → archive + close + if pb.availableType(from: [kLiveWindowDragType]) != nil, + item is iTermWindowProject { + return .move + } + // Project group dropped anywhere on left → close all + if pb.availableType(from: [kProjectGroupDragType]) != nil { + // Redirect to root if dropped on a leaf or nothing + if item == nil || item is iTermWindowProject { + return .move + } + outlineView.setDropItem(nil, dropChildIndex: NSOutlineViewDropOnItemIndex) + return .move + } + return [] + } + + func outlineView(_ outlineView: NSOutlineView, + acceptDrop info: NSDraggingInfo, + item: Any?, + childIndex index: Int) -> Bool { + let pb = info.draggingPasteboard + + // Drop live window onto project → archive + close + if let wnStr = pb.string(forType: kLiveWindowDragType), + let wn = Int(wnStr), + let project = item as? iTermWindowProject { + let all = (iTermController.sharedInstance().terminals as? [PseudoTerminal]) ?? [] + guard let terminal = all.first(where: { $0.window()?.windowNumber == wn }) else { + return false + } + iTermWindowProjectsModel.shared.archiveWindow(terminal, to: project, andClose: true) + return true + } + + // Drop project group → close-all for that project + if let uuidStr = pb.string(forType: kProjectGroupDragType), + let uuid = UUID(uuidString: uuidStr), + let project = iTermWindowProjectsModel.shared.project(id: uuid) { + iTermWindowProjectsModel.shared.closeProject(project) + return true + } + + return false + } + + // MARK: Hover Preview + + override func mouseMoved(with event: NSEvent) { + let point = outlineView.convert(event.locationInWindow, from: nil) + let row = outlineView.row(at: point) + if row == previewRow { return } + cancelPreview() + guard row >= 0 else { return } + previewRow = row + previewTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in + self?.showPreview(forRow: row) + } + } + + override func mouseExited(with event: NSEvent) { cancelPreview() } + + private func cancelPreview() { + previewTimer?.invalidate() + previewTimer = nil + previewRow = -1 + previewPopover.close() + } + + private func showPreview(forRow row: Int) { + let item = outlineView.item(atRow: row) + + if let box = item as? iTermArchivedWindowBox, + let arrangement = box.window.arrangement { + let rowRect = outlineView.rect(ofRow: row) + guard rowRect != .zero else { return } + let pv = ArrangementPreviewView(frame: NSRect(x: 0, y: 0, width: 320, height: 200)) + pv.setArrangement([arrangement as Any]) + showPopover(contentView: pv, anchor: rowRect, preferredEdge: .maxX) + return + } + + if let liveBox = item as? iTermLiveWindowBox, + let nsWindow = liveBox.terminal.window(), + nsWindow.windowNumber > 0 { + showLivePreview(windowNumber: CGWindowID(nsWindow.windowNumber), + anchor: outlineView.rect(ofRow: row), + preferredEdge: .maxX) + } + } + + // MARK: Notifications + + @objc private func modelChanged(_ note: Notification) { reload() } + + // MARK: Helpers + + private func updateButtons() { + let hasProject = selectedProject != nil + let hasArchived = selectedArchivedBox != nil + addSubprojectButton.isEnabled = hasProject + deleteButton.isEnabled = hasProject || hasArchived + restoreButton.isEnabled = hasArchived + restoreAllButton.isEnabled = hasProject + if let proj = selectedProject { + closeProjectButton.isEnabled = iTermWindowProjectsModel.shared.hasLiveWindows(for: proj) + } else { + closeProjectButton.isEnabled = false + } + } + + private func makeProjectCell(identifier: NSUserInterfaceItemIdentifier) -> NSTableCellView { + let cell = NSTableCellView() + cell.identifier = identifier + + let iv = NSImageView() + iv.translatesAutoresizingMaskIntoConstraints = false + iv.imageScaling = .scaleProportionallyDown + cell.imageView = iv + + let tf = NSTextField(labelWithString: "") + tf.translatesAutoresizingMaskIntoConstraints = false + tf.lineBreakMode = .byTruncatingTail + cell.textField = tf + + cell.addSubview(iv) + cell.addSubview(tf) + NSLayoutConstraint.activate([ + iv.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 2), + iv.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + iv.widthAnchor.constraint(equalToConstant: 16), + iv.heightAnchor.constraint(equalToConstant: 16), + tf.leadingAnchor.constraint(equalTo: iv.trailingAnchor, constant: 4), + tf.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -4), + tf.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + ]) + return cell + } + + private func showPopover(contentView: NSView, anchor: NSRect, preferredEdge: NSRectEdge) { + let vc = NSViewController() + vc.view = contentView + previewPopover.contentViewController = vc + previewPopover.contentSize = contentView.frame.size + previewPopover.show(relativeTo: anchor, of: outlineView, preferredEdge: preferredEdge) + } +} + +// MARK: - Right Pane: Open Windows (grouped by project) + +final class iTermOpenWindowsController: NSViewController, + NSOutlineViewDataSource, + NSOutlineViewDelegate { + weak var projectsController: iTermProjectsOutlineController? + + private var outlineView = NSOutlineView() + private var scrollView = NSScrollView() + private var associateButton = NSButton() + private var statusLabel = NSTextField(labelWithString: "") + + // Hover preview + private var previewPopover = NSPopover() + private var previewTimer: Timer? + private var previewRow = -1 + + private var groups: [iTermOpenProjectGroup] = [] + + private var terminals: [PseudoTerminal] { + (iTermController.sharedInstance().terminals as? [PseudoTerminal]) ?? [] + } + + var selectedTerminal: PseudoTerminal? { + outlineView.item(atRow: outlineView.selectedRow) as? PseudoTerminal + } + var selectedGroup: iTermOpenProjectGroup? { + outlineView.item(atRow: outlineView.selectedRow) as? iTermOpenProjectGroup + } + + override func loadView() { view = NSView() } + + override func viewDidLoad() { + super.viewDidLoad() + setupOutlineView() + setupBottomBar() + setupPreviewPopover() + setupObservers() + updateActionButtons() + } + + // MARK: Setup + + private func recomputeGroups() { + let model = iTermWindowProjectsModel.shared + let allTerms = terminals + + var byProjectID: [UUID: (project: iTermWindowProject, terminals: [PseudoTerminal])] = [:] + var unassociated: [PseudoTerminal] = [] + + for terminal in allTerms { + if let proj = model.project(for: terminal) { + if byProjectID[proj.id] != nil { + byProjectID[proj.id]!.terminals.append(terminal) + } else { + byProjectID[proj.id] = (project: proj, terminals: [terminal]) + } + } else { + unassociated.append(terminal) + } + } + + groups = byProjectID.values + .sorted { $0.project.name.localizedCaseInsensitiveCompare($1.project.name) == .orderedAscending } + .map { iTermOpenProjectGroup(project: $0.project, terminals: $0.terminals) } + + if !unassociated.isEmpty { + groups.append(iTermOpenProjectGroup(project: nil, terminals: unassociated)) + } + } + + private func setupOutlineView() { + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.borderType = .noBorder + scrollView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(scrollView) + + let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("main")) + col.title = "" + outlineView.addTableColumn(col) + outlineView.outlineTableColumn = col + outlineView.headerView = nil + outlineView.rowHeight = 22 + outlineView.dataSource = self + outlineView.delegate = self + outlineView.allowsEmptySelection = true + outlineView.allowsMultipleSelection = false + outlineView.focusRingType = .none + + // Drag source + destination + outlineView.setDraggingSourceOperationMask(.every, forLocal: true) + outlineView.setDraggingSourceOperationMask(.every, forLocal: false) + outlineView.registerForDraggedTypes([kLiveWindowDragType, + kArchivedWindowDragType, + kProjectDragType]) + + scrollView.documentView = outlineView + + let sep = NSBox() + sep.boxType = .separator + sep.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(sep) + + let header = makeSectionHeader("OPEN WINDOWS") + header.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(header) + + NSLayoutConstraint.activate([ + header.topAnchor.constraint(equalTo: view.topAnchor), + header.leadingAnchor.constraint(equalTo: view.leadingAnchor), + header.trailingAnchor.constraint(equalTo: view.trailingAnchor), + header.heightAnchor.constraint(equalToConstant: 24), + + scrollView.topAnchor.constraint(equalTo: header.bottomAnchor), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: sep.topAnchor), + + sep.leadingAnchor.constraint(equalTo: view.leadingAnchor), + sep.trailingAnchor.constraint(equalTo: view.trailingAnchor), + sep.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -32), + sep.heightAnchor.constraint(equalToConstant: 1), + ]) + + let tracking = NSTrackingArea(rect: .zero, + options: [.mouseMoved, .mouseEnteredAndExited, + .activeInKeyWindow, .inVisibleRect], + owner: self, + userInfo: nil) + outlineView.addTrackingArea(tracking) + } + + private func setupBottomBar() { + let bar = NSView() + bar.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(bar) + NSLayoutConstraint.activate([ + bar.leadingAnchor.constraint(equalTo: view.leadingAnchor), + bar.trailingAnchor.constraint(equalTo: view.trailingAnchor), + bar.bottomAnchor.constraint(equalTo: view.bottomAnchor), + bar.heightAnchor.constraint(equalToConstant: 32), + ]) + + statusLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize) + statusLabel.textColor = .secondaryLabelColor + statusLabel.translatesAutoresizingMaskIntoConstraints = false + + configure(&associateButton, + label: "Associate with Project", + tip: "Mark the selected open window as belonging to the selected project (auto-archives on close)", + action: #selector(associateSelected(_:))) + + let stack = NSStackView(views: [statusLabel, NSView(), associateButton]) + stack.orientation = .horizontal + stack.spacing = 6 + stack.edgeInsets = NSEdgeInsets(top: 0, left: 6, bottom: 0, right: 6) + stack.distribution = .fill + stack.translatesAutoresizingMaskIntoConstraints = false + bar.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: bar.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: bar.trailingAnchor), + stack.topAnchor.constraint(equalTo: bar.topAnchor), + stack.bottomAnchor.constraint(equalTo: bar.bottomAnchor), + ]) + } + + private func setupPreviewPopover() { + previewPopover.behavior = .transient + previewPopover.animates = false + } + + private func setupObservers() { + let nc = NotificationCenter.default + nc.addObserver(self, selector: #selector(reload), + name: iTermWindowProjectsModel.didChangeNotification, object: nil) + nc.addObserver(self, selector: #selector(reload), + name: NSWindow.willCloseNotification, object: nil) + nc.addObserver(self, selector: #selector(reload), + name: NSWindow.didBecomeMainNotification, object: nil) + } + + @objc func reload() { + recomputeGroups() + outlineView.reloadData() + let n = terminals.count + statusLabel.stringValue = n == 1 ? "1 window" : "\(n) windows" + updateActionButtons() + for group in groups { + outlineView.expandItem(group) + } + } + + func updateActionButtons() { + let hasTerminal = selectedTerminal != nil + let hasProject = projectsController?.selectedProject != nil + associateButton.isEnabled = hasTerminal && hasProject + } + + // MARK: NSOutlineViewDataSource + + func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { + if item == nil { return groups.count } + if let group = item as? iTermOpenProjectGroup { return group.terminals.count } + return 0 + } + + func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { + if item == nil { return groups[index] } + return (item as! iTermOpenProjectGroup).terminals[index] + } + + func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { + item is iTermOpenProjectGroup + } + + // MARK: NSOutlineViewDelegate + + func outlineView(_ outlineView: NSOutlineView, + viewFor tableColumn: NSTableColumn?, + item: Any) -> NSView? { + if let group = item as? iTermOpenProjectGroup { + let id = NSUserInterfaceItemIdentifier("GroupCell") + let cell: NSTableCellView + if let existing = outlineView.makeView(withIdentifier: id, owner: nil) as? NSTableCellView { + cell = existing + } else { + cell = makeGroupCell(identifier: id) + } + let count = group.terminals.count + let countStr = count == 1 ? "1 window" : "\(count) windows" + if let proj = group.project { + cell.textField?.stringValue = "\(proj.name) \(countStr)" + cell.textField?.textColor = .labelColor + cell.imageView?.image = NSImage(systemSymbolName: "folder.fill", + accessibilityDescription: nil) + } else { + cell.textField?.stringValue = "Unassociated \(countStr)" + cell.textField?.textColor = .secondaryLabelColor + cell.imageView?.image = NSImage(systemSymbolName: "questionmark.folder", + accessibilityDescription: nil) + } + return cell + } + + if let terminal = item as? PseudoTerminal { + let id = NSUserInterfaceItemIdentifier("WindowCell") + let cell: NSTableCellView + if let existing = outlineView.makeView(withIdentifier: id, owner: nil) as? NSTableCellView { + cell = existing + } else { + cell = makeWindowCell(identifier: id) + } + cell.textField?.stringValue = terminal.window()?.title ?? "Window" + cell.imageView?.image = NSImage(systemSymbolName: "terminal", + accessibilityDescription: nil) + return cell + } + return nil + } + + func outlineView(_ outlineView: NSOutlineView, shouldShowOutlineCellForItem item: Any) -> Bool { + item is iTermOpenProjectGroup + } + + func outlineViewSelectionDidChange(_ notification: Notification) { + updateActionButtons() + } + + // MARK: Actions + + @objc private func associateSelected(_ sender: Any?) { + guard let terminal = selectedTerminal, + let project = projectsController?.selectedProject else { return } + iTermWindowProjectsModel.shared.associateWindow(terminal, with: project) + reload() + projectsController?.reload() + } + + // MARK: Context Menu + + override func rightMouseDown(with event: NSEvent) { + let point = outlineView.convert(event.locationInWindow, from: nil) + let row = outlineView.row(at: point) + guard row >= 0 else { super.rightMouseDown(with: event); return } + outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + + let menu = NSMenu() + if let terminal = selectedTerminal { + menu.addItem(NSMenuItem(title: "Bring to Front", + action: #selector(bringSelectedToFront(_:)), + keyEquivalent: "")) + let isAssociated = iTermWindowProjectsModel.shared.project(for: terminal) != nil + if isAssociated { + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Close & Archive to Project", + action: #selector(closeAndArchiveSelected(_:)), + keyEquivalent: "")) + menu.addItem(NSMenuItem(title: "Disassociate from Project", + action: #selector(disassociateSelected(_:)), + keyEquivalent: "")) + } + } else if let group = selectedGroup { + if let proj = group.project { + menu.addItem(NSMenuItem(title: "Close All in "\(proj.name)"", + action: #selector(closeSelectedGroup(_:)), + keyEquivalent: "")) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Disassociate All from "\(proj.name)"", + action: #selector(disassociateSelectedGroup(_:)), + keyEquivalent: "")) + } + _ = group + } + menu.items.forEach { $0.target = self } + NSMenu.popUpContextMenu(menu, with: event, for: outlineView) + } + + @objc private func bringSelectedToFront(_ sender: Any?) { + selectedTerminal?.window()?.makeKeyAndOrderFront(nil) + } + + @objc private func closeAndArchiveSelected(_ sender: Any?) { + guard let terminal = selectedTerminal, + let project = iTermWindowProjectsModel.shared.project(for: terminal) else { return } + iTermWindowProjectsModel.shared.archiveWindow(terminal, to: project, andClose: true) + } + + @objc private func disassociateSelected(_ sender: Any?) { + guard let terminal = selectedTerminal else { return } + iTermWindowProjectsModel.shared.disassociateWindow(terminal) + } + + @objc private func closeSelectedGroup(_ sender: Any?) { + guard let proj = selectedGroup?.project else { return } + iTermWindowProjectsModel.shared.closeProject(proj) + } + + @objc private func disassociateSelectedGroup(_ sender: Any?) { + guard let group = selectedGroup else { return } + for terminal in group.terminals { + iTermWindowProjectsModel.shared.disassociateWindow(terminal) + } + } + + // MARK: Drag Source (right → left: archive; right → right: reassign) + + func outlineView(_ outlineView: NSOutlineView, + pasteboardWriterForItem item: Any) -> NSPasteboardWriting? { + if let terminal = item as? PseudoTerminal { + guard let wn = terminal.window()?.windowNumber, wn > 0 else { return nil } + let pb = NSPasteboardItem() + pb.setString(String(wn), forType: kLiveWindowDragType) + return pb + } + if let group = item as? iTermOpenProjectGroup, let proj = group.project { + let pb = NSPasteboardItem() + pb.setString(proj.id.uuidString, forType: kProjectGroupDragType) + return pb + } + return nil + } + + // MARK: Drop Destination (left → right: restore; right → right: reassign/disassociate) + + func outlineView(_ outlineView: NSOutlineView, + validateDrop info: NSDraggingInfo, + proposedItem item: Any?, + proposedChildIndex index: Int) -> NSDragOperation { + let pb = info.draggingPasteboard + + // Live window from right dropped on a group → reassign or disassociate + if pb.availableType(from: [kLiveWindowDragType]) != nil { + if item is iTermOpenProjectGroup || item == nil { + return .link + } + // Redirect drops on terminal children to the parent group + if let terminal = item as? PseudoTerminal, + let parentGroup = groups.first(where: { $0.terminals.contains { $0 === terminal } }) { + outlineView.setDropItem(parentGroup, dropChildIndex: NSOutlineViewDropOnItemIndex) + return .link + } + return [] + } + + // Archived window from left → restore anywhere in the right pane + if pb.availableType(from: [kArchivedWindowDragType]) != nil { + outlineView.setDropItem(nil, dropChildIndex: NSOutlineViewDropOnItemIndex) + return .copy + } + + // Project from left → restore all, anywhere in the right pane + if pb.availableType(from: [kProjectDragType]) != nil { + outlineView.setDropItem(nil, dropChildIndex: NSOutlineViewDropOnItemIndex) + return .copy + } + + return [] + } + + func outlineView(_ outlineView: NSOutlineView, + acceptDrop info: NSDraggingInfo, + item: Any?, + childIndex index: Int) -> Bool { + let pb = info.draggingPasteboard + + // Live window → reassign or disassociate + if let wnStr = pb.string(forType: kLiveWindowDragType), let wn = Int(wnStr) { + let all = (iTermController.sharedInstance().terminals as? [PseudoTerminal]) ?? [] + guard let terminal = all.first(where: { $0.window()?.windowNumber == wn }) else { + return false + } + if let group = item as? iTermOpenProjectGroup, let proj = group.project { + iTermWindowProjectsModel.shared.associateWindow(terminal, with: proj) + } else { + // Dropped on "Unassociated" group or root → disassociate + iTermWindowProjectsModel.shared.disassociateWindow(terminal) + } + return true + } + + // Archived window → restore (and remove archive entry) + if let uuidStr = pb.string(forType: kArchivedWindowDragType), + let uuid = UUID(uuidString: uuidStr), + let (archived, project) = iTermWindowProjectsModel.shared.archivedWindow(id: uuid) { + iTermWindowProjectsModel.shared.restoreWindow(archived) + iTermWindowProjectsModel.shared.removeWindow(archived, from: project) + return true + } + + // Project → restore all + if let uuidStr = pb.string(forType: kProjectDragType), + let uuid = UUID(uuidString: uuidStr), + let project = iTermWindowProjectsModel.shared.project(id: uuid) { + iTermWindowProjectsModel.shared.restoreAllWindows(in: project) + return true + } + + return false + } + + // MARK: Hover Preview + + override func mouseMoved(with event: NSEvent) { + let point = outlineView.convert(event.locationInWindow, from: nil) + let row = outlineView.row(at: point) + if row == previewRow { return } + cancelPreview() + guard row >= 0 else { return } + previewRow = row + previewTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in + self?.showPreview(forRow: row) + } + } + + override func mouseExited(with event: NSEvent) { cancelPreview() } + + private func cancelPreview() { + previewTimer?.invalidate() + previewTimer = nil + previewRow = -1 + previewPopover.close() + } + + private func showPreview(forRow row: Int) { + guard let terminal = outlineView.item(atRow: row) as? PseudoTerminal, + let nsWindow = terminal.window(), + nsWindow.windowNumber > 0 else { return } + let rowRect = outlineView.rect(ofRow: row) + guard rowRect != .zero else { return } + showLivePreview(windowNumber: CGWindowID(nsWindow.windowNumber), + anchor: rowRect, + preferredEdge: .minX) + } + + private func showLivePreview(windowNumber: CGWindowID, + anchor: NSRect, + preferredEdge: NSRectEdge) { + let cgImage = CGWindowListCreateImage( + .null, + .optionIncludingWindow, + windowNumber, + [.boundsIgnoreFraming, .bestResolution]) + guard let cgImage else { return } + let img = NSImage(cgImage: cgImage, size: .zero) + let aspectW: CGFloat = 320 + let aspectH = cgImage.height == 0 ? 200 + : aspectW * CGFloat(cgImage.height) / CGFloat(cgImage.width) + let iv = NSImageView(frame: NSRect(x: 0, y: 0, width: aspectW, height: min(aspectH, 240))) + iv.image = img + iv.imageScaling = .scaleProportionallyUpOrDown + + let vc = NSViewController() + vc.view = iv + previewPopover.contentViewController = vc + previewPopover.contentSize = iv.frame.size + previewPopover.show(relativeTo: anchor, of: outlineView, preferredEdge: preferredEdge) + } + + // MARK: Cell Factories + + private func makeGroupCell(identifier: NSUserInterfaceItemIdentifier) -> NSTableCellView { + let cell = NSTableCellView() + cell.identifier = identifier + + let iv = NSImageView() + iv.translatesAutoresizingMaskIntoConstraints = false + iv.imageScaling = .scaleProportionallyDown + cell.imageView = iv + + let tf = NSTextField(labelWithString: "") + tf.translatesAutoresizingMaskIntoConstraints = false + tf.lineBreakMode = .byTruncatingTail + tf.font = .boldSystemFont(ofSize: NSFont.systemFontSize) + cell.textField = tf + + cell.addSubview(iv) + cell.addSubview(tf) + NSLayoutConstraint.activate([ + iv.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 2), + iv.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + iv.widthAnchor.constraint(equalToConstant: 16), + iv.heightAnchor.constraint(equalToConstant: 16), + tf.leadingAnchor.constraint(equalTo: iv.trailingAnchor, constant: 4), + tf.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -4), + tf.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + ]) + return cell + } + + private func makeWindowCell(identifier: NSUserInterfaceItemIdentifier) -> NSTableCellView { + let cell = NSTableCellView() + cell.identifier = identifier + + let iv = NSImageView() + iv.translatesAutoresizingMaskIntoConstraints = false + iv.imageScaling = .scaleProportionallyDown + cell.imageView = iv + + let tf = NSTextField(labelWithString: "") + tf.translatesAutoresizingMaskIntoConstraints = false + tf.lineBreakMode = .byTruncatingTail + cell.textField = tf + + cell.addSubview(iv) + cell.addSubview(tf) + NSLayoutConstraint.activate([ + iv.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 6), + iv.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + iv.widthAnchor.constraint(equalToConstant: 16), + iv.heightAnchor.constraint(equalToConstant: 16), + tf.leadingAnchor.constraint(equalTo: iv.trailingAnchor, constant: 4), + tf.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -4), + tf.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + ]) + return cell + } +} + +// MARK: - Shared Helpers + +/// The left pane uses this to show live-window previews (screenshots). +private extension iTermProjectsOutlineController { + func showLivePreview(windowNumber: CGWindowID, anchor: NSRect, preferredEdge: NSRectEdge) { + let cgImage = CGWindowListCreateImage( + .null, + .optionIncludingWindow, + windowNumber, + [.boundsIgnoreFraming, .bestResolution]) + guard let cgImage else { return } + let img = NSImage(cgImage: cgImage, size: .zero) + let aspectW: CGFloat = 320 + let aspectH = cgImage.height == 0 ? 200 + : aspectW * CGFloat(cgImage.height) / CGFloat(cgImage.width) + let iv = NSImageView(frame: NSRect(x: 0, y: 0, width: aspectW, height: min(aspectH, 240))) + iv.image = img + iv.imageScaling = .scaleProportionallyUpOrDown + let vc = NSViewController() + vc.view = iv + previewPopover.contentViewController = vc + previewPopover.contentSize = iv.frame.size + previewPopover.show(relativeTo: anchor, of: outlineView, preferredEdge: preferredEdge) + } +} + +private func makeSectionHeader(_ title: String) -> NSView { + let box = NSView() + let tf = NSTextField(labelWithString: title) + tf.font = NSFont.systemFont(ofSize: 10, weight: .semibold) + tf.textColor = .secondaryLabelColor + tf.translatesAutoresizingMaskIntoConstraints = false + box.addSubview(tf) + NSLayoutConstraint.activate([ + tf.leadingAnchor.constraint(equalTo: box.leadingAnchor, constant: 8), + tf.centerYAnchor.constraint(equalTo: box.centerYAnchor), + ]) + return box +} + +private func configure(_ button: inout NSButton, + label: String, + tip: String, + action: Selector) { + button = NSButton(title: label, target: nil, action: action) + button.bezelStyle = .inline + button.controlSize = .small + button.toolTip = tip +} + +private func showAlert(_ message: String) { + let alert = NSAlert() + alert.messageText = message + alert.runModal() +} + +private func promptForName(title: String, + prompt: String, + initial: String = "", + completion: @escaping (String) -> Void) { + let alert = NSAlert() + alert.messageText = title + alert.addButton(withTitle: "OK") + alert.addButton(withTitle: "Cancel") + let tf = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24)) + tf.stringValue = initial + tf.placeholderString = prompt + alert.accessoryView = tf + alert.window.initialFirstResponder = tf + if alert.runModal() == .alertFirstButtonReturn { + let name = tf.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + if !name.isEmpty { completion(name) } + } +} diff --git a/sources/iTermWindowProjectsModel.swift b/sources/iTermWindowProjectsModel.swift new file mode 100644 index 0000000000..56d3844d03 --- /dev/null +++ b/sources/iTermWindowProjectsModel.swift @@ -0,0 +1,348 @@ +// iTermWindowProjectsModel.swift +// iTerm2 +// +// Data model and persistence for per-window project archives. + +import Foundation +import AppKit + +// MARK: - Archived Window + +/// A single archived (closed) window belonging to a project. +struct iTermArchivedWindow: Codable { + let id: UUID + var name: String + let timestamp: Date + /// Binary-plist–encoded NSDictionary from PseudoTerminal.arrangementExcludingTmuxTabs. + private let arrangementBase64: String + + init(id: UUID = UUID(), + name: String, + timestamp: Date = Date(), + arrangement: [AnyHashable: Any]) { + self.id = id + self.name = name + self.timestamp = timestamp + let data = try? PropertyListSerialization.data( + fromPropertyList: arrangement, + format: .binary, + options: 0) + self.arrangementBase64 = data?.base64EncodedString() ?? "" + } + + /// Decoded arrangement dict, or nil if data is corrupt. + var arrangement: [AnyHashable: Any]? { + guard !arrangementBase64.isEmpty, + let data = Data(base64Encoded: arrangementBase64) else { return nil } + return (try? PropertyListSerialization.propertyList( + from: data, + options: [], + format: nil)) as? [AnyHashable: Any] + } +} + +// MARK: - Window Project Node + +/// A named project that holds subprojects and archived windows. +final class iTermWindowProject: NSObject, Codable { + var id: UUID + var name: String + var children: [iTermWindowProject] + var windows: [iTermArchivedWindow] + /// Updated whenever a window is archived to or restored from this project. + var lastUsed: Date + + init(name: String) { + self.id = UUID() + self.name = name + self.children = [] + self.windows = [] + self.lastUsed = Date() + } + + enum CodingKeys: String, CodingKey { case id, name, children, windows, lastUsed } + + required init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(UUID.self, forKey: .id) + name = try c.decode(String.self, forKey: .name) + children = try c.decode([iTermWindowProject].self, forKey: .children) + windows = try c.decode([iTermArchivedWindow].self,forKey: .windows) + lastUsed = (try? c.decode(Date.self, forKey: .lastUsed)) ?? .distantPast + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(id, forKey: .id) + try c.encode(name, forKey: .name) + try c.encode(children, forKey: .children) + try c.encode(windows, forKey: .windows) + try c.encode(lastUsed, forKey: .lastUsed) + } + + /// Total archived windows in this project and all descendants. + var totalWindowCount: Int { + windows.count + children.reduce(0) { $0 + $1.totalWindowCount } + } +} + +// MARK: - Model Singleton + +@objc final class iTermWindowProjectsModel: NSObject { + @objc static let shared = iTermWindowProjectsModel() + @objc static let didChangeNotification = NSNotification.Name("iTermWindowProjectsModelDidChange") + + private(set) var rootProjects: [iTermWindowProject] = [] + + /// Runtime-only mapping: NSWindow.windowNumber → project UUID. + /// Not persisted — live associations reset when the app restarts. + private var liveAssociations: [Int: UUID] = [:] + + private static var saveURL: URL { + let support = FileManager.default.urls(for: .applicationSupportDirectory, + in: .userDomainMask)[0] + let dir = support.appendingPathComponent("iTerm2") + try? FileManager.default.createDirectory(at: dir, + withIntermediateDirectories: true) + return dir.appendingPathComponent("WindowProjects.json") + } + + private override init() { + super.init() + load() + NotificationCenter.default.addObserver( + self, + selector: #selector(windowWillClose(_:)), + name: NSWindow.willCloseNotification, + object: nil) + } + + // MARK: Persistence + + func save() { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = .prettyPrinted + guard let data = try? encoder.encode(rootProjects) else { return } + try? data.write(to: Self.saveURL) + NotificationCenter.default.post(name: Self.didChangeNotification, object: self) + } + + private func load() { + guard let data = try? Data(contentsOf: Self.saveURL) else { return } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + rootProjects = (try? decoder.decode([iTermWindowProject].self, from: data)) ?? [] + } + + // MARK: Project CRUD + + @discardableResult + func createProject(named name: String, parent: iTermWindowProject? = nil) -> iTermWindowProject { + let p = iTermWindowProject(name: name) + if let parent = parent { + parent.children.append(p) + } else { + rootProjects.append(p) + } + save() + return p + } + + func renameProject(_ project: iTermWindowProject, to newName: String) { + project.name = newName + save() + } + + @discardableResult + func deleteProject(_ project: iTermWindowProject) -> Bool { + if removeProject(project, from: &rootProjects) { + save() + return true + } + return false + } + + private func removeProject(_ target: iTermWindowProject, + from list: inout [iTermWindowProject]) -> Bool { + if let idx = list.firstIndex(where: { $0.id == target.id }) { + list.remove(at: idx) + return true + } + for p in list where removeProject(target, from: &p.children) { return true } + return false + } + + // MARK: Live Window Associations + + /// Marks `terminal` as belonging to `project` without closing it. + /// When the window later closes, it will be auto-archived to this project. + func associateWindow(_ terminal: PseudoTerminal, with project: iTermWindowProject) { + guard let wn = terminal.window()?.windowNumber, wn > 0 else { return } + liveAssociations[wn] = project.id + NotificationCenter.default.post(name: Self.didChangeNotification, object: self) + } + + /// Removes the project association from `terminal`, leaving the window open but untracked. + func disassociateWindow(_ terminal: PseudoTerminal) { + guard let wn = terminal.window()?.windowNumber, wn > 0, + liveAssociations.removeValue(forKey: wn) != nil else { return } + NotificationCenter.default.post(name: Self.didChangeNotification, object: self) + } + + /// Returns the project `terminal` is currently associated with, or nil. + func project(for terminal: PseudoTerminal) -> iTermWindowProject? { + guard let wn = terminal.window()?.windowNumber, wn > 0, + let pid = liveAssociations[wn] else { return nil } + return project(id: pid) + } + + /// Returns all currently open windows associated with `project`. + func liveWindows(for project: iTermWindowProject) -> [PseudoTerminal] { + let all = (iTermController.sharedInstance().terminals as? [PseudoTerminal]) ?? [] + return all.filter { t in + guard let wn = t.window()?.windowNumber, wn > 0 else { return false } + return liveAssociations[wn] == project.id + } + } + + /// True if `project` has at least one open window associated with it. + func hasLiveWindows(for project: iTermWindowProject) -> Bool { + let all = (iTermController.sharedInstance().terminals as? [PseudoTerminal]) ?? [] + return all.contains { t in + guard let wn = t.window()?.windowNumber, wn > 0 else { return false } + return liveAssociations[wn] == project.id + } + } + + /// Closes and archives every open window currently associated with `project`. + func closeProject(_ project: iTermWindowProject) { + for terminal in liveWindows(for: project) { + guard let wn = terminal.window()?.windowNumber else { continue } + let arrangement = terminal.arrangementExcludingTmuxTabs(true, includingContents: false) + let title = terminal.window()?.title ?? "Window" + project.windows.append(iTermArchivedWindow(name: title, arrangement: arrangement)) + liveAssociations.removeValue(forKey: wn) + terminal.close() + } + project.lastUsed = Date() + save() + } + + /// Called when any NSWindow is about to close. Auto-archives the window if it has a + /// live association, so the project retains the arrangement for later restoration. + @objc private func windowWillClose(_ note: Notification) { + guard let window = note.object as? NSWindow else { return } + let wn = window.windowNumber + guard let projectID = liveAssociations[wn], + let project = project(id: projectID) else { + liveAssociations.removeValue(forKey: wn) + return + } + let all = (iTermController.sharedInstance().terminals as? [PseudoTerminal]) ?? [] + guard let terminal = all.first(where: { $0.window()?.windowNumber == wn }) else { + liveAssociations.removeValue(forKey: wn) + return + } + let arrangement = terminal.arrangementExcludingTmuxTabs(true, includingContents: false) + let title = window.title.isEmpty ? "Window" : window.title + project.windows.append(iTermArchivedWindow(name: title, arrangement: arrangement)) + liveAssociations.removeValue(forKey: wn) + project.lastUsed = Date() + save() + } + + // MARK: Window Archiving + + /// Saves `terminal`'s arrangement into `project` and optionally closes the window. + /// Any existing live association is cleared. + func archiveWindow(_ terminal: PseudoTerminal, + to project: iTermWindowProject, + andClose close: Bool) { + if let wn = terminal.window()?.windowNumber, wn > 0 { + liveAssociations.removeValue(forKey: wn) + } + let arrangement = terminal.arrangementExcludingTmuxTabs(true, includingContents: false) + let title = terminal.window()?.title ?? "Window" + let entry = iTermArchivedWindow(name: title, arrangement: arrangement) + project.windows.append(entry) + project.lastUsed = Date() + save() + if close { + terminal.close() + } + } + + func removeWindow(_ window: iTermArchivedWindow, from project: iTermWindowProject) { + project.windows.removeAll { $0.id == window.id } + save() + } + + // MARK: Restoration + + func restoreWindow(_ archived: iTermArchivedWindow) { + if let parent = parentProject(of: archived) { + parent.lastUsed = Date() + save() + } + guard let arrangement = archived.arrangement else { return } + iTermController.sharedInstance().tryOpenArrangement( + arrangement, + named: nil, + asTabsInWindow: nil) + } + + func restoreAllWindows(in project: iTermWindowProject) { + project.lastUsed = Date() + for archived in project.windows { + guard let arrangement = archived.arrangement else { continue } + iTermController.sharedInstance().tryOpenArrangement( + arrangement, + named: nil, + asTabsInWindow: nil) + } + save() + } + + // MARK: Lookup Helpers + + func project(id: UUID) -> iTermWindowProject? { + findProject(id: id, in: rootProjects) + } + + private func findProject(id: UUID, in list: [iTermWindowProject]) -> iTermWindowProject? { + for p in list { + if p.id == id { return p } + if let found = findProject(id: id, in: p.children) { return found } + } + return nil + } + + func parentProject(of archived: iTermArchivedWindow) -> iTermWindowProject? { + findParent(of: archived, in: rootProjects) + } + + private func findParent(of archived: iTermArchivedWindow, + in projects: [iTermWindowProject]) -> iTermWindowProject? { + for p in projects { + if p.windows.contains(where: { $0.id == archived.id }) { return p } + if let found = findParent(of: archived, in: p.children) { return found } + } + return nil + } + + /// Finds a specific archived window by UUID anywhere in the tree. + func archivedWindow(id: UUID) -> (window: iTermArchivedWindow, project: iTermWindowProject)? { + findArchivedWindow(id: id, in: rootProjects) + } + + private func findArchivedWindow(id: UUID, + in projects: [iTermWindowProject] + ) -> (window: iTermArchivedWindow, project: iTermWindowProject)? { + for p in projects { + if let w = p.windows.first(where: { $0.id == id }) { return (w, p) } + if let found = findArchivedWindow(id: id, in: p.children) { return found } + } + return nil + } +}