diff --git a/ModernTests/IntervalTreeGraphEncodingTests.swift b/ModernTests/IntervalTreeGraphEncodingTests.swift index 371b8c0a55..00157945fb 100644 --- a/ModernTests/IntervalTreeGraphEncodingTests.swift +++ b/ModernTests/IntervalTreeGraphEncodingTests.swift @@ -8,6 +8,14 @@ import XCTest @testable import iTerm2SharedARC +fileprivate func iTermMark() -> iTermMark { + return (iTermMark() as iTermMark?)! +} + +fileprivate func VT100ScreenMark() -> VT100ScreenMark { + return (VT100ScreenMark() as VT100ScreenMark?)! +} + class IntervalTreeGraphEncodingTests: XCTestCase { // MARK: - iTermMark GUID Tests diff --git a/ModernTests/iTermWindowProjectsTests.swift b/ModernTests/iTermWindowProjectsTests.swift new file mode 100644 index 0000000000..174d8bb0b0 --- /dev/null +++ b/ModernTests/iTermWindowProjectsTests.swift @@ -0,0 +1,617 @@ +// +// iTermWindowProjectsTests.swift +// iTerm2 +// +// Created by Gemini CLI on 6/17/26. +// + +import XCTest +@testable import iTerm2SharedARC + +class iTermWindowProjectsTests: XCTestCase { + private var savedProjects: [iTermWindowProject] = [] + private var savedAssociations: [String: UUID] = [:] + private var savedIsTerminating = false + + override func setUp() { + super.setUp() + let model = iTermWindowProjectsModel.shared + // 1. Back up any existing in-memory projects and associations + savedProjects = model.rootProjects + savedAssociations = model.testOnlyAssociations + savedIsTerminating = model.testOnlyIsTerminating + + // 2. Clear rootProjects for a clean test environment by deleting them via the public API + while !model.rootProjects.isEmpty { + model.deleteProject(model.rootProjects[0]) + } + model.testOnlyAssociations = [:] + model.testOnlyIsTerminating = false + } + + override func tearDown() { + // Restore user's original projects and associations back to the singleton + let model = iTermWindowProjectsModel.shared + model.testOnlySetRootProjects(savedProjects) + model.testOnlyAssociations = savedAssociations + model.testOnlyIsTerminating = savedIsTerminating + super.tearDown() + } + + func testProjectCRUD() { + let model = iTermWindowProjectsModel.shared + + // 1. Verify initial clean slate + XCTAssertEqual(model.rootProjects.count, 0) + + // 2. Create a root project + let projectA = model.createProject(named: "Project-A") + XCTAssertEqual(model.rootProjects.count, 1) + XCTAssertEqual(model.rootProjects[0].name, "Project-A") + XCTAssertEqual(model.rootProjects[0].id, projectA.id) + + // 3. Create a nested child project + let subProject = model.createProject(named: "Sub-Project-B", parent: projectA) + XCTAssertEqual(projectA.children.count, 1) + XCTAssertEqual(projectA.children[0].name, "Sub-Project-B") + XCTAssertEqual(projectA.children[0].id, subProject.id) + + // 4. Lookups + let foundProject = model.project(id: subProject.id) + XCTAssertNotNil(foundProject) + XCTAssertEqual(foundProject?.name, "Sub-Project-B") + + // 5. Rename project + model.renameProject(subProject, to: "Sub-Project-B-Renamed") + XCTAssertEqual(projectA.children[0].name, "Sub-Project-B-Renamed") + + // 6. Delete project + let deleted = model.deleteProject(projectA) + XCTAssertTrue(deleted) + XCTAssertEqual(model.rootProjects.count, 0) + XCTAssertNil(model.project(id: subProject.id)) + } + + func testArchivedWindowSerializationAndDeserialization() { + let dummyArrangement: [AnyHashable: Any] = [ + "Columns": 120, + "Rows": 45, + "WorkingDirectory": "/tmp" + ] + + // Create an archived window + let archived = iTermArchivedWindow(name: "Window-A", arrangement: dummyArrangement) + XCTAssertEqual(archived.name, "Window-A") + XCTAssertNotNil(archived.arrangement) + + // Verify arrangement values survive base64 plist encoding/decoding cycle + let recoveredArrangement = archived.arrangement + XCTAssertNotNil(recoveredArrangement) + XCTAssertEqual(recoveredArrangement?["Columns"] as? Int, 120) + XCTAssertEqual(recoveredArrangement?["Rows"] as? Int, 45) + XCTAssertEqual(recoveredArrangement?["WorkingDirectory"] as? String, "/tmp") + } + + func testProjectHierarchyWindowCascading() { + let model = iTermWindowProjectsModel.shared + let root = model.createProject(named: "Project-C") + let dummyArrangement: [AnyHashable: Any] = ["Columns": 80] + + let archivedWin = iTermArchivedWindow(name: "Window-B", arrangement: dummyArrangement) + root.windows.append(archivedWin) + + // Verify we can find the archived window inside the tree + let found = model.archivedWindow(id: archivedWin.id) + XCTAssertNotNil(found) + XCTAssertEqual(found?.0.name, "Window-B") + XCTAssertEqual(found?.1.id, root.id) + + // Verify we find parent project of the window + let parent = model.parentProject(of: archivedWin) + XCTAssertEqual(parent?.id, root.id) + + // Remove archived window + model.removeWindow(archivedWin, from: root) + XCTAssertEqual(root.windows.count, 0) + XCTAssertNil(model.archivedWindow(id: archivedWin.id)) + } + + func testUnlimitedHistoryFlag() { + XCTAssertFalse(PseudoTerminal.useUnlimitedHistoryForArrangement()) + + PseudoTerminal.setUseUnlimitedHistoryForArrangement(true) + XCTAssertTrue(PseudoTerminal.useUnlimitedHistoryForArrangement()) + + PseudoTerminal.setUseUnlimitedHistoryForArrangement(false) + XCTAssertFalse(PseudoTerminal.useUnlimitedHistoryForArrangement()) + } + + func testIsOrphanedAndRunning() { + // 1. Test with a dead/non-existent PID (e.g. 999999) + let deadArrangement: [AnyHashable: Any] = [ + "Tabs": [ + [ + "Navigation": [ + "Server PID": 999999 + ] + ] + ] + ] + let archivedDead = iTermArchivedWindow(name: "DeadWindow", arrangement: deadArrangement) + XCTAssertFalse(archivedDead.isOrphanedAndRunning, "Window with a non-existent PID should not be detected as running") + + // 2. Test with a live PID (our own running test process PID is guaranteed to be alive!) + let livePID = Int(ProcessInfo.processInfo.processIdentifier) + let liveArrangement: [AnyHashable: Any] = [ + "Tabs": [ + [ + "Navigation": [ + "Server PID": livePID + ] + ] + ] + ] + let archivedLive = iTermArchivedWindow(name: "LiveWindow", arrangement: liveArrangement) + XCTAssertTrue(archivedLive.isOrphanedAndRunning, "Window with our own running process PID should be detected as active") + } + + func testArchiveAndReattachmentMetadata() { + let livePID = Int(ProcessInfo.processInfo.processIdentifier) + let arrangementWithArchive: [AnyHashable: Any] = [ + "Tabs": [ + [ + "Navigation": [ + "Server PID": livePID + ] + ] + ], + "Archive": [ + "columns": 80, + "rows": 24 + ] + ] + + let archived = iTermArchivedWindow(name: "TestArchiveWindow", arrangement: arrangementWithArchive) + + // Assert the arrangement has the archive key with columns and rows + let retrieved = archived.arrangement + XCTAssertNotNil(retrieved) + let archiveDict = retrieved?["Archive"] as? [String: Int] + XCTAssertNotNil(archiveDict) + XCTAssertEqual(archiveDict?["columns"], 80) + XCTAssertEqual(archiveDict?["rows"], 24) + + // Assert it is detected as running/active since PID matches our live process + XCTAssertTrue(archived.isOrphanedAndRunning) + } + + func testProjectRecursiveCRUD() { + let model = iTermWindowProjectsModel.shared + + // 1. Create 3 levels of nesting + let level1 = model.createProject(named: "Level-1") + let level2 = model.createProject(named: "Level-2", parent: level1) + let level3 = model.createProject(named: "Level-3", parent: level2) + + XCTAssertEqual(model.rootProjects.count, 1) + XCTAssertEqual(level1.children.count, 1) + XCTAssertEqual(level2.children.count, 1) + + // 2. Lookup deeply nested descendants + let foundL3 = model.project(id: level3.id) + XCTAssertNotNil(foundL3) + XCTAssertEqual(foundL3?.name, "Level-3") + + // 3. Delete the parent project (Level-1) and check that recursive cascading occurs + let deleted = model.deleteProject(level1) + XCTAssertTrue(deleted) + XCTAssertEqual(model.rootProjects.count, 0) + + // Grandchild should be recursively deleted and unsearchable + XCTAssertNil(model.project(id: level3.id)) + XCTAssertNil(model.project(id: level2.id)) + } + + func testModelPersistence() { + let model = iTermWindowProjectsModel.shared + + // Create an unique setup to serialize + let persistentProject = model.createProject(named: "Persistence-Test-Project") + let sub = model.createProject(named: "Persistence-Sub-Project", parent: persistentProject) + + // Add a dummy window arrangement to verify full nested tree serialization + let dummyArrangement: [AnyHashable: Any] = ["Columns": 100, "Rows": 30] + let dummyArchivedWindow = iTermArchivedWindow(name: "PersistWindow", arrangement: dummyArrangement) + sub.windows.append(dummyArchivedWindow) + + // Trigger save explicitly + model.save() + + // Locate the save JSON file URL + let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + let saveURL = support.appendingPathComponent("iTerm2").appendingPathComponent("WindowProjects_test.json") + + // Assert the file was actually written to disk + XCTAssertTrue(FileManager.default.fileExists(atPath: saveURL.path)) + + // Decode file directly to prove model schema integrity and file format consistency + guard let data = try? Data(contentsOf: saveURL) else { + XCTFail("Failed to read persistent JSON data from disk") + return + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + do { + let loadedProjects = try decoder.decode([iTermWindowProject].self, from: data) + XCTAssertEqual(loadedProjects.count, 1) + XCTAssertEqual(loadedProjects[0].name, "Persistence-Test-Project") + + let loadedSub = loadedProjects[0].children[0] + XCTAssertEqual(loadedSub.name, "Persistence-Sub-Project") + XCTAssertEqual(loadedSub.windows.count, 1) + XCTAssertEqual(loadedSub.windows[0].name, "PersistWindow") + + let recoveredArr = loadedSub.windows[0].arrangement + XCTAssertEqual(recoveredArr?["Columns"] as? Int, 100) + XCTAssertEqual(recoveredArr?["Rows"] as? Int, 30) + } catch { + XCTFail("Failed decoding saved window projects JSON structure: \(error)") + } + + // Clean up the project after testing + model.deleteProject(persistentProject) + } + + func testProjectsOutlineControllerDataSource() { + let model = iTermWindowProjectsModel.shared + + // Setup mock projects hierarchy + let p1 = model.createProject(named: "TestUI-P1") + let sub1 = model.createProject(named: "TestUI-Sub1", parent: p1) + + let dummyArrangement: [AnyHashable: Any] = ["Columns": 80] + let arch1 = iTermArchivedWindow(name: "TestUI-ArchivedWindow", arrangement: dummyArrangement) + sub1.windows.append(arch1) + + // Instantiate the controller + let controller = iTermProjectsOutlineController() + controller.loadView() + controller.viewDidLoad() + + // 1. Verify root level count (item is nil) + let rootCount = controller.outlineView(controller.outlineView, numberOfChildrenOfItem: nil) + XCTAssertEqual(rootCount, 1) + + // 2. Verify root child is project p1 + let rootChild = controller.outlineView(controller.outlineView, child: 0, ofItem: nil) as? iTermWindowProject + XCTAssertNotNil(rootChild) + XCTAssertEqual(rootChild?.name, "TestUI-P1") + + // 3. Verify subproject children under p1 + let subCount = controller.outlineView(controller.outlineView, numberOfChildrenOfItem: p1) + XCTAssertEqual(subCount, 1) + + let subChild = controller.outlineView(controller.outlineView, child: 0, ofItem: p1) as? iTermWindowProject + XCTAssertNotNil(subChild) + XCTAssertEqual(subChild?.name, "TestUI-Sub1") + + // 4. Verify archived window box under sub1 + let windowCount = controller.outlineView(controller.outlineView, numberOfChildrenOfItem: sub1) + XCTAssertEqual(windowCount, 1) + + let windowChild = controller.outlineView(controller.outlineView, child: 0, ofItem: sub1) as? iTermArchivedWindowBox + XCTAssertNotNil(windowChild) + XCTAssertEqual(windowChild?.window.name, "TestUI-ArchivedWindow") + XCTAssertEqual(windowChild?.project.id, sub1.id) + + // Cleanup + model.deleteProject(p1) + } + + func testProjectsOutlineControllerButtonStates() { + let model = iTermWindowProjectsModel.shared + + let p1 = model.createProject(named: "TestUI-P2") + let arch1 = iTermArchivedWindow(name: "TestUI-ArchivedWindow-2", arrangement: ["Columns": 80]) + p1.windows.append(arch1) + + let controller = iTermProjectsOutlineController() + controller.loadView() + controller.viewDidLoad() + + // Let's reload outlineView and force its selectedRow or selection. + controller.outlineView.reloadData() + + // Select row 0 (which is p1) + controller.outlineView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) + + // Verify selection computes correctly + XCTAssertEqual(controller.selectedProject?.id, p1.id) + XCTAssertNil(controller.selectedArchivedBox) + + // Trigger outlineViewSelectionDidChange to update buttons + controller.outlineViewSelectionDidChange(Notification(name: NSOutlineView.selectionDidChangeNotification)) + + // Assert project buttons are active. The single Restore button acts on the + // selection, so selecting a project that has a saved window enables it and its + // label reflects the count (1 saved window → “Restore”). + XCTAssertTrue(controller.addSubprojectButton.isEnabled) + XCTAssertTrue(controller.deleteButton.isEnabled) // Can delete project + XCTAssertTrue(controller.restoreButton.isEnabled) // Project has 1 saved window + XCTAssertEqual(controller.restoreButton.title, "Restore") + + // Cleanup + model.deleteProject(p1) + } + + func testLiveReattachmentClearsArchiveState() { + print("--- STARTING SWIZZLE TEST ---") + // 1. Swizzle tryToAttachToServerWithProcessId:tty: on PTYSession + let originalSelector = Selector("tryToAttachToServerWithProcessId:tty:") + let swizzledSelector = Selector("mock_tryToAttachToServerWithProcessId:tty:") + + guard let originalMethod = class_getInstanceMethod(PTYSession.self, originalSelector), + let swizzledMethod = class_getInstanceMethod(PTYSession.self, swizzledSelector) else { + XCTFail("Failed to find selectors for PTYSession swizzling") + return + } + + // 2. Swizzle runJobsInServers on iTermAdvancedSettingsModel class + let settingsSelector = Selector("runJobsInServers") + let swizzledSettingsSelector = Selector("mock_runJobsInServers") + + guard let originalSettingsMethod = class_getClassMethod(iTermAdvancedSettingsModel.self, settingsSelector), + let swizzledSettingsMethod = class_getClassMethod(iTermAdvancedSettingsModel.self, swizzledSettingsSelector) else { + XCTFail("Failed to find selectors for iTermAdvancedSettingsModel swizzling") + return + } + + print("Swizzling PTYSession & iTermAdvancedSettingsModel...") + method_exchangeImplementations(originalMethod, swizzledMethod) + method_exchangeImplementations(originalSettingsMethod, swizzledSettingsMethod) + + // Swap back at the end of the test to keep state clean! + defer { + print("Unswizzling...") + method_exchangeImplementations(originalMethod, swizzledMethod) + method_exchangeImplementations(originalSettingsMethod, swizzledSettingsMethod) + } + + // Setup mock arrangement with a server PID and TTY + let arrangement: [AnyHashable: Any] = [ + "Columns": 80, + "Rows": 24, + "Bookmark": [ + "GUID": "default-guid" + ], + "Server PID": NSNumber(value: 12345), + "TTY": "/dev/ttyp0" as NSString + ] + + // Setup options with the Archive flag + let view = SessionView(frame: .zero) + let options: [AnyHashable: Any] = [ + PTYSessionArrangementOptionsArchive: true + ] + + print("Restoring session...") + // Restore session from arrangement + let session = PTYSession( + fromArrangement: arrangement, + named: "TestSession", + in: view, + with: nil, + for: .paneObject, + partialAttachments: nil, + options: options) + + XCTAssertNotNil(session) + print("Session isArchive after restore: \(session?.isArchive ?? false)") + // This assertion will FAIL under the reverted bug state (isArchive will be true) + // but will PASS once we re-apply our fix (isArchive will be false)! + XCTAssertFalse(session?.isArchive ?? true, "Restored session with live attached process must NOT be marked as an archive (which freezes output/input)") + XCTAssertTrue(session?.screen.terminalEnabled ?? false, "Restored session screen must be explicitly enabled to receive keyboard inputs and process output characters") + } + + // MARK: - Multiserver arrangement parsing (headless data layer) + + /// Builds a session arrangement node containing a multiserver "Server Dict" + /// with the given socket and child PID, mirroring what iTerm2 captures. + private func arrangement(socket: Int, childPID: Int) -> [AnyHashable: Any] { + return [ + "Tabs": [ + [ + "Root": [ + "Subviews": [ + [ + "Session": [ + "Server Dict": [ + "Socket": socket, + "Child PID": childPID + ] + ] + ] + ] + ] + ] + ] + ] + } + + func testServerDictExtraction() { + let arr = arrangement(socket: 7, childPID: 4242) + let result = iTermWindowProjectsModel.serverDict(in: arr) + XCTAssertNotNil(result) + XCTAssertEqual(result?.socket, 7) + XCTAssertEqual(result?.childPid, 4242) + } + + func testServerDictExtractionWithNSNumberValues() { + // iTerm2's decoded plists frequently surface integers as NSNumber. + let arr: [AnyHashable: Any] = [ + "Session": [ + "Server Dict": [ + "Socket": NSNumber(value: 3), + "Child PID": NSNumber(value: 9001) + ] + ] + ] + let result = iTermWindowProjectsModel.serverDict(in: arr) + XCTAssertEqual(result?.socket, 3) + XCTAssertEqual(result?.childPid, 9001) + } + + func testServerDictExtractionReturnsNilWhenAbsent() { + let arr: [AnyHashable: Any] = ["Columns": 80, "Rows": 24] + XCTAssertNil(iTermWindowProjectsModel.serverDict(in: arr)) + } + + func testAllServerChildPIDsCollectsEverySession() { + // A multi-pane window: two sessions, each with its own Server Dict. + let arr: [AnyHashable: Any] = [ + "Tabs": [ + ["Session": ["Server Dict": ["Socket": 2, "Child PID": 100]]], + ["Session": ["Server Dict": ["Socket": 2, "Child PID": 200]]] + ] + ] + let pids = iTermWindowProjectsModel.allServerChildPIDs(in: arr).sorted() + XCTAssertEqual(pids, [100, 200]) + } + + func testAllServerChildPIDsEmptyWhenNoServerDict() { + let arr: [AnyHashable: Any] = ["Columns": 80, "Rows": 24] + XCTAssertTrue(iTermWindowProjectsModel.allServerChildPIDs(in: arr).isEmpty) + } + + func testClaimedMultiserverChildPIDsAcrossProjectTree() { + let model = iTermWindowProjectsModel.shared + + // Two projects, one nested, each with an archived window holding a live + // (parked) child PID. claimedMultiserverChildPIDs walks the whole tree. + let root = model.createProject(named: "Claim-Root") + let child = model.createProject(named: "Claim-Child", parent: root) + + root.windows.append(iTermArchivedWindow(name: "W1", arrangement: arrangement(socket: 2, childPID: 111))) + child.windows.append(iTermArchivedWindow(name: "W2", arrangement: arrangement(socket: 2, childPID: 222))) + + let claimed = model.claimedMultiserverChildPIDs() + XCTAssertTrue(claimed.contains(111)) + XCTAssertTrue(claimed.contains(222)) + + model.deleteProject(root) + } + + func testClaimedMultiserverChildPIDsEmptyWithNoArchives() { + let model = iTermWindowProjectsModel.shared + model.createProject(named: "Empty-Project") + XCTAssertTrue(model.claimedMultiserverChildPIDs().isEmpty) + } + + func testTotalWindowCountIsRecursive() { + let model = iTermWindowProjectsModel.shared + let root = model.createProject(named: "Count-Root") + let sub = model.createProject(named: "Count-Sub", parent: root) + + root.windows.append(iTermArchivedWindow(name: "A", arrangement: ["Columns": 80])) + sub.windows.append(iTermArchivedWindow(name: "B", arrangement: ["Columns": 80])) + sub.windows.append(iTermArchivedWindow(name: "C", arrangement: ["Columns": 80])) + + XCTAssertEqual(sub.totalWindowCount, 2) + XCTAssertEqual(root.totalWindowCount, 3) + + model.deleteProject(root) + } + + // MARK: - Empty-arrangement guard (DesignNotes #10) + + func testIsArchivableRejectsEmptyAndNilArrangements() { + XCTAssertFalse(iTermWindowProjectsModel.isArchivable(nil)) + XCTAssertFalse(iTermWindowProjectsModel.isArchivable([:])) + // A capture with no Tabs (the ~42-byte empty plist) must be rejected. + XCTAssertFalse(iTermWindowProjectsModel.isArchivable(["Columns": 80, "Rows": 24])) + // A present-but-empty Tabs array is still not restorable. + XCTAssertFalse(iTermWindowProjectsModel.isArchivable(["Tabs": [Any]()])) + } + + func testIsArchivableAcceptsArrangementWithTabs() { + let arr = arrangement(socket: 2, childPID: 555) + XCTAssertTrue(iTermWindowProjectsModel.isArchivable(arr)) + XCTAssertTrue(iTermWindowProjectsModel.isArchivable(["Tabs": [["Root": [:]]]])) + } + + // MARK: - Association persistence (Option A: guid → project, round-trip) + + func testAssociationPersistenceRoundTrip() { + let model = iTermWindowProjectsModel.shared + let project = model.createProject(named: "Assoc-Project") + let guid = "TERMINAL-GUID-ABC123" + + // Associate by guid and persist to the (isolated test) associations file. + model.testOnlyAssociations = [guid: project.id] + + // Drop the in-memory map and reload from disk, exercising the real + // save/load serialization (guid → UUID-string and back). + model.testOnlyReloadAssociationsFromDisk() + + let reloaded = model.testOnlyAssociations + XCTAssertEqual(reloaded[guid], project.id) + + model.deleteProject(project) + } + + func testAssociationPersistenceSurvivesMultipleEntries() { + let model = iTermWindowProjectsModel.shared + let p1 = model.createProject(named: "Assoc-P1") + let p2 = model.createProject(named: "Assoc-P2") + + model.testOnlyAssociations = [ + "guid-1": p1.id, + "guid-2": p2.id, + "guid-3": p1.id + ] + model.testOnlyReloadAssociationsFromDisk() + + let reloaded = model.testOnlyAssociations + XCTAssertEqual(reloaded.count, 3) + XCTAssertEqual(reloaded["guid-1"], p1.id) + XCTAssertEqual(reloaded["guid-2"], p2.id) + XCTAssertEqual(reloaded["guid-3"], p1.id) + + model.deleteProject(p1) + model.deleteProject(p2) + } + + /// A dangling association (project deleted, guid entry not pruned) must not + /// resolve to a project — lookup tolerates it. See DesignNotes §9 (low-priority + /// pruning) and the project(id:) guard. + func testDanglingAssociationResolvesToNil() { + let model = iTermWindowProjectsModel.shared + let project = model.createProject(named: "Dangling-Project") + let danglingID = project.id + + model.testOnlyAssociations = ["ghost-guid": danglingID] + model.deleteProject(project) + + // The association entry still exists, but the project is gone. + XCTAssertEqual(model.testOnlyAssociations["ghost-guid"], danglingID) + XCTAssertNil(model.project(id: danglingID)) + } +} + +extension PTYSession { + @objc(mock_tryToAttachToServerWithProcessId:tty:) + func mock_tryToAttachToServerWithProcessId(_ serverPid: Int32, tty: NSString?) -> Bool { + print("MOCK ATTACH TO SERVER CALLED successfully for pid \(serverPid)!") + return true + } +} + +extension iTermAdvancedSettingsModel { + @objc(mock_runJobsInServers) + class func mock_runJobsInServers() -> Bool { + print("MOCK RUN JOBS IN SERVERS CALLED successfully!") + return true + } +} diff --git a/iTerm2.xcodeproj/project.pbxproj b/iTerm2.xcodeproj/project.pbxproj index 4022d379c9..ba4a7f06f4 100644 --- a/iTerm2.xcodeproj/project.pbxproj +++ b/iTerm2.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 00BB549157025E2AFE86B3D6 /* iTermProjectsDropOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13ACC6F2DFDB64638DCDB228 /* iTermProjectsDropOverlay.swift */; }; 01A01787661B9E1F5177DAB7 /* iTermJobTerminationMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 677BAE3BF6167A6719904C54 /* iTermJobTerminationMonitor.swift */; }; 01D8567525D898E2B0DB6FF1 /* iTermWindowBorderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77FCEB05FACE33F5F7947261 /* iTermWindowBorderView.swift */; }; 0277DCA424D4359E736F9E65 /* CompanionAlertBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D8B8B423791410F363E9D3A /* CompanionAlertBridge.swift */; }; @@ -1025,6 +1026,8 @@ 759637C82593AFB500E278CC /* iTerm2SandboxedWorkerProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 7596379C2593AE0700E278CC /* iTerm2SandboxedWorkerProtocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; 775288AB392F2F6937D0B97E /* OrchestratorError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE33CBB56EA96FD0239D8CED /* OrchestratorError.swift */; }; 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 */; }; 7822303C5D0B67588F07F281 /* CompanionStreamPacer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B577B7726D9E6991593F0069 /* CompanionStreamPacer.swift */; }; 7889196D8646277EB77FCAE7 /* iTermSessionPreviewPanel.h in Sources */ = {isa = PBXBuildFile; fileRef = 330E8619FD42B5DC2EDA60D0 /* iTermSessionPreviewPanel.h */; }; 79F784C0D62EE69089FB4CD2 /* WorkgroupToolbarShortcut.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2C2044C323A7D2F314F6C1 /* WorkgroupToolbarShortcut.swift */; }; @@ -1054,6 +1057,7 @@ 8E6EF413219EDC420B51CD17 /* MentionParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD02F2955BAF479EBAC063FE /* MentionParser.swift */; }; 8E9796208C12DD69744D3606 /* iTermLocatedString+ScreenCharArray.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81E145AE29B68F8F2A69891A /* iTermLocatedString+ScreenCharArray.swift */; }; 8EA9D682DE4199E114D920A5 /* iTermStreamingScreenshotEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B9A9D7BB06953CE9297F1FC /* iTermStreamingScreenshotEncoder.swift */; }; + 90A1E13B186F9EA4003EC3E8 /* AppleScriptTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 90A1E13A186F9EA4003EC3E8 /* AppleScriptTest.m */; }; 8FA8DE5495DE937551F3B51F /* iTermWorkgroupToolbarItemRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26D937108B4A3A7BC121FF1D /* iTermWorkgroupToolbarItemRegistry.swift */; }; 8FE3D19DEFFBE43EF22CE2AA /* WorkgroupAutoRequestReviewToolbarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84897A6E8B159D2C3608965D /* WorkgroupAutoRequestReviewToolbarItem.swift */; }; 90C2E83D1854DB67AD5F08E1 /* AIExplanationRequest+Mac.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C9EF4B4D9DF2F4E3CA2A3F5 /* AIExplanationRequest+Mac.swift */; }; @@ -4107,6 +4111,8 @@ A6D1784421BC5A1500FE499C /* iTermSavePanelFileFormatAccessory.h in Headers */ = {isa = PBXBuildFile; fileRef = A6D1784121BC5A1500FE499C /* iTermSavePanelFileFormatAccessory.h */; }; 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 */; }; @@ -5806,6 +5812,7 @@ 10C7A9E53E5460D18210F62D /* AILiveAppleIntelligenceClassifier.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AILiveAppleIntelligenceClassifier.swift; sourceTree = ""; }; 1179CD897A4780286BB29AAC /* iTermDockBadgeController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = iTermDockBadgeController.swift; sourceTree = ""; }; 11C735CA9A1AF5E864EC718E /* iTermStreamingPNGWriter.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = iTermStreamingPNGWriter.m; sourceTree = ""; }; + 13ACC6F2DFDB64638DCDB228 /* iTermProjectsDropOverlay.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = iTermProjectsDropOverlay.swift; sourceTree = ""; }; 11FDA9247035DE0FCCE7D23D /* iTermCursorBlinkFadeCurveIcon.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = iTermCursorBlinkFadeCurveIcon.h; sourceTree = ""; }; 13A6B1D037AF894E662A0EFC /* iTermWorkgroup.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = iTermWorkgroup.swift; sourceTree = ""; }; 13C5AE44EEA24B945DE0A064 /* iTermLargeContentObject.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = iTermLargeContentObject.h; sourceTree = ""; }; @@ -6304,6 +6311,8 @@ 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 = ""; }; 232A06BD577404F71FFBC0AF /* SafetyTranscriptProjection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SafetyTranscriptProjection.swift; sourceTree = ""; }; 234916CDB04523CFD1E0390B /* ClippingsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ClippingsView.swift; sourceTree = ""; }; 23DA26C236E4BC3ECF3CF916 /* WorkgroupModeSwitcherItem.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorkgroupModeSwitcherItem.swift; sourceTree = ""; }; @@ -6660,6 +6669,8 @@ 86FC661122588ABDA0EE3245 /* PTYTextViewAccessibilityTest.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = PTYTextViewAccessibilityTest.m; sourceTree = ""; }; 8721B9515977EC8CB513CDAA /* PTYSessionPeerPort.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PTYSessionPeerPort.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 = ""; }; 877B9444B69DE32D9CF87354 /* NotifyingDictionary.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotifyingDictionary.swift; sourceTree = ""; }; 88578347A529CFE749B6F433 /* AIChatWireLogger.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIChatWireLogger.swift; sourceTree = ""; }; 889A9128E944B618CDE75E18 /* iTermCursorSlideAnimator.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = iTermCursorSlideAnimator.h; path = sources/Drawing/iTermCursorSlideAnimator.h; sourceTree = SOURCE_ROOT; }; @@ -6676,6 +6687,7 @@ 941E5905370297E8D4BD0408 /* TUISafetyPrompt.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TUISafetyPrompt.swift; sourceTree = ""; }; 95E27DEB0B9DD8AE0960FAE5 /* ChatIconGenerator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ChatIconGenerator.swift; sourceTree = ""; }; 97539C440B63BAC6011E9208 /* iTermScreenshotRedaction.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = iTermScreenshotRedaction.swift; sourceTree = ""; }; + 98E9463133DCF8311E0E5B6E /* iTermFindOnPageHelperTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = iTermFindOnPageHelperTests.swift; sourceTree = ""; }; 9801DA0646B0541548CD6B0E /* ApplyLayoutBuiltInFunction.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ApplyLayoutBuiltInFunction.swift; sourceTree = ""; }; 98DA05BC2B6C48B5397AF1D8 /* iTermWorkgroupSessionDetailViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = iTermWorkgroupSessionDetailViewController.swift; sourceTree = ""; }; 9A1F7DEDB57FDB370787B646 /* CompanionPushNonceRegistry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CompanionPushNonceRegistry.swift; sourceTree = ""; }; @@ -10109,6 +10121,9 @@ A60D02322683D60F00E19362 /* iTerm2-Bridging-Header.h */, A60D022F2683D1E200E19362 /* iTerm2SharedARC-Bridging-Header.h */, A6E1524D27613EEC00D0F41C /* iTerm2XCTests-Bridging-Header.h */, + 8C3C91DF2BBB018FD38146E7 /* iTermWindowProjectsModel.swift */, + 2343E2DF433BD7B9647283A3 /* iTermProjectsPanelController.swift */, + 13ACC6F2DFDB64638DCDB228 /* iTermProjectsDropOverlay.swift */, A62A779D2F9873C6007CAA0B /* iTermController */, A6AD64442F984F6400A216BA /* Keyboard */, A62A77AB2F987810007CAA0B /* Language */, @@ -21672,6 +21687,11 @@ 29C503BA29752DF6A4DB2A02 /* iTermBlurRowBuffer.swift in Sources */, 8EA9D682DE4199E114D920A5 /* iTermStreamingScreenshotEncoder.swift in Sources */, 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 */, + 00BB549157025E2AFE86B3D6 /* iTermProjectsDropOverlay.swift in Sources */, EDD2335A6BDA84CE2B29C285 /* Box.swift in Sources */, 745A29B54D2537CEDE30520E /* iTermLargeContent.swift in Sources */, 639530D5AD779882EF35DAE4 /* iTermLargeContentObject.h in Sources */, @@ -21934,6 +21954,10 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A6C811F42DCFD5E40088E628 /* SearchEngineTests.swift in Sources */, + EEBDD6CF78595292D0CF8977 /* PrivateIPCheckerTests.swift in Sources */, + 56C476620E56B783B8972219 /* iTermCharacterSourceTests.swift in Sources */, + 82FAE5CD376B4D65172ECCF2 /* NSStringQuotedStringForPasteTests.swift in Sources */, CF138846630690AA6A7BCFD5 /* iTermGitClient.m in Sources */, 0CA1074480F384E60CA30B2F /* AILiveDriver.swift in Sources */, E8482495BB3E33AB7F3292DB /* AILiveHarness.swift in Sources */, diff --git a/sources/AppKit/iTermApplicationDelegate.m b/sources/AppKit/iTermApplicationDelegate.m index 1b95236c37..1e1d99aed5 100644 --- a/sources/AppKit/iTermApplicationDelegate.m +++ b/sources/AppKit/iTermApplicationDelegate.m @@ -345,6 +345,11 @@ - (instancetype)init { forEventClass:kInternetEventClass andEventID:kAEGetURL]; [[iTermOrphanServerAdopter sharedInstance] setDelegate:self]; + // Let Window Projects claim its detached windows' children so they aren't + // adopted into a generic recovered window at startup. + [iTermOrphanServerAdopter sharedInstance].claimedChildPIDsProvider = ^NSSet *{ + return [[iTermWindowProjectsModel shared] claimedMultiserverChildPIDs]; + }; launchTime_ = [[NSDate date] retain]; _workspaceSessionActive = YES; _focusFollowsMouseController = [[iTermFocusFollowsMouseController alloc] init]; @@ -369,6 +374,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]]; @@ -3265,6 +3284,10 @@ - (IBAction)importWindowArrangement:(id)sender { }]; } +- (IBAction)showWindowProjectsPanel:(id)sender { + [[iTermProjectsPanelController shared] showPanel]; +} + - (IBAction)saveWindowArrangement:(id)sender { [[iTermController sharedInstance] saveWindowArrangement:YES]; } diff --git a/sources/PTYSession/PTYSession.h b/sources/PTYSession/PTYSession.h index a7e337d28e..1e45c0e2ea 100644 --- a/sources/PTYSession/PTYSession.h +++ b/sources/PTYSession/PTYSession.h @@ -386,6 +386,7 @@ backgroundColor:(nullable NSColor *)backgroundColor; // update the tab and window titles, stop "tail find" (searching the live session repeatedly // because the find window is open), and evaluating partial-line triggers. @property(nonatomic, assign) BOOL active; +@property(nonatomic, assign) BOOL orphanOnDealloc; @property(nonatomic, assign) BOOL alertOnNextMark; // Prevents the pane from being dragged or detached. diff --git a/sources/PTYSession/PTYSession.m b/sources/PTYSession/PTYSession.m index 1431329b4a..8a5a03e630 100644 --- a/sources/PTYSession/PTYSession.m +++ b/sources/PTYSession/PTYSession.m @@ -78,6 +78,7 @@ #import "VT100Terminal.h" #import "VT100Token.h" #import "WindowArrangements.h" +#import "PseudoTerminal.h" #import "WindowControllerInterface.h" #import "iTerm.h" #import "iTerm2SharedARC-Swift.h" @@ -2155,6 +2156,8 @@ + (PTYSession *)sessionFromArrangement:(NSDictionary *)arrangement arrangement[SESSION_ARRANGEMENT_TMUX_GATEWAY_SESSION_NAME] != nil && arrangement[SESSION_ARRANGEMENT_TMUX_GATEWAY_SESSION_ID] != nil); tmuxDCSIdentifier = arrangement[SESSION_ARRANGEMENT_TMUX_DCS_ID]; + aSession->_isArchive = NO; + [aSession->_screen setTerminalEnabled:YES]; } else { if (isTmuxGateway) { [aSession.screen performBlockWithJoinedThreads:^(VT100Terminal *terminal, VT100ScreenMutableState *mutableState, id delegate) { @@ -3800,6 +3803,7 @@ - (void)hardStop { }]; [_view release]; // This balances a retain in -terminate. [[self retain] autorelease]; + _shell.orphanOnDealloc = self.orphanOnDealloc; [_shell stop]; _shell.delegate = nil; [_textview setDataSource:nil]; @@ -6758,7 +6762,7 @@ - (BOOL)encodeArrangementWithContents:(BOOL)includeContents if (includeContents) { __block int numberOfLinesDropped = 0; if (!self.isBrowserSession) { - const BOOL unlimited = [options[PTYSessionArrangementOptionsUnlimitedHistory] boolValue]; + const BOOL unlimited = [options[PTYSessionArrangementOptionsUnlimitedHistory] boolValue] || [PseudoTerminal useUnlimitedHistoryForArrangement]; [result encodeDictionaryWithKey:SESSION_ARRANGEMENT_CONTENTS generation:iTermGenerationAlwaysEncode block:^BOOL(id _Nonnull encoder) { diff --git a/sources/Tasks/PTYTask.h b/sources/Tasks/PTYTask.h index 1137f075a2..59c28b8545 100644 --- a/sources/Tasks/PTYTask.h +++ b/sources/Tasks/PTYTask.h @@ -153,6 +153,7 @@ typedef NS_OPTIONS(NSUInteger, iTermJobManagerAttachResults) { // No reading or writing allowed for now. @property(atomic, assign) BOOL paused; +@property(atomic, assign) BOOL orphanOnDealloc; @property(nonatomic, readonly) BOOL isSessionRestorationPossible; @property(nonatomic, readonly) id sessionRestorationIdentifier; @@ -189,6 +190,15 @@ typedef NS_OPTIONS(NSUInteger, iTermJobManagerAttachResults) { + (NSMutableDictionary *)mutableEnvironmentDictionary; +- (void)setJobManagerType:(iTermGeneralServerConnectionType)type; +- (void)closeFileDescriptorAndDeregisterIfPossible; + +// In-process freeze/thaw support. Stops reading this task's file descriptor and +// parks its running child on its multiserver connection (keeping the fd open and +// the process alive) so a thawed window can re-adopt it. Returns the parked +// child's pid, or -1 if the task isn't a re-adoptable multiserver child. +- (pid_t)parkChildForReattachment; + - (instancetype)init; - (BOOL)hasBrokenPipe; diff --git a/sources/Tasks/PTYTask.m b/sources/Tasks/PTYTask.m index a32869c2eb..5154e1ed85 100644 --- a/sources/Tasks/PTYTask.m +++ b/sources/Tasks/PTYTask.m @@ -103,7 +103,9 @@ - (void)dealloc { // pid_t. Are they guaranteed to always be the same for process group // leaders? It is not clear from git history why killpg is used here and // not in other places. I suspect it's what we ought to use everywhere. - [self.jobManager killWithMode:iTermJobManagerKillingModeProcessGroup]; + if (!self.orphanOnDealloc) { + [self.jobManager killWithMode:iTermJobManagerKillingModeProcessGroup]; + } if (_tmuxClientProcessID) { [[iTermProcessCache sharedInstance] unregisterTrackedPID:_tmuxClientProcessID.intValue]; } @@ -383,11 +385,13 @@ - (void)stop { RLog(@"stop %@", self); self.paused = NO; [self.loggingHelper stop]; - [self killWithMode:iTermJobManagerKillingModeRegular]; + if (!self.orphanOnDealloc) { + [self killWithMode:iTermJobManagerKillingModeRegular]; - // Ensure the server is broken out of accept()ing for future connections - // in case the child doesn't die right away. - [self killWithMode:iTermJobManagerKillingModeBrokenPipe]; + // Ensure the server is broken out of accept()ing for future connections + // in case the child doesn't die right away. + [self killWithMode:iTermJobManagerKillingModeBrokenPipe]; + } [self closeFileDescriptorAndDeregisterIfPossible]; } @@ -909,6 +913,20 @@ - (void)closeFileDescriptorAndDeregisterIfPossible { } } +- (pid_t)parkChildForReattachment { + iTermMultiServerJobManager *multi = [iTermMultiServerJobManager castFrom:self.jobManager]; + if (!multi) { + DLog(@"parkChildForReattachment: job manager %@ is not multiserver; cannot park", self.jobManager); + return -1; + } + // Stop the task notifier from reading this fd, but DO NOT close it — the + // child keeps it open so the process can be re-adopted on thaw. + [[TaskNotifier sharedInstance] deregisterTask:self]; + const pid_t pid = [multi parkChildForReattachment]; + DLog(@"parkChildForReattachment: parked pid %@ for task %@", @(pid), self); + return pid; +} + #pragma mark - iTermLoggingHelper // NOTE: This can be called before the task is launched. It is not used when logging plain text. diff --git a/sources/Tasks/iTermMultiServerConnection.h b/sources/Tasks/iTermMultiServerConnection.h index 26dad985f9..364fc74d54 100644 --- a/sources/Tasks/iTermMultiServerConnection.h +++ b/sources/Tasks/iTermMultiServerConnection.h @@ -35,11 +35,22 @@ NS_ASSUME_NONNULL_BEGIN - (void)attachToProcessID:(pid_t)pid callback:(iTermCallback *)callback; +// In-process freeze/thaw support. Re-adds a child that was previously attached +// (and therefore removed from unattachedChildren) back into the unattached list +// so a later -attachToProcessID: can re-adopt it WITHOUT tearing down and +// re-handshaking this connection (which is shared by every session on the +// daemon). The child's file descriptor is left open by the caller. +- (void)reinsertUnattachedChild:(iTermFileDescriptorMultiClientChild *)child; + +// argv/newEnviron: pre-existing lines, not touched by Window Projects work. +// Explicit inner-pointer annotation needed because NS_ASSUME_NONNULL only +// covers the outer pointer of a double pointer; TODO remove once an upstream +// commit annotates these itself (prefer upstream's version over this). - (void)launchWithTTYState:(iTermTTYState)ttyState argpath:(const char *)argpath - argv:(char **)argv + argv:(char * _Nullable * _Nonnull)argv initialPwd:(const char *)initialPwd - newEnviron:(char **)newEnviron + newEnviron:(char * _Nullable * _Nonnull)newEnviron callback:(iTermCallback *> *)callback; - (void)waitForChild:(iTermFileDescriptorMultiClientChild *)child diff --git a/sources/Tasks/iTermMultiServerConnection.m b/sources/Tasks/iTermMultiServerConnection.m index 5a78fbc3cc..86443797f0 100644 --- a/sources/Tasks/iTermMultiServerConnection.m +++ b/sources/Tasks/iTermMultiServerConnection.m @@ -369,6 +369,22 @@ - (void)attachToProcessID:(pid_t)pid }]; } +- (void)reinsertUnattachedChild:(iTermFileDescriptorMultiClientChild *)child { + if (!child) { + return; + } + [_thread dispatchAsync:^(iTermMultiServerPerConnectionState * _Nullable state) { + if ([state.unattachedChildren containsObject:child]) { + DLog(@"reinsertUnattachedChild: pid %@ already unattached on socket %@", + @(child.pid), @(self.socketNumber)); + return; + } + DLog(@"reinsertUnattachedChild: re-add pid %@ (fd %@) to unattached children on socket %@", + @(child.pid), @(child.fd), @(self.socketNumber)); + [state.unattachedChildren addObject:child]; + }]; +} + // These C pointers live until the callback is run. - (void)launchWithTTYState:(iTermTTYState)ttyState argpath:(const char *)argpath diff --git a/sources/Tasks/iTermMultiServerJobManager.h b/sources/Tasks/iTermMultiServerJobManager.h index 3502dc983e..e66af1d4f1 100644 --- a/sources/Tasks/iTermMultiServerJobManager.h +++ b/sources/Tasks/iTermMultiServerJobManager.h @@ -60,6 +60,13 @@ extern NSString *const iTermMultiServerRestorationKeyChildPID; + (BOOL)getGeneralConnection:(iTermGeneralServerConnection *)generalConnection fromRestorationIdentifier:(NSDictionary *)dict; + +// In-process freeze/thaw support. Detaches the running child from this job +// manager and returns it to its connection's unattached list so it can be +// re-adopted on thaw, WITHOUT closing the child's file descriptor and WITHOUT +// tearing down the shared connection. Returns the parked child's pid, or -1 if +// there was nothing to park (e.g., no live child or non-multiserver child). +- (pid_t)parkChildForReattachment; @end NS_ASSUME_NONNULL_END diff --git a/sources/Tasks/iTermMultiServerJobManager.m b/sources/Tasks/iTermMultiServerJobManager.m index 6e33c27c0c..bbf24990dd 100644 --- a/sources/Tasks/iTermMultiServerJobManager.m +++ b/sources/Tasks/iTermMultiServerJobManager.m @@ -434,6 +434,26 @@ - (void)didAttachToProcess:(pid_t)pid task:(id)task state:(iTermMainT [[iTermProcessCache sharedInstance] setNeedsUpdate:YES]; } +- (pid_t)parkChildForReattachment { + __block pid_t pid = -1; + [self.thread dispatchRecursiveSync:^(iTermMultiServerJobManagerState * _Nullable state) { + if (state.child == nil || state.conn == nil) { + DLog(@"parkChildForReattachment: nothing to park (child=%@ conn=%@)", state.child, state.conn); + return; + } + pid = state.child.pid; + DLog(@"parkChildForReattachment: parking pid %@ (fd %@) on socket %@ for later in-process re-adoption", + @(pid), @(state.child.fd), @(state.conn.socketNumber)); + // Hand the live child (with its still-open fd) back to the connection so + // a thaw-time -attachToProcessID: can find it again. Then forget it here + // WITHOUT closing the fd (unlike -closeFileDescriptor). + [state.conn reinsertUnattachedChild:state.child]; + state.child = nil; + self.cachedChild = nil; + }]; + return pid; +} + - (void)reallyAttachToServer:(iTermGeneralServerConnection)serverConnection withProcessID:(NSNumber *)thePid brokenPipe:(void (^)(void))brokenPipe diff --git a/sources/Tasks/iTermOrphanServerAdopter.h b/sources/Tasks/iTermOrphanServerAdopter.h index 4f60890a12..2fe617a807 100644 --- a/sources/Tasks/iTermOrphanServerAdopter.h +++ b/sources/Tasks/iTermOrphanServerAdopter.h @@ -9,6 +9,11 @@ #import #import "PTYTask.h" +// NS_ASSUME_NONNULL added by Window Projects work so claimedChildPIDsProvider's +// _Nullable below doesn't trigger a partial-nullability-audit error on every +// other pointer in this file. +NS_ASSUME_NONNULL_BEGIN + @class PTYSession; @protocol iTermOrphanServerAdopterDelegate @@ -26,9 +31,20 @@ @property(nonatomic, readonly) BOOL haveOrphanServers; @property(nonatomic, weak) id delegate; +// Multiserver child PIDs that another subsystem (Window Projects cold storage) +// owns and will re-adopt on demand. The adopter must NOT pull these into a +// generic recovered window at startup; it leaves them in unattachedChildren so +// a project restore can re-attach them later. Evaluated lazily during adoption. +@property(nonatomic, copy) NSSet * _Nullable (^claimedChildPIDsProvider)(void); + + (instancetype)sharedInstance; -- (void)openWindowWithOrphansWithCompletion:(void (^)(void))completion; +// Pre-existing declaration (not Window Projects work); marked _Nullable +// because iTermApplicationDelegate.m has pre-existing call sites passing nil. +// TODO revert if upstream annotates this itself. +- (void)openWindowWithOrphansWithCompletion:(void (^ _Nullable)(void))completion; - (void)removePath:(NSString *)path; - (void)adoptPartialAttachments:(NSArray> *)partialAttachments; @end + +NS_ASSUME_NONNULL_END diff --git a/sources/Tasks/iTermOrphanServerAdopter.m b/sources/Tasks/iTermOrphanServerAdopter.m index 5aca830df4..877372cea7 100644 --- a/sources/Tasks/iTermOrphanServerAdopter.m +++ b/sources/Tasks/iTermOrphanServerAdopter.m @@ -220,8 +220,18 @@ - (void)didEstablishMultiserverConnection:(iTermMultiServerConnection *)connecti dispatch_group_t group = dispatch_group_create(); DLog(@"Multiserver adoption beginning."); + NSSet *claimed = self.claimedChildPIDsProvider ? self.claimedChildPIDsProvider() : nil; NSArray *children = [connection.unattachedChildren copy]; for (iTermFileDescriptorMultiClientChild *child in children) { + if ([claimed containsObject:@(child.pid)]) { + // Owned by Window Projects cold storage — leave it parked so a + // project restore can re-attach it instead of creating a stray tab. + DLog(@"Orphan adopter: skipping child pid %@ on socket %@ — claimed by Window Projects", + @(child.pid), @(number)); + NSLog(@"[WindowProjects] Orphan adopter skipping claimed child pid %d on socket %ld", + child.pid, (long)number); + continue; + } iTermGeneralServerConnection generalConnection = { .type = iTermGeneralServerConnectionTypeMulti, .multi = { diff --git a/sources/TerminalView/PseudoTerminal.h b/sources/TerminalView/PseudoTerminal.h index 0ed4ad2aac..e3077c6aec 100644 --- a/sources/TerminalView/PseudoTerminal.h +++ b/sources/TerminalView/PseudoTerminal.h @@ -94,6 +94,7 @@ extern NSString *const iTermDidCreateTerminalWindowNotification; // Are we in the process of restoring a window with NSWindowRestoration? If so, do not order // the window as it may be minimized (issue 5258) @property(nonatomic) BOOL restoringWindow; +@property(nonatomic) BOOL orphanJobsOnClose; // Set to YES when the window has been created but window:didDecodeRestorableState: hasn't been // called yet. @@ -174,6 +175,9 @@ extern NSString *const iTermDidCreateTerminalWindowNotification; + (void)performWhenWindowCreationIsSafeForLionFullScreen:(BOOL)lionFullScreen block:(void (^)(void))block; ++ (BOOL)useUnlimitedHistoryForArrangement; ++ (void)setUseUnlimitedHistoryForArrangement:(BOOL)use; + // Initialize a new PseudoTerminal. // smartLayout: If true then position windows using the "smart layout" // algorithm. diff --git a/sources/TerminalView/PseudoTerminal.m b/sources/TerminalView/PseudoTerminal.m index df4c59694d..5bbd11986c 100644 --- a/sources/TerminalView/PseudoTerminal.m +++ b/sources/TerminalView/PseudoTerminal.m @@ -2131,6 +2131,11 @@ - (IBAction)closeTerminalWindow:(id)sender { } - (void)close { + if (self.orphanJobsOnClose) { + for (PTYSession *session in [self allSessions]) { + session.orphanOnDealloc = YES; + } + } if (self.swipeIdentifier) { [[NSNotificationCenter defaultCenter] postNotificationName:iTermSwipeHandlerCancelSwipe object:self.swipeIdentifier]; @@ -3243,6 +3248,16 @@ + (BOOL)arrangementIsLionFullScreen:(NSDictionary *)arrangement { return [PseudoTerminal _windowTypeForArrangement:arrangement percentage:&dummy] == WINDOW_TYPE_LION_FULL_SCREEN; } +static BOOL gUseUnlimitedHistoryForArrangement = NO; + ++ (BOOL)useUnlimitedHistoryForArrangement { + return gUseUnlimitedHistoryForArrangement; +} + ++ (void)setUseUnlimitedHistoryForArrangement:(BOOL)use { + gUseUnlimitedHistoryForArrangement = use; +} + + (BOOL)shouldRestoreHotKeyWindowWithGUID:(NSString *)guid { if (!guid) { // Something went wrong and we don't know the GUID. Or you just upgraded and a GUID wasn't @@ -4400,6 +4415,19 @@ - (BOOL)windowShouldClose:(NSNotification *)aNotification { iTermApplicationDelegate *appDelegate = [iTermApplication.sharedApplication delegate]; [appDelegate userDidInteractWithASession]; + // Window Projects: an associated window uses its own Save & Detach / Save & + // Close / Remove confirmation in place of the generic running-jobs prompt. + switch ([[iTermWindowProjectsModel shared] handleUserInitiatedCloseOf:self]) { + case iTermWindowProjectCloseHandlingHandled: + case iTermWindowProjectCloseHandlingCancelled: + // Either Window Projects will close the window itself (after + // archiving/detaching/removing) or the user cancelled; AppKit must + // not continue the close in either case. + return NO; + case iTermWindowProjectCloseHandlingNotAssociated: + break; + } + BOOL needPrompt = NO; if ([self promptOnCloseReason].hasReason) { needPrompt = YES; diff --git a/sources/TerminalView/WindowInitialPositioner.swift b/sources/TerminalView/WindowInitialPositioner.swift index 4beb0106ec..be0f386531 100644 --- a/sources/TerminalView/WindowInitialPositioner.swift +++ b/sources/TerminalView/WindowInitialPositioner.swift @@ -156,7 +156,7 @@ class WindowInitialPositioner: NSObject { window.setFrame(frame, display: false) } - let numberOfTerminalWindows = iTermController.sharedInstance()?.terminals().count ?? 0 + let numberOfTerminalWindows = iTermController.sharedInstance()?.terminals()?.count ?? 0 let placement = iTermPreferences.unsignedInteger(forKey: kPreferenceKeyWindowPlacement) DLog("windowWillShowInitial: numberOfTerminalWindows=\(numberOfTerminalWindows) placement=\(placement)") diff --git a/sources/iTerm2SharedARC-Bridging-Header.h b/sources/iTerm2SharedARC-Bridging-Header.h index ee1a49b4b5..f8af4bd440 100644 --- a/sources/iTerm2SharedARC-Bridging-Header.h +++ b/sources/iTerm2SharedARC-Bridging-Header.h @@ -261,6 +261,13 @@ #import "PTYFontInfo.h" #import "iTermCharacterParts.h" #import "iTermCharacterSourceTestHelper.h" +#import "iTermMultiServerConnection.h" +#import "iTermMultiServerJobManager.h" +#import "iTermOrphanServerAdopter.h" +#import "iTermFileDescriptorMultiClient.h" +#import "iTermFileDescriptorMultiClientChild.h" +#import "PTYTask.h" +#import "iTermThreadSafety.h" #import "iTermImageComparison.h" #import "iTermBackgroundColorRLETestHelper.h" #import "iTermUnderlineSpanTestHelper.h" diff --git a/sources/iTermProjectsDropOverlay.swift b/sources/iTermProjectsDropOverlay.swift new file mode 100644 index 0000000000..38c380c7b8 --- /dev/null +++ b/sources/iTermProjectsDropOverlay.swift @@ -0,0 +1,172 @@ +// iTermProjectsDropOverlay.swift +// iTerm2 +// +// A translucent drop-zone overlay shown over a Window Projects pane *during a drag*. +// The overlay is installed on the destination pane when a drag of a matching payload +// begins (see iTermProjectsSplitViewController.dragDidBegin) and removed when the drag +// ends. It divides the pane into one or two labeled zones (e.g. Archive / Detach, or a +// single Restore); the zone under the cursor highlights, and dropping on it invokes the +// delegate with the chosen zone. +// +// Driving the zones off the live NSDraggingInfo (rather than click/mouse tracking) keeps +// this much simpler than SplitSelectionView, which solves a similar visual problem for +// session splitting. + +import AppKit + +/// One labeled drop zone. Zones stack top→bottom in declaration order, each taking +/// `fraction` of the overlay's height (fractions should sum to 1). +struct iTermProjectsDropZone { + let id: String // action identifier, e.g. "archive", "detach", "restore" + let title: String + let symbol: String // SF Symbol name + let fraction: CGFloat // share of the overlay height + let operation: NSDragOperation +} + +protocol iTermProjectsDropOverlayDelegate: AnyObject { + /// Execute the drop for `zone`. Return true if the drop was accepted. + func dropOverlay(_ overlay: iTermProjectsDropOverlay, + didDropZone zone: iTermProjectsDropZone, + info: NSDraggingInfo) -> Bool +} + +final class iTermProjectsDropOverlay: NSView { + weak var delegate: iTermProjectsDropOverlayDelegate? + + private let zones: [iTermProjectsDropZone] + private var hoveredZoneIndex = -1 + + init(zones: [iTermProjectsDropZone], + dragTypes: [NSPasteboard.PasteboardType]) { + self.zones = zones + super.init(frame: .zero) + wantsLayer = true + autoresizingMask = [.width, .height] + registerForDraggedTypes(dragTypes) + } + + required init?(coder: NSCoder) { it_fatalError("not implemented") } + + // MARK: Geometry + + /// Rects for each zone, stacked top→bottom (zones[0] is at the top). + private func zoneRects() -> [NSRect] { + let h = bounds.height + var rects: [NSRect] = [] + var y = bounds.maxY + for z in zones { + let zh = h * z.fraction + y -= zh + rects.append(NSRect(x: bounds.minX, y: y, width: bounds.width, height: zh)) + } + return rects + } + + private func zoneIndex(at point: NSPoint) -> Int { + let rects = zoneRects() + for (i, r) in rects.enumerated() where r.contains(point) { return i } + // A single-zone overlay swallows the whole pane. + return zones.count == 1 ? 0 : -1 + } + + private func location(_ sender: NSDraggingInfo) -> NSPoint { + convert(sender.draggingLocation, from: nil) + } + + // MARK: Drawing + + override func draw(_ dirtyRect: NSRect) { + NSColor.black.withAlphaComponent(0.55).setFill() + bounds.fill() + + let rects = zoneRects() + for (i, zone) in zones.enumerated() { + let hovered = (i == hoveredZoneIndex) + let card = rects[i].insetBy(dx: 12, dy: 8) + let path = NSBezierPath(roundedRect: card, xRadius: 10, yRadius: 10) + let fill = hovered ? NSColor.controlAccentColor : NSColor.white + fill.withAlphaComponent(hovered ? 0.9 : 0.16).setFill() + path.fill() + NSColor.white.withAlphaComponent(hovered ? 1.0 : 0.55).setStroke() + path.lineWidth = hovered ? 3 : 1.5 + path.stroke() + drawContents(of: zone, in: card, hovered: hovered) + } + } + + private func drawContents(of zone: iTermProjectsDropZone, in rect: NSRect, hovered: Bool) { + let tint = NSColor.white + let symbolConfig = NSImage.SymbolConfiguration(pointSize: 26, weight: hovered ? .bold : .regular) + let image = NSImage(systemSymbolName: zone.symbol, accessibilityDescription: nil)? + .withSymbolConfiguration(symbolConfig) + + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 13, weight: hovered ? .semibold : .regular), + .foregroundColor: tint, + ] + let titleSize = (zone.title as NSString).size(withAttributes: attrs) + + let imageHeight: CGFloat = image != nil ? 30 : 0 + let gap: CGFloat = image != nil ? 6 : 0 + let blockHeight = imageHeight + gap + titleSize.height + var y = rect.midY + blockHeight / 2 + + if let image = image { + y -= imageHeight + let imgRect = NSRect(x: rect.midX - 15, y: y, width: 30, height: imageHeight) + // Composite the tint over the (template) symbol so it renders solid `tint` + // on the dark overlay regardless of the symbol's own colors. + let tinted = NSImage(size: imgRect.size, flipped: false) { drawRect in + image.draw(in: drawRect) + tint.set() + drawRect.fill(using: .sourceAtop) + return true + } + tinted.draw(in: imgRect) + y -= gap + } + y -= titleSize.height + (zone.title as NSString).draw( + at: NSPoint(x: rect.midX - titleSize.width / 2, y: y), + withAttributes: attrs) + } + + // MARK: NSDraggingDestination + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + updateHover(sender) + return currentOperation(sender) + } + + override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { + updateHover(sender) + return currentOperation(sender) + } + + override func draggingExited(_ sender: NSDraggingInfo?) { + if hoveredZoneIndex != -1 { + hoveredZoneIndex = -1 + needsDisplay = true + } + } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + let idx = zoneIndex(at: location(sender)) + guard idx >= 0, idx < zones.count else { return false } + return delegate?.dropOverlay(self, didDropZone: zones[idx], info: sender) ?? false + } + + private func updateHover(_ sender: NSDraggingInfo) { + let idx = zoneIndex(at: location(sender)) + if idx != hoveredZoneIndex { + hoveredZoneIndex = idx + needsDisplay = true + } + } + + private func currentOperation(_ sender: NSDraggingInfo) -> NSDragOperation { + let idx = zoneIndex(at: location(sender)) + return (idx >= 0 && idx < zones.count) ? zones[idx].operation : [] + } +} diff --git a/sources/iTermProjectsPanelController.swift b/sources/iTermProjectsPanelController.swift new file mode 100644 index 0000000000..c045fbf2a5 --- /dev/null +++ b/sources/iTermProjectsPanelController.swift @@ -0,0 +1,1904 @@ +// 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: - Drag payload + drop-overlay zones + +/// What a drag is carrying, classified once when the drag begins (from the dragged +/// model items) so the split controller can decide which drop overlay — if any — to +/// show on the destination pane. See iTermProjectsSplitViewController.dragDidBegin. +enum iTermProjectsDragPayload { + case liveWindows([PseudoTerminal]) // open windows (right pane) + case projectGroups([iTermWindowProject]) // right-pane project group(s) + case archivedWindows([iTermArchivedWindowBox]) // saved windows (left pane) + case projects([iTermWindowProject]) // left-pane project(s) + case other +} + +/// LEFT-pane overlay: drag an associated open window (or project group) here to +/// Archive (close + save, process dies) or Detach (close + save, keep process). Archive +/// is the large default target; Detach is the smaller, special band. +private let kArchiveDetachZones: [iTermProjectsDropZone] = [ + iTermProjectsDropZone(id: "archive", title: "Archive (close)", + symbol: "archivebox", fraction: 0.75, operation: .move), + iTermProjectsDropZone(id: "detach", title: "Detach (keep running)", + symbol: "bolt.horizontal.circle", fraction: 0.25, operation: .move), +] +private let kArchiveDetachDragTypes = [kLiveWindowDragType, kProjectGroupDragType] + +/// RIGHT-pane overlay: drag a saved window (or project) here to Restore it. +private let kRestoreZones: [iTermProjectsDropZone] = [ + iTermProjectsDropZone(id: "restore", title: "Restore", + symbol: "arrow.up.left.and.arrow.down.right", + fraction: 1.0, operation: .copy), +] +private let kRestoreDragTypes = [kArchivedWindowDragType, kProjectDragType] + +// 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, iTermProjectsDropOverlayDelegate { + let projectsVC = iTermProjectsOutlineController() + let windowsVC = iTermOpenWindowsController() + + /// The payload of the in-flight drag, captured when it begins so the overlay's + /// drop handler can act on the real model objects (not re-parse the pasteboard). + private var pendingPayload: iTermProjectsDragPayload = .other + + override func viewDidLoad() { + super.viewDidLoad() + splitView.isVertical = true + splitView.dividerStyle = .paneSplitter + + projectsVC.onSelectionChange = { [weak self] in + self?.windowsVC.updateActionButtons() + } + windowsVC.projectsController = projectsVC + projectsVC.splitController = self + windowsVC.splitController = self + + 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: Drop-overlay coordination + + /// Called by either outline controller when a drag begins. Decides which drop + /// overlay (if any) to show on the *other* pane, based on the drag's payload and + /// origin. When no overlay is shown, the destination outline's own row-drop + /// handlers stay active (e.g. drop an unassociated window on a project = associate). + func dragDidBegin(_ payload: iTermProjectsDragPayload, from source: NSViewController) { + pendingPayload = payload + if source === windowsVC { + // Right → left: only associated windows / project groups get the + // Archive|Detach overlay. Unassociated windows fall through to the left + // pane's row-drop (= associate onto a project). + switch payload { + case .liveWindows(let terminals): + let associated = terminals.filter { + iTermWindowProjectsModel.shared.project(for: $0) != nil + } + guard !associated.isEmpty else { return } + pendingPayload = .liveWindows(associated) + projectsVC.showDropOverlay(zones: kArchiveDetachZones, + dragTypes: kArchiveDetachDragTypes, + delegate: self) + case .projectGroups(let projects) where !projects.isEmpty: + projectsVC.showDropOverlay(zones: kArchiveDetachZones, + dragTypes: kArchiveDetachDragTypes, + delegate: self) + default: + break + } + } else if source === projectsVC { + // Left → right: saved windows / projects get the Restore overlay. + switch payload { + case .archivedWindows(let boxes) where !boxes.isEmpty: + windowsVC.showDropOverlay(zones: kRestoreZones, + dragTypes: kRestoreDragTypes, + delegate: self) + case .projects(let projects) where !projects.isEmpty: + windowsVC.showDropOverlay(zones: kRestoreZones, + dragTypes: kRestoreDragTypes, + delegate: self) + default: + break + } + } + } + + func dragDidEnd() { + projectsVC.removeDropOverlay() + windowsVC.removeDropOverlay() + pendingPayload = .other + } + + // MARK: iTermProjectsDropOverlayDelegate + + func dropOverlay(_ overlay: iTermProjectsDropOverlay, + didDropZone zone: iTermProjectsDropZone, + info: NSDraggingInfo) -> Bool { + let model = iTermWindowProjectsModel.shared + switch zone.id { + case "archive", "detach": + let keepJobs = (zone.id == "detach") + switch pendingPayload { + case .liveWindows(let terminals): + for terminal in terminals { + guard let project = model.project(for: terminal) else { continue } + model.archiveWindow(terminal, to: project, andClose: true, keepJobsRunning: keepJobs) + } + case .projectGroups(let projects): + for project in projects { + model.closeProject(project, keepJobsRunning: keepJobs) + } + default: + return false + } + reloadAll() + return true + case "restore": + switch pendingPayload { + case .archivedWindows(let boxes): + for box in boxes { model.restoreWindow(box.window) } + case .projects(let projects): + for project in projects { model.restoreAllWindows(in: project) } + default: + return false + } + reloadAll() + return true + default: + return false + } + } +} + +// MARK: - Left Pane: Project Tree (open + archived windows) + +final class iTermProjectsOutlineController: NSViewController, + NSOutlineViewDataSource, + NSOutlineViewDelegate { + var onSelectionChange: (() -> Void)? + weak var splitController: iTermProjectsSplitViewController? + private var dropOverlay: iTermProjectsDropOverlay? + + private(set) var outlineView = NSOutlineView() + private var scrollView = NSScrollView() + var addProjectButton = NSButton() + var addSubprojectButton = NSButton() + var deleteButton = NSButton() + var restoreButton = NSButton() + var closeProjectButton = NSButton() + var freezeProjectButton = NSButton() + + private var sortOrder = ProjectSortOrder.recent + private var sortSegment = NSSegmentedControl() + + // Hover preview (pinned to the left so it never covers the right pane) + private let preview = iTermProjectsSidePreview(side: .left) + 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 + } + + /// All selected items of a given kind (multi-select). + private func selectedItems(_ type: T.Type) -> [T] { + outlineView.selectedRowIndexes.compactMap { outlineView.item(atRow: $0) as? T } + } + var selectedProjects: [iTermWindowProject] { selectedItems(iTermWindowProject.self) } + var selectedArchivedBoxes: [iTermArchivedWindowBox] { selectedItems(iTermArchivedWindowBox.self) } + var selectedLiveBoxes: [iTermLiveWindowBox] { selectedItems(iTermLiveWindowBox.self) } + + /// Number of saved windows the current selection would restore. A selected project + /// contributes its own saved windows (restoreAllWindows is non-recursive); archived + /// rows count as one each. Drives the Restore button's count label. + private var restorableSelectionCount: Int { + selectedArchivedBoxes.count + selectedProjects.reduce(0) { $0 + $1.windows.count } + } + + /// Projects whose *open* windows the Archive All / Detach All buttons act on: the + /// selected projects plus the projects of any directly-selected open windows; or — + /// only when nothing at all is selected — every project. Returns empty when the + /// selection contains only saved (closed/detached) windows, so the buttons stay off + /// (there's nothing open to archive or detach). + private var archiveDetachTargetProjects: [iTermWindowProject] { + let projects = selectedProjects + let liveProjects = selectedLiveBoxes.map { $0.project } + if !projects.isEmpty || !liveProjects.isEmpty { + var seen = Set() + return (projects + liveProjects).filter { seen.insert($0.id).inserted } + } + return outlineView.selectedRowIndexes.isEmpty + ? iTermWindowProjectsModel.shared.rootProjects + : [] + } + + override func loadView() { view = NSView() } + + override func viewDidLoad() { + super.viewDidLoad() + setupOutlineView() + setupBottomBar() + 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 = true + outlineView.focusRingType = .none + outlineView.doubleAction = #selector(doubleClicked(_:)) + outlineView.target = self + + // Drag source + destination. (Project-group drags from the right pane are + // handled by the Archive|Detach overlay, not a row drop here.) + outlineView.setDraggingSourceOperationMask(.every, forLocal: true) + outlineView.setDraggingSourceOperationMask(.every, forLocal: false) + outlineView.registerForDraggedTypes([kLiveWindowDragType, + kArchivedWindowDragType]) + + 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", target: self, + action: #selector(addProject(_:))) + configure(&addSubprojectButton, label: "+sub", tip: "New sub-project under selection", target: self, + action: #selector(addSubproject(_:))) + configure(&deleteButton, label: "−", tip: "Delete selection", target: self, + action: #selector(deleteSelected(_:))) + configure(&restoreButton, label: "Restore All…", tip: "Restore the selected saved windows (or all of them if nothing is selected)", target: self, + action: #selector(restoreSelected(_:))) + configure(&closeProjectButton, label: "Archive All", tip: "Close and archive all open windows in the selected project(s)", target: self, + action: #selector(closeSelectedProject(_:))) + configure(&freezeProjectButton, label: "Detach All", tip: "Close and archive all open windows in the selected project(s), keeping their jobs running", target: self, + action: #selector(freezeSelectedProjectAndKeepJobs(_:))) + + let spacer = NSView() + let stack = NSStackView(views: [addProjectButton, addSubprojectButton, deleteButton, + spacer, + restoreButton, closeProjectButton, freezeProjectButton]) + 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 setupObservers() { + NotificationCenter.default.addObserver( + self, + selector: #selector(modelChanged(_:)), + name: iTermWindowProjectsModel.didChangeNotification, + object: nil) + } + + func reload() { + outlineView.reloadData() + updateButtons() + } + + // 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 (detached, closed) = Self.savedCounts(project) + // Distinguish saved windows by state; omit any zero category. + let parts = [liveCount > 0 ? "\(liveCount) open" : nil, + detached > 0 ? "\(detached) detached" : nil, + closed > 0 ? "\(closed) closed" : nil].compactMap { $0 } + let suffix = parts.isEmpty ? "" : " (\(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.ptyWindow()?.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()) + + // A saved window whose process is still alive is “detached”; otherwise “closed”. + let isRunning = box.window.isOrphanedAndRunning + let state = isRunning ? "Detached" : "Closed" + cell.textField?.stringValue = "\(box.window.name) \(age) · \(state)" + cell.textField?.font = .systemFont(ofSize: NSFont.systemFontSize) + cell.textField?.textColor = isRunning ? .labelColor : .secondaryLabelColor + + let iconName = isRunning ? "terminal.fill" : "terminal" + cell.imageView?.image = NSImage(systemSymbolName: iconName, + accessibilityDescription: nil) + } + return cell + } + + /// (detached, closed) saved-window counts for a project and all descendants. + /// detached = process still alive (`isOrphanedAndRunning`); closed = the rest. + static func savedCounts(_ project: iTermWindowProject) -> (detached: Int, closed: Int) { + var detached = 0, closed = 0 + func walk(_ p: iTermWindowProject) { + for w in p.windows { + if w.isOrphanedAndRunning { detached += 1 } else { closed += 1 } + } + p.children.forEach(walk) + } + walk(project) + return (detached, closed) + } + + 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.ptyWindow()?.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?) { + let model = iTermWindowProjectsModel.shared + let boxes = selectedArchivedBoxes + let projects = selectedProjects + guard !boxes.isEmpty || !projects.isEmpty else { return } + + if !projects.isEmpty { + let alert = NSAlert() + alert.messageText = projects.count == 1 + ? "Delete “\(projects[0].name)”?" + : "Delete \(projects.count) projects?" + alert.informativeText = "This removes the project(s) and all their saved windows. Open windows are unaffected." + alert.addButton(withTitle: "Delete") + alert.addButton(withTitle: "Cancel") + alert.buttons[0].hasDestructiveAction = true + guard alert.runModal() == .alertFirstButtonReturn else { return } + for project in projects { model.deleteProject(project) } + } + for box in boxes { model.removeWindow(box.window, from: box.project) } + reload() + } + + /// Restores the current selection (saved windows and/or whole projects). With no + /// selection, confirms then restores every saved window in every project. + @objc func restoreSelected(_ sender: Any?) { + let model = iTermWindowProjectsModel.shared + let boxes = selectedArchivedBoxes + let projects = selectedProjects + if boxes.isEmpty && projects.isEmpty { + let alert = NSAlert() + alert.messageText = "Restore all saved windows?" + alert.informativeText = "Nothing is selected. This will restore every saved window in every project." + alert.addButton(withTitle: "Restore All") + alert.addButton(withTitle: "Cancel") + guard alert.runModal() == .alertFirstButtonReturn else { return } + for project in model.rootProjects { model.restoreAllWindows(in: project) } + reload() + return + } + for box in boxes { model.restoreWindow(box.window) } + for project in projects { model.restoreAllWindows(in: project) } + reload() + } + + @objc private func closeSelectedProject(_ sender: Any?) { + archiveOrDetachSelectedProjects(keepJobs: false) + } + + @objc private func freezeSelectedProjectAndKeepJobs(_ sender: Any?) { + archiveOrDetachSelectedProjects(keepJobs: true) + } + + /// Archives (keepJobs=false) or detaches (keepJobs=true) every open window in the + /// selected project(s) — or, if no project is selected, across all projects. + private func archiveOrDetachSelectedProjects(keepJobs: Bool) { + let model = iTermWindowProjectsModel.shared + let targets = archiveDetachTargetProjects.filter { model.hasLiveWindows(for: $0) } + guard !targets.isEmpty else { + showAlert("No open windows are associated with the selected project(s).") + return + } + for project in targets { model.closeProject(project, keepJobsRunning: keepJobs) } + reload() + } + + @objc private func detachLiveWindow(_ sender: Any?) { + let boxes = selectedLiveBoxes + guard !boxes.isEmpty else { return } + for box in boxes { + iTermWindowProjectsModel.shared.archiveWindow(box.terminal, + to: box.project, + andClose: true, + keepJobsRunning: true) + } + 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 } + // Keep an existing multi-selection if the clicked row is part of it; otherwise + // select just the clicked row. + if !outlineView.selectedRowIndexes.contains(row) { + outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + } + + let clicked = outlineView.item(atRow: row) + let menu = NSMenu() + if clicked is iTermArchivedWindowBox { + menu.addItem(NSMenuItem(title: "Restore", + action: #selector(restoreSelected(_:)), + keyEquivalent: "")) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Remove from Project", + action: #selector(deleteSelected(_:)), + keyEquivalent: "")) + } else if clicked is iTermLiveWindowBox { + menu.addItem(NSMenuItem(title: "Bring to Front", + action: #selector(bringLiveWindowToFront(_:)), + keyEquivalent: "")) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Archive (close)", + action: #selector(archiveLiveWindow(_:)), + keyEquivalent: "")) + menu.addItem(NSMenuItem(title: "Detach (keep running)", + action: #selector(detachLiveWindow(_:)), + keyEquivalent: "")) + menu.addItem(NSMenuItem(title: "Disassociate from Project", + action: #selector(disassociateLiveWindow(_:)), + keyEquivalent: "")) + } else if let project = clicked as? iTermWindowProject { + menu.addItem(NSMenuItem(title: "Restore", + action: #selector(restoreSelected(_:)), + keyEquivalent: "")) + if iTermWindowProjectsModel.shared.hasLiveWindows(for: project) { + menu.addItem(NSMenuItem(title: "Archive All Open Windows", + action: #selector(closeSelectedProject(_:)), + keyEquivalent: "")) + menu.addItem(NSMenuItem(title: "Detach All (Keep Jobs Running)", + action: #selector(freezeSelectedProjectAndKeepJobs(_:)), + 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: "")) + } + 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?) { + for box in selectedLiveBoxes { + box.terminal.ptyWindow()?.makeKeyAndOrderFront(nil) + } + } + + @objc private func archiveLiveWindow(_ sender: Any?) { + let boxes = selectedLiveBoxes + guard !boxes.isEmpty else { return } + for box in boxes { + iTermWindowProjectsModel.shared.archiveWindow(box.terminal, + to: box.project, + andClose: true, + keepJobsRunning: false) + } + reload() + } + + @objc private func disassociateLiveWindow(_ sender: Any?) { + for box in selectedLiveBoxes { + iTermWindowProjectsModel.shared.disassociateWindow(box.terminal) + } + reload() + } + + // MARK: Drag Source + + 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 box = item as? iTermLiveWindowBox { + guard let wn = box.terminal.ptyWindow()?.windowNumber, wn > 0 else { return nil } + let pb = NSPasteboardItem() + pb.setString(String(wn), forType: kLiveWindowDragType) + return pb + } + if let project = item as? iTermWindowProject { + let pb = NSPasteboardItem() + pb.setString(project.id.uuidString, forType: kProjectDragType) + return pb + } + return nil + } + + // MARK: Drop Destination + + /// The project a drop targets: the row itself if it's a project, or the owning + /// project if the row is one of its window leaves. Used to force whole-project drop + /// targeting (no confusing between-rows insertion lines). + private func projectDropTarget(for item: Any?) -> iTermWindowProject? { + if let project = item as? iTermWindowProject { return project } + if let box = item as? iTermArchivedWindowBox { return box.project } + if let box = item as? iTermLiveWindowBox { return box.project } + return nil + } + + func outlineView(_ outlineView: NSOutlineView, + validateDrop info: NSDraggingInfo, + proposedItem item: Any?, + proposedChildIndex index: Int) -> NSDragOperation { + let pb = info.draggingPasteboard + // Live windows (associate) and archived windows (move between projects) may only + // be dropped ONTO a project. Retarget to the whole project so there's never a + // between-rows insertion line. + let accepts = pb.availableType(from: [kLiveWindowDragType]) != nil + || pb.availableType(from: [kArchivedWindowDragType]) != nil + guard accepts, let project = projectDropTarget(for: item) else { return [] } + outlineView.setDropItem(project, dropChildIndex: NSOutlineViewDropOnItemIndex) + // Use .link so the cursor shows the same redirect-arrow badge as the right pane. + return .link + } + + func outlineView(_ outlineView: NSOutlineView, + acceptDrop info: NSDraggingInfo, + item: Any?, + childIndex index: Int) -> Bool { + let model = iTermWindowProjectsModel.shared + guard let project = projectDropTarget(for: item) else { return false } + + // Live window(s) onto a project → associate (keep open). + let wnStrings = info.allStrings(forType: kLiveWindowDragType) + if !wnStrings.isEmpty { + let numbers = Set(wnStrings.compactMap { Int($0) }) + let all = iTermController.sharedInstance().terminals() ?? [] + let dragged = all.filter { numbers.contains($0.ptyWindow()?.windowNumber ?? -1) } + for terminal in dragged { model.associateWindow(terminal, with: project) } + return !dragged.isEmpty + } + + // Archived window(s) onto a project → move between projects. + let uuidStrings = info.allStrings(forType: kArchivedWindowDragType) + if !uuidStrings.isEmpty { + var moved = false + for s in uuidStrings { + guard let uuid = UUID(uuidString: s), + let (archived, oldProject) = model.archivedWindow(id: uuid) else { continue } + oldProject.windows.removeAll { $0.id == archived.id } + project.windows.append(archived) + moved = true + } + if moved { model.save() } + return moved + } + + return false + } + + // MARK: Drag session → drop overlays + + func outlineView(_ outlineView: NSOutlineView, + draggingSession session: NSDraggingSession, + willBeginAt screenPoint: NSPoint, + forItems draggedItems: [Any]) { + let boxes = draggedItems.compactMap { $0 as? iTermArchivedWindowBox } + let projects = draggedItems.compactMap { $0 as? iTermWindowProject } + let payload: iTermProjectsDragPayload + if !boxes.isEmpty { + payload = .archivedWindows(boxes) + } else if !projects.isEmpty { + payload = .projects(projects) + } else { + payload = .other + } + splitController?.dragDidBegin(payload, from: self) + } + + func outlineView(_ outlineView: NSOutlineView, + draggingSession session: NSDraggingSession, + endedAt screenPoint: NSPoint, + operation: NSDragOperation) { + splitController?.dragDidEnd() + } + + func showDropOverlay(zones: [iTermProjectsDropZone], + dragTypes: [NSPasteboard.PasteboardType], + delegate: iTermProjectsDropOverlayDelegate) { + removeDropOverlay() + let overlay = iTermProjectsDropOverlay(zones: zones, dragTypes: dragTypes) + overlay.delegate = delegate + overlay.frame = scrollView.frame + view.addSubview(overlay, positioned: .above, relativeTo: scrollView) + dropOverlay = overlay + } + + func removeDropOverlay() { + dropOverlay?.removeFromSuperview() + dropOverlay = nil + } + + // 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 + preview.close() + } + + private func showPreview(forRow row: Int) { + let item = outlineView.item(atRow: row) + + if let box = item as? iTermArchivedWindowBox { + let rowRect = outlineView.rect(ofRow: row) + guard rowRect != .zero else { return } + let fileURL = iTermWindowProjectsModel.thumbnailURL(for: box.window.id) + if FileManager.default.fileExists(atPath: fileURL.path), + let img = NSImage(contentsOf: fileURL) { + // Display at a fixed 320pt width, height from the image's aspect ratio + // (so the higher-resolution saved PNG stays crisp without distorting). + let aspectH = img.size.width > 0 ? 320 * img.size.height / img.size.width : 200 + let iv = NSImageView(frame: NSRect(x: 0, y: 0, width: 320, height: min(aspectH, 240))) + iv.image = img + iv.imageScaling = .scaleProportionallyUpOrDown + preview.show(iv, rowRect: rowRect, anchorView: outlineView) + } else if let arrangement = box.window.arrangement { + let pv = ArrangementPreviewView(frame: NSRect(x: 0, y: 0, width: 320, height: 200)) + pv.setArrangement([arrangement as Any]) + preview.show(pv, rowRect: rowRect, anchorView: outlineView) + } + return + } + + if let liveBox = item as? iTermLiveWindowBox, + let nsWindow = liveBox.terminal.ptyWindow(), + nsWindow.windowNumber > 0 { + showLivePreview(windowNumber: CGWindowID(nsWindow.windowNumber), + rowRect: outlineView.rect(ofRow: row)) + } + } + + // MARK: Notifications + + @objc private func modelChanged(_ note: Notification) { reload() } + + // MARK: Helpers + + private var anyProjectHasLiveWindows: Bool { + return iTermWindowProjectsModel.shared.rootProjects.contains { p in + iTermWindowProjectsModel.shared.hasLiveWindows(for: p) + } + } + + private var anyProjectHasArchivedWindows: Bool { + return iTermWindowProjectsModel.shared.rootProjects.contains { p in + p.totalWindowCount > 0 + } + } + + private func updateButtons() { + let projects = selectedProjects + let hasArchived = !selectedArchivedBoxes.isEmpty + + addSubprojectButton.isEnabled = projects.count == 1 // one parent for the new sub-project + deleteButton.isEnabled = !projects.isEmpty || hasArchived + + // Single Restore button: label carries the count; empty selection restores all. + let count = restorableSelectionCount + switch count { + case 0: + restoreButton.title = "Restore All…" + restoreButton.isEnabled = anyProjectHasArchivedWindows + case 1: + restoreButton.title = "Restore" + restoreButton.isEnabled = true + default: + restoreButton.title = "Restore \(count)" + restoreButton.isEnabled = true + } + + // Archive All / Detach All only when the effective target projects actually + // have open windows (selecting only closed windows leaves them off). + let model = iTermWindowProjectsModel.shared + let hasLive = archiveDetachTargetProjects.contains { model.hasLiveWindows(for: $0) } + closeProjectButton.isEnabled = hasLive + freezeProjectButton.isEnabled = hasLive + } + + 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 + } +} + +// MARK: - Right Pane: Open Windows (grouped by project) + +final class iTermOpenWindowsController: NSViewController, + NSOutlineViewDataSource, + NSOutlineViewDelegate { + weak var projectsController: iTermProjectsOutlineController? + weak var splitController: iTermProjectsSplitViewController? + private var dropOverlay: iTermProjectsDropOverlay? + + private var outlineView = NSOutlineView() + private var scrollView = NSScrollView() + private var associateButton = NSButton() + private var archiveButton = NSButton() + private var detachButton = NSButton() + private var statusLabel = NSTextField(labelWithString: "") + + // Hover preview + private let preview = iTermProjectsSidePreview(side: .right) + private var previewTimer: Timer? + private var previewRow = -1 + + private var groups: [iTermOpenProjectGroup] = [] + + private var terminals: [PseudoTerminal] { + iTermController.sharedInstance().terminals() ?? [] + } + + var selectedTerminal: PseudoTerminal? { + outlineView.item(atRow: outlineView.selectedRow) as? PseudoTerminal + } + var selectedGroup: iTermOpenProjectGroup? { + outlineView.item(atRow: outlineView.selectedRow) as? iTermOpenProjectGroup + } + + /// All selected items of a given kind (multi-select). + private func selectedItems(_ type: T.Type) -> [T] { + outlineView.selectedRowIndexes.compactMap { outlineView.item(atRow: $0) as? T } + } + var selectedTerminals: [PseudoTerminal] { selectedItems(PseudoTerminal.self) } + var selectedGroups: [iTermOpenProjectGroup] { selectedItems(iTermOpenProjectGroup.self) } + + override func loadView() { view = NSView() } + + override func viewDidLoad() { + super.viewDidLoad() + setupOutlineView() + setupBottomBar() + 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) + } + } + + let projectGroups = byProjectID.values + .sorted { $0.project.name.localizedCaseInsensitiveCompare($1.project.name) == .orderedAscending } + .map { iTermOpenProjectGroup(project: $0.project, terminals: $0.terminals) } + + // The Unassociated group is always present and pinned on top: it doubles as the + // disassociate drop target (drag an associated window onto it), so it must exist + // even when empty. Dropping into empty space no longer disassociates. + groups = [iTermOpenProjectGroup(project: nil, terminals: unassociated)] + projectGroups + } + + 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 = true + outlineView.focusRingType = .none + + // Drag source + destination. (Saved-window / project drags from the left pane + // are handled by the Restore overlay, not a row drop here — so this pane only + // registers the live-window type for in-pane reassign/disassociate.) + outlineView.setDraggingSourceOperationMask(.every, forLocal: true) + outlineView.setDraggingSourceOperationMask(.every, forLocal: false) + outlineView.registerForDraggedTypes([kLiveWindowDragType]) + + 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(s) as belonging to the selected project", + target: self, + action: #selector(associateSelected(_:))) + + configure(&archiveButton, + label: "Archive", + tip: "Close and archive the selected open window(s) into their project", + target: self, + action: #selector(archiveSelected(_:))) + + configure(&detachButton, + label: "Detach", + tip: "Close and archive the selected open window(s) into their project, keeping their jobs running", + target: self, + action: #selector(detachSelected(_:))) + + let stack = NSStackView(views: [statusLabel, NSView(), detachButton, archiveButton, 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 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 terminals = selectedTerminals + let hasProject = projectsController?.selectedProject != nil + associateButton.isEnabled = !terminals.isEmpty && hasProject + // Archive/Detach act on a window's own project, so they need at least one + // selected window that is already associated. + let anyAssociated = terminals.contains { + iTermWindowProjectsModel.shared.project(for: $0) != nil + } + archiveButton.isEnabled = anyAssociated + detachButton.isEnabled = anyAssociated + } + + // 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 = count == 0 ? "Unassociated" : "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.ptyWindow()?.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 project = projectsController?.selectedProject else { return } + let terminals = selectedTerminals + guard !terminals.isEmpty else { return } + for terminal in terminals { + 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 } + // Keep an existing multi-selection if the clicked row is part of it. + if !outlineView.selectedRowIndexes.contains(row) { + outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + } + + let clicked = outlineView.item(atRow: row) + let menu = NSMenu() + if clicked is PseudoTerminal { + menu.addItem(NSMenuItem(title: "Bring to Front", + action: #selector(bringSelectedToFront(_:)), + keyEquivalent: "")) + // Show archive/detach only if at least one selected window is associated. + let anyAssociated = selectedTerminals.contains { + iTermWindowProjectsModel.shared.project(for: $0) != nil + } + if anyAssociated { + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Archive (close)", + action: #selector(archiveSelected(_:)), + keyEquivalent: "")) + menu.addItem(NSMenuItem(title: "Detach (keep running)", + action: #selector(detachSelected(_:)), + keyEquivalent: "")) + menu.addItem(NSMenuItem(title: "Disassociate from Project", + action: #selector(disassociateSelected(_:)), + keyEquivalent: "")) + } + } else if let proj = (clicked as? iTermOpenProjectGroup)?.project { + menu.addItem(NSMenuItem(title: "Archive All in “\(proj.name)”", + action: #selector(closeSelectedGroup(_:)), + keyEquivalent: "")) + menu.addItem(NSMenuItem(title: "Detach All in “\(proj.name)” (Keep Jobs)", + action: #selector(freezeSelectedGroupAndKeepJobs(_:)), + keyEquivalent: "")) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Disassociate All from “\(proj.name)”", + action: #selector(disassociateSelectedGroup(_:)), + keyEquivalent: "")) + } + menu.items.forEach { $0.target = self } + NSMenu.popUpContextMenu(menu, with: event, for: outlineView) + } + + @objc private func bringSelectedToFront(_ sender: Any?) { + for terminal in selectedTerminals { + terminal.ptyWindow()?.makeKeyAndOrderFront(nil) + } + } + + @objc private func archiveSelected(_ sender: Any?) { + archiveOrDetachSelectedWindows(keepJobs: false) + } + + @objc private func detachSelected(_ sender: Any?) { + archiveOrDetachSelectedWindows(keepJobs: true) + } + + private func archiveOrDetachSelectedWindows(keepJobs: Bool) { + let model = iTermWindowProjectsModel.shared + for terminal in selectedTerminals { + guard let project = model.project(for: terminal) else { continue } + model.archiveWindow(terminal, to: project, andClose: true, keepJobsRunning: keepJobs) + } + } + + @objc private func disassociateSelected(_ sender: Any?) { + for terminal in selectedTerminals { + iTermWindowProjectsModel.shared.disassociateWindow(terminal) + } + } + + @objc private func closeSelectedGroup(_ sender: Any?) { + for proj in selectedGroups.compactMap({ $0.project }) { + iTermWindowProjectsModel.shared.closeProject(proj, keepJobsRunning: false) + } + } + + @objc private func freezeSelectedGroupAndKeepJobs(_ sender: Any?) { + for proj in selectedGroups.compactMap({ $0.project }) { + iTermWindowProjectsModel.shared.closeProject(proj, keepJobsRunning: true) + } + } + + @objc private func disassociateSelectedGroup(_ sender: Any?) { + for group in selectedGroups { + 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.ptyWindow()?.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(s) from this pane dropped on a group → reassign (project group) + // or disassociate (the always-present Unassociated group). Only group rows are + // valid targets — dropping into empty space does nothing, so a window can't be + // disassociated by accident. (Saved-window / project drags from the left are + // handled by the Restore overlay, which covers this pane during such a drag.) + if pb.availableType(from: [kLiveWindowDragType]) != nil { + let group: iTermOpenProjectGroup? + if let g = item as? iTermOpenProjectGroup { + group = g + } else if let terminal = item as? PseudoTerminal { + group = groups.first { $0.terminals.contains { $0 === terminal } } + } else { + group = nil + } + if let group = group { + // Force drop-on (never a between-rows insertion line). + outlineView.setDropItem(group, dropChildIndex: NSOutlineViewDropOnItemIndex) + return .link + } + } + return [] + } + + func outlineView(_ outlineView: NSOutlineView, + acceptDrop info: NSDraggingInfo, + item: Any?, + childIndex index: Int) -> Bool { + // Live window(s) → reassign to the target group's project, or disassociate when + // dropped on the Unassociated group / root. + let wnStrings = info.allStrings(forType: kLiveWindowDragType) + guard !wnStrings.isEmpty else { return false } + let numbers = Set(wnStrings.compactMap { Int($0) }) + let all = iTermController.sharedInstance().terminals() ?? [] + let dragged = all.filter { numbers.contains($0.ptyWindow()?.windowNumber ?? -1) } + guard !dragged.isEmpty else { return false } + // Only a group is a valid drop target (validateDrop enforces this). A project + // group reassigns; the Unassociated group disassociates. + guard let group = item as? iTermOpenProjectGroup else { return false } + let model = iTermWindowProjectsModel.shared + if let proj = group.project { + for terminal in dragged { model.associateWindow(terminal, with: proj) } + } else { + for terminal in dragged { model.disassociateWindow(terminal) } + } + return true + } + + // MARK: Drag session → drop overlays + + func outlineView(_ outlineView: NSOutlineView, + draggingSession session: NSDraggingSession, + willBeginAt screenPoint: NSPoint, + forItems draggedItems: [Any]) { + let terminals = draggedItems.compactMap { $0 as? PseudoTerminal } + let projects = draggedItems.compactMap { ($0 as? iTermOpenProjectGroup)?.project } + let payload: iTermProjectsDragPayload + if !terminals.isEmpty { + payload = .liveWindows(terminals) + } else if !projects.isEmpty { + payload = .projectGroups(projects) + } else { + payload = .other + } + splitController?.dragDidBegin(payload, from: self) + } + + func outlineView(_ outlineView: NSOutlineView, + draggingSession session: NSDraggingSession, + endedAt screenPoint: NSPoint, + operation: NSDragOperation) { + splitController?.dragDidEnd() + } + + func showDropOverlay(zones: [iTermProjectsDropZone], + dragTypes: [NSPasteboard.PasteboardType], + delegate: iTermProjectsDropOverlayDelegate) { + removeDropOverlay() + let overlay = iTermProjectsDropOverlay(zones: zones, dragTypes: dragTypes) + overlay.delegate = delegate + overlay.frame = scrollView.frame + view.addSubview(overlay, positioned: .above, relativeTo: scrollView) + dropOverlay = overlay + } + + func removeDropOverlay() { + dropOverlay?.removeFromSuperview() + dropOverlay = nil + } + + // 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 + preview.close() + } + + private func showPreview(forRow row: Int) { + guard let terminal = outlineView.item(atRow: row) as? PseudoTerminal, + let nsWindow = terminal.ptyWindow(), + nsWindow.windowNumber > 0 else { return } + let rowRect = outlineView.rect(ofRow: row) + guard rowRect != .zero, + let iv = liveWindowPreviewImageView(windowNumber: CGWindowID(nsWindow.windowNumber)) else { return } + preview.show(iv, rowRect: rowRect, anchorView: outlineView) + } + + // 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, rowRect: NSRect) { + guard rowRect != .zero, + let iv = liveWindowPreviewImageView(windowNumber: windowNumber) else { return } + preview.show(iv, rowRect: rowRect, anchorView: outlineView) + } +} + +/// Builds an NSImageView holding a screenshot of the given window, sized to a 320pt +/// width (capped height), or nil if the window can't be captured. +func liveWindowPreviewImageView(windowNumber: CGWindowID) -> NSImageView? { + let cgImage = CGWindowListCreateImage( + .null, + .optionIncludingWindow, + windowNumber, + [.boundsIgnoreFraming, .bestResolution]) + guard let cgImage else { return nil } + 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 + return iv +} + +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 +} + +/// `.inline` buttons barely dim when disabled, which made it hard to tell what was +/// actionable. Dim the whole button so the disabled state is unmistakable. +private final class iTermDimmableButton: NSButton { + override var isEnabled: Bool { + didSet { alphaValue = isEnabled ? 1.0 : 0.35 } + } +} + +private func configure(_ button: inout NSButton, + label: String, + tip: String, + target: Any?, + action: Selector) { + let b = iTermDimmableButton(title: label, target: target as AnyObject?, action: action) + b.bezelStyle = .inline + b.controlSize = .small + b.toolTip = tip + button = b +} + +extension NSDraggingInfo { + /// Every string of `type` across all dragged pasteboard items (a multi-row drag + /// produces one item per row), not just the first. + func allStrings(forType type: NSPasteboard.PasteboardType) -> [String] { + (draggingPasteboard.pasteboardItems ?? []).compactMap { $0.string(forType: type) } + } +} + +/// A hover preview pinned to one side of the panel window. Unlike NSPopover — which +/// flips to the opposite edge when the preferred side lacks screen room, which is what +/// put the right pane's previews over the left pane — this always appears on its own +/// side, clamped to the screen, so the two panes' previews never cover each other. +final class iTermProjectsSidePreview { + enum Side { case left, right } + private let side: Side + private var window: NSWindow? + + init(side: Side) { self.side = side } + + /// Shows `content` beside `anchorView`'s window, vertically centered on `rowRect` + /// (in `anchorView` coordinates), with a little arrow pointing back at the row. + func show(_ content: NSView, rowRect: NSRect, anchorView: NSView) { + guard let panel = anchorView.window, + let screen = panel.screen ?? NSScreen.main else { return } + let contentSize = content.frame.size + // Preview on the left → arrow on its right edge; on the right → arrow on its + // left edge (always pointing back toward the panel/row). + let arrowOnRight = (side == .left) + let bubble = iTermProjectsPreviewBubble(content: content, arrowOnRight: arrowOnRight) + let winSize = NSSize(width: contentSize.width + iTermProjectsPreviewBubble.arrowSize, + height: contentSize.height) + + let w = window ?? makeWindow() + window = w + w.setContentSize(winSize) + w.contentView = bubble + bubble.frame = NSRect(origin: .zero, size: winSize) + + let rowOnScreen = panel.convertToScreen(anchorView.convert(rowRect, to: nil)) + let visible = screen.visibleFrame + let gap: CGFloat = 2 + var x = arrowOnRight ? panel.frame.minX - gap - winSize.width + : panel.frame.maxX + gap + x = min(max(x, visible.minX), visible.maxX - winSize.width) + var y = rowOnScreen.midY - winSize.height / 2 + y = min(max(y, visible.minY), visible.maxY - winSize.height) + w.setFrameOrigin(NSPoint(x: x, y: y)) + + bubble.arrowY = rowOnScreen.midY - y // keep the arrow aligned with the row + bubble.needsDisplay = true + w.orderFront(nil) + } + + func close() { window?.orderOut(nil) } + + private func makeWindow() -> NSWindow { + let w = NSWindow(contentRect: .zero, styleMask: [.borderless], + backing: .buffered, defer: true) + w.isOpaque = false + w.backgroundColor = .clear + w.hasShadow = true + w.level = .floating + w.ignoresMouseEvents = true + w.collectionBehavior = [.transient, .ignoresCycle] + return w + } +} + +/// Hosts the preview content and draws a small arrow on its inner edge pointing back at +/// the hovered row (the affordance the old NSPopover gave for free). +private final class iTermProjectsPreviewBubble: NSView { + static let arrowSize: CGFloat = 9 + private let content: NSView + private let arrowOnRight: Bool + /// Arrow tip Y in view coordinates (origin bottom-left). + var arrowY: CGFloat = 0 + + init(content: NSView, arrowOnRight: Bool) { + self.content = content + self.arrowOnRight = arrowOnRight + super.init(frame: .zero) + wantsLayer = true + addSubview(content) + } + required init?(coder: NSCoder) { it_fatalError("not implemented") } + + private var contentRect: NSRect { + let a = Self.arrowSize + return arrowOnRight ? NSRect(x: 0, y: 0, width: bounds.width - a, height: bounds.height) + : NSRect(x: a, y: 0, width: bounds.width - a, height: bounds.height) + } + + override func layout() { + super.layout() + content.frame = contentRect + } + + override func draw(_ dirtyRect: NSRect) { + let body = contentRect + let y = min(max(arrowY, body.minY + Self.arrowSize), body.maxY - Self.arrowSize) + let tipX = arrowOnRight ? bounds.maxX : bounds.minX + let baseX = arrowOnRight ? body.maxX : body.minX + let tri = NSBezierPath() + tri.move(to: NSPoint(x: baseX, y: y + Self.arrowSize)) + tri.line(to: NSPoint(x: tipX, y: y)) + tri.line(to: NSPoint(x: baseX, y: y - Self.arrowSize)) + tri.close() + NSColor.windowBackgroundColor.setFill() + tri.fill() + NSColor.separatorColor.setStroke() + tri.lineWidth = 1 + tri.stroke() + } +} + +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..8b7298a5d7 --- /dev/null +++ b/sources/iTermWindowProjectsModel.swift @@ -0,0 +1,986 @@ +// 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] + } + + /// Returns true if this archived window's process is still running — i.e. it is + /// *detached* rather than *closed*. + var isOrphanedAndRunning: Bool { + guard let arrangement = arrangement else { return false } + // Multiserver (current): the live child is referenced under Server Dict→Child PID + // (PTYSession's "Server PID" key is monoserver-only, so reading only that always + // reported "closed" for modern arrangements). + for pid in iTermWindowProjectsModel.allServerChildPIDs(in: arrangement) where pid > 0 { + if kill(pid, 0) == 0 { return true } + } + // Monoserver (legacy) fallback: a top-level "Server PID". + return hasLiveServerPID(in: arrangement) + } + + private func hasLiveServerPID(in value: Any) -> Bool { + if let dict = value as? [String: Any] { + if let pidNum = dict["Server PID"] as? Int, pidNum > 0 { + if kill(pid_t(pidNum), 0) == 0 { + return true + } + } + for val in dict.values { + if hasLiveServerPID(in: val) { return true } + } + } else if let array = value as? [Any] { + for val in array { + if hasLiveServerPID(in: val) { return true } + } + } + return false + } +} + +// 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: - User-close routing + +/// Outcome of routing a user-initiated window close (red button / Cmd-W) +/// through Window Projects. Returned to `PseudoTerminal.windowShouldClose:` so +/// it knows whether to let AppKit proceed with the close. +@objc enum iTermWindowProjectCloseHandling: Int { + /// The window isn't associated with a project; the caller should run its + /// normal close logic. + case notAssociated + /// The user picked an action; Window Projects will perform it (and close + /// the window itself). The caller must veto AppKit's close. + case handled + /// The user cancelled. The caller must veto AppKit's close. + case cancelled +} + +// 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] = [] + + func testOnlySetRootProjects(_ projects: [iTermWindowProject]) { + self.rootProjects = projects + save() + } + + /// Test-only access to the in-memory guid→project association map. + var testOnlyAssociations: [String: UUID] { + get { liveAssociations } + set { + liveAssociations = newValue + saveAssociations() + } + } + + /// Test-only: drop the in-memory map and re-read it from disk, exercising the + /// real save/load serialization path (the guid→UUID-string round trip). + func testOnlyReloadAssociationsFromDisk() { + liveAssociations = [:] + loadAssociations() + } + + /// Test-only access to the applicationWillTerminate guard so the no-archive-on-quit + /// behavior (Option A) can be exercised without a real NSApplication teardown. + var testOnlyIsTerminating: Bool { + get { isTerminating } + set { isTerminating = newValue } + } + + /// Persisted mapping: stable PseudoTerminal.terminalGuid → project UUID. + /// Keyed by GUID (not window number) so an open associated window that is + /// brought back by native window restoration after a quit/crash re-joins its + /// project automatically — the restored window keeps its terminalGuid. + private var liveAssociations: [String: UUID] = [:] + + /// Set true once applicationWillTerminate fires, so the per-window + /// willClose notifications that fire during app teardown don't get treated + /// as user-initiated closes (which would archive + drop associations). + private var isTerminating = false + + /// terminalGuid for a window, or nil if it has none yet. + private static func guid(for terminal: PseudoTerminal) -> String? { + let g = terminal.terminalGuid + return (g?.isEmpty == false) ? g : nil + } + + private static var isTesting: Bool { + return NSClassFromString("XCTestCase") != nil + } + + /// The iTerm2 application-support directory, honoring the `-suite ` + /// launch argument (see main.m). Launching a dogfooding instance with + /// `-suite dev` redirects this to ~/Library/Application Support/dev/, so its + /// Window Projects, associations, and thumbnails stay out of the real + /// ~/Library/Application Support/iTerm2/ — matching how the rest of iTerm + /// (prefs, daemon socket) already isolates per-suite. With no -suite the + /// ObjC helper returns the same path as before (executable name "iTerm2"), + /// so the prod location is unchanged. Falls back to the hardcoded path only + /// if the helper ever returns nil. + private static var supportDirectory: URL { + if let path = FileManager.default.applicationSupportDirectory(), !path.isEmpty { + return URL(fileURLWithPath: path, isDirectory: true) + } + 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 + } + + private static var saveURL: URL { + let filename = isTesting ? "WindowProjects_test.json" : "WindowProjects.json" + return supportDirectory.appendingPathComponent(filename) + } + + private static var thumbnailsDirectoryURL: URL { + let folderName = isTesting ? "WindowProjectThumbnails_test" : "WindowProjectThumbnails" + let dir = supportDirectory.appendingPathComponent(folderName) + try? FileManager.default.createDirectory(at: dir, + withIntermediateDirectories: true) + return dir + } + + @objc static func thumbnailURL(for uuid: UUID) -> URL { + return thumbnailsDirectoryURL.appendingPathComponent("\(uuid.uuidString).png") + } + + static func deleteThumbnail(for uuid: UUID) { + try? FileManager.default.removeItem(at: thumbnailURL(for: uuid)) + } + + static func saveThumbnail(for windowNumber: Int, uuid: UUID) { + guard windowNumber > 0 else { return } + let windowID = CGWindowID(windowNumber) + guard let cgImage = CGWindowListCreateImage(.null, .optionIncludingWindow, windowID, [.boundsIgnoreFraming, .bestResolution]) else { return } + + let aspectW: CGFloat = 320 + let aspectH = cgImage.height == 0 ? 200 : aspectW * CGFloat(cgImage.height) / CGFloat(cgImage.width) + let size = NSSize(width: aspectW, height: min(aspectH, 240)) + + // Render at 2× so the saved PNG stays crisp when shown on a Retina display + // (the preview is displayed at the logical `size`). + let scale: CGFloat = 2 + guard let bitmapRep = NSBitmapImageRep(bitmapDataPlanes: nil, + pixelsWide: Int(size.width * scale), + pixelsHigh: Int(size.height * scale), + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .calibratedRGB, + bytesPerRow: 0, + bitsPerPixel: 0) else { return } + + bitmapRep.size = size + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmapRep) + + let nsImage = NSImage(cgImage: cgImage, size: .zero) + nsImage.draw(in: NSRect(origin: .zero, size: size), + from: .zero, + operation: .copy, + fraction: 1.0) + + NSGraphicsContext.restoreGraphicsState() + + if let pngData = bitmapRep.representation(using: .png, properties: [:]) { + try? pngData.write(to: thumbnailURL(for: uuid)) + } + } + + private override init() { + super.init() + load() + loadAssociations() + NotificationCenter.default.addObserver( + self, + selector: #selector(windowWillClose(_:)), + name: NSWindow.willCloseNotification, + object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationWillTerminate(_:)), + name: NSApplication.willTerminateNotification, + object: nil) + } + + // MARK: Persistence + + private static var associationsURL: URL { + let filename = isTesting ? "WindowProjectAssociations_test.json" : "WindowProjectAssociations.json" + return saveURL.deletingLastPathComponent().appendingPathComponent(filename) + } + + func save(postNotification: Bool = true) { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = .prettyPrinted + guard let data = try? encoder.encode(rootProjects) else { return } + try? data.write(to: Self.saveURL) + if postNotification { + NotificationCenter.default.post(name: Self.didChangeNotification, object: self) + } + } + + /// Persists the guid→project association map (separate file so the projects + /// JSON format is untouched). + private func saveAssociations() { + let stringMap = liveAssociations.mapValues { $0.uuidString } + guard let data = try? JSONEncoder().encode(stringMap) else { return } + try? data.write(to: Self.associationsURL) + } + + private func loadAssociations() { + guard let data = try? Data(contentsOf: Self.associationsURL), + let stringMap = try? JSONDecoder().decode([String: String].self, from: data) else { return } + liveAssociations = stringMap.compactMapValues { UUID(uuidString: $0) } + Self.wpLog("loadAssociations: \(liveAssociations.count) persisted guid→project entries: \(liveAssociations.keys.sorted())") + } + + /// Logs which currently-open windows resolve to a project (for verifying + /// re-association after a restart). Safe to call any time. + func logResolvedAssociations(_ phase: String) { + let all = iTermController.sharedInstance().terminals() ?? [] + var lines: [String] = [] + for t in all { + guard let guid = Self.guid(for: t) else { continue } + if let pid = liveAssociations[guid], let proj = project(id: pid) { + lines.append("guid=\(guid)→\(proj.name)") + } + } + Self.wpLog("\(phase): \(lines.count) open window(s) resolved to a project: \(lines)") + } + + 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() + } + + private func deleteThumbnails(for project: iTermWindowProject) { + for w in project.windows { + Self.deleteThumbnail(for: w.id) + } + for sub in project.children { + deleteThumbnails(for: sub) + } + } + + @discardableResult + func deleteProject(_ project: iTermWindowProject) -> Bool { + deleteThumbnails(for: project) + 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 guid = Self.guid(for: terminal) else { return } + liveAssociations[guid] = project.id + saveAssociations() + 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 guid = Self.guid(for: terminal), + liveAssociations.removeValue(forKey: guid) != nil else { return } + saveAssociations() + 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 guid = Self.guid(for: terminal), + let pid = liveAssociations[guid] 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() ?? [] + return all.filter { t in + guard let guid = Self.guid(for: t) else { return false } + return liveAssociations[guid] == 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() ?? [] + return all.contains { t in + guard let guid = Self.guid(for: t) else { return false } + return liveAssociations[guid] == project.id + } + } + + /// Closes and archives every open window currently associated with `project`. + func closeProject(_ project: iTermWindowProject, keepJobsRunning: Bool = false) { + for terminal in liveWindows(for: project) { + guard let wn = terminal.ptyWindow()?.windowNumber else { continue } + PseudoTerminal.setUseUnlimitedHistoryForArrangement(true) + let arrangement = terminal.arrangementExcludingTmuxTabs(true, includingContents: true) ?? [:] + PseudoTerminal.setUseUnlimitedHistoryForArrangement(false) + // Skip an empty capture (DesignNotes #10): leave this window open and + // associated rather than close it into an unrestorable archive. + guard Self.isArchivable(arrangement) else { + Self.devReportEmptyArrangement(context: "closeProject", arrangement: arrangement, terminal: terminal) + continue + } + let title = terminal.ptyWindow()?.title ?? "Window" + let uuid = UUID() + Self.saveThumbnail(for: wn, uuid: uuid) + project.windows.append(iTermArchivedWindow(id: uuid, name: title, arrangement: arrangement)) + if let guid = Self.guid(for: terminal) { liveAssociations.removeValue(forKey: guid) } + + // Park live children so the thawed window can re-adopt them in-place + // (see parkSessionsForReattachment). Must run before close(). + if keepJobsRunning { + Self.parkSessionsForReattachment(terminal) + } + + terminal.orphanJobsOnClose = keepJobsRunning + terminal.close() + } + project.lastUsed = Date() + saveAssociations() + save() + } + + /// Called when any NSWindow is about to close. This is the *fallback* + /// archive path for closes that don't go through + /// `PseudoTerminal.windowShouldClose:` — e.g. the last session exiting or a + /// programmatic close — where prompting would be wrong. A user-initiated + /// close (red button / Cmd-W) is instead routed through + /// `handleUserInitiatedClose(of:)`, which removes the association before the + /// window closes, so this handler's `liveAssociations[guid]` guard fails and + /// it no-ops (no double archive). During app termination this is also a + /// no-op: the association is preserved on disk and native window restoration + /// brings the window back live on next launch (re-associated by guid) — + /// archiving here would create a duplicate. + @objc private func windowWillClose(_ note: Notification) { + if isTerminating { return } + guard let window = note.object as? NSWindow else { return } + let wn = window.windowNumber + let all = iTermController.sharedInstance().terminals() ?? [] + guard let terminal = all.first(where: { $0.ptyWindow()?.windowNumber == wn }), + let guid = Self.guid(for: terminal) else { return } + guard let projectID = liveAssociations[guid], + let project = project(id: projectID) else { return } + PseudoTerminal.setUseUnlimitedHistoryForArrangement(true) + let arrangement = terminal.arrangementExcludingTmuxTabs(true, includingContents: true) ?? [:] + PseudoTerminal.setUseUnlimitedHistoryForArrangement(false) + // The window is closing regardless. On an empty capture (DesignNotes #10), + // skip the unrestorable archive entry but still drop the now-stale guid + // association. + guard Self.isArchivable(arrangement) else { + Self.devReportEmptyArrangement(context: "windowWillClose", arrangement: arrangement, terminal: terminal) + liveAssociations.removeValue(forKey: guid) + saveAssociations() + return + } + let title = window.title.isEmpty ? "Window" : window.title + let uuid = UUID() + Self.saveThumbnail(for: wn, uuid: uuid) + project.windows.append(iTermArchivedWindow(id: uuid, name: title, arrangement: arrangement)) + liveAssociations.removeValue(forKey: guid) + saveAssociations() + project.lastUsed = Date() + save() + } + + @objc private func applicationWillTerminate(_ note: Notification) { + // Option A: open associated windows come back via native window + // restoration and are re-associated by guid on next launch — do NOT + // archive them here (that produced a live window + a stale archive + // duplicate). Just mark terminating so the willClose notifications fired + // during teardown don't archive/drop their associations. + isTerminating = true + saveAssociations() + } + + // MARK: Window Archiving + + /// True if `arrangement` is substantive enough to archive. Some capture + /// timings yield an empty (~42-byte) plist with no `Tabs`, which would restore + /// to nothing; refuse to archive those so a detached/closed window never + /// becomes an unrestorable entry (see DesignNotes #10). + static func isArchivable(_ arrangement: [AnyHashable: Any]?) -> Bool { + guard let tabs = arrangement?["Tabs"] as? [Any] else { return false } + return !tabs.isEmpty + } + + // ⚠️ DEV DIAGNOSTIC — TEMPORARY, STRIP BEFORE SHIPPING ⚠️ + // We are not convinced the empty-arrangement capture (DesignNotes #10) still + // happens on current builds — it may be residue from an older build. If it + // ever does fire, dump a full trace + the offending arrangement to + // /tmp/iterm_wp.log AND surface a dialog so we can catch it in the act and + // learn which capture path/timing produced it. Remove this together with the + // isArchivable() guard wiring once the question is settled. + static func devReportEmptyArrangement(context: String, + arrangement: [AnyHashable: Any], + terminal: PseudoTerminal?) { + let bytes = (try? PropertyListSerialization.data(fromPropertyList: arrangement, + format: .binary, + options: 0))?.count ?? -1 + let title = terminal?.ptyWindow()?.title ?? "" + let windowNumber = terminal?.ptyWindow()?.windowNumber ?? -1 + let sessionCount = (terminal?.allSessions() as? [PTYSession])?.count ?? -1 + let keys = arrangement.keys.map { "\($0)" }.sorted() + let stack = Thread.callStackSymbols.prefix(25).joined(separator: "\n") + let detail = """ + ⚠️ EMPTY ARRANGEMENT (DesignNotes #10) at \(context) + size=\(bytes)B keys=\(keys) title=\(title) windowNumber=\(windowNumber) liveSessions=\(sessionCount) + arrangement=\(arrangement) + callStack: + \(stack) + """ + wpLog(detail) + + DispatchQueue.main.async { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Window Projects (dev): empty arrangement captured" + alert.informativeText = "An empty terminal arrangement was captured during “\(context)” " + + "(\(bytes) bytes, keys: \(keys)). This is a temporary dev diagnostic for " + + "DesignNotes #10 — full trace written to /tmp/iterm_wp.log. The window was left " + + "open instead of being archived into an unrestorable entry." + alert.runModal() + } + } + + /// 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, + keepJobsRunning: Bool = false) { + let wn = terminal.ptyWindow()?.windowNumber ?? 0 + PseudoTerminal.setUseUnlimitedHistoryForArrangement(true) + let arrangement = terminal.arrangementExcludingTmuxTabs(true, includingContents: true) ?? [:] + PseudoTerminal.setUseUnlimitedHistoryForArrangement(false) + // Refuse to archive an empty capture (DesignNotes #10): leave the window + // open and associated rather than create an unrestorable archive entry. + guard Self.isArchivable(arrangement) else { + Self.devReportEmptyArrangement(context: "archiveWindow", arrangement: arrangement, terminal: terminal) + return + } + if let guid = Self.guid(for: terminal) { + liveAssociations.removeValue(forKey: guid) + saveAssociations() + } + let title = terminal.ptyWindow()?.title ?? "Window" + let uuid = UUID() + Self.saveThumbnail(for: wn, uuid: uuid) + let entry = iTermArchivedWindow(id: uuid, name: title, arrangement: arrangement) + project.windows.append(entry) + project.lastUsed = Date() + save() + + if close && keepJobsRunning { + Self.parkSessionsForReattachment(terminal) + } + + if close { + terminal.orphanJobsOnClose = keepJobsRunning + terminal.close() + } + } + + // MARK: User-initiated close of an associated window + + private static let closeActionWarningIdentifier = "NoSyncWindowProjectCloseAction" + + /// Routes a *user-initiated* close (red button / Cmd-W) of an associated + /// window through a confirmation dialog offering Save & Detach, Save & + /// Close, or Remove from Project. The chosen action is applied — and the + /// window closed — on the next runloop tick so we don't re-enter the close + /// machinery from within `windowShouldClose:`. Returns `.notAssociated` for + /// windows that don't belong to a project so the caller falls back to its + /// normal close prompt. iTermWarning persists a “Remember my choice” + /// selection under `closeActionWarningIdentifier`; Cancel is never + /// remembered (so a window can never become unclosable). + @objc func handleUserInitiatedClose(of terminal: PseudoTerminal) -> iTermWindowProjectCloseHandling { + guard let project = project(for: terminal) else { return .notAssociated } + + let windowTitle = terminal.ptyWindow()?.title ?? "This window" + let warning = iTermWarning() + warning.heading = "Window Belongs to a Project" + warning.title = "“\(windowTitle)” is part of the project “\(project.name).” " + + "Save a snapshot and keep its jobs running (detach), save and close it, " + + "or remove it from the project?" + let remove = iTermWarningAction(label: "Remove from Project") + remove.destructive = true + // The “Remember my choice” selection is persisted by index, so do not + // reorder these without supplying an actionToSelectionMap. + warning.warningActions = [ + iTermWarningAction(label: "Save & Detach"), + iTermWarningAction(label: "Save & Close"), + remove, + iTermWarningAction(label: "Cancel"), + ] + warning.identifier = Self.closeActionWarningIdentifier + warning.warningType = .kiTermWarningTypePermanentlySilenceable + warning.cancelLabel = "Cancel" + warning.window = terminal.window + + let action: () -> Void + switch warning.runModal() { + case .kiTermWarningSelection0: // Save & Detach: snapshot + park + keep jobs + action = { self.archiveWindow(terminal, to: project, andClose: true, keepJobsRunning: true) } + case .kiTermWarningSelection1: // Save & Close: snapshot + close (process ends) + action = { self.archiveWindow(terminal, to: project, andClose: true, keepJobsRunning: false) } + case .kiTermWarningSelection2: // Remove from Project: disassociate, no snapshot + action = { + self.disassociateWindow(terminal) + terminal.close() + } + default: // Cancel / error + return .cancelled + } + DispatchQueue.main.async(execute: action) + return .handled + } + + // MARK: Freeze/Thaw diagnostics + + /// Parks each of `terminal`'s live multiserver children back onto their + /// (shared) connection's unattachedChildren list so a thawed window can + /// re-adopt the running process in-place — without closing the fd, tearing + /// down the connection, or re-handshaking. Must be called while the sessions + /// are still live and BEFORE `terminal.close()`. Gated by ITERM_WP_PARK so + /// the pre-fix failure mode stays reproducible (set ITERM_WP_PARK=0). + static func parkSessionsForReattachment(_ terminal: PseudoTerminal) { + let park = (ProcessInfo.processInfo.environment["ITERM_WP_PARK"] ?? "1") != "0" + guard let sessions = terminal.allSessions() else { return } + guard park else { + Self.wpLog("FREEZE park: DISABLED (ITERM_WP_PARK=0). Process kept running but NOT parked — thaw is expected to fall back to a fresh shell.") + return + } + for session in sessions { + let task = session.shell + let pid = task.parkChildForReattachment() + Self.wpLog("FREEZE park: session=\(session.guid) parkedPid=\(pid) tty=\(task.tty ?? "nil")") + } + } + + /// Appends a timestamped line to /tmp/iterm_wp.log and the system log so the + /// freeze/thaw cycle can be inspected without a debugger. + static func wpLog(_ message: String) { + NSLog("[WindowProjects] %@", message) + let line = "\(ISO8601DateFormatter().string(from: Date())) \(message)\n" + if let data = line.data(using: .utf8) { + let url = URL(fileURLWithPath: "/tmp/iterm_wp.log") + if let handle = try? FileHandle(forWritingTo: url) { + handle.seekToEndOfFile() + handle.write(data) + try? handle.close() + } else { + try? data.write(to: url) + } + } + } + + /// Collects every multiserver child PID referenced by `arrangement` (one per + /// session/split pane), not just the first. + static func allServerChildPIDs(in arrangement: [AnyHashable: Any]) -> [Int32] { + var pids: [Int32] = [] + func walk(_ node: Any) { + if let dict = node as? [AnyHashable: Any] { + if let sd = dict["Server Dict"] as? [AnyHashable: Any], + let p = (sd["Child PID"] as? Int) ?? (sd["Child PID"] as? NSNumber)?.intValue { + pids.append(Int32(p)) + } + for v in dict.values { walk(v) } + } else if let arr = node as? [Any] { + for v in arr { walk(v) } + } + } + walk(arrangement) + return pids + } + + /// Child PIDs owned by archived (detached) windows across all projects. The + /// orphan-server adopter consults this at startup so it leaves these parked + /// for on-demand project restore instead of pulling them into a generic + /// recovered window. (ObjC bridges Set to NSSet.) + @objc func claimedMultiserverChildPIDs() -> Set { + var result = Set() + func walk(_ projects: [iTermWindowProject]) { + for p in projects { + for w in p.windows { + guard let arr = w.arrangement else { continue } + for pid in Self.allServerChildPIDs(in: arr) { + result.insert(Int(pid)) + } + } + walk(p.children) + } + } + walk(rootProjects) + Self.wpLog("claimedMultiserverChildPIDs: \(result.sorted())") + return result + } + + /// Extracts (socket, childPid) from an arrangement's multiserver Server Dict. + static func serverDict(in arrangement: [AnyHashable: Any]) -> (socket: Int32, childPid: Int32)? { + func walk(_ node: Any) -> (Int32, Int32)? { + if let dict = node as? [AnyHashable: Any] { + if let sd = dict["Server Dict"] as? [AnyHashable: Any], + let s = (sd["Socket"] as? Int) ?? (sd["Socket"] as? NSNumber)?.intValue, + let p = (sd["Child PID"] as? Int) ?? (sd["Child PID"] as? NSNumber)?.intValue { + return (Int32(s), Int32(p)) + } + for v in dict.values { + if let r = walk(v) { return r } + } + } else if let arr = node as? [Any] { + for v in arr { + if let r = walk(v) { return r } + } + } + return nil + } + return walk(arrangement) + } + + /// Logs whether the orphaned child named in `arrangement` is currently + /// present in its multiserver connection's unattachedChildren list. This is + /// the precondition the native attach path requires; it is the single fact + /// that distinguishes "will re-attach" from "will spawn a fresh shell". + static func logUnattachedState(forArrangement arrangement: [AnyHashable: Any], phase: String) { + guard let (socket, childPid) = serverDict(in: arrangement) else { + wpLog("\(phase): arrangement has no multiserver Server Dict") + return + } + var done = false + var present = false + var total = -1 + let thread = iTermThread.main() + let callback = thread.newCallback { (_: Any?, value: Any?) in + if let result = value as? iTermResult { + result.handleObject({ connection in + let kids = connection.unattachedChildren + total = kids.count + present = kids.contains { $0.pid == childPid } + }, error: { _ in }) + } + done = true + } + let bridged = callback as! iTermCallback + // createIfPossible:false returns the cached connection without + // re-handshaking when it already exists (the in-process case), so this + // observes — does not mutate — the live state. + iTermMultiServerConnection.getForSocketNumber( socket, createIfPossible: false, callback: bridged) + let limit = Date(timeIntervalSinceNow: 1.0) + while !done && Date() < limit { + RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 0.05)) + } + wpLog("\(phase): socket=\(socket) childPid=\(childPid) unattachedCount=\(total) childPresent=\(present)") + } + + /// After a window is restored, logs whether its session actually re-attached + /// to the expected orphaned child (pid matches) or fell back to a brand-new + /// shell (pid differs). This is the ground-truth success signal. + static func logRestoredAttachment(_ term: PseudoTerminal, expectedFrom arrangement: [AnyHashable: Any], phase: String) { + guard let (socket, childPid) = serverDict(in: arrangement) else { return } + guard let session = term.allSessions()?.first as? PTYSession else { + wpLog("\(phase): restored window has no session/shell") + return + } + let actualPid = session.shell.pid + let reattached = (actualPid == childPid) + wpLog("\(phase): socket=\(socket) expectedChildPid=\(childPid) restoredShellPid=\(actualPid) reattached=\(reattached)") + } + + func removeWindow(_ window: iTermArchivedWindow, from project: iTermWindowProject) { + project.windows.removeAll { $0.id == window.id } + Self.deleteThumbnail(for: window.id) + save() + } + + // MARK: Restoration + + func restoreWindow(_ archived: iTermArchivedWindow) { + guard let project = parentProject(of: archived) else { return } + guard let arrangement = archived.arrangement else { return } + + let lionFullScreen = PseudoTerminal.arrangementIsLionFullScreen(arrangement) + PseudoTerminal.performWhenWindowCreationIsSafe(forLionFullScreen: lionFullScreen) { [weak self] in + guard let self = self else { return } + Self.logUnattachedState(forArrangement: arrangement, phase: "THAW before restore") + guard let term = PseudoTerminal( + arrangement: arrangement, + named: nil, + forceOpeningHotKeyWindow: false) else { return } + + iTermController.sharedInstance().addTerminalWindow(term) + Self.logRestoredAttachment(term, expectedFrom: arrangement, phase: "THAW after restore") + + // Remove the archived window from the project's list so it cannot be restored twice! + project.windows.removeAll { $0.id == archived.id } + Self.deleteThumbnail(for: archived.id) + + // Associate the newly opened window with the project! + if let guid = Self.guid(for: term) { + self.liveAssociations[guid] = project.id + self.saveAssociations() + } + + project.lastUsed = Date() + self.save() + } + } + + func restoreAllWindows(in project: iTermWindowProject) { + project.lastUsed = Date() + let windowsToRestore = project.windows + for archived in windowsToRestore { + guard let arrangement = archived.arrangement else { continue } + let lionFullScreen = PseudoTerminal.arrangementIsLionFullScreen(arrangement) + PseudoTerminal.performWhenWindowCreationIsSafe(forLionFullScreen: lionFullScreen) { [weak self] in + guard let self = self else { return } + Self.logUnattachedState(forArrangement: arrangement, phase: "THAW before restore") + guard let term = PseudoTerminal( + arrangement: arrangement, + named: nil, + forceOpeningHotKeyWindow: false) else { return } + + iTermController.sharedInstance().addTerminalWindow(term) + Self.logRestoredAttachment(term, expectedFrom: arrangement, phase: "THAW after restore") + + if let guid = Self.guid(for: term) { + self.liveAssociations[guid] = project.id + self.saveAssociations() + NotificationCenter.default.post(name: Self.didChangeNotification, object: self) + } + } + Self.deleteThumbnail(for: archived.id) + } + project.windows.removeAll() + 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 + } +} + +@objc public final class iTermWindowProjectsPathfinder: NSObject { + + /// Dumps, for daemon sockets 1...10, the cached/established multiserver + /// connection state and any unattached (orphaned, adoptable) children. This + /// is the same precondition the native session-restore attach path depends + /// on, surfaced for manual inspection. + private class func dumpConnectionState() -> String { + var log = "" + func line(_ s: String) { + log += s + "\n" + NSLog("[WindowProjects-Pathfinder] %@", s) + } + let thread = iTermThread.main() + for socketNumber in 1...10 { + var done = false + var summary = "socket \(socketNumber): " + let callback = thread.newCallback { (_: Any?, value: Any?) in + if let result = value as? iTermResult { + result.handleObject({ connection in + let kids = connection.unattachedChildren + let pids = kids.map { "\($0.pid)(fd \($0.fd))" }.joined(separator: ", ") + summary = "socket \(socketNumber): serverPid=\(connection.pid) unattachedChildren=[\(pids)]" + }, error: { _ in }) + } + done = true + } + let bridged = callback as! iTermCallback + // createIfPossible:false: observe the cached connection without + // re-handshaking (so we don't perturb live sessions on the daemon). + iTermMultiServerConnection.getForSocketNumber( Int32(socketNumber), + createIfPossible: false, + callback: bridged) + let limit = Date(timeIntervalSinceNow: 0.5) + while !done && Date() < limit { + RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 0.05)) + } + line(summary) + } + return log + } + + @objc public class func tryManualAdoption(on session: PTYSession) -> String { + var log = "--- Pathfinder: multiserver connection state ---\n" + log += "Target session TTY: \(session.tty ?? "nil"), shell pid: \(session.shell.pid)\n\n" + log += dumpConnectionState() + try? log.write(toFile: "/tmp/iterm_wp_pathfinder.log", atomically: true, encoding: .utf8) + return log + } + + @objc public class func runDiagnostics(on terminal: PseudoTerminal) -> String { + var log = "--- Pathfinder: multiserver connection state ---\n" + if let session = terminal.currentSession() { + log += "Current session TTY: \(session.tty ?? "nil"), shell pid: \(session.shell.pid)\n\n" + } + log += dumpConnectionState() + try? log.write(toFile: "/tmp/iterm_wp_pathfinder.log", atomically: true, encoding: .utf8) + return log + } +} diff --git a/tools/test_cold_storage_e2e.sh b/tools/test_cold_storage_e2e.sh new file mode 100755 index 0000000000..91d73e75f2 --- /dev/null +++ b/tools/test_cold_storage_e2e.sh @@ -0,0 +1,128 @@ +#!/bin/bash +# iTerm2 Window Projects & Cold Storage E2E Integration Test (PID isolated) +# Spawns iTerm2, starts a background command, freezes it, verifies process survival, and restores it. + +set -o pipefail + +APP_PATH="/Users/ysaxon/Library/Developer/Xcode/DerivedData/iTerm2-hghphmhudlrmuogydilxgbnitkjo/Build/Products/Development/iTerm2.app" +TEST_PROJECT="E2E-Test-Project" + +echo "--------------------------------------------------------" +echo "🚀 STARTING COLD STORAGE E2E SYSTEM INTEGRATION TEST" +echo "--------------------------------------------------------" + +# 1. Launch our specific development iTerm2 app +echo "1. Launching development iTerm2..." +open "$APP_PATH" +sleep 4 + +# Retrieve the specific PID of our development instance +DEV_PID=$(pgrep -f "$APP_PATH/Contents/MacOS/iTerm2" | head -n 1) +if [ -z "$DEV_PID" ]; then + echo "❌ Error: Could not find the PID of the running development iTerm2!" + exit 1 +fi +echo "✅ Isolated development iTerm2 PID: $DEV_PID (Safely avoiding your production iTerm)" + +# 2. Spawn a terminal window and run our unique loop targeting the dev path +echo "2. Launching a terminal window with a sleep task..." +osascript <