diff --git a/idx0/Services/Session/SessionService+Lifecycle.swift b/idx0/Services/Session/SessionService+Lifecycle.swift index 3ef9a5d..8fb08ab 100644 --- a/idx0/Services/Session/SessionService+Lifecycle.swift +++ b/idx0/Services/Session/SessionService+Lifecycle.swift @@ -3,558 +3,566 @@ import Foundation import SwiftUI extension SessionService { - func createQuickSession(atPath path: String? = nil, title: String? = nil) { - Task { - do { - let created = try await createSession( - from: SessionCreationRequest( - title: title, - repoPath: nil, - createWorktree: false, - branchName: nil, - existingWorktreePath: nil, - shellPath: nil - ) - ) - if let normalizedPath = normalizePath(path) { - applyLaunchDirectory(normalizedPath, to: created.session.id) - } - } catch { - Logger.error("Failed creating quick session: \(error.localizedDescription)") - } - } - } - - func createQuickSession() { - createQuickSession(atPath: nil, title: nil) - } - - func createSession(from request: SessionCreationRequest) async throws -> SessionCreationResult { - let normalizedRepo = normalizeRepoPath(request.repoPath) - let resolvedShell = try shellHealthService.resolvedShell( - explicitShell: request.shellPath, - preferredShell: settings.preferredShellPath + func createQuickSession(atPath path: String? = nil, title: String? = nil) { + Task { + do { + let created = try await createSession( + from: SessionCreationRequest( + title: title, + repoPath: nil, + createWorktree: false, + branchName: nil, + existingWorktreePath: nil, + shellPath: nil + ) ) - - var repoPath: String? - var branchName: String? - var worktreePath: String? - var isWorktreeBacked = false - var createdWorktree: WorktreeInfo? - - if let normalizedRepo { - branchName = request.branchName?.trimmingCharacters(in: .whitespacesAndNewlines) - if branchName?.isEmpty == true { branchName = nil } - - if request.createWorktree { - let repoInfo = try await worktreeService.validateRepo(path: normalizedRepo) - repoPath = repoInfo.topLevelPath - let worktree: WorktreeInfo - if let existingWorktreePath = normalizePath(request.existingWorktreePath) { - worktree = try await worktreeService.attachExistingWorktree( - repoPath: repoInfo.topLevelPath, - worktreePath: existingWorktreePath - ) - } else { - worktree = try await worktreeService.createWorktree( - repoPath: repoInfo.topLevelPath, - branchName: branchName, - sessionTitle: request.title - ) - } - worktreePath = worktree.worktreePath - branchName = worktree.branchName - isWorktreeBacked = true - createdWorktree = worktree - } else { - if let repoInfo = try? await worktreeService.validateRepo(path: normalizedRepo) { - repoPath = repoInfo.topLevelPath - if branchName == nil { - branchName = repoInfo.currentBranch - } - } else { - repoPath = normalizedRepo - } - } + if let normalizedPath = normalizePath(path) { + applyLaunchDirectory(normalizedPath, to: created.session.id) } - - let now = Date() - let resolvedTitle = resolveSessionTitle( - requested: request.title, - repoPath: repoPath, - branchName: branchName - ) - - let sandboxProfile = request.sandboxProfile ?? settings.defaultSandboxProfile - let enforcementState: SandboxEnforcementState = .unenforced - let networkPolicy = request.networkPolicy ?? settings.defaultNetworkPolicy - let sessionID = UUID() - - let session = Session( - id: sessionID, - title: resolvedTitle, - hasCustomTitle: !(request.title?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true), - isPinned: false, - createdAt: now, - lastActiveAt: now, - repoPath: repoPath, + } catch { + Logger.error("Failed creating quick session: \(error.localizedDescription)") + } + } + } + + func createQuickSession() { + createQuickSession(atPath: nil, title: nil) + } + + func createSession(from request: SessionCreationRequest) async throws -> SessionCreationResult { + let normalizedRepo = normalizeRepoPath(request.repoPath) + let resolvedShell = try shellHealthService.resolvedShell( + explicitShell: request.shellPath, + preferredShell: settings.preferredShellPath + ) + + var repoPath: String? + var branchName: String? + var worktreePath: String? + var isWorktreeBacked = false + var createdWorktree: WorktreeInfo? + + if let normalizedRepo { + branchName = request.branchName?.trimmingCharacters(in: .whitespacesAndNewlines) + if branchName?.isEmpty == true { branchName = nil } + + if request.createWorktree { + let repoInfo = try await worktreeService.validateRepo(path: normalizedRepo) + repoPath = repoInfo.topLevelPath + let worktree: WorktreeInfo = if let existingWorktreePath = normalizePath(request.existingWorktreePath) { + try await worktreeService.attachExistingWorktree( + repoPath: repoInfo.topLevelPath, + worktreePath: existingWorktreePath + ) + } else { + try await worktreeService.createWorktree( + repoPath: repoInfo.topLevelPath, branchName: branchName, - worktreePath: worktreePath, - worktreeState: isWorktreeBacked ? .attached : nil, - isWorktreeBacked: isWorktreeBacked, - shellPath: resolvedShell, - lastLaunchCwd: worktreePath ?? repoPath, - attentionState: .normal, - latestAttentionReason: nil, - sandboxProfile: sandboxProfile, - sandboxEnforcementState: enforcementState, - networkPolicy: networkPolicy, - statusText: nil, - lastKnownCwd: worktreePath ?? repoPath, - browserState: nil, - lastLaunchManifest: SessionLaunchManifest( - sessionID: sessionID, - cwd: normalizePath(worktreePath ?? repoPath ?? FileManager.default.homeDirectoryForCurrentUser.path) - ?? FileManager.default.homeDirectoryForCurrentUser.path, - shellPath: resolvedShell, - repoPath: normalizePath(repoPath), - worktreePath: normalizePath(worktreePath), - sandboxProfile: sandboxProfile, - networkPolicy: networkPolicy, - tempRoot: sandboxProfile == .worktreeAndTemp ? defaultTempRoot(for: sessionID) : nil, - environment: [:], - projectID: nil, - ipcSocketPath: ipcSocketPath - ), - selectedVibeToolID: request.launchToolID - ) - - sessions.append(session) - ensureTabState(for: session.id, defaultRootControllerID: session.id) - synchronizeProjectGroups() - selectSession(session.id) - persistSoon() - onSessionCreated?(session) - - return SessionCreationResult(session: session, worktree: createdWorktree) + sessionTitle: request.title + ) + } + worktreePath = worktree.worktreePath + branchName = worktree.branchName + isWorktreeBacked = true + createdWorktree = worktree + } else { + if let repoInfo = try? await worktreeService.validateRepo(path: normalizedRepo) { + repoPath = repoInfo.topLevelPath + if branchName == nil { + branchName = repoInfo.currentBranch + } + } else { + repoPath = normalizedRepo + } + } } - func selectSession(_ id: UUID) { - selectSession(id, updatesRecency: true) + let now = Date() + let resolvedTitle = resolveSessionTitle( + requested: request.title, + repoPath: repoPath, + branchName: branchName + ) + + let sandboxProfile = request.sandboxProfile ?? settings.defaultSandboxProfile + let enforcementState: SandboxEnforcementState = .unenforced + let networkPolicy = request.networkPolicy ?? settings.defaultNetworkPolicy + let sessionID = UUID() + + let session = Session( + id: sessionID, + title: resolvedTitle, + hasCustomTitle: !(request.title?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true), + isPinned: false, + createdAt: now, + lastActiveAt: now, + repoPath: repoPath, + branchName: branchName, + worktreePath: worktreePath, + worktreeState: isWorktreeBacked ? .attached : nil, + isWorktreeBacked: isWorktreeBacked, + shellPath: resolvedShell, + lastLaunchCwd: worktreePath ?? repoPath, + attentionState: .normal, + latestAttentionReason: nil, + sandboxProfile: sandboxProfile, + sandboxEnforcementState: enforcementState, + networkPolicy: networkPolicy, + statusText: nil, + lastKnownCwd: worktreePath ?? repoPath, + browserState: nil, + lastLaunchManifest: SessionLaunchManifest( + sessionID: sessionID, + cwd: normalizePath(worktreePath ?? repoPath ?? FileManager.default.homeDirectoryForCurrentUser.path) + ?? FileManager.default.homeDirectoryForCurrentUser.path, + shellPath: resolvedShell, + repoPath: normalizePath(repoPath), + worktreePath: normalizePath(worktreePath), + sandboxProfile: sandboxProfile, + networkPolicy: networkPolicy, + tempRoot: sandboxProfile == .worktreeAndTemp ? defaultTempRoot(for: sessionID) : nil, + environment: [:], + projectID: nil, + ipcSocketPath: ipcSocketPath + ), + selectedVibeToolID: request.launchToolID + ) + + sessions.append(session) + ensureTabState(for: session.id, defaultRootControllerID: session.id) + synchronizeProjectGroups() + selectSession(session.id) + persistSoon() + onSessionCreated?(session) + + return SessionCreationResult(session: session, worktree: createdWorktree) + } + + func selectSession(_ id: UUID) { + selectSession(id, updatesRecency: true) + } + + func focusSession(_ id: UUID) { + selectSession(id, updatesRecency: false) + } + + func selectSession(_ id: UUID, updatesRecency: Bool) { + guard sessions.contains(where: { $0.id == id }) else { return } + let previousSelectedSessionID = selectedSessionID + selectedSessionID = id + if let previousSelectedSessionID, previousSelectedSessionID != id { + controllerBecameHidden(sessionID: previousSelectedSessionID) } - - func focusSession(_ id: UUID) { - selectSession(id, updatesRecency: false) + if updatesRecency { + updateLastActive(id, at: Date()) } - - func selectSession(_ id: UUID, updatesRecency: Bool) { - guard sessions.contains(where: { $0.id == id }) else { return } - let previousSelectedSessionID = selectedSessionID - selectedSessionID = id - if let previousSelectedSessionID, previousSelectedSessionID != id { - controllerBecameHidden(sessionID: previousSelectedSessionID) - } - if updatesRecency { - updateLastActive(id, at: Date()) - } - resolveAttentionOnVisit(sessionID: id) - onSessionFocused?(id) - reconcileActiveState() - persistSoon() - - let browserVisible = sessions.first(where: { $0.id == id })?.browserState?.isVisible == true - let shouldPreferBrowserFocus = browserVisible && lastFocusedSurfaceBySession[id] == .browser - let terminalController = ensureController(for: id) - let launchedTerminalControllerIDs: Set = settings.niriCanvasEnabled - ? launchFocusedNiriTerminalIfVisible(sessionID: id, reason: .selectedSessionVisible) - : requestLaunchForActiveTerminals(in: id, reason: .selectedSessionVisible) - let shouldFocusTerminal = !settings.niriCanvasEnabled || !launchedTerminalControllerIDs.isEmpty - - if shouldPreferBrowserFocus { - _ = browserController(for: id) - } else if shouldFocusTerminal { - terminalController?.focus() - setLastFocusedSurface(for: id, surface: .terminal) - if browserVisible { - _ = browserController(for: id) - } - } + resolveAttentionOnVisit(sessionID: id) + onSessionFocused?(id) + reconcileActiveState() + persistSoon() + + let browserVisible = sessions.first(where: { $0.id == id })?.browserState?.isVisible == true + let shouldPreferBrowserFocus = browserVisible && lastFocusedSurfaceBySession[id] == .browser + let terminalController = ensureController(for: id) + let launchedTerminalControllerIDs: Set = settings.niriCanvasEnabled + ? launchFocusedNiriTerminalIfVisible(sessionID: id, reason: .selectedSessionVisible) + : requestLaunchForActiveTerminals(in: id, reason: .selectedSessionVisible) + let shouldFocusTerminal = !settings.niriCanvasEnabled || !launchedTerminalControllerIDs.isEmpty + + if shouldPreferBrowserFocus { + _ = browserController(for: id) + } else if shouldFocusTerminal { + terminalController?.focus() + setLastFocusedSurface(for: id, surface: .terminal) + if browserVisible { + _ = browserController(for: id) + } } - - func renameSession(_ id: UUID, title: String) { - let cleaned = title.trimmingCharacters(in: .whitespacesAndNewlines) - guard !cleaned.isEmpty else { return } - - guard let index = indexOfSession(id) else { return } - sessions[index].title = cleaned - sessions[index].hasCustomTitle = true - synchronizeProjectGroups() - persistSoon() + } + + func renameSession(_ id: UUID, title: String) { + let cleaned = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleaned.isEmpty else { return } + + guard let index = indexOfSession(id) else { return } + sessions[index].title = cleaned + sessions[index].hasCustomTitle = true + synchronizeProjectGroups() + persistSoon() + } + + func setPinned(_ id: UUID, pinned: Bool) { + guard let index = indexOfSession(id) else { return } + guard sessions[index].isPinned != pinned else { return } + sessions[index].isPinned = pinned + persistSoon() + } + + func togglePinned(_ id: UUID) { + guard let index = indexOfSession(id) else { return } + setPinned(id, pinned: !sessions[index].isPinned) + } + + func moveSessions(from source: IndexSet, to destination: Int) { + sessions.move(fromOffsets: source, toOffset: destination) + synchronizeProjectGroups() + persistSoon() + } + + func moveProjectGroups(from source: IndexSet, to destination: Int) { + projectService.moveGroups(from: source, to: destination) + projectGroups = projectService.groups + persistSoon() + } + + func toggleProjectCollapsed(_ groupID: UUID) { + projectService.toggleCollapsed(groupID) + projectGroups = projectService.groups + persistSoon() + } + + /// Focus the most recently active session in the Nth project group (1-based). + func focusProjectGroup(at index: Int) { + let sections = projectSections + guard index >= 1, index <= sections.count else { return } + let section = sections[index - 1] + // Pick the most recently active session in this group + if let best = section.sessions.max(by: { $0.lastActiveAt < $1.lastActiveAt }) { + focusSession(best.id) } - - func setPinned(_ id: UUID, pinned: Bool) { - guard let index = indexOfSession(id) else { return } - guard sessions[index].isPinned != pinned else { return } - sessions[index].isPinned = pinned - persistSoon() + } + + /// Focus the next session (by flat order). Wraps around. + func focusNextSession() { + guard !sessions.isEmpty else { return } + guard let currentID = selectedSessionID, + let currentIndex = sessions.firstIndex(where: { $0.id == currentID }) + else { + focusSession(sessions[0].id) + return } - - func togglePinned(_ id: UUID) { - guard let index = indexOfSession(id) else { return } - setPinned(id, pinned: !sessions[index].isPinned) + let nextIndex = (currentIndex + 1) % sessions.count + focusSession(sessions[nextIndex].id) + } + + /// Focus the previous session (by flat order). Wraps around. + func focusPreviousSession() { + guard !sessions.isEmpty else { return } + guard let currentID = selectedSessionID, + let currentIndex = sessions.firstIndex(where: { $0.id == currentID }) + else { + focusSession(sessions[0].id) + return } + let prevIndex = (currentIndex - 1 + sessions.count) % sessions.count + focusSession(sessions[prevIndex].id) + } - func moveSessions(from source: IndexSet, to destination: Int) { - sessions.move(fromOffsets: source, toOffset: destination) - synchronizeProjectGroups() - persistSoon() - } + // MARK: - Tabs - func moveProjectGroups(from source: IndexSet, to destination: Int) { - projectService.moveGroups(from: source, to: destination) - projectGroups = projectService.groups - persistSoon() + func tabs(for sessionID: UUID) -> [SessionTerminalTabItem] { + let tabs = tabsBySession[sessionID] ?? [] + return tabs.map { + SessionTerminalTabItem(id: $0.id, title: $0.title, paneCount: $0.paneCount) } - - func toggleProjectCollapsed(_ groupID: UUID) { - projectService.toggleCollapsed(groupID) - projectGroups = projectService.groups - persistSoon() + } + + func selectedTabID(for sessionID: UUID) -> UUID? { + selectedTabIDBySession[sessionID] ?? tabsBySession[sessionID]?.first?.id + } + + func tabState(sessionID: UUID, tabID: UUID) -> SessionTerminalTab? { + tabsBySession[sessionID]?.first(where: { $0.id == tabID }) + } + + @discardableResult + func createTab(in sessionID: UUID, activate: Bool = true) -> UUID? { + guard sessions.contains(where: { $0.id == sessionID }) else { return nil } + ensureTabState(for: sessionID, defaultRootControllerID: sessionID) + var tabs = tabsBySession[sessionID, default: []] + let tab = SessionTerminalTab( + id: UUID(), + title: nextTabTitle(for: tabs), + rootControllerID: UUID(), + paneTree: nil, + focusedPaneControllerID: nil + ) + tabs.append(tab) + tabsBySession[sessionID] = tabs + if activate { + selectedTabIDBySession[sessionID] = tab.id + syncActivePaneState(for: sessionID) + if shouldLaunchVisibleTerminals(for: sessionID) { + _ = requestLaunchForActiveTerminals(in: sessionID, reason: .activeSplitPaneVisible) + } + ensureController(for: sessionID)?.focus() + setLastFocusedSurface(for: sessionID, surface: .terminal) } - - /// Focus the most recently active session in the Nth project group (1-based). - func focusProjectGroup(at index: Int) { - let sections = projectSections - guard index >= 1, index <= sections.count else { return } - let section = sections[index - 1] - // Pick the most recently active session in this group - if let best = section.sessions.max(by: { $0.lastActiveAt < $1.lastActiveAt }) { - focusSession(best.id) - } + return tab.id + } + + func closeActiveTab(in sessionID: UUID) { + ensureTabState(for: sessionID, defaultRootControllerID: sessionID) + var tabs = tabsBySession[sessionID, default: []] + guard tabs.count > 1 else { return } + guard let activeIndex = activeTabIndex(for: sessionID) else { return } + + let closingTab = tabs[activeIndex] + for controllerID in Set(closingTab.allControllerIDs) { + runtimeControllers[controllerID]?.terminate() + runtimeControllers.removeValue(forKey: controllerID) + ownerSessionIDByControllerID.removeValue(forKey: controllerID) + clearLaunchTracking(for: controllerID) } - /// Focus the next session (by flat order). Wraps around. - func focusNextSession() { - guard !sessions.isEmpty else { return } - guard let currentID = selectedSessionID, - let currentIndex = sessions.firstIndex(where: { $0.id == currentID }) else { - focusSession(sessions[0].id) - return - } - let nextIndex = (currentIndex + 1) % sessions.count - focusSession(sessions[nextIndex].id) + tabs.remove(at: activeIndex) + tabsBySession[sessionID] = tabs + let nextIndex = min(activeIndex, tabs.count - 1) + selectedTabIDBySession[sessionID] = tabs[nextIndex].id + removeNiriCells(sessionID: sessionID, matchingTabID: closingTab.id) + syncNiriFocusWithSelectedTab(sessionID: sessionID) + syncActivePaneState(for: sessionID) + if shouldLaunchVisibleTerminals(for: sessionID) { + _ = requestLaunchForActiveTerminals(in: sessionID, reason: .activeSplitPaneVisible) } - - /// Focus the previous session (by flat order). Wraps around. - func focusPreviousSession() { - guard !sessions.isEmpty else { return } - guard let currentID = selectedSessionID, - let currentIndex = sessions.firstIndex(where: { $0.id == currentID }) else { - focusSession(sessions[0].id) - return - } - let prevIndex = (currentIndex - 1 + sessions.count) % sessions.count - focusSession(sessions[prevIndex].id) + if selectedSessionID == sessionID { + ensureController(for: sessionID)?.focus() } - - // MARK: - Tabs - - func tabs(for sessionID: UUID) -> [SessionTerminalTabItem] { - let tabs = tabsBySession[sessionID] ?? [] - return tabs.map { - SessionTerminalTabItem(id: $0.id, title: $0.title, paneCount: $0.paneCount) - } + } + + func focusNextTab(in sessionID: UUID) { + ensureTabState(for: sessionID, defaultRootControllerID: sessionID) + guard let tabs = tabsBySession[sessionID], tabs.count > 1 else { return } + guard let selected = selectedTabIDBySession[sessionID], + let currentIndex = tabs.firstIndex(where: { $0.id == selected }) else { return } + let nextIndex = (currentIndex + 1) % tabs.count + selectedTabIDBySession[sessionID] = tabs[nextIndex].id + syncNiriFocusWithSelectedTab(sessionID: sessionID) + syncActivePaneState(for: sessionID) + if shouldLaunchVisibleTerminals(for: sessionID) { + _ = requestLaunchForActiveTerminals(in: sessionID, reason: .activeSplitPaneVisible) } - - func selectedTabID(for sessionID: UUID) -> UUID? { - selectedTabIDBySession[sessionID] ?? tabsBySession[sessionID]?.first?.id + if selectedSessionID == sessionID { + ensureController(for: sessionID)?.focus() } - - func tabState(sessionID: UUID, tabID: UUID) -> SessionTerminalTab? { - tabsBySession[sessionID]?.first(where: { $0.id == tabID }) + } + + func focusPreviousTab(in sessionID: UUID) { + ensureTabState(for: sessionID, defaultRootControllerID: sessionID) + guard let tabs = tabsBySession[sessionID], tabs.count > 1 else { return } + guard let selected = selectedTabIDBySession[sessionID], + let currentIndex = tabs.firstIndex(where: { $0.id == selected }) else { return } + let previousIndex = (currentIndex - 1 + tabs.count) % tabs.count + selectedTabIDBySession[sessionID] = tabs[previousIndex].id + syncNiriFocusWithSelectedTab(sessionID: sessionID) + syncActivePaneState(for: sessionID) + if shouldLaunchVisibleTerminals(for: sessionID) { + _ = requestLaunchForActiveTerminals(in: sessionID, reason: .activeSplitPaneVisible) } - - @discardableResult - func createTab(in sessionID: UUID, activate: Bool = true) -> UUID? { - guard sessions.contains(where: { $0.id == sessionID }) else { return nil } - ensureTabState(for: sessionID, defaultRootControllerID: sessionID) - var tabs = tabsBySession[sessionID, default: []] - let tab = SessionTerminalTab( - id: UUID(), - title: nextTabTitle(for: tabs), - rootControllerID: UUID(), - paneTree: nil, - focusedPaneControllerID: nil - ) - tabs.append(tab) - tabsBySession[sessionID] = tabs - if activate { - selectedTabIDBySession[sessionID] = tab.id - syncActivePaneState(for: sessionID) - if shouldLaunchVisibleTerminals(for: sessionID) { - _ = requestLaunchForActiveTerminals(in: sessionID, reason: .activeSplitPaneVisible) - } - ensureController(for: sessionID)?.focus() - setLastFocusedSurface(for: sessionID, surface: .terminal) - } - return tab.id + if selectedSessionID == sessionID { + ensureController(for: sessionID)?.focus() } - - func closeActiveTab(in sessionID: UUID) { - ensureTabState(for: sessionID, defaultRootControllerID: sessionID) - var tabs = tabsBySession[sessionID, default: []] - guard tabs.count > 1 else { return } - guard let activeIndex = activeTabIndex(for: sessionID) else { return } - - let closingTab = tabs[activeIndex] - for controllerID in Set(closingTab.allControllerIDs) { - runtimeControllers[controllerID]?.terminate() - runtimeControllers.removeValue(forKey: controllerID) - ownerSessionIDByControllerID.removeValue(forKey: controllerID) - clearLaunchTracking(for: controllerID) - } - - tabs.remove(at: activeIndex) - tabsBySession[sessionID] = tabs - let nextIndex = min(activeIndex, tabs.count - 1) - selectedTabIDBySession[sessionID] = tabs[nextIndex].id - removeNiriCells(sessionID: sessionID, matchingTabID: closingTab.id) - syncNiriFocusWithSelectedTab(sessionID: sessionID) - syncActivePaneState(for: sessionID) - if shouldLaunchVisibleTerminals(for: sessionID) { - _ = requestLaunchForActiveTerminals(in: sessionID, reason: .activeSplitPaneVisible) - } - if selectedSessionID == sessionID { - ensureController(for: sessionID)?.focus() - } + } + + func selectTab(sessionID: UUID, tabID: UUID) { + ensureTabState(for: sessionID, defaultRootControllerID: sessionID) + guard let tabs = tabsBySession[sessionID], tabs.contains(where: { $0.id == tabID }) else { return } + selectedTabIDBySession[sessionID] = tabID + syncNiriFocusWithSelectedTab(sessionID: sessionID) + syncActivePaneState(for: sessionID) + if shouldLaunchVisibleTerminals(for: sessionID) { + _ = requestLaunchForActiveTerminals(in: sessionID, reason: .activeSplitPaneVisible) } - - func focusNextTab(in sessionID: UUID) { - ensureTabState(for: sessionID, defaultRootControllerID: sessionID) - guard let tabs = tabsBySession[sessionID], tabs.count > 1 else { return } - guard let selected = selectedTabIDBySession[sessionID], - let currentIndex = tabs.firstIndex(where: { $0.id == selected }) else { return } - let nextIndex = (currentIndex + 1) % tabs.count - selectedTabIDBySession[sessionID] = tabs[nextIndex].id - syncNiriFocusWithSelectedTab(sessionID: sessionID) - syncActivePaneState(for: sessionID) - if shouldLaunchVisibleTerminals(for: sessionID) { - _ = requestLaunchForActiveTerminals(in: sessionID, reason: .activeSplitPaneVisible) - } - if selectedSessionID == sessionID { - ensureController(for: sessionID)?.focus() - } + if selectedSessionID == sessionID { + ensureController(for: sessionID)?.focus() } - - func focusPreviousTab(in sessionID: UUID) { - ensureTabState(for: sessionID, defaultRootControllerID: sessionID) - guard let tabs = tabsBySession[sessionID], tabs.count > 1 else { return } - guard let selected = selectedTabIDBySession[sessionID], - let currentIndex = tabs.firstIndex(where: { $0.id == selected }) else { return } - let previousIndex = (currentIndex - 1 + tabs.count) % tabs.count - selectedTabIDBySession[sessionID] = tabs[previousIndex].id - syncNiriFocusWithSelectedTab(sessionID: sessionID) - syncActivePaneState(for: sessionID) - if shouldLaunchVisibleTerminals(for: sessionID) { - _ = requestLaunchForActiveTerminals(in: sessionID, reason: .activeSplitPaneVisible) - } - if selectedSessionID == sessionID { - ensureController(for: sessionID)?.focus() - } + } + + // MARK: - Split Panes + + /// Split the currently focused pane in the active tab for a session. + func splitPane(sessionID: UUID, direction: PaneSplitDirection) { + guard let session = sessions.first(where: { $0.id == sessionID }) else { return } + ensureTabState(for: sessionID, defaultRootControllerID: sessionID) + guard var tabs = tabsBySession[sessionID], let activeIndex = activeTabIndex(for: sessionID) else { return } + var tab = tabs[activeIndex] + + // Ensure the active root controller exists before we build a split tree. + _ = ensureController(forControllerID: tab.rootControllerID, ownerSessionID: sessionID) + + // Resolve shell and cwd for the new pane without touching the session's manifest + let shellPath = session.shellPath + let launchDir = session.lastKnownCwd + ?? session.worktreePath + ?? session.repoPath + ?? FileManager.default.homeDirectoryForCurrentUser.path + + // Create a new controller for the new pane + let newControllerID = UUID() + let newController = TerminalSessionController( + sessionID: newControllerID, + launchDirectory: launchDir, + shellPath: shellPath, + host: host + ) + queueTerminalStartupCommandIfNeeded( + controller: newController, + ownerSessionID: sessionID, + launchDirectory: launchDir + ) + wireControllerCallbacks(newController, sessionID: sessionID) + runtimeControllers[newControllerID] = newController + ownerSessionIDByControllerID[newControllerID] = sessionID + + if let existingTree = tab.paneTree { + let focusedID = tab.focusedPaneControllerID ?? existingTree.terminalControllerIDs.first ?? tab.rootControllerID + tab.paneTree = existingTree.splitting( + controllerID: focusedID, + direction: direction, + newControllerID: newControllerID + ) + } else { + let singlePane = PaneNode.terminal(id: UUID(), controllerID: tab.rootControllerID) + let newPane = PaneNode.terminal(id: UUID(), controllerID: newControllerID) + tab.paneTree = .split( + id: UUID(), + direction: direction, + first: singlePane, + second: newPane, + fraction: 0.5 + ) } - func selectTab(sessionID: UUID, tabID: UUID) { - ensureTabState(for: sessionID, defaultRootControllerID: sessionID) - guard let tabs = tabsBySession[sessionID], tabs.contains(where: { $0.id == tabID }) else { return } - selectedTabIDBySession[sessionID] = tabID - syncNiriFocusWithSelectedTab(sessionID: sessionID) - syncActivePaneState(for: sessionID) - if shouldLaunchVisibleTerminals(for: sessionID) { - _ = requestLaunchForActiveTerminals(in: sessionID, reason: .activeSplitPaneVisible) - } - if selectedSessionID == sessionID { - ensureController(for: sessionID)?.focus() - } + tab.focusedPaneControllerID = newControllerID + tabs[activeIndex] = tab + tabsBySession[sessionID] = tabs + syncActivePaneState(for: sessionID) + if shouldLaunchVisibleTerminals(for: sessionID) { + _ = requestLaunchForActiveTerminals(in: sessionID, reason: .activeSplitPaneVisible) } - - // MARK: - Split Panes - - /// Split the currently focused pane in the active tab for a session. - func splitPane(sessionID: UUID, direction: PaneSplitDirection) { - guard let session = sessions.first(where: { $0.id == sessionID }) else { return } - ensureTabState(for: sessionID, defaultRootControllerID: sessionID) - guard var tabs = tabsBySession[sessionID], let activeIndex = activeTabIndex(for: sessionID) else { return } - var tab = tabs[activeIndex] - - // Ensure the active root controller exists before we build a split tree. - _ = ensureController(forControllerID: tab.rootControllerID, ownerSessionID: sessionID) - - // Resolve shell and cwd for the new pane without touching the session's manifest - let shellPath = session.shellPath - let launchDir = session.lastKnownCwd - ?? session.worktreePath - ?? session.repoPath - ?? FileManager.default.homeDirectoryForCurrentUser.path - - // Create a new controller for the new pane - let newControllerID = UUID() - let newController = TerminalSessionController( - sessionID: newControllerID, - launchDirectory: launchDir, - shellPath: shellPath, - host: host - ) - queueTerminalStartupCommandIfNeeded( - controller: newController, - ownerSessionID: sessionID, - launchDirectory: launchDir - ) - wireControllerCallbacks(newController, sessionID: sessionID) - runtimeControllers[newControllerID] = newController - ownerSessionIDByControllerID[newControllerID] = sessionID - - if let existingTree = tab.paneTree { - let focusedID = tab.focusedPaneControllerID ?? existingTree.terminalControllerIDs.first ?? tab.rootControllerID - tab.paneTree = existingTree.splitting( - controllerID: focusedID, - direction: direction, - newControllerID: newControllerID - ) - } else { - let singlePane = PaneNode.terminal(id: UUID(), controllerID: tab.rootControllerID) - let newPane = PaneNode.terminal(id: UUID(), controllerID: newControllerID) - tab.paneTree = .split( - id: UUID(), - direction: direction, - first: singlePane, - second: newPane, - fraction: 0.5 - ) - } - - tab.focusedPaneControllerID = newControllerID - tabs[activeIndex] = tab - tabsBySession[sessionID] = tabs - syncActivePaneState(for: sessionID) - if shouldLaunchVisibleTerminals(for: sessionID) { - _ = requestLaunchForActiveTerminals(in: sessionID, reason: .activeSplitPaneVisible) - } + } + + /// Close the currently focused pane in the active tab for a session. + /// If only one pane remains, no-op (option 1 behavior). + func closePane(sessionID: UUID) { + ensureTabState(for: sessionID, defaultRootControllerID: sessionID) + guard var tabs = tabsBySession[sessionID], let activeIndex = activeTabIndex(for: sessionID) else { return } + var tab = tabs[activeIndex] + guard let tree = tab.paneTree else { return } + let focusedID = tab.focusedPaneControllerID ?? tree.terminalControllerIDs.first ?? tab.rootControllerID + + // Don't close if it's the last pane. + guard tree.terminalCount > 1 else { return } + + runtimeControllers[focusedID]?.terminate() + runtimeControllers.removeValue(forKey: focusedID) + ownerSessionIDByControllerID.removeValue(forKey: focusedID) + clearLaunchTracking(for: focusedID) + + if let remaining = tree.removing(controllerID: focusedID) { + if remaining.terminalCount == 1 { + let remainingControllerID = remaining.terminalControllerIDs.first ?? tab.rootControllerID + tab.rootControllerID = remainingControllerID + tab.paneTree = nil + tab.focusedPaneControllerID = nil + } else { + tab.paneTree = remaining + tab.focusedPaneControllerID = remaining.terminalControllerIDs.first + } + } else { + tab.paneTree = nil + tab.focusedPaneControllerID = nil } - /// Close the currently focused pane in the active tab for a session. - /// If only one pane remains, no-op (option 1 behavior). - func closePane(sessionID: UUID) { - ensureTabState(for: sessionID, defaultRootControllerID: sessionID) - guard var tabs = tabsBySession[sessionID], let activeIndex = activeTabIndex(for: sessionID) else { return } - var tab = tabs[activeIndex] - guard let tree = tab.paneTree else { return } - let focusedID = tab.focusedPaneControllerID ?? tree.terminalControllerIDs.first ?? tab.rootControllerID - - // Don't close if it's the last pane. - guard tree.terminalCount > 1 else { return } - - runtimeControllers[focusedID]?.terminate() - runtimeControllers.removeValue(forKey: focusedID) - ownerSessionIDByControllerID.removeValue(forKey: focusedID) - clearLaunchTracking(for: focusedID) - - if let remaining = tree.removing(controllerID: focusedID) { - if remaining.terminalCount == 1 { - let remainingControllerID = remaining.terminalControllerIDs.first ?? tab.rootControllerID - tab.rootControllerID = remainingControllerID - tab.paneTree = nil - tab.focusedPaneControllerID = nil - } else { - tab.paneTree = remaining - tab.focusedPaneControllerID = remaining.terminalControllerIDs.first - } - } else { - tab.paneTree = nil - tab.focusedPaneControllerID = nil - } - - tabs[activeIndex] = tab - tabsBySession[sessionID] = tabs - syncActivePaneState(for: sessionID) + tabs[activeIndex] = tab + tabsBySession[sessionID] = tabs + syncActivePaneState(for: sessionID) + } + + /// Cycle focus to the next pane in the active tab. + func focusNextPane(sessionID: UUID) { + ensureTabState(for: sessionID, defaultRootControllerID: sessionID) + guard var tabs = tabsBySession[sessionID], let activeIndex = activeTabIndex(for: sessionID) else { return } + var tab = tabs[activeIndex] + guard let tree = tab.paneTree else { return } + let ids = tree.terminalControllerIDs + guard ids.count > 1 else { return } + let currentID = tab.focusedPaneControllerID ?? ids.first! + if let idx = ids.firstIndex(of: currentID) { + let nextIdx = (idx + 1) % ids.count + tab.focusedPaneControllerID = ids[nextIdx] + tabs[activeIndex] = tab + tabsBySession[sessionID] = tabs + syncActivePaneState(for: sessionID) + if shouldLaunchVisibleTerminals(for: sessionID) { + _ = requestLaunch(for: ids[nextIdx], ownerSessionID: sessionID, reason: .activeSplitPaneVisible) + } + ensurePaneController(for: ids[nextIdx])?.focus() } - - /// Cycle focus to the next pane in the active tab. - func focusNextPane(sessionID: UUID) { - ensureTabState(for: sessionID, defaultRootControllerID: sessionID) - guard var tabs = tabsBySession[sessionID], let activeIndex = activeTabIndex(for: sessionID) else { return } - var tab = tabs[activeIndex] - guard let tree = tab.paneTree else { return } - let ids = tree.terminalControllerIDs - guard ids.count > 1 else { return } - let currentID = tab.focusedPaneControllerID ?? ids.first! - if let idx = ids.firstIndex(of: currentID) { - let nextIdx = (idx + 1) % ids.count - tab.focusedPaneControllerID = ids[nextIdx] - tabs[activeIndex] = tab - tabsBySession[sessionID] = tabs - syncActivePaneState(for: sessionID) - if shouldLaunchVisibleTerminals(for: sessionID) { - _ = requestLaunch(for: ids[nextIdx], ownerSessionID: sessionID, reason: .activeSplitPaneVisible) - } - ensurePaneController(for: ids[nextIdx])?.focus() - } + } + + /// Cycle focus to the previous pane in the active tab. + func focusPreviousPane(sessionID: UUID) { + ensureTabState(for: sessionID, defaultRootControllerID: sessionID) + guard var tabs = tabsBySession[sessionID], let activeIndex = activeTabIndex(for: sessionID) else { return } + var tab = tabs[activeIndex] + guard let tree = tab.paneTree else { return } + let ids = tree.terminalControllerIDs + guard ids.count > 1 else { return } + let currentID = tab.focusedPaneControllerID ?? ids.first! + if let idx = ids.firstIndex(of: currentID) { + let prevIdx = (idx - 1 + ids.count) % ids.count + tab.focusedPaneControllerID = ids[prevIdx] + tabs[activeIndex] = tab + tabsBySession[sessionID] = tabs + syncActivePaneState(for: sessionID) + if shouldLaunchVisibleTerminals(for: sessionID) { + _ = requestLaunch(for: ids[prevIdx], ownerSessionID: sessionID, reason: .activeSplitPaneVisible) + } + ensurePaneController(for: ids[prevIdx])?.focus() } - - /// Cycle focus to the previous pane in the active tab. - func focusPreviousPane(sessionID: UUID) { - ensureTabState(for: sessionID, defaultRootControllerID: sessionID) - guard var tabs = tabsBySession[sessionID], let activeIndex = activeTabIndex(for: sessionID) else { return } - var tab = tabs[activeIndex] - guard let tree = tab.paneTree else { return } - let ids = tree.terminalControllerIDs - guard ids.count > 1 else { return } - let currentID = tab.focusedPaneControllerID ?? ids.first! - if let idx = ids.firstIndex(of: currentID) { - let prevIdx = (idx - 1 + ids.count) % ids.count - tab.focusedPaneControllerID = ids[prevIdx] - tabs[activeIndex] = tab - tabsBySession[sessionID] = tabs - syncActivePaneState(for: sessionID) - if shouldLaunchVisibleTerminals(for: sessionID) { - _ = requestLaunch(for: ids[prevIdx], ownerSessionID: sessionID, reason: .activeSplitPaneVisible) - } - ensurePaneController(for: ids[prevIdx])?.focus() - } + } + + /// Set the focused pane controller in the active tab (called from PaneTreeView tap). + func setFocusedPane(sessionID: UUID, controllerID: UUID) { + ensureTabState(for: sessionID, defaultRootControllerID: sessionID) + guard var tabs = tabsBySession[sessionID], let activeIndex = activeTabIndex(for: sessionID) else { return } + var tab = tabs[activeIndex] + tab.focusedPaneControllerID = controllerID + tabs[activeIndex] = tab + tabsBySession[sessionID] = tabs + syncActivePaneState(for: sessionID) + // Pane focus indicates explicit terminal intent (used when deciding + // whether session selection should prefer browser/app focus). + setLastFocusedSurface(for: sessionID, surface: .terminal) + if shouldLaunchVisibleTerminals(for: sessionID) { + _ = requestLaunch(for: controllerID, ownerSessionID: sessionID, reason: .activeSplitPaneVisible) } - - /// Set the focused pane controller in the active tab (called from PaneTreeView tap). - func setFocusedPane(sessionID: UUID, controllerID: UUID) { - ensureTabState(for: sessionID, defaultRootControllerID: sessionID) - guard var tabs = tabsBySession[sessionID], let activeIndex = activeTabIndex(for: sessionID) else { return } - var tab = tabs[activeIndex] - tab.focusedPaneControllerID = controllerID - tabs[activeIndex] = tab - tabsBySession[sessionID] = tabs - syncActivePaneState(for: sessionID) - if shouldLaunchVisibleTerminals(for: sessionID) { - _ = requestLaunch(for: controllerID, ownerSessionID: sessionID, reason: .activeSplitPaneVisible) - } + // Keep Cmd-driven edit actions (copy/paste/select all) routed to the + // focused pane by synchronizing AppKit first-responder focus. + if selectedSessionID == sessionID { + ensurePaneController(for: controllerID)?.focus() } - - func wireControllerCallbacks(_ controller: TerminalSessionController, sessionID: UUID) { - controller.onTitleChanged = { [weak self] title in - guard let self else { return } - // Only update title from the primary controller - if controller.sessionID == sessionID { - if let idx = self.indexOfSession(sessionID), !self.sessions[idx].hasCustomTitle { - self.sessions[idx].title = title - self.synchronizeProjectGroups() - self.persistSoon() - } - } - } - controller.onCwdChanged = { [weak self] cwd in - guard let self else { return } - if let idx = self.indexOfSession(sessionID) { - self.sessions[idx].lastKnownCwd = cwd - self.persistSoon() - } + } + + func wireControllerCallbacks(_ controller: TerminalSessionController, sessionID: UUID) { + controller.onTitleChanged = { [weak self] title in + guard let self else { return } + // Only update title from the primary controller + if controller.sessionID == sessionID { + if let idx = indexOfSession(sessionID), !self.sessions[idx].hasCustomTitle { + sessions[idx].title = title + synchronizeProjectGroups() + persistSoon() } + } } - + controller.onCwdChanged = { [weak self] cwd in + guard let self else { return } + if let idx = indexOfSession(sessionID) { + sessions[idx].lastKnownCwd = cwd + persistSoon() + } + } + } } diff --git a/idx0/Terminal/GhosttyTerminalSurface.swift b/idx0/Terminal/GhosttyTerminalSurface.swift index 6199493..4b221d7 100644 --- a/idx0/Terminal/GhosttyTerminalSurface.swift +++ b/idx0/Terminal/GhosttyTerminalSurface.swift @@ -3,729 +3,786 @@ import Foundation @MainActor final class GhosttyTerminalSurface: ObservableObject { - let sessionID: UUID - let workingDirectory: String - let shellPath: String - internal var surface: ghostty_surface_t? - let view: GhosttyNativeView - - private(set) var callbackContext: Unmanaged? - private var pendingInputQueue: [PendingInputAction] = [] - - private enum PendingInputAction { - case text(String) - case returnKey - } - - init( - sessionID: UUID, - workingDirectory: String, - shellPath: String, - view: GhosttyNativeView, - callbackContext: Unmanaged - ) { - self.sessionID = sessionID - self.workingDirectory = workingDirectory - self.shellPath = shellPath - self.view = view - self.callbackContext = callbackContext - - callbackContext.takeUnretainedValue().surface = self - } - - deinit { - callbackContext?.release() - } - - /// Create the ghostty surface. Must be called BEFORE the view is added - /// to any layer-backed hierarchy, because ghostty sets up a layer-hosting - /// view by setting layer before wantsLayer. - func createSurfaceIfNeeded() { - guard surface == nil else { - flushPendingTextIfReady() - return - } - GhosttyAppHost.shared.createSurface(for: self) - flushPendingTextIfReady() - } - - func destroy(freeSynchronously: Bool = false) { - let context = callbackContext - callbackContext = nil - context?.takeUnretainedValue().surface = nil - - guard let surfaceToFree = surface else { - view.prepareForSurfaceTeardown() - context?.release() - return - } - - GhosttyAppHost.shared.removeSurface(self) - surface = nil - - idx0_ghostty_surface_set_focus(surfaceToFree, false) - idx0_ghostty_surface_set_occlusion(surfaceToFree, false) - view.prepareForSurfaceTeardown() - - if freeSynchronously { - idx0_ghostty_surface_free(surfaceToFree) - context?.release() - return - } - - // Keep free asynchronous to avoid tearing down while AppKit/CALayer is - // still in the same render transaction for this view. - Task { @MainActor in - idx0_ghostty_surface_free(surfaceToFree) - context?.release() - } - } - - func resizeToCurrentViewBounds() { - guard surface != nil else { return } - let pointSize = view.bounds.size - let backingSize = view.convertToBacking(NSRect(origin: .zero, size: pointSize)).size - GhosttyAppHost.shared.resizeSurface(self, pointSize: pointSize, backingSize: backingSize) - } - - func focus() { - guard surface != nil else { return } - GhosttyAppHost.shared.focusSurface(self) - } - - func blur() { - guard surface != nil else { return } - GhosttyAppHost.shared.blurSurface(self) - } - - func send(text: String) { - guard !text.isEmpty else { return } - guard surface != nil else { - pendingInputQueue.append(.text(text)) - return - } + let sessionID: UUID + let workingDirectory: String + let shellPath: String + var surface: ghostty_surface_t? + let view: GhosttyNativeView + + private(set) var callbackContext: Unmanaged? + private var pendingInputQueue: [PendingInputAction] = [] + + private enum PendingInputAction { + case text(String) + case returnKey + } + + init( + sessionID: UUID, + workingDirectory: String, + shellPath: String, + view: GhosttyNativeView, + callbackContext: Unmanaged + ) { + self.sessionID = sessionID + self.workingDirectory = workingDirectory + self.shellPath = shellPath + self.view = view + self.callbackContext = callbackContext + + callbackContext.takeUnretainedValue().surface = self + } + + deinit { + callbackContext?.release() + } + + /// Create the ghostty surface. Must be called BEFORE the view is added + /// to any layer-backed hierarchy, because ghostty sets up a layer-hosting + /// view by setting layer before wantsLayer. + func createSurfaceIfNeeded() { + guard surface == nil else { + flushPendingTextIfReady() + return + } + GhosttyAppHost.shared.createSurface(for: self) + flushPendingTextIfReady() + } + + func destroy(freeSynchronously: Bool = false) { + let context = callbackContext + callbackContext = nil + context?.takeUnretainedValue().surface = nil + + guard let surfaceToFree = surface else { + view.prepareForSurfaceTeardown() + context?.release() + return + } + + GhosttyAppHost.shared.removeSurface(self) + surface = nil + + idx0_ghostty_surface_set_focus(surfaceToFree, false) + idx0_ghostty_surface_set_occlusion(surfaceToFree, false) + view.prepareForSurfaceTeardown() + + if freeSynchronously { + idx0_ghostty_surface_free(surfaceToFree) + context?.release() + return + } + + // Keep free asynchronous to avoid tearing down while AppKit/CALayer is + // still in the same render transaction for this view. + Task { @MainActor in + idx0_ghostty_surface_free(surfaceToFree) + context?.release() + } + } + + func resizeToCurrentViewBounds() { + guard surface != nil else { return } + let pointSize = view.bounds.size + let backingSize = view.convertToBacking(NSRect(origin: .zero, size: pointSize)).size + GhosttyAppHost.shared.resizeSurface(self, pointSize: pointSize, backingSize: backingSize) + } + + func focus() { + guard surface != nil else { return } + GhosttyAppHost.shared.focusSurface(self) + } + + func blur() { + guard surface != nil else { return } + GhosttyAppHost.shared.blurSurface(self) + } + + func send(text: String) { + guard !text.isEmpty else { return } + guard surface != nil else { + pendingInputQueue.append(.text(text)) + return + } + GhosttyAppHost.shared.sendText(text, to: self) + } + + func sendReturnKey() { + guard surface != nil else { + pendingInputQueue.append(.returnKey) + return + } + sendReturnKeyToSurface() + } + + func refresh() { + guard surface != nil else { return } + GhosttyAppHost.shared.refreshSurface(self) + } + + private func flushPendingTextIfReady() { + guard surface != nil, !pendingInputQueue.isEmpty else { return } + let queued = pendingInputQueue + pendingInputQueue.removeAll(keepingCapacity: true) + for action in queued { + switch action { + case let .text(text): GhosttyAppHost.shared.sendText(text, to: self) - } - - func sendReturnKey() { - guard surface != nil else { - pendingInputQueue.append(.returnKey) - return - } + case .returnKey: sendReturnKeyToSurface() - } - - func refresh() { - guard surface != nil else { return } - GhosttyAppHost.shared.refreshSurface(self) - } - - private func flushPendingTextIfReady() { - guard surface != nil, !pendingInputQueue.isEmpty else { return } - let queued = pendingInputQueue - pendingInputQueue.removeAll(keepingCapacity: true) - for action in queued { - switch action { - case .text(let text): - GhosttyAppHost.shared.sendText(text, to: self) - case .returnKey: - sendReturnKeyToSurface() - } - } - } - - private func sendReturnKeyToSurface() { - guard let surface else { return } - idx0_ghostty_surface_set_focus(surface, true) - var press = ghostty_input_key_s() - press.action = GHOSTTY_ACTION_PRESS - press.keycode = 36 // Return key virtual key code on macOS - press.mods = GHOSTTY_MODS_NONE - press.consumed_mods = GHOSTTY_MODS_NONE - press.composing = false - press.unshifted_codepoint = 13 - "\r".withCString { ptr in - press.text = ptr - _ = idx0_ghostty_surface_key(surface, press) - } - - var release = ghostty_input_key_s() - release.action = GHOSTTY_ACTION_RELEASE - release.keycode = 36 - release.mods = GHOSTTY_MODS_NONE - release.consumed_mods = GHOSTTY_MODS_NONE - release.text = nil - release.composing = false - release.unshifted_codepoint = 0 - _ = idx0_ghostty_surface_key(surface, release) - GhosttyAppHost.shared.scheduleTick() - } + } + } + } + + private func sendReturnKeyToSurface() { + guard let surface else { return } + idx0_ghostty_surface_set_focus(surface, true) + var press = ghostty_input_key_s() + press.action = GHOSTTY_ACTION_PRESS + press.keycode = 36 // Return key virtual key code on macOS + press.mods = GHOSTTY_MODS_NONE + press.consumed_mods = GHOSTTY_MODS_NONE + press.composing = false + press.unshifted_codepoint = 13 + "\r".withCString { ptr in + press.text = ptr + _ = idx0_ghostty_surface_key(surface, press) + } + + var release = ghostty_input_key_s() + release.action = GHOSTTY_ACTION_RELEASE + release.keycode = 36 + release.mods = GHOSTTY_MODS_NONE + release.consumed_mods = GHOSTTY_MODS_NONE + release.text = nil + release.composing = false + release.unshifted_codepoint = 0 + _ = idx0_ghostty_surface_key(surface, release) + GhosttyAppHost.shared.scheduleTick() + } } final class GhosttyNativeView: NSView { - weak var terminalSurface: GhosttyTerminalSurface? - private var resizeDebounceItem: DispatchWorkItem? - /// When true, layout-triggered resizes are suppressed (e.g. during overview scaling). - var suppressResize = false - - override var acceptsFirstResponder: Bool { true } - override var mouseDownCanMoveWindow: Bool { false } - override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } - - /// Text accumulated by insertText during interpretKeyEvents - private var keyTextAccumulator: [String]? - /// Current marked (preedit/IME) text - private var markedTextStorage = NSMutableAttributedString() - private var markedRange_ = NSRange(location: NSNotFound, length: 0) - private var selectedRange_ = NSRange(location: 0, length: 0) - - func prepareForSurfaceTeardown() { - resizeDebounceItem?.cancel() - resizeDebounceItem = nil - - if window?.firstResponder === self { - window?.makeFirstResponder(nil) - } - - terminalSurface = nil - removeFromSuperview() - } - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - wantsLayer = true - registerForDraggedTypes([.fileURL, .URL, .string]) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - guard window != nil else { return } - - // Create the surface now that the view is in a window (deferred from makeSurface). - // This matches cmux's approach where surface creation only happens once - // the view has a window, so ghostty can get display ID and backing scale. - terminalSurface?.createSurfaceIfNeeded() - - terminalSurface?.resizeToCurrentViewBounds() - - if window?.isKeyWindow == true { - DispatchQueue.main.async { [weak self] in - self?.terminalSurface?.focus() - } - } - } - - override func layout() { - super.layout() - guard !suppressResize else { return } - // Debounce resize to coalesce rapid layout changes (e.g. live sidebar drag). - resizeDebounceItem?.cancel() - let item = DispatchWorkItem { [weak self] in - guard let self, !self.suppressResize else { return } - self.terminalSurface?.resizeToCurrentViewBounds() - } - resizeDebounceItem = item - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: item) - } - - override func becomeFirstResponder() -> Bool { - let became = super.becomeFirstResponder() - if became { - terminalSurface?.focus() - } - return became - } - - override func resignFirstResponder() -> Bool { - let resigned = super.resignFirstResponder() - if resigned { - terminalSurface?.blur() - } - return resigned - } - - // MARK: - Mouse Events - - override func mouseDown(with event: NSEvent) { - window?.makeFirstResponder(self) - terminalSurface?.focus() - guard let surface = terminalSurface?.surface else { return } - let pos = convertToSurfacePoint(event) - idx0_ghostty_surface_mouse_pos(surface, pos.x, pos.y, modsFromEvent(event)) - idx0_ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, modsFromEvent(event)) - GhosttyAppHost.shared.scheduleTick() - } - - override func mouseUp(with event: NSEvent) { - guard let surface = terminalSurface?.surface else { return } - let pos = convertToSurfacePoint(event) - idx0_ghostty_surface_mouse_pos(surface, pos.x, pos.y, modsFromEvent(event)) - idx0_ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, modsFromEvent(event)) - GhosttyAppHost.shared.scheduleTick() - } - - override func mouseDragged(with event: NSEvent) { - guard let surface = terminalSurface?.surface else { return } - let pos = convertToSurfacePoint(event) - idx0_ghostty_surface_mouse_pos(surface, pos.x, pos.y, modsFromEvent(event)) - GhosttyAppHost.shared.scheduleTick() - } - - override func mouseMoved(with event: NSEvent) { - guard let surface = terminalSurface?.surface else { return } - let pos = convertToSurfacePoint(event) - idx0_ghostty_surface_mouse_pos(surface, pos.x, pos.y, modsFromEvent(event)) - } - - override func rightMouseDown(with event: NSEvent) { - guard let surface = terminalSurface?.surface else { - super.rightMouseDown(with: event) - return - } - let pos = convertToSurfacePoint(event) - idx0_ghostty_surface_mouse_pos(surface, pos.x, pos.y, modsFromEvent(event)) - idx0_ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, modsFromEvent(event)) - GhosttyAppHost.shared.scheduleTick() - } - - override func rightMouseUp(with event: NSEvent) { - guard let surface = terminalSurface?.surface else { return } - let pos = convertToSurfacePoint(event) - idx0_ghostty_surface_mouse_pos(surface, pos.x, pos.y, modsFromEvent(event)) - idx0_ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, modsFromEvent(event)) - GhosttyAppHost.shared.scheduleTick() - } - - override func rightMouseDragged(with event: NSEvent) { - guard let surface = terminalSurface?.surface else { return } - let pos = convertToSurfacePoint(event) - idx0_ghostty_surface_mouse_pos(surface, pos.x, pos.y, modsFromEvent(event)) - GhosttyAppHost.shared.scheduleTick() - } - - override func scrollWheel(with event: NSEvent) { - guard let surface = terminalSurface?.surface else { return } - - var x = event.scrollingDeltaX - var y = event.scrollingDeltaY - let precision = event.hasPreciseScrollingDeltas - - if precision { - // Match Ghostty's 2x multiplier for trackpad feel - x *= 2 - y *= 2 - } - - // Pack scroll mods: bit 0 = precision, bits 1-3 = momentum phase - var scrollMods: Int32 = 0 - if precision { - scrollMods |= 0b0000_0001 - } - let momentum: Int32 = switch event.momentumPhase { - case .began: 1 - case .stationary: 2 - case .changed: 3 - case .ended: 4 - case .cancelled: 5 - case .mayBegin: 6 - default: 0 - } - scrollMods |= momentum << 1 - - idx0_ghostty_surface_mouse_scroll(surface, x, y, scrollMods) - GhosttyAppHost.shared.scheduleTick() - } - - private func convertToSurfacePoint(_ event: NSEvent) -> NSPoint { - let local = convert(event.locationInWindow, from: nil) - // Ghostty expects top-left origin - return NSPoint(x: local.x, y: bounds.height - local.y) - } - - // MARK: - Drag and Drop - - override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { - droppedFileURLs(from: sender.draggingPasteboard).isEmpty ? [] : .copy - } - - override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { - draggingEntered(sender) - } - - override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { - !droppedFileURLs(from: sender.draggingPasteboard).isEmpty - } - - override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { - let fileURLs = droppedFileURLs(from: sender.draggingPasteboard) - guard !fileURLs.isEmpty else { return false } - - let escapedPaths = fileURLs.map { GhosttyAppHost.shellEscapedCommand($0.path) } - let insertion = escapedPaths.joined(separator: " ") - guard !insertion.isEmpty else { return false } - - window?.makeFirstResponder(self) - terminalSurface?.focus() - terminalSurface?.send(text: "\(insertion) ") - return true - } - - private func droppedFileURLs(from pasteboard: NSPasteboard) -> [URL] { - let options: [NSPasteboard.ReadingOptionKey: Any] = [ - .urlReadingFileURLsOnly: true, - ] - guard let nsURLs = pasteboard.readObjects(forClasses: [NSURL.self], options: options) as? [NSURL] else { - return [] - } - return nsURLs.compactMap { url in - let asURL = url as URL - return asURL.isFileURL ? asURL : nil - } - } - - // MARK: - Keyboard Events - - override func keyDown(with event: NSEvent) { - guard let surface = terminalSurface?.surface else { - super.keyDown(with: event) - return - } - - // Command key events bypass ghostty and go to macOS menu handling - if event.modifierFlags.contains(.command) { - super.keyDown(with: event) - return - } - - // Ensure ghostty knows we have focus - idx0_ghostty_surface_set_focus(surface, true) - - // Fast path for Ctrl-modified keys (Ctrl+C, Ctrl+D, etc.) - // Bypass IME and send directly to ghostty - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - if flags.contains(.control) && !flags.contains(.option) && !hasMarkedText() { - var keyEvent = ghostty_input_key_s() - keyEvent.action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS - keyEvent.keycode = UInt32(event.keyCode) - keyEvent.mods = modsFromEvent(event) - keyEvent.consumed_mods = GHOSTTY_MODS_NONE - keyEvent.composing = false - keyEvent.unshifted_codepoint = unshiftedCodepointFromEvent(event) - - let text = event.charactersIgnoringModifiers ?? event.characters ?? "" - if text.isEmpty { - keyEvent.text = nil - let handled = idx0_ghostty_surface_key(surface, keyEvent) - if handled { - GhosttyAppHost.shared.scheduleTick() - return - } - } else { - let handled = text.withCString { ptr -> Bool in - keyEvent.text = ptr - return idx0_ghostty_surface_key(surface, keyEvent) - } - if handled { - GhosttyAppHost.shared.scheduleTick() - return - } - } - } - - let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS - - // Translate mods to respect ghostty config (e.g. macos-option-as-alt) - let translationModsGhostty = idx0_ghostty_surface_key_translation_mods(surface, modsFromEvent(event)) - var translationMods = event.modifierFlags - for flag in [NSEvent.ModifierFlags.shift, .control, .option, .command] { - let hasFlag: Bool - switch flag { - case .shift: - hasFlag = (translationModsGhostty.rawValue & GHOSTTY_MODS_SHIFT.rawValue) != 0 - case .control: - hasFlag = (translationModsGhostty.rawValue & GHOSTTY_MODS_CTRL.rawValue) != 0 - case .option: - hasFlag = (translationModsGhostty.rawValue & GHOSTTY_MODS_ALT.rawValue) != 0 - case .command: - hasFlag = (translationModsGhostty.rawValue & GHOSTTY_MODS_SUPER.rawValue) != 0 - default: - hasFlag = translationMods.contains(flag) - } - if hasFlag { - translationMods.insert(flag) - } else { - translationMods.remove(flag) - } - } - - let translationEvent: NSEvent - if translationMods == event.modifierFlags { - translationEvent = event - } else { - translationEvent = NSEvent.keyEvent( - with: event.type, - location: event.locationInWindow, - modifierFlags: translationMods, - timestamp: event.timestamp, - windowNumber: event.windowNumber, - context: nil, - characters: event.characters(byApplyingModifiers: translationMods) ?? "", - charactersIgnoringModifiers: event.charactersIgnoringModifiers ?? "", - isARepeat: event.isARepeat, - keyCode: event.keyCode - ) ?? event - } - - // Set up text accumulator for interpretKeyEvents - keyTextAccumulator = [] - defer { keyTextAccumulator = nil } - - let markedTextBefore = markedTextStorage.length > 0 - - // Let the input system handle the event (for IME, dead keys, etc.) - interpretKeyEvents([translationEvent]) - - // Build the key event - var keyEvent = ghostty_input_key_s() - keyEvent.action = action - keyEvent.keycode = UInt32(event.keyCode) - keyEvent.mods = modsFromEvent(event) - keyEvent.consumed_mods = consumedModsFromFlags(translationMods) - keyEvent.unshifted_codepoint = unshiftedCodepointFromEvent(event) - keyEvent.composing = markedTextStorage.length > 0 || markedTextBefore - - let accumulatedText = keyTextAccumulator ?? [] - if !accumulatedText.isEmpty { - // Text from insertText (IME result) - not composing - keyEvent.composing = false - for text in accumulatedText { - text.withCString { ptr in - keyEvent.text = ptr - _ = idx0_ghostty_surface_key(surface, keyEvent) - } - } - } else { - // Get text for this key event - if let text = textForKeyEvent(translationEvent) { - text.withCString { ptr in - keyEvent.text = ptr - _ = idx0_ghostty_surface_key(surface, keyEvent) - } - } else { - keyEvent.text = nil - _ = idx0_ghostty_surface_key(surface, keyEvent) - } - } - - GhosttyAppHost.shared.scheduleTick() - } - - override func keyUp(with event: NSEvent) { - guard let surface = terminalSurface?.surface else { - super.keyUp(with: event) - return - } - - if event.modifierFlags.contains(.command) { - super.keyUp(with: event) - return - } - - var keyEvent = ghostty_input_key_s() - keyEvent.action = GHOSTTY_ACTION_RELEASE - keyEvent.keycode = UInt32(event.keyCode) - keyEvent.mods = modsFromEvent(event) - keyEvent.consumed_mods = GHOSTTY_MODS_NONE + weak var terminalSurface: GhosttyTerminalSurface? + private var resizeDebounceItem: DispatchWorkItem? + /// When true, layout-triggered resizes are suppressed (e.g. during overview scaling). + var suppressResize = false + + override var acceptsFirstResponder: Bool { + true + } + + override var mouseDownCanMoveWindow: Bool { + false + } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + true + } + + /// Text accumulated by insertText during interpretKeyEvents + private var keyTextAccumulator: [String]? + /// Current marked (preedit/IME) text + private var markedTextStorage = NSMutableAttributedString() + private var markedRange_ = NSRange(location: NSNotFound, length: 0) + private var selectedRange_ = NSRange(location: 0, length: 0) + + func prepareForSurfaceTeardown() { + resizeDebounceItem?.cancel() + resizeDebounceItem = nil + + if window?.firstResponder === self { + window?.makeFirstResponder(nil) + } + + terminalSurface = nil + removeFromSuperview() + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + registerForDraggedTypes([.fileURL, .URL, .string]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard window != nil else { return } + + // Create the surface now that the view is in a window (deferred from makeSurface). + // This matches cmux's approach where surface creation only happens once + // the view has a window, so ghostty can get display ID and backing scale. + terminalSurface?.createSurfaceIfNeeded() + + terminalSurface?.resizeToCurrentViewBounds() + + if window?.isKeyWindow == true { + DispatchQueue.main.async { [weak self] in + self?.terminalSurface?.focus() + } + } + } + + override func layout() { + super.layout() + guard !suppressResize else { return } + // Debounce resize to coalesce rapid layout changes (e.g. live sidebar drag). + resizeDebounceItem?.cancel() + let item = DispatchWorkItem { [weak self] in + guard let self, !self.suppressResize else { return } + terminalSurface?.resizeToCurrentViewBounds() + } + resizeDebounceItem = item + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: item) + } + + override func becomeFirstResponder() -> Bool { + let became = super.becomeFirstResponder() + if became { + terminalSurface?.focus() + } + return became + } + + override func resignFirstResponder() -> Bool { + let resigned = super.resignFirstResponder() + if resigned { + terminalSurface?.blur() + } + return resigned + } + + // MARK: - Mouse Events + + override func mouseDown(with event: NSEvent) { + window?.makeFirstResponder(self) + terminalSurface?.focus() + guard let surface = terminalSurface?.surface else { return } + let pos = convertToSurfacePoint(event) + idx0_ghostty_surface_mouse_pos(surface, pos.x, pos.y, modsFromEvent(event)) + idx0_ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, modsFromEvent(event)) + GhosttyAppHost.shared.scheduleTick() + } + + override func mouseUp(with event: NSEvent) { + guard let surface = terminalSurface?.surface else { return } + let pos = convertToSurfacePoint(event) + idx0_ghostty_surface_mouse_pos(surface, pos.x, pos.y, modsFromEvent(event)) + idx0_ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, modsFromEvent(event)) + GhosttyAppHost.shared.scheduleTick() + } + + override func mouseDragged(with event: NSEvent) { + guard let surface = terminalSurface?.surface else { return } + let pos = convertToSurfacePoint(event) + idx0_ghostty_surface_mouse_pos(surface, pos.x, pos.y, modsFromEvent(event)) + GhosttyAppHost.shared.scheduleTick() + } + + override func mouseMoved(with event: NSEvent) { + guard let surface = terminalSurface?.surface else { return } + let pos = convertToSurfacePoint(event) + idx0_ghostty_surface_mouse_pos(surface, pos.x, pos.y, modsFromEvent(event)) + } + + override func rightMouseDown(with event: NSEvent) { + guard let surface = terminalSurface?.surface else { + super.rightMouseDown(with: event) + return + } + let pos = convertToSurfacePoint(event) + idx0_ghostty_surface_mouse_pos(surface, pos.x, pos.y, modsFromEvent(event)) + idx0_ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, modsFromEvent(event)) + GhosttyAppHost.shared.scheduleTick() + } + + override func rightMouseUp(with event: NSEvent) { + guard let surface = terminalSurface?.surface else { return } + let pos = convertToSurfacePoint(event) + idx0_ghostty_surface_mouse_pos(surface, pos.x, pos.y, modsFromEvent(event)) + idx0_ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, modsFromEvent(event)) + GhosttyAppHost.shared.scheduleTick() + } + + override func rightMouseDragged(with event: NSEvent) { + guard let surface = terminalSurface?.surface else { return } + let pos = convertToSurfacePoint(event) + idx0_ghostty_surface_mouse_pos(surface, pos.x, pos.y, modsFromEvent(event)) + GhosttyAppHost.shared.scheduleTick() + } + + override func scrollWheel(with event: NSEvent) { + guard let surface = terminalSurface?.surface else { return } + + var x = event.scrollingDeltaX + var y = event.scrollingDeltaY + let precision = event.hasPreciseScrollingDeltas + + if precision { + // Match Ghostty's 2x multiplier for trackpad feel + x *= 2 + y *= 2 + } + + // Pack scroll mods: bit 0 = precision, bits 1-3 = momentum phase + var scrollMods: Int32 = 0 + if precision { + scrollMods |= 0b0000_0001 + } + let momentum: Int32 = switch event.momentumPhase { + case .began: 1 + case .stationary: 2 + case .changed: 3 + case .ended: 4 + case .cancelled: 5 + case .mayBegin: 6 + default: 0 + } + scrollMods |= momentum << 1 + + idx0_ghostty_surface_mouse_scroll(surface, x, y, scrollMods) + GhosttyAppHost.shared.scheduleTick() + } + + private func convertToSurfacePoint(_ event: NSEvent) -> NSPoint { + let local = convert(event.locationInWindow, from: nil) + // Ghostty expects top-left origin + return NSPoint(x: local.x, y: bounds.height - local.y) + } + + // MARK: - Drag and Drop + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + droppedFileURLs(from: sender.draggingPasteboard).isEmpty ? [] : .copy + } + + override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { + draggingEntered(sender) + } + + override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { + !droppedFileURLs(from: sender.draggingPasteboard).isEmpty + } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + let fileURLs = droppedFileURLs(from: sender.draggingPasteboard) + guard !fileURLs.isEmpty else { return false } + + let escapedPaths = fileURLs.map { GhosttyAppHost.shellEscapedCommand($0.path) } + let insertion = escapedPaths.joined(separator: " ") + guard !insertion.isEmpty else { return false } + + window?.makeFirstResponder(self) + terminalSurface?.focus() + terminalSurface?.send(text: "\(insertion) ") + return true + } + + private func droppedFileURLs(from pasteboard: NSPasteboard) -> [URL] { + let options: [NSPasteboard.ReadingOptionKey: Any] = [ + .urlReadingFileURLsOnly: true, + ] + guard let nsURLs = pasteboard.readObjects(forClasses: [NSURL.self], options: options) as? [NSURL] else { + return [] + } + return nsURLs.compactMap { url in + let asURL = url as URL + return asURL.isFileURL ? asURL : nil + } + } + + // MARK: - Keyboard Events + + override func keyDown(with event: NSEvent) { + guard let surface = terminalSurface?.surface else { + super.keyDown(with: event) + return + } + + // Command key events bypass ghostty and go to macOS menu handling + if event.modifierFlags.contains(.command) { + super.keyDown(with: event) + return + } + + // Ensure ghostty knows we have focus + idx0_ghostty_surface_set_focus(surface, true) + + // Fast path for Ctrl-modified keys (Ctrl+C, Ctrl+D, etc.) + // Bypass IME and send directly to ghostty + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if flags.contains(.control) && !flags.contains(.option) && !hasMarkedText() { + var keyEvent = ghostty_input_key_s() + keyEvent.action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS + keyEvent.keycode = UInt32(event.keyCode) + keyEvent.mods = modsFromEvent(event) + keyEvent.consumed_mods = GHOSTTY_MODS_NONE + keyEvent.composing = false + keyEvent.unshifted_codepoint = unshiftedCodepointFromEvent(event) + + let text = event.charactersIgnoringModifiers ?? event.characters ?? "" + if text.isEmpty { keyEvent.text = nil - keyEvent.composing = false - keyEvent.unshifted_codepoint = 0 - _ = idx0_ghostty_surface_key(surface, keyEvent) - } - - override func flagsChanged(with event: NSEvent) { - guard let surface = terminalSurface?.surface else { - super.flagsChanged(with: event) - return - } - - var keyEvent = ghostty_input_key_s() - keyEvent.action = GHOSTTY_ACTION_PRESS - keyEvent.keycode = UInt32(event.keyCode) - keyEvent.mods = modsFromEvent(event) - keyEvent.consumed_mods = GHOSTTY_MODS_NONE + let handled = idx0_ghostty_surface_key(surface, keyEvent) + if handled { + GhosttyAppHost.shared.scheduleTick() + return + } + } else { + let handled = text.withCString { ptr -> Bool in + keyEvent.text = ptr + return idx0_ghostty_surface_key(surface, keyEvent) + } + if handled { + GhosttyAppHost.shared.scheduleTick() + return + } + } + } + + let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS + + // Translate mods to respect ghostty config (e.g. macos-option-as-alt) + let translationModsGhostty = idx0_ghostty_surface_key_translation_mods(surface, modsFromEvent(event)) + var translationMods = event.modifierFlags + for flag in [NSEvent.ModifierFlags.shift, .control, .option, .command] { + let hasFlag: Bool = switch flag { + case .shift: + (translationModsGhostty.rawValue & GHOSTTY_MODS_SHIFT.rawValue) != 0 + case .control: + (translationModsGhostty.rawValue & GHOSTTY_MODS_CTRL.rawValue) != 0 + case .option: + (translationModsGhostty.rawValue & GHOSTTY_MODS_ALT.rawValue) != 0 + case .command: + (translationModsGhostty.rawValue & GHOSTTY_MODS_SUPER.rawValue) != 0 + default: + translationMods.contains(flag) + } + if hasFlag { + translationMods.insert(flag) + } else { + translationMods.remove(flag) + } + } + + let translationEvent: NSEvent = if translationMods == event.modifierFlags { + event + } else { + NSEvent.keyEvent( + with: event.type, + location: event.locationInWindow, + modifierFlags: translationMods, + timestamp: event.timestamp, + windowNumber: event.windowNumber, + context: nil, + characters: event.characters(byApplyingModifiers: translationMods) ?? "", + charactersIgnoringModifiers: event.charactersIgnoringModifiers ?? "", + isARepeat: event.isARepeat, + keyCode: event.keyCode + ) ?? event + } + + // Set up text accumulator for interpretKeyEvents + keyTextAccumulator = [] + defer { keyTextAccumulator = nil } + + let markedTextBefore = markedTextStorage.length > 0 + + // Let the input system handle the event (for IME, dead keys, etc.) + interpretKeyEvents([translationEvent]) + + // Build the key event + var keyEvent = ghostty_input_key_s() + keyEvent.action = action + keyEvent.keycode = UInt32(event.keyCode) + keyEvent.mods = modsFromEvent(event) + keyEvent.consumed_mods = GHOSTTY_MODS_NONE + keyEvent.unshifted_codepoint = unshiftedCodepointFromEvent(event) + keyEvent.composing = markedTextStorage.length > 0 || markedTextBefore + + let accumulatedText = keyTextAccumulator ?? [] + if !accumulatedText.isEmpty { + // Text from insertText (IME result) - not composing + keyEvent.composing = false + for text in accumulatedText { + text.withCString { ptr in + keyEvent.text = ptr + keyEvent.consumed_mods = consumedModsFromFlags(translationMods, text: text) + _ = idx0_ghostty_surface_key(surface, keyEvent) + } + } + } else { + // Get text for this key event + if let text = textForKeyEvent(translationEvent) { + text.withCString { ptr in + keyEvent.text = ptr + keyEvent.consumed_mods = consumedModsFromFlags(translationMods, text: text) + _ = idx0_ghostty_surface_key(surface, keyEvent) + } + } else { keyEvent.text = nil - keyEvent.composing = false - keyEvent.unshifted_codepoint = 0 + keyEvent.consumed_mods = GHOSTTY_MODS_NONE _ = idx0_ghostty_surface_key(surface, keyEvent) - } - - // MARK: - Input Helpers - - private func modsFromEvent(_ event: NSEvent) -> ghostty_input_mods_e { - var mods = GHOSTTY_MODS_NONE.rawValue - if event.modifierFlags.contains(.shift) { mods |= GHOSTTY_MODS_SHIFT.rawValue } - if event.modifierFlags.contains(.control) { mods |= GHOSTTY_MODS_CTRL.rawValue } - if event.modifierFlags.contains(.option) { mods |= GHOSTTY_MODS_ALT.rawValue } - if event.modifierFlags.contains(.command) { mods |= GHOSTTY_MODS_SUPER.rawValue } - return ghostty_input_mods_e(rawValue: mods) - } - - private func consumedModsFromFlags(_ flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e { - var mods = GHOSTTY_MODS_NONE.rawValue - if flags.contains(.shift) { mods |= GHOSTTY_MODS_SHIFT.rawValue } - if flags.contains(.option) { mods |= GHOSTTY_MODS_ALT.rawValue } - return ghostty_input_mods_e(rawValue: mods) - } + } + } + + GhosttyAppHost.shared.scheduleTick() + } + + override func keyUp(with event: NSEvent) { + guard let surface = terminalSurface?.surface else { + super.keyUp(with: event) + return + } + + if event.modifierFlags.contains(.command) { + super.keyUp(with: event) + return + } + + var keyEvent = ghostty_input_key_s() + keyEvent.action = GHOSTTY_ACTION_RELEASE + keyEvent.keycode = UInt32(event.keyCode) + keyEvent.mods = modsFromEvent(event) + keyEvent.consumed_mods = GHOSTTY_MODS_NONE + keyEvent.text = nil + keyEvent.composing = false + keyEvent.unshifted_codepoint = 0 + _ = idx0_ghostty_surface_key(surface, keyEvent) + } + + override func flagsChanged(with event: NSEvent) { + guard let surface = terminalSurface?.surface else { + super.flagsChanged(with: event) + return + } + + var keyEvent = ghostty_input_key_s() + keyEvent.action = modifierActionFromFlagsChangedEvent(event) + keyEvent.keycode = UInt32(event.keyCode) + keyEvent.mods = modsFromEvent(event) + keyEvent.consumed_mods = GHOSTTY_MODS_NONE + keyEvent.text = nil + keyEvent.composing = false + keyEvent.unshifted_codepoint = 0 + _ = idx0_ghostty_surface_key(surface, keyEvent) + } + + // MARK: - Input Helpers + + private func modsFromEvent(_ event: NSEvent) -> ghostty_input_mods_e { + var mods = GHOSTTY_MODS_NONE.rawValue + if event.modifierFlags.contains(.shift) { mods |= GHOSTTY_MODS_SHIFT.rawValue } + if event.modifierFlags.contains(.control) { mods |= GHOSTTY_MODS_CTRL.rawValue } + if event.modifierFlags.contains(.option) { mods |= GHOSTTY_MODS_ALT.rawValue } + if event.modifierFlags.contains(.command) { mods |= GHOSTTY_MODS_SUPER.rawValue } + return ghostty_input_mods_e(rawValue: mods) + } + + private func consumedModsFromFlags(_ flags: NSEvent.ModifierFlags, text: String?) -> ghostty_input_mods_e { + GhosttyKeyEventTranslator.consumedMods(flags: flags, text: text) + } + + private func modifierActionFromFlagsChangedEvent(_ event: NSEvent) -> ghostty_input_action_e { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + return GhosttyKeyEventTranslator.flagsChangedAction(keyCode: event.keyCode, flags: flags) + } + + private func unshiftedCodepointFromEvent(_ event: NSEvent) -> UInt32 { + guard let chars = event.charactersIgnoringModifiers, let scalar = chars.unicodeScalars.first else { + return 0 + } + return scalar.value + } + + private func textForKeyEvent(_ event: NSEvent) -> String? { + guard let chars = event.characters, !chars.isEmpty else { return nil } + + if chars.count == 1, let scalar = chars.unicodeScalars.first { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + + // Function keys (arrows, F1-F12, Home, End, etc.) use Unicode PUA + // characters (0xF700+). Don't send these as text — ghostty handles + // them by keycode. + if scalar.value >= 0xF700, scalar.value <= 0xF8FF { + return nil + } - private func unshiftedCodepointFromEvent(_ event: NSEvent) -> UInt32 { - guard let chars = event.charactersIgnoringModifiers, let scalar = chars.unicodeScalars.first else { - return 0 - } - return scalar.value - } - - private func textForKeyEvent(_ event: NSEvent) -> String? { - guard let chars = event.characters, !chars.isEmpty else { return nil } - - if chars.count == 1, let scalar = chars.unicodeScalars.first { - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - - // Function keys (arrows, F1-F12, Home, End, etc.) use Unicode PUA - // characters (0xF700+). Don't send these as text — ghostty handles - // them by keycode. - if scalar.value >= 0xF700 && scalar.value <= 0xF8FF { - return nil - } - - // If we have a control character, return the character without the - // control modifier so ghostty's KeyEncoder can handle it - if scalar.value < 0x20 { - if flags.contains(.control) { - return event.characters(byApplyingModifiers: event.modifierFlags.subtracting(.control)) - } - // Non-control-modified control chars (like Return, Tab, Escape) - // should still be sent as text - return chars - } + // If we have a control character, return the character without the + // control modifier so ghostty's KeyEncoder can handle it + if scalar.value < 0x20 { + if flags.contains(.control) { + return event.characters(byApplyingModifiers: event.modifierFlags.subtracting(.control)) } - + // Non-control-modified control chars (like Return, Tab, Escape) + // should still be sent as text return chars + } } + return chars + } } -// MARK: - NSTextInputClient -extension GhosttyNativeView: @preconcurrency NSTextInputClient { - func insertText(_ string: Any, replacementRange: NSRange) { - let text: String - if let s = string as? String { - text = s - } else if let s = string as? NSAttributedString { - text = s.string - } else { - return - } - - // Clear any marked text since we're committing - markedTextStorage.mutableString.setString("") - markedRange_ = NSRange(location: NSNotFound, length: 0) - selectedRange_ = NSRange(location: 0, length: 0) - - keyTextAccumulator?.append(text) - } - - func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { - if let s = string as? String { - markedTextStorage.mutableString.setString(s) - } else if let s = string as? NSAttributedString { - markedTextStorage.setAttributedString(s) - } - - if markedTextStorage.length > 0 { - markedRange_ = NSRange(location: 0, length: markedTextStorage.length) - } else { - markedRange_ = NSRange(location: NSNotFound, length: 0) - } - selectedRange_ = selectedRange - } - - func unmarkText() { - markedTextStorage.mutableString.setString("") - markedRange_ = NSRange(location: NSNotFound, length: 0) - } - - func selectedRange() -> NSRange { - return selectedRange_ - } - - func markedRange() -> NSRange { - return markedRange_ - } - - func hasMarkedText() -> Bool { - return markedRange_.location != NSNotFound && markedRange_.length > 0 - } - - func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { - return nil - } - - func validAttributesForMarkedText() -> [NSAttributedString.Key] { - return [] - } - - func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect { - guard let window = window else { return .zero } - let viewRect = convert(bounds, to: nil) - return window.convertToScreen(viewRect) - } - - func characterIndex(for point: NSPoint) -> Int { - return 0 - } - - override func doCommand(by selector: Selector) { - // interpretKeyEvents can route non-text keys (return, arrows, delete, etc.) - // through AppKit command selectors. If they bubble to NSResponder defaults, - // AppKit emits the system "dink" sound. Ghostty already handles the key - // stream directly in keyDown/keyUp, so swallow these selectors while active. - if terminalSurface?.surface != nil { - return - } - super.doCommand(by: selector) - } - - // MARK: - Edit Menu Actions (Cmd+C, Cmd+V, Cmd+A) - - private func performSurfaceAction(_ action: String) -> Bool { - guard let surface = terminalSurface?.surface else { return false } - return idx0_ghostty_surface_binding_action(surface, action, UInt(action.utf8.count)) - } - - @IBAction func copy(_ sender: Any?) { - _ = performSurfaceAction("copy_to_clipboard") - } +enum GhosttyKeyEventTranslator { + static func consumedMods(flags: NSEvent.ModifierFlags, text: String?) -> ghostty_input_mods_e { + guard let text, !text.isEmpty else { return GHOSTTY_MODS_NONE } + var mods = GHOSTTY_MODS_NONE.rawValue + // Control-key text (Tab, Return, Escape, etc.) should not consume Shift; + // otherwise modified non-text keys lose their Shift semantics. + if flags.contains(.shift), !isOnlyControlText(text) { + mods |= GHOSTTY_MODS_SHIFT.rawValue + } + if flags.contains(.option) { + mods |= GHOSTTY_MODS_ALT.rawValue + } + return ghostty_input_mods_e(rawValue: mods) + } + + static func flagsChangedAction(keyCode: UInt16, flags: NSEvent.ModifierFlags) -> ghostty_input_action_e { + guard let modifierFlag = modifierFlag(forKeyCode: keyCode) else { + return GHOSTTY_ACTION_PRESS + } + return flags.contains(modifierFlag) ? GHOSTTY_ACTION_PRESS : GHOSTTY_ACTION_RELEASE + } + + private static func modifierFlag(forKeyCode keyCode: UInt16) -> NSEvent.ModifierFlags? { + switch keyCode { + case 56, 60: + .shift + case 59, 62: + .control + case 58, 61: + .option + case 54, 55: + .command + default: + nil + } + } + + private static func isOnlyControlText(_ text: String) -> Bool { + text.unicodeScalars.allSatisfy { scalar in + let value = scalar.value + return value < 0x20 || (0x7F ... 0x9F).contains(value) + } + } +} - @IBAction func paste(_ sender: Any?) { - _ = performSurfaceAction("paste_from_clipboard") - } +// MARK: - NSTextInputClient - @IBAction override func selectAll(_ sender: Any?) { - if !performSurfaceAction("select_all") { - super.selectAll(sender) - } - } +extension GhosttyNativeView: @preconcurrency NSTextInputClient { + func insertText(_ string: Any, replacementRange: NSRange) { + let text: String + if let s = string as? String { + text = s + } else if let s = string as? NSAttributedString { + text = s.string + } else { + return + } + + // Clear any marked text since we're committing + markedTextStorage.mutableString.setString("") + markedRange_ = NSRange(location: NSNotFound, length: 0) + selectedRange_ = NSRange(location: 0, length: 0) + + keyTextAccumulator?.append(text) + } + + func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { + if let s = string as? String { + markedTextStorage.mutableString.setString(s) + } else if let s = string as? NSAttributedString { + markedTextStorage.setAttributedString(s) + } + + if markedTextStorage.length > 0 { + markedRange_ = NSRange(location: 0, length: markedTextStorage.length) + } else { + markedRange_ = NSRange(location: NSNotFound, length: 0) + } + selectedRange_ = selectedRange + } + + func unmarkText() { + markedTextStorage.mutableString.setString("") + markedRange_ = NSRange(location: NSNotFound, length: 0) + } + + func selectedRange() -> NSRange { + selectedRange_ + } + + func markedRange() -> NSRange { + markedRange_ + } + + func hasMarkedText() -> Bool { + markedRange_.location != NSNotFound && markedRange_.length > 0 + } + + func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { + nil + } + + func validAttributesForMarkedText() -> [NSAttributedString.Key] { + [] + } + + func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect { + guard let window else { return .zero } + let viewRect = convert(bounds, to: nil) + return window.convertToScreen(viewRect) + } + + func characterIndex(for point: NSPoint) -> Int { + 0 + } + + override func doCommand(by selector: Selector) { + // interpretKeyEvents can route non-text keys (return, arrows, delete, etc.) + // through AppKit command selectors. If they bubble to NSResponder defaults, + // AppKit emits the system "dink" sound. Ghostty already handles the key + // stream directly in keyDown/keyUp, so swallow these selectors while active. + if terminalSurface?.surface != nil { + return + } + super.doCommand(by: selector) + } + + // MARK: - Edit Menu Actions (Cmd+C, Cmd+V, Cmd+A) + + private func performSurfaceAction(_ action: String) -> Bool { + guard let surface = terminalSurface?.surface else { return false } + return idx0_ghostty_surface_binding_action(surface, action, UInt(action.utf8.count)) + } + + @IBAction func copy(_ sender: Any?) { + _ = performSurfaceAction("copy_to_clipboard") + } + + @IBAction func paste(_ sender: Any?) { + _ = performSurfaceAction("paste_from_clipboard") + } + + @IBAction override func selectAll(_ sender: Any?) { + if !performSurfaceAction("select_all") { + super.selectAll(sender) + } + } } private extension NSScreen { - var displayID: UInt32? { - guard let screenNumber = deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else { - return nil - } - return screenNumber.uint32Value + var displayID: UInt32? { + guard let screenNumber = deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else { + return nil } + return screenNumber.uint32Value + } } diff --git a/idx0Tests/Keyboard/ShortcutRegistryTests.swift b/idx0Tests/Keyboard/ShortcutRegistryTests.swift index f8ed972..b7c224b 100644 --- a/idx0Tests/Keyboard/ShortcutRegistryTests.swift +++ b/idx0Tests/Keyboard/ShortcutRegistryTests.swift @@ -1,110 +1,141 @@ -import XCTest +import AppKit @testable import idx0 +import XCTest final class ShortcutRegistryTests: XCTestCase { - func testDefaultSettingsHaveNoShortcutConflicts() { - let validator = ShortcutValidator() - let conflicts = validator.conflicts(for: AppSettings()) - XCTAssertTrue( - conflicts.isEmpty, - conflicts.map(\.message).joined(separator: "\n") - ) - } - - func testPrimaryBindingUsesMacDefaultInBothMode() { - let registry = ShortcutRegistry.shared - var settings = AppSettings() - settings.keybindingMode = .both - settings.modKeySetting = .commandOption - - let binding = registry.primaryBinding(for: .niriFocusLeft, settings: settings) - - XCTAssertEqual(binding?.key, .leftArrow) - XCTAssertEqual(binding?.modifiers, [.command, .option]) - } - - func testPrimaryBindingUsesNiriDefaultInNiriFirstMode() { - let registry = ShortcutRegistry.shared - var settings = AppSettings() - settings.keybindingMode = .niriFirst - settings.modKeySetting = .commandOption - - let binding = registry.primaryBinding(for: .niriFocusLeft, settings: settings) - - XCTAssertEqual(binding?.key, .h) - XCTAssertEqual(binding?.modifiers, [.command, .option]) - } - - func testNiriBindingUsesConfiguredModKey() { - let registry = ShortcutRegistry.shared - var settings = AppSettings() - settings.keybindingMode = .niriFirst - settings.modKeySetting = .control - - let binding = registry.primaryBinding(for: .niriFocusRight, settings: settings) - - XCTAssertEqual(binding?.key, .l) - XCTAssertEqual(binding?.modifiers, [.control]) - } - - func testNiriAddTerminalRightUsesModTInNiriFirstMode() { - let registry = ShortcutRegistry.shared - var settings = AppSettings() - settings.keybindingMode = .niriFirst - settings.modKeySetting = .commandOption - - let binding = registry.primaryBinding(for: .niriAddTerminalRight, settings: settings) - - XCTAssertEqual(binding?.key, .t) - XCTAssertEqual(binding?.modifiers, [.command, .option]) - } - - func testClosePaneUsesModWInNiriFirstMode() { - let registry = ShortcutRegistry.shared - var settings = AppSettings() - settings.keybindingMode = .niriFirst - settings.modKeySetting = .commandOption - - let binding = registry.primaryBinding(for: .closePane, settings: settings) - - XCTAssertEqual(binding?.key, .w) - XCTAssertEqual(binding?.modifiers, [.command, .option]) - } - - func testNiriTabbedToggleUsesModShiftTInNiriFirstMode() { - let registry = ShortcutRegistry.shared - var settings = AppSettings() - settings.keybindingMode = .niriFirst - settings.modKeySetting = .commandOption - - let binding = registry.primaryBinding(for: .niriToggleColumnTabbedDisplay, settings: settings) - - XCTAssertEqual(binding?.key, .t) - XCTAssertEqual(binding?.modifiers, [.command, .option, .shift]) - } - - func testCustomBindingOverridesPrimaryBinding() { - let registry = ShortcutRegistry.shared - var settings = AppSettings() - settings.keybindingMode = .custom - settings.customKeybindings[ShortcutActionID.niriFocusLeft.rawValue] = KeyChord(key: .x, modifiers: [.command]) - - let binding = registry.primaryBinding(for: .niriFocusLeft, settings: settings) - - XCTAssertEqual(binding?.key, .x) - XCTAssertEqual(binding?.modifiers, [.command]) - } - - func testValidatorDetectsConflictingCustomBindings() { - let validator = ShortcutValidator() - var settings = AppSettings() - settings.keybindingMode = .custom - let duplicate = KeyChord(key: .q, modifiers: [.command]) - settings.customKeybindings[ShortcutActionID.closeSession.rawValue] = duplicate - settings.customKeybindings[ShortcutActionID.closePane.rawValue] = duplicate - - let conflicts = validator.conflicts(for: settings) - - XCTAssertFalse(conflicts.isEmpty) - } + func testDefaultSettingsHaveNoShortcutConflicts() { + let validator = ShortcutValidator() + let conflicts = validator.conflicts(for: AppSettings()) + XCTAssertTrue( + conflicts.isEmpty, + conflicts.map(\.message).joined(separator: "\n") + ) + } + + func testPrimaryBindingUsesMacDefaultInBothMode() { + let registry = ShortcutRegistry.shared + var settings = AppSettings() + settings.keybindingMode = .both + settings.modKeySetting = .commandOption + + let binding = registry.primaryBinding(for: .niriFocusLeft, settings: settings) + + XCTAssertEqual(binding?.key, .leftArrow) + XCTAssertEqual(binding?.modifiers, [.command, .option]) + } + + func testPrimaryBindingUsesNiriDefaultInNiriFirstMode() { + let registry = ShortcutRegistry.shared + var settings = AppSettings() + settings.keybindingMode = .niriFirst + settings.modKeySetting = .commandOption + + let binding = registry.primaryBinding(for: .niriFocusLeft, settings: settings) + + XCTAssertEqual(binding?.key, .h) + XCTAssertEqual(binding?.modifiers, [.command, .option]) + } + + func testNiriBindingUsesConfiguredModKey() { + let registry = ShortcutRegistry.shared + var settings = AppSettings() + settings.keybindingMode = .niriFirst + settings.modKeySetting = .control + + let binding = registry.primaryBinding(for: .niriFocusRight, settings: settings) + + XCTAssertEqual(binding?.key, .l) + XCTAssertEqual(binding?.modifiers, [.control]) + } + + func testNiriAddTerminalRightUsesModTInNiriFirstMode() { + let registry = ShortcutRegistry.shared + var settings = AppSettings() + settings.keybindingMode = .niriFirst + settings.modKeySetting = .commandOption + + let binding = registry.primaryBinding(for: .niriAddTerminalRight, settings: settings) + + XCTAssertEqual(binding?.key, .t) + XCTAssertEqual(binding?.modifiers, [.command, .option]) + } + + func testClosePaneUsesModWInNiriFirstMode() { + let registry = ShortcutRegistry.shared + var settings = AppSettings() + settings.keybindingMode = .niriFirst + settings.modKeySetting = .commandOption + + let binding = registry.primaryBinding(for: .closePane, settings: settings) + + XCTAssertEqual(binding?.key, .w) + XCTAssertEqual(binding?.modifiers, [.command, .option]) + } + + func testNiriTabbedToggleUsesModShiftTInNiriFirstMode() { + let registry = ShortcutRegistry.shared + var settings = AppSettings() + settings.keybindingMode = .niriFirst + settings.modKeySetting = .commandOption + + let binding = registry.primaryBinding(for: .niriToggleColumnTabbedDisplay, settings: settings) + + XCTAssertEqual(binding?.key, .t) + XCTAssertEqual(binding?.modifiers, [.command, .option, .shift]) + } + + func testCustomBindingOverridesPrimaryBinding() { + let registry = ShortcutRegistry.shared + var settings = AppSettings() + settings.keybindingMode = .custom + settings.customKeybindings[ShortcutActionID.niriFocusLeft.rawValue] = KeyChord(key: .x, modifiers: [.command]) + + let binding = registry.primaryBinding(for: .niriFocusLeft, settings: settings) + + XCTAssertEqual(binding?.key, .x) + XCTAssertEqual(binding?.modifiers, [.command]) + } + + func testValidatorDetectsConflictingCustomBindings() { + let validator = ShortcutValidator() + var settings = AppSettings() + settings.keybindingMode = .custom + let duplicate = KeyChord(key: .q, modifiers: [.command]) + settings.customKeybindings[ShortcutActionID.closeSession.rawValue] = duplicate + settings.customKeybindings[ShortcutActionID.closePane.rawValue] = duplicate + + let conflicts = validator.conflicts(for: settings) + + XCTAssertFalse(conflicts.isEmpty) + } + + func testConsumedModsDoesNotConsumeShiftForControlText() { + let mods = GhosttyKeyEventTranslator.consumedMods(flags: [.shift], text: "\r") + + XCTAssertEqual(mods.rawValue, GHOSTTY_MODS_NONE.rawValue) + } + + func testConsumedModsConsumesShiftForPrintableText() { + let mods = GhosttyKeyEventTranslator.consumedMods(flags: [.shift], text: "A") + + XCTAssertEqual(mods.rawValue, GHOSTTY_MODS_SHIFT.rawValue) + } + + func testConsumedModsConsumesOptionForPrintableText() { + let mods = GhosttyKeyEventTranslator.consumedMods(flags: [.option], text: "å") + + XCTAssertEqual(mods.rawValue, GHOSTTY_MODS_ALT.rawValue) + } + + func testFlagsChangedReleaseWhenModifierFlagClears() { + let action = GhosttyKeyEventTranslator.flagsChangedAction(keyCode: 56, flags: []) + + XCTAssertEqual(action, GHOSTTY_ACTION_RELEASE) + } + + func testFlagsChangedPressWhenModifierFlagSet() { + let action = GhosttyKeyEventTranslator.flagsChangedAction(keyCode: 56, flags: [.shift]) + + XCTAssertEqual(action, GHOSTTY_ACTION_PRESS) + } } diff --git a/idx0Tests/SessionServiceTests.swift b/idx0Tests/SessionServiceTests.swift index a549ec3..42db32e 100644 --- a/idx0Tests/SessionServiceTests.swift +++ b/idx0Tests/SessionServiceTests.swift @@ -1,820 +1,857 @@ import Foundation +@testable import idx0 import SwiftUI import XCTest -@testable import idx0 @MainActor final class SessionServiceTests: XCTestCase { - func testCloseSelectedSessionSelectsPreviousSessionWhenPossible() async throws { - let fixture = try Fixture() - let service = fixture.service - - let first = try await service.createSession(from: SessionCreationRequest(title: "One", repoPath: nil, createWorktree: false, branchName: nil, existingWorktreePath: nil, shellPath: nil)).session - let second = try await service.createSession(from: SessionCreationRequest(title: "Two", repoPath: nil, createWorktree: false, branchName: nil, existingWorktreePath: nil, shellPath: nil)).session - let third = try await service.createSession(from: SessionCreationRequest(title: "Three", repoPath: nil, createWorktree: false, branchName: nil, existingWorktreePath: nil, shellPath: nil)).session - - service.selectSession(second.id) - service.closeSession(second.id) - - XCTAssertEqual(service.selectedSessionID, first.id) - XCTAssertTrue(service.sessions.contains(where: { $0.id == first.id })) - XCTAssertFalse(service.sessions.contains(where: { $0.id == second.id })) - XCTAssertTrue(service.sessions.contains(where: { $0.id == third.id })) - } - - func testSuggestedTitleIgnoredWhenCustomTitleExists() async throws { - let fixture = try Fixture() - let service = fixture.service - - let custom = try await service.createSession(from: SessionCreationRequest(title: "Custom", repoPath: nil, createWorktree: false, branchName: nil, existingWorktreePath: nil, shellPath: nil)).session - service.updateTerminalMetadata(custom.id, cwd: nil, suggestedTitle: "From Terminal") - - XCTAssertEqual(service.sessions.first(where: { $0.id == custom.id })?.title, "Custom") - - let auto = try await service.createSession(from: SessionCreationRequest(title: nil, repoPath: nil, createWorktree: false, branchName: nil, existingWorktreePath: nil, shellPath: nil)).session - service.updateTerminalMetadata(auto.id, cwd: nil, suggestedTitle: "Auto Updated") - - XCTAssertEqual(service.sessions.first(where: { $0.id == auto.id })?.title, "Auto Updated") - } - - func testGroupsSessionsByProject() async throws { - let fixture = try Fixture() - let service = fixture.service - - let first = try await service.createSession(from: SessionCreationRequest( - title: "A1", - repoPath: "/tmp/repo-a", - createWorktree: false - )).session - let second = try await service.createSession(from: SessionCreationRequest( - title: "A2", - repoPath: "/tmp/repo-a", - createWorktree: false - )).session - _ = try await service.createSession(from: SessionCreationRequest( - title: "B1", - repoPath: "/tmp/repo-b", - createWorktree: false - )).session - - XCTAssertEqual(service.projectSections.count, 2) - - let sectionForA = service.projectSections.first(where: { $0.group.title == "repo-a" }) - XCTAssertNotNil(sectionForA) - XCTAssertEqual(sectionForA?.sessions.map(\.id), [second.id, first.id]) - } - - func testPinnedSessionsStayStableAboveRecentSorting() async throws { - let fixture = try Fixture() - let service = fixture.service - - let first = try await service.createSession(from: SessionCreationRequest( - title: "A1", - repoPath: "/tmp/repo-a", - createWorktree: false - )).session - let second = try await service.createSession(from: SessionCreationRequest( - title: "A2", - repoPath: "/tmp/repo-a", - createWorktree: false - )).session - - service.setPinned(second.id, pinned: true) - service.selectSession(first.id) - - let pinnedFirstOrder = service.projectSections - .first(where: { $0.group.title == "repo-a" })? - .sessions - .map(\.id) - XCTAssertEqual(pinnedFirstOrder?.first, second.id) - - service.setPinned(second.id, pinned: false) - let unpinnedOrder = service.projectSections - .first(where: { $0.group.title == "repo-a" })? - .sessions - .map(\.id) - XCTAssertEqual(unpinnedOrder?.first, first.id) - } - - func testFocusSessionDoesNotUpdateRecentOrdering() async throws { - let fixture = try Fixture() - let service = fixture.service - - let first = try await service.createSession(from: SessionCreationRequest( - title: "A1", - repoPath: "/tmp/repo-a", - createWorktree: false - )).session - let second = try await service.createSession(from: SessionCreationRequest( - title: "A2", - repoPath: "/tmp/repo-a", - createWorktree: false - )).session - - let beforeOrder = service.projectSections - .first(where: { $0.group.title == "repo-a" })? - .sessions - .map(\.id) - XCTAssertEqual(beforeOrder, [second.id, first.id]) - - service.focusSession(first.id) - - let afterOrder = service.projectSections - .first(where: { $0.group.title == "repo-a" })? - .sessions - .map(\.id) - XCTAssertEqual(afterOrder, [second.id, first.id]) - XCTAssertEqual(service.selectedSessionID, first.id) - } - - func testAttentionQueueUrgencyAndVisitResolution() async throws { - let fixture = try Fixture() - let service = fixture.service - - let first = try await service.createSession(from: SessionCreationRequest(title: "One")).session - let second = try await service.createSession(from: SessionCreationRequest(title: "Two")).session - - service.injectAttention(sessionID: first.id, reason: .notification, message: "FYI") - service.injectAttention(sessionID: second.id, reason: .error, message: "Boom") - service.injectAttention(sessionID: first.id, reason: .needsInput, message: "Approve") - - let unresolved = service.unresolvedAttentionItems - XCTAssertEqual(unresolved.count, 2) - XCTAssertEqual(unresolved.first?.sessionID, second.id) - XCTAssertEqual(unresolved.first?.reason, .error) - XCTAssertEqual(unresolved.last?.sessionID, first.id) - XCTAssertEqual(unresolved.last?.reason, .needsInput) - - service.selectSession(first.id) - XCTAssertEqual(service.unresolvedAttentionItems.count, 1) - XCTAssertEqual(service.unresolvedAttentionItems.first?.sessionID, second.id) - XCTAssertEqual(service.sessions.first(where: { $0.id == first.id })?.latestAttentionReason, nil) - } - - func testRestoreMetadataBehaviorMarksSessionsAsNotRunning() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("idx0-service-restore-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let service = try Self.makeService(root: root) - _ = try await service.createSession(from: SessionCreationRequest(title: "Saved")).session - - try await Task.sleep(nanoseconds: 500_000_000) - - // Override to metadata-only so this test exercises the metadata-only path - let service2 = try Self.makeService(root: root) - service2.saveSettings { $0.restoreBehavior = .restoreMetadataOnly } - try await Task.sleep(nanoseconds: 200_000_000) - - let restored = try Self.makeService(root: root) - XCTAssertEqual(restored.settings.restoreBehavior, .restoreMetadataOnly) - XCTAssertTrue(restored.sessions.first?.statusText?.contains("Restored metadata only") ?? false) - } - - func testRelaunchSelectedRestoreBehaviorMarksOnlyUnselectedSessions() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("idx0-service-restore-selected-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let service = try Self.makeService(root: root) - let first = try await service.createSession(from: SessionCreationRequest(title: "One")).session - _ = try await service.createSession(from: SessionCreationRequest(title: "Two")).session - service.selectSession(first.id) - service.saveSettings { $0.restoreBehavior = .relaunchSelectedSession } - - try await Task.sleep(nanoseconds: 500_000_000) - - let restored = try Self.makeService(root: root) - let selected = restored.sessions.first(where: { $0.id == first.id }) - let unselected = restored.sessions.first(where: { $0.id != first.id }) - - XCTAssertFalse(selected?.statusText?.contains("Restored metadata only") ?? false) - XCTAssertTrue(unselected?.statusText?.contains("Restored metadata only") ?? false) - } - - func testRelaunchAllRestoreBehaviorDoesNotUseMetadataOnlyStatus() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("idx0-service-restore-all-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let service = try Self.makeService(root: root) - _ = try await service.createSession(from: SessionCreationRequest(title: "One")).session - _ = try await service.createSession(from: SessionCreationRequest(title: "Two")).session - service.saveSettings { $0.restoreBehavior = .relaunchAllSessions } - - try await Task.sleep(nanoseconds: 500_000_000) - - let restored = try Self.makeService(root: root) - let hasMetadataOnly = restored.sessions.contains { session in - session.statusText?.contains("Restored metadata only") == true + func testCloseSelectedSessionSelectsPreviousSessionWhenPossible() async throws { + let fixture = try Fixture() + let service = fixture.service + + let first = try await service.createSession(from: SessionCreationRequest(title: "One", repoPath: nil, createWorktree: false, branchName: nil, existingWorktreePath: nil, shellPath: nil)).session + let second = try await service.createSession(from: SessionCreationRequest(title: "Two", repoPath: nil, createWorktree: false, branchName: nil, existingWorktreePath: nil, shellPath: nil)).session + let third = try await service.createSession(from: SessionCreationRequest(title: "Three", repoPath: nil, createWorktree: false, branchName: nil, existingWorktreePath: nil, shellPath: nil)).session + + service.selectSession(second.id) + service.closeSession(second.id) + + XCTAssertEqual(service.selectedSessionID, first.id) + XCTAssertTrue(service.sessions.contains(where: { $0.id == first.id })) + XCTAssertFalse(service.sessions.contains(where: { $0.id == second.id })) + XCTAssertTrue(service.sessions.contains(where: { $0.id == third.id })) + } + + func testSuggestedTitleIgnoredWhenCustomTitleExists() async throws { + let fixture = try Fixture() + let service = fixture.service + + let custom = try await service.createSession(from: SessionCreationRequest(title: "Custom", repoPath: nil, createWorktree: false, branchName: nil, existingWorktreePath: nil, shellPath: nil)).session + service.updateTerminalMetadata(custom.id, cwd: nil, suggestedTitle: "From Terminal") + + XCTAssertEqual(service.sessions.first(where: { $0.id == custom.id })?.title, "Custom") + + let auto = try await service.createSession(from: SessionCreationRequest(title: nil, repoPath: nil, createWorktree: false, branchName: nil, existingWorktreePath: nil, shellPath: nil)).session + service.updateTerminalMetadata(auto.id, cwd: nil, suggestedTitle: "Auto Updated") + + XCTAssertEqual(service.sessions.first(where: { $0.id == auto.id })?.title, "Auto Updated") + } + + func testGroupsSessionsByProject() async throws { + let fixture = try Fixture() + let service = fixture.service + + let first = try await service.createSession(from: SessionCreationRequest( + title: "A1", + repoPath: "/tmp/repo-a", + createWorktree: false + )).session + let second = try await service.createSession(from: SessionCreationRequest( + title: "A2", + repoPath: "/tmp/repo-a", + createWorktree: false + )).session + _ = try await service.createSession(from: SessionCreationRequest( + title: "B1", + repoPath: "/tmp/repo-b", + createWorktree: false + )).session + + XCTAssertEqual(service.projectSections.count, 2) + + let sectionForA = service.projectSections.first(where: { $0.group.title == "repo-a" }) + XCTAssertNotNil(sectionForA) + XCTAssertEqual(sectionForA?.sessions.map(\.id), [second.id, first.id]) + } + + func testPinnedSessionsStayStableAboveRecentSorting() async throws { + let fixture = try Fixture() + let service = fixture.service + + let first = try await service.createSession(from: SessionCreationRequest( + title: "A1", + repoPath: "/tmp/repo-a", + createWorktree: false + )).session + let second = try await service.createSession(from: SessionCreationRequest( + title: "A2", + repoPath: "/tmp/repo-a", + createWorktree: false + )).session + + service.setPinned(second.id, pinned: true) + service.selectSession(first.id) + + let pinnedFirstOrder = service.projectSections + .first(where: { $0.group.title == "repo-a" })? + .sessions + .map(\.id) + XCTAssertEqual(pinnedFirstOrder?.first, second.id) + + service.setPinned(second.id, pinned: false) + let unpinnedOrder = service.projectSections + .first(where: { $0.group.title == "repo-a" })? + .sessions + .map(\.id) + XCTAssertEqual(unpinnedOrder?.first, first.id) + } + + func testFocusSessionDoesNotUpdateRecentOrdering() async throws { + let fixture = try Fixture() + let service = fixture.service + + let first = try await service.createSession(from: SessionCreationRequest( + title: "A1", + repoPath: "/tmp/repo-a", + createWorktree: false + )).session + let second = try await service.createSession(from: SessionCreationRequest( + title: "A2", + repoPath: "/tmp/repo-a", + createWorktree: false + )).session + + let beforeOrder = service.projectSections + .first(where: { $0.group.title == "repo-a" })? + .sessions + .map(\.id) + XCTAssertEqual(beforeOrder, [second.id, first.id]) + + service.focusSession(first.id) + + let afterOrder = service.projectSections + .first(where: { $0.group.title == "repo-a" })? + .sessions + .map(\.id) + XCTAssertEqual(afterOrder, [second.id, first.id]) + XCTAssertEqual(service.selectedSessionID, first.id) + } + + func testSetFocusedPaneMarksTerminalAsLastFocusedSurface() async throws { + let fixture = try Fixture() + let service = fixture.service + + let first = try await service.createSession(from: SessionCreationRequest(title: "One")).session + let second = try await service.createSession(from: SessionCreationRequest(title: "Two")).session + + service.toggleBrowserSplit(for: second.id) + service.markBrowserFocused(for: second.id) + service.focusSession(first.id) + + XCTAssertEqual(service.lastFocusedSurfaceBySession[second.id], .browser) + + service.setFocusedPane(sessionID: second.id, controllerID: second.id) + + XCTAssertEqual(service.selectedSessionID, first.id) + XCTAssertEqual(service.lastFocusedSurfaceBySession[second.id], .terminal) + } + + func testFocusSessionPrefersTerminalAfterSetFocusedPaneOnUnselectedSession() async throws { + let fixture = try Fixture() + let service = fixture.service + + let first = try await service.createSession(from: SessionCreationRequest(title: "One")).session + let second = try await service.createSession(from: SessionCreationRequest(title: "Two")).session + + service.toggleBrowserSplit(for: second.id) + service.markBrowserFocused(for: second.id) + service.focusSession(first.id) + service.focusSession(second.id) + XCTAssertEqual(service.lastFocusedSurfaceBySession[second.id], .browser) + + service.focusSession(first.id) + service.setFocusedPane(sessionID: second.id, controllerID: second.id) + service.focusSession(second.id) + + XCTAssertEqual(service.lastFocusedSurfaceBySession[second.id], .terminal) + } + + func testAttentionQueueUrgencyAndVisitResolution() async throws { + let fixture = try Fixture() + let service = fixture.service + + let first = try await service.createSession(from: SessionCreationRequest(title: "One")).session + let second = try await service.createSession(from: SessionCreationRequest(title: "Two")).session + + service.injectAttention(sessionID: first.id, reason: .notification, message: "FYI") + service.injectAttention(sessionID: second.id, reason: .error, message: "Boom") + service.injectAttention(sessionID: first.id, reason: .needsInput, message: "Approve") + + let unresolved = service.unresolvedAttentionItems + XCTAssertEqual(unresolved.count, 2) + XCTAssertEqual(unresolved.first?.sessionID, second.id) + XCTAssertEqual(unresolved.first?.reason, .error) + XCTAssertEqual(unresolved.last?.sessionID, first.id) + XCTAssertEqual(unresolved.last?.reason, .needsInput) + + service.selectSession(first.id) + XCTAssertEqual(service.unresolvedAttentionItems.count, 1) + XCTAssertEqual(service.unresolvedAttentionItems.first?.sessionID, second.id) + XCTAssertEqual(service.sessions.first(where: { $0.id == first.id })?.latestAttentionReason, nil) + } + + func testRestoreMetadataBehaviorMarksSessionsAsNotRunning() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("idx0-service-restore-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let service = try Self.makeService(root: root) + _ = try await service.createSession(from: SessionCreationRequest(title: "Saved")).session + + try await Task.sleep(nanoseconds: 500_000_000) + + // Override to metadata-only so this test exercises the metadata-only path + let service2 = try Self.makeService(root: root) + service2.saveSettings { $0.restoreBehavior = .restoreMetadataOnly } + try await Task.sleep(nanoseconds: 200_000_000) + + let restored = try Self.makeService(root: root) + XCTAssertEqual(restored.settings.restoreBehavior, .restoreMetadataOnly) + XCTAssertTrue(restored.sessions.first?.statusText?.contains("Restored metadata only") ?? false) + } + + func testRelaunchSelectedRestoreBehaviorMarksOnlyUnselectedSessions() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("idx0-service-restore-selected-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let service = try Self.makeService(root: root) + let first = try await service.createSession(from: SessionCreationRequest(title: "One")).session + _ = try await service.createSession(from: SessionCreationRequest(title: "Two")).session + service.selectSession(first.id) + service.saveSettings { $0.restoreBehavior = .relaunchSelectedSession } + + try await Task.sleep(nanoseconds: 500_000_000) + + let restored = try Self.makeService(root: root) + let selected = restored.sessions.first(where: { $0.id == first.id }) + let unselected = restored.sessions.first(where: { $0.id != first.id }) + + XCTAssertFalse(selected?.statusText?.contains("Restored metadata only") ?? false) + XCTAssertTrue(unselected?.statusText?.contains("Restored metadata only") ?? false) + } + + func testRelaunchAllRestoreBehaviorDoesNotUseMetadataOnlyStatus() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("idx0-service-restore-all-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let service = try Self.makeService(root: root) + _ = try await service.createSession(from: SessionCreationRequest(title: "One")).session + _ = try await service.createSession(from: SessionCreationRequest(title: "Two")).session + service.saveSettings { $0.restoreBehavior = .relaunchAllSessions } + + try await Task.sleep(nanoseconds: 500_000_000) + + let restored = try Self.makeService(root: root) + let hasMetadataOnly = restored.sessions.contains { session in + session.statusText?.contains("Restored metadata only") == true + } + XCTAssertFalse(hasMetadataOnly) + } + + func testPersistenceQueueKeepsLatestSessionSnapshotUnderBurstUpdates() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("idx0-persist-burst-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let service = try Self.makeService(root: root) + let session = try await service.createSession(from: SessionCreationRequest(title: "Burst 0")).session + + for index in 1 ... 40 { + service.renameSession(session.id, title: "Burst \(index)") + } + service.persistNow() + + let payload = try SessionStore(url: root.appendingPathComponent("sessions.json", isDirectory: false)).load() + let persisted = payload.sessions.first(where: { $0.id == session.id }) + XCTAssertEqual(persisted?.title, "Burst 40") + } + + func testTileStateRestoresAcrossRelaunchWhenCleanupOnCloseDisabled() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("idx0-tile-restore-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let service = try Self.makeService(root: root) + service.saveSettings { $0.cleanupOnClose = false } + let session = try await service.createSession(from: SessionCreationRequest(title: "Tile Restore")).session + service.ensureNiriLayoutState(for: session.id) + _ = service.niriAddTerminalRight(in: session.id) + _ = service.niriAddBrowserRight(in: session.id) + + let tabCountBefore = service.tabs(for: session.id).count + let browserTileCountBefore = service.niriLayout(for: session.id) + .workspaces + .flatMap(\.columns) + .flatMap(\.items) + .count(where: { item in + if case .browser = item.ref { + return true } - XCTAssertFalse(hasMetadataOnly) - } - - func testPersistenceQueueKeepsLatestSessionSnapshotUnderBurstUpdates() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("idx0-persist-burst-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let service = try Self.makeService(root: root) - let session = try await service.createSession(from: SessionCreationRequest(title: "Burst 0")).session - - for index in 1...40 { - service.renameSession(session.id, title: "Burst \(index)") + return false + }) + + XCTAssertGreaterThan(tabCountBefore, 1) + XCTAssertEqual(browserTileCountBefore, 1) + + service.prepareForTermination() + + let restored = try Self.makeService(root: root) + let restoredTabCount = restored.tabs(for: session.id).count + let restoredBrowserTileCount = restored.niriLayout(for: session.id) + .workspaces + .flatMap(\.columns) + .flatMap(\.items) + .count(where: { item in + if case .browser = item.ref { + return true } - service.persistNow() - - let payload = try SessionStore(url: root.appendingPathComponent("sessions.json", isDirectory: false)).load() - let persisted = payload.sessions.first(where: { $0.id == session.id }) - XCTAssertEqual(persisted?.title, "Burst 40") - } - - func testTileStateRestoresAcrossRelaunchWhenCleanupOnCloseDisabled() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("idx0-tile-restore-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let service = try Self.makeService(root: root) - service.saveSettings { $0.cleanupOnClose = false } - let session = try await service.createSession(from: SessionCreationRequest(title: "Tile Restore")).session - service.ensureNiriLayoutState(for: session.id) - _ = service.niriAddTerminalRight(in: session.id) - _ = service.niriAddBrowserRight(in: session.id) - - let tabCountBefore = service.tabs(for: session.id).count - let browserTileCountBefore = service.niriLayout(for: session.id) - .workspaces - .flatMap(\.columns) - .flatMap(\.items) - .filter { item in - if case .browser = item.ref { - return true - } - return false - } - .count - - XCTAssertGreaterThan(tabCountBefore, 1) - XCTAssertEqual(browserTileCountBefore, 1) - - service.prepareForTermination() - - let restored = try Self.makeService(root: root) - let restoredTabCount = restored.tabs(for: session.id).count - let restoredBrowserTileCount = restored.niriLayout(for: session.id) - .workspaces - .flatMap(\.columns) - .flatMap(\.items) - .filter { item in - if case .browser = item.ref { - return true - } - return false - } - .count - - XCTAssertEqual(restoredTabCount, tabCountBefore) - XCTAssertEqual(restoredBrowserTileCount, browserTileCountBefore) - } - - func testTileStateIsClearedAcrossRelaunchWhenCleanupOnCloseEnabled() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("idx0-tile-cleanup-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let service = try Self.makeService(root: root) - let session = try await service.createSession(from: SessionCreationRequest(title: "Tile Cleanup")).session - service.ensureNiriLayoutState(for: session.id) - _ = service.niriAddTerminalRight(in: session.id) - _ = service.niriAddBrowserRight(in: session.id) - service.saveSettings { $0.cleanupOnClose = true } - - service.prepareForTermination() - - let restored = try Self.makeService(root: root) - restored.ensureNiriLayoutState(for: session.id) - let restoredBrowserTileCount = restored.niriLayout(for: session.id) - .workspaces - .flatMap(\.columns) - .flatMap(\.items) - .filter { item in - if case .browser = item.ref { - return true - } - return false - } - .count - - XCTAssertTrue(restored.settings.cleanupOnClose) - XCTAssertEqual(restored.tabs(for: session.id).count, 1) - XCTAssertEqual(restoredBrowserTileCount, 0) - } - - func testSchema1SessionsMigrateIntoProjectGroups() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("idx0-schema1-migration-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let paths = FileSystemPaths( - appSupportDirectory: root, - sessionsFile: root.appendingPathComponent("sessions.json"), - projectsFile: root.appendingPathComponent("projects.json"), - inboxFile: root.appendingPathComponent("inbox.json"), - settingsFile: root.appendingPathComponent("settings.json"), - runDirectory: root.appendingPathComponent("run", isDirectory: true), - tempDirectory: root.appendingPathComponent("temp", isDirectory: true), - worktreesDirectory: root.appendingPathComponent("worktrees", isDirectory: true) - ) - try paths.ensureDirectories() - - let now = Date() - let sessionA = Session( - id: UUID(), - title: "RepoA", - hasCustomTitle: true, - createdAt: now, - lastActiveAt: now, - repoPath: "/tmp/repo-a", - branchName: "main", - worktreePath: nil, - isWorktreeBacked: false, - shellPath: "/bin/zsh", - attentionState: .normal, - statusText: nil, - lastKnownCwd: "/tmp/repo-a" - ) - let sessionB = Session( - id: UUID(), - title: "RepoB", - hasCustomTitle: true, - createdAt: now, - lastActiveAt: now, - repoPath: "/tmp/repo-b", - branchName: "main", - worktreePath: nil, - isWorktreeBacked: false, - shellPath: "/bin/zsh", - attentionState: .normal, - statusText: nil, - lastKnownCwd: "/tmp/repo-b" - ) - - try SessionStore(url: paths.sessionsFile).save( - payload: SessionsFilePayload( - schemaVersion: 1, - selectedSessionID: sessionA.id, - sessions: [sessionA, sessionB] - ) - ) - - let service = try Self.makeService(root: root) - XCTAssertEqual(service.projectSections.count, 2) - XCTAssertNotNil(service.projectSections.first(where: { $0.group.title == "repo-a" })) - XCTAssertNotNil(service.projectSections.first(where: { $0.group.title == "repo-b" })) - } - - func testSchema1SessionsWithSameRepoMigrateIntoSingleGroup() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("idx0-schema1-single-group-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let paths = FileSystemPaths( - appSupportDirectory: root, - sessionsFile: root.appendingPathComponent("sessions.json"), - projectsFile: root.appendingPathComponent("projects.json"), - inboxFile: root.appendingPathComponent("inbox.json"), - settingsFile: root.appendingPathComponent("settings.json"), - runDirectory: root.appendingPathComponent("run", isDirectory: true), - tempDirectory: root.appendingPathComponent("temp", isDirectory: true), - worktreesDirectory: root.appendingPathComponent("worktrees", isDirectory: true) - ) - try paths.ensureDirectories() - - let now = Date() - let older = now.addingTimeInterval(-60) - let sessionA = Session( - id: UUID(), - title: "RepoA-main", - hasCustomTitle: true, - createdAt: older, - lastActiveAt: older, - repoPath: "/tmp/repo-a", - branchName: "main", - worktreePath: nil, - isWorktreeBacked: false, - shellPath: "/bin/zsh", - attentionState: .normal, - statusText: nil, - lastKnownCwd: "/tmp/repo-a" - ) - let sessionB = Session( - id: UUID(), - title: "RepoA-feature", - hasCustomTitle: true, - createdAt: now, - lastActiveAt: now, - repoPath: "/tmp/repo-a", - branchName: "feature", - worktreePath: nil, - isWorktreeBacked: false, - shellPath: "/bin/zsh", - attentionState: .normal, - statusText: nil, - lastKnownCwd: "/tmp/repo-a" - ) - - try SessionStore(url: paths.sessionsFile).save( - payload: SessionsFilePayload( - schemaVersion: 1, - selectedSessionID: sessionB.id, - sessions: [sessionA, sessionB] - ) - ) - - let service = try Self.makeService(root: root) - let repoASections = service.projectSections.filter { $0.group.title == "repo-a" } - XCTAssertEqual(repoASections.count, 1) - XCTAssertEqual(repoASections.first?.sessions.count, 2) - XCTAssertEqual(repoASections.first?.sessions.first?.id, sessionB.id) - } - - func testUnsupportedSchemaDoesNotOverwriteExistingSessionsFileOnPersist() throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("idx0-schema-future-protect-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let paths = FileSystemPaths( - appSupportDirectory: root, - sessionsFile: root.appendingPathComponent("sessions.json"), - projectsFile: root.appendingPathComponent("projects.json"), - inboxFile: root.appendingPathComponent("inbox.json"), - settingsFile: root.appendingPathComponent("settings.json"), - runDirectory: root.appendingPathComponent("run", isDirectory: true), - tempDirectory: root.appendingPathComponent("temp", isDirectory: true), - worktreesDirectory: root.appendingPathComponent("worktrees", isDirectory: true) - ) - try paths.ensureDirectories() - - let now = Date() - let persistedSession = Session( - id: UUID(), - title: "Future Session", - hasCustomTitle: true, - createdAt: now, - lastActiveAt: now, - repoPath: "/tmp/repo-future", - branchName: "main", - worktreePath: nil, - isWorktreeBacked: false, - shellPath: "/bin/zsh", - attentionState: .normal, - statusText: nil, - lastKnownCwd: "/tmp/repo-future" - ) - let futurePayload = SessionsFilePayload( - schemaVersion: PersistenceSchema.currentVersion + 1, - selectedSessionID: persistedSession.id, - sessions: [persistedSession] - ) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - encoder.dateEncodingStrategy = .iso8601 - try encoder.encode(futurePayload).write(to: paths.sessionsFile) - - let worktree = WorktreeService(gitService: GitService(), paths: paths) - let service = SessionService( - sessionStore: SessionStore(url: paths.sessionsFile), - projectStore: ProjectStore(url: paths.projectsFile), - inboxStore: InboxStore(url: paths.inboxFile), - settingsStore: SettingsStore(url: paths.settingsFile), - worktreeService: worktree, - launcherDirectory: root.appendingPathComponent("launchers", isDirectory: true), - host: .shared - ) - - service.prepareForTermination() - - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - let persisted = try decoder.decode(SessionsFilePayload.self, from: Data(contentsOf: paths.sessionsFile)) - XCTAssertEqual(persisted.schemaVersion, PersistenceSchema.currentVersion + 1) - XCTAssertEqual(persisted.sessions.map(\.title), ["Future Session"]) - } - - func testSwipeTrackerHistoryWindowDropsOldEvents() { - var tracker = SwipeTracker(historyLimit: 0.100, deceleration: 0.997) - tracker.push(delta: 40, at: 0.00) - tracker.push(delta: 10, at: 0.20) - tracker.push(delta: 10, at: 0.24) - - XCTAssertGreaterThan(tracker.velocity(), 300) - } - - func makeStubNiriAppDescriptor(appID: String, tracker: StubNiriAppTracker) -> NiriAppDescriptor { - NiriAppDescriptor( - id: appID, - displayName: "Stub App", - icon: "puzzlepiece.extension", - menuSubtitle: "Test app", - isVisibleInMenus: true, - supportsWebZoomPersistence: true, - startTile: { _, _ in nil }, - retryTile: { _, sessionID, itemID in - tracker.retryCalls.append((sessionID: sessionID, itemID: itemID)) - tracker.controller(for: itemID)?.retry() - }, - stopTile: { _, itemID in - tracker.stopCalls.append(itemID) - tracker.controller(for: itemID)?.stop() - tracker.removeController(for: itemID) - }, - ensureController: { _, sessionID, itemID in - tracker.ensureCalls.append((sessionID: sessionID, itemID: itemID)) - return tracker.ensureController(sessionID: sessionID, itemID: itemID) - }, - makeTileView: { _, _, _ in AnyView(EmptyView()) }, - cleanupSessionArtifacts: { _, sessionID in - tracker.recordCleanup(for: sessionID) - } - ) - } - - func addStubNiriAppTile(appID: String, sessionID: UUID, service: SessionService) -> UUID { - service.ensureNiriLayoutState(for: sessionID) - var layout = service.niriLayout(for: sessionID) - let workspaceIndex = niriActiveWorkspaceIndex(layout) ?? 0 - - let itemID = UUID() - let item = NiriLayoutItem(id: itemID, ref: .app(appID: appID)) - let column = NiriColumn(id: UUID(), items: [item], focusedItemID: itemID, displayMode: .normal) - - layout.workspaces[workspaceIndex].columns.append(column) - layout.camera.activeWorkspaceID = layout.workspaces[workspaceIndex].id - layout.camera.activeColumnID = column.id - layout.camera.focusedItemID = itemID - service.setNiriLayoutForTesting(sessionID: sessionID, layout: layout) - - return itemID - } - - func assertHasSingleTrailingEmptyWorkspace( - _ layout: NiriCanvasLayout, - file: StaticString = #filePath, - line: UInt = #line - ) { - XCTAssertFalse(layout.workspaces.isEmpty, file: file, line: line) - let trailingEmptyCount = layout.workspaces.suffix(1).filter { $0.columns.isEmpty }.count - XCTAssertEqual(trailingEmptyCount, 1, file: file, line: line) - XCTAssertTrue(layout.workspaces.dropLast().allSatisfy { !$0.columns.isEmpty }, file: file, line: line) - } - - func niriActiveWorkspaceIndex(_ layout: NiriCanvasLayout) -> Int? { - if let activeWorkspaceID = layout.camera.activeWorkspaceID, - let index = layout.workspaces.firstIndex(where: { $0.id == activeWorkspaceID }) { - return index - } - return layout.workspaces.firstIndex(where: { !$0.columns.isEmpty }) ?? (layout.workspaces.isEmpty ? nil : 0) - } - - func niriActiveColumnIndex(_ layout: NiriCanvasLayout, workspaceIndex: Int) -> Int? { - guard workspaceIndex >= 0, workspaceIndex < layout.workspaces.count else { return nil } - let workspace = layout.workspaces[workspaceIndex] - if let activeColumnID = layout.camera.activeColumnID, - let index = workspace.columns.firstIndex(where: { $0.id == activeColumnID }) { - return index - } - return workspace.columns.isEmpty ? nil : 0 - } - - @MainActor - final class StubNiriAppTracker { - var ensureCalls: [(sessionID: UUID, itemID: UUID)] = [] - var retryCalls: [(sessionID: UUID, itemID: UUID)] = [] - var stopCalls: [UUID] = [] - - private var cleanupCallsBySession: [UUID: Int] = [:] - private var controllersByItemID: [UUID: StubNiriAppController] = [:] - - func ensureController(sessionID: UUID, itemID: UUID) -> StubNiriAppController { - if let existing = controllersByItemID[itemID] { - return existing - } - let created = StubNiriAppController(sessionID: sessionID) - controllersByItemID[itemID] = created - return created - } - - func controller(for itemID: UUID) -> StubNiriAppController? { - controllersByItemID[itemID] - } - - func removeController(for itemID: UUID) { - controllersByItemID.removeValue(forKey: itemID) - } - - func recordCleanup(for sessionID: UUID) { - cleanupCallsBySession[sessionID, default: 0] += 1 - } - - func cleanupCallCount(for sessionID: UUID) -> Int { - cleanupCallsBySession[sessionID, default: 0] - } - } - - @MainActor - final class StubNiriAppController: NiriAppTileRuntimeControlling { - let sessionID: UUID - private(set) var retryCount = 0 - private(set) var stopCount = 0 - private(set) var zoomAdjustments: [CGFloat] = [] - - init(sessionID: UUID) { - self.sessionID = sessionID - } - - func retry() { - retryCount += 1 - } - - func stop() { - stopCount += 1 - } - - @discardableResult - func adjustZoom(by delta: CGFloat) -> Bool { - zoomAdjustments.append(delta) - return true + return false + }) + + XCTAssertEqual(restoredTabCount, tabCountBefore) + XCTAssertEqual(restoredBrowserTileCount, browserTileCountBefore) + } + + func testTileStateIsClearedAcrossRelaunchWhenCleanupOnCloseEnabled() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("idx0-tile-cleanup-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let service = try Self.makeService(root: root) + let session = try await service.createSession(from: SessionCreationRequest(title: "Tile Cleanup")).session + service.ensureNiriLayoutState(for: session.id) + _ = service.niriAddTerminalRight(in: session.id) + _ = service.niriAddBrowserRight(in: session.id) + service.saveSettings { $0.cleanupOnClose = true } + + service.prepareForTermination() + + let restored = try Self.makeService(root: root) + restored.ensureNiriLayoutState(for: session.id) + let restoredBrowserTileCount = restored.niriLayout(for: session.id) + .workspaces + .flatMap(\.columns) + .flatMap(\.items) + .count(where: { item in + if case .browser = item.ref { + return true } - } + return false + }) + + XCTAssertTrue(restored.settings.cleanupOnClose) + XCTAssertEqual(restored.tabs(for: session.id).count, 1) + XCTAssertEqual(restoredBrowserTileCount, 0) + } + + func testSchema1SessionsMigrateIntoProjectGroups() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("idx0-schema1-migration-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let paths = FileSystemPaths( + appSupportDirectory: root, + sessionsFile: root.appendingPathComponent("sessions.json"), + projectsFile: root.appendingPathComponent("projects.json"), + inboxFile: root.appendingPathComponent("inbox.json"), + settingsFile: root.appendingPathComponent("settings.json"), + runDirectory: root.appendingPathComponent("run", isDirectory: true), + tempDirectory: root.appendingPathComponent("temp", isDirectory: true), + worktreesDirectory: root.appendingPathComponent("worktrees", isDirectory: true) + ) + try paths.ensureDirectories() + + let now = Date() + let sessionA = Session( + id: UUID(), + title: "RepoA", + hasCustomTitle: true, + createdAt: now, + lastActiveAt: now, + repoPath: "/tmp/repo-a", + branchName: "main", + worktreePath: nil, + isWorktreeBacked: false, + shellPath: "/bin/zsh", + attentionState: .normal, + statusText: nil, + lastKnownCwd: "/tmp/repo-a" + ) + let sessionB = Session( + id: UUID(), + title: "RepoB", + hasCustomTitle: true, + createdAt: now, + lastActiveAt: now, + repoPath: "/tmp/repo-b", + branchName: "main", + worktreePath: nil, + isWorktreeBacked: false, + shellPath: "/bin/zsh", + attentionState: .normal, + statusText: nil, + lastKnownCwd: "/tmp/repo-b" + ) + + try SessionStore(url: paths.sessionsFile).save( + payload: SessionsFilePayload( + schemaVersion: 1, + selectedSessionID: sessionA.id, + sessions: [sessionA, sessionB] + ) + ) + + let service = try Self.makeService(root: root) + XCTAssertEqual(service.projectSections.count, 2) + XCTAssertNotNil(service.projectSections.first(where: { $0.group.title == "repo-a" })) + XCTAssertNotNil(service.projectSections.first(where: { $0.group.title == "repo-b" })) + } + + func testSchema1SessionsWithSameRepoMigrateIntoSingleGroup() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("idx0-schema1-single-group-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let paths = FileSystemPaths( + appSupportDirectory: root, + sessionsFile: root.appendingPathComponent("sessions.json"), + projectsFile: root.appendingPathComponent("projects.json"), + inboxFile: root.appendingPathComponent("inbox.json"), + settingsFile: root.appendingPathComponent("settings.json"), + runDirectory: root.appendingPathComponent("run", isDirectory: true), + tempDirectory: root.appendingPathComponent("temp", isDirectory: true), + worktreesDirectory: root.appendingPathComponent("worktrees", isDirectory: true) + ) + try paths.ensureDirectories() + + let now = Date() + let older = now.addingTimeInterval(-60) + let sessionA = Session( + id: UUID(), + title: "RepoA-main", + hasCustomTitle: true, + createdAt: older, + lastActiveAt: older, + repoPath: "/tmp/repo-a", + branchName: "main", + worktreePath: nil, + isWorktreeBacked: false, + shellPath: "/bin/zsh", + attentionState: .normal, + statusText: nil, + lastKnownCwd: "/tmp/repo-a" + ) + let sessionB = Session( + id: UUID(), + title: "RepoA-feature", + hasCustomTitle: true, + createdAt: now, + lastActiveAt: now, + repoPath: "/tmp/repo-a", + branchName: "feature", + worktreePath: nil, + isWorktreeBacked: false, + shellPath: "/bin/zsh", + attentionState: .normal, + statusText: nil, + lastKnownCwd: "/tmp/repo-a" + ) + + try SessionStore(url: paths.sessionsFile).save( + payload: SessionsFilePayload( + schemaVersion: 1, + selectedSessionID: sessionB.id, + sessions: [sessionA, sessionB] + ) + ) + + let service = try Self.makeService(root: root) + let repoASections = service.projectSections.filter { $0.group.title == "repo-a" } + XCTAssertEqual(repoASections.count, 1) + XCTAssertEqual(repoASections.first?.sessions.count, 2) + XCTAssertEqual(repoASections.first?.sessions.first?.id, sessionB.id) + } + + func testUnsupportedSchemaDoesNotOverwriteExistingSessionsFileOnPersist() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("idx0-schema-future-protect-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let paths = FileSystemPaths( + appSupportDirectory: root, + sessionsFile: root.appendingPathComponent("sessions.json"), + projectsFile: root.appendingPathComponent("projects.json"), + inboxFile: root.appendingPathComponent("inbox.json"), + settingsFile: root.appendingPathComponent("settings.json"), + runDirectory: root.appendingPathComponent("run", isDirectory: true), + tempDirectory: root.appendingPathComponent("temp", isDirectory: true), + worktreesDirectory: root.appendingPathComponent("worktrees", isDirectory: true) + ) + try paths.ensureDirectories() + + let now = Date() + let persistedSession = Session( + id: UUID(), + title: "Future Session", + hasCustomTitle: true, + createdAt: now, + lastActiveAt: now, + repoPath: "/tmp/repo-future", + branchName: "main", + worktreePath: nil, + isWorktreeBacked: false, + shellPath: "/bin/zsh", + attentionState: .normal, + statusText: nil, + lastKnownCwd: "/tmp/repo-future" + ) + let futurePayload = SessionsFilePayload( + schemaVersion: PersistenceSchema.currentVersion + 1, + selectedSessionID: persistedSession.id, + sessions: [persistedSession] + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + try encoder.encode(futurePayload).write(to: paths.sessionsFile) + + let worktree = WorktreeService(gitService: GitService(), paths: paths) + let service = SessionService( + sessionStore: SessionStore(url: paths.sessionsFile), + projectStore: ProjectStore(url: paths.projectsFile), + inboxStore: InboxStore(url: paths.inboxFile), + settingsStore: SettingsStore(url: paths.settingsFile), + worktreeService: worktree, + launcherDirectory: root.appendingPathComponent("launchers", isDirectory: true), + host: .shared + ) + + service.prepareForTermination() + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let persisted = try decoder.decode(SessionsFilePayload.self, from: Data(contentsOf: paths.sessionsFile)) + XCTAssertEqual(persisted.schemaVersion, PersistenceSchema.currentVersion + 1) + XCTAssertEqual(persisted.sessions.map(\.title), ["Future Session"]) + } + + func testSwipeTrackerHistoryWindowDropsOldEvents() { + var tracker = SwipeTracker(historyLimit: 0.100, deceleration: 0.997) + tracker.push(delta: 40, at: 0.00) + tracker.push(delta: 10, at: 0.20) + tracker.push(delta: 10, at: 0.24) + + XCTAssertGreaterThan(tracker.velocity(), 300) + } + + func makeStubNiriAppDescriptor(appID: String, tracker: StubNiriAppTracker) -> NiriAppDescriptor { + NiriAppDescriptor( + id: appID, + displayName: "Stub App", + icon: "puzzlepiece.extension", + menuSubtitle: "Test app", + isVisibleInMenus: true, + supportsWebZoomPersistence: true, + startTile: { _, _ in nil }, + retryTile: { _, sessionID, itemID in + tracker.retryCalls.append((sessionID: sessionID, itemID: itemID)) + tracker.controller(for: itemID)?.retry() + }, + stopTile: { _, itemID in + tracker.stopCalls.append(itemID) + tracker.controller(for: itemID)?.stop() + tracker.removeController(for: itemID) + }, + ensureController: { _, sessionID, itemID in + tracker.ensureCalls.append((sessionID: sessionID, itemID: itemID)) + return tracker.ensureController(sessionID: sessionID, itemID: itemID) + }, + makeTileView: { _, _, _ in AnyView(EmptyView()) }, + cleanupSessionArtifacts: { _, sessionID in + tracker.recordCleanup(for: sessionID) + } + ) + } + + func addStubNiriAppTile(appID: String, sessionID: UUID, service: SessionService) -> UUID { + service.ensureNiriLayoutState(for: sessionID) + var layout = service.niriLayout(for: sessionID) + let workspaceIndex = niriActiveWorkspaceIndex(layout) ?? 0 + + let itemID = UUID() + let item = NiriLayoutItem(id: itemID, ref: .app(appID: appID)) + let column = NiriColumn(id: UUID(), items: [item], focusedItemID: itemID, displayMode: .normal) + + layout.workspaces[workspaceIndex].columns.append(column) + layout.camera.activeWorkspaceID = layout.workspaces[workspaceIndex].id + layout.camera.activeColumnID = column.id + layout.camera.focusedItemID = itemID + service.setNiriLayoutForTesting(sessionID: sessionID, layout: layout) + + return itemID + } + + func assertHasSingleTrailingEmptyWorkspace( + _ layout: NiriCanvasLayout, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertFalse(layout.workspaces.isEmpty, file: file, line: line) + let trailingEmptyCount = layout.workspaces.suffix(1).count(where: { $0.columns.isEmpty }) + XCTAssertEqual(trailingEmptyCount, 1, file: file, line: line) + XCTAssertTrue(layout.workspaces.dropLast().allSatisfy { !$0.columns.isEmpty }, file: file, line: line) + } + + func niriActiveWorkspaceIndex(_ layout: NiriCanvasLayout) -> Int? { + if let activeWorkspaceID = layout.camera.activeWorkspaceID, + let index = layout.workspaces.firstIndex(where: { $0.id == activeWorkspaceID }) + { + return index + } + return layout.workspaces.firstIndex(where: { !$0.columns.isEmpty }) ?? (layout.workspaces.isEmpty ? nil : 0) + } + + func niriActiveColumnIndex(_ layout: NiriCanvasLayout, workspaceIndex: Int) -> Int? { + guard workspaceIndex >= 0, workspaceIndex < layout.workspaces.count else { return nil } + let workspace = layout.workspaces[workspaceIndex] + if let activeColumnID = layout.camera.activeColumnID, + let index = workspace.columns.firstIndex(where: { $0.id == activeColumnID }) + { + return index + } + return workspace.columns.isEmpty ? nil : 0 + } + + @MainActor + final class StubNiriAppTracker { + var ensureCalls: [(sessionID: UUID, itemID: UUID)] = [] + var retryCalls: [(sessionID: UUID, itemID: UUID)] = [] + var stopCalls: [UUID] = [] + + private var cleanupCallsBySession: [UUID: Int] = [:] + private var controllersByItemID: [UUID: StubNiriAppController] = [:] + + func ensureController(sessionID: UUID, itemID: UUID) -> StubNiriAppController { + if let existing = controllersByItemID[itemID] { + return existing + } + let created = StubNiriAppController(sessionID: sessionID) + controllersByItemID[itemID] = created + return created + } + + func controller(for itemID: UUID) -> StubNiriAppController? { + controllersByItemID[itemID] + } + + func removeController(for itemID: UUID) { + controllersByItemID.removeValue(forKey: itemID) + } + + func recordCleanup(for sessionID: UUID) { + cleanupCallsBySession[sessionID, default: 0] += 1 + } + + func cleanupCallCount(for sessionID: UUID) -> Int { + cleanupCallsBySession[sessionID, default: 0] + } + } + + @MainActor + final class StubNiriAppController: NiriAppTileRuntimeControlling { + let sessionID: UUID + private(set) var retryCount = 0 + private(set) var stopCount = 0 + private(set) var zoomAdjustments: [CGFloat] = [] + + init(sessionID: UUID) { + self.sessionID = sessionID + } + + func retry() { + retryCount += 1 + } + + func stop() { + stopCount += 1 + } + + @discardableResult + func adjustZoom(by delta: CGFloat) -> Bool { + zoomAdjustments.append(delta) + return true + } + } + + static func makeService( + root: URL, + niriAppRegistry: NiriAppRegistry = NiriAppRegistry() + ) throws -> SessionService { + let paths = FileSystemPaths( + appSupportDirectory: root, + sessionsFile: root.appendingPathComponent("sessions.json"), + projectsFile: root.appendingPathComponent("projects.json"), + inboxFile: root.appendingPathComponent("inbox.json"), + settingsFile: root.appendingPathComponent("settings.json"), + runDirectory: root.appendingPathComponent("run", isDirectory: true), + tempDirectory: root.appendingPathComponent("temp", isDirectory: true), + worktreesDirectory: root.appendingPathComponent("worktrees", isDirectory: true) + ) + try paths.ensureDirectories() - static func makeService( - root: URL, - niriAppRegistry: NiriAppRegistry = NiriAppRegistry() - ) throws -> SessionService { - let paths = FileSystemPaths( - appSupportDirectory: root, - sessionsFile: root.appendingPathComponent("sessions.json"), - projectsFile: root.appendingPathComponent("projects.json"), - inboxFile: root.appendingPathComponent("inbox.json"), - settingsFile: root.appendingPathComponent("settings.json"), - runDirectory: root.appendingPathComponent("run", isDirectory: true), - tempDirectory: root.appendingPathComponent("temp", isDirectory: true), - worktreesDirectory: root.appendingPathComponent("worktrees", isDirectory: true) - ) - try paths.ensureDirectories() - - let gitService = GitService() - let worktree = WorktreeService(gitService: gitService, paths: paths) - return SessionService( - sessionStore: SessionStore(url: paths.sessionsFile), - projectStore: ProjectStore(url: paths.projectsFile), - inboxStore: InboxStore(url: paths.inboxFile), - settingsStore: SettingsStore(url: paths.settingsFile), - worktreeService: worktree, - launcherDirectory: root.appendingPathComponent("launchers", isDirectory: true), - niriAppRegistry: niriAppRegistry, - host: .shared - ) - } + let gitService = GitService() + let worktree = WorktreeService(gitService: gitService, paths: paths) + return SessionService( + sessionStore: SessionStore(url: paths.sessionsFile), + projectStore: ProjectStore(url: paths.projectsFile), + inboxStore: InboxStore(url: paths.inboxFile), + settingsStore: SettingsStore(url: paths.settingsFile), + worktreeService: worktree, + launcherDirectory: root.appendingPathComponent("launchers", isDirectory: true), + niriAppRegistry: niriAppRegistry, + host: .shared + ) + } - @MainActor - struct Fixture { - let service: SessionService + @MainActor + struct Fixture { + let service: SessionService - init(niriAppRegistry: NiriAppRegistry = NiriAppRegistry()) throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("idx0-service-tests-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + init(niriAppRegistry: NiriAppRegistry = NiriAppRegistry()) throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("idx0-service-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - service = try SessionServiceTests.makeService(root: root, niriAppRegistry: niriAppRegistry) - } + service = try SessionServiceTests.makeService(root: root, niriAppRegistry: niriAppRegistry) } + } - func runBashScript(_ script: String) throws -> String { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/bash") - process.arguments = ["--noprofile", "--norc", "-c", script] + func runBashScript(_ script: String) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/bash") + process.arguments = ["--noprofile", "--norc", "-c", script] - let stdout = Pipe() - process.standardOutput = stdout + let stdout = Pipe() + process.standardOutput = stdout - try process.run() - process.waitUntilExit() + try process.run() + process.waitUntilExit() - XCTAssertEqual(process.terminationStatus, 0) + XCTAssertEqual(process.terminationStatus, 0) - let data = stdout.fileHandleForReading.readDataToEndOfFile() - return String(decoding: data, as: UTF8.self) - } + let data = stdout.fileHandleForReading.readDataToEndOfFile() + return String(decoding: data, as: UTF8.self) + } } final class ShellIntegrationHealthServiceTests: XCTestCase { - private let service = ShellIntegrationHealthService() - - func testExplicitShellTakesPrecedenceWhenExecutable() throws { - let explicit = try makeExecutableFile() - let preferred = try makeExecutableFile() - defer { - try? FileManager.default.removeItem(atPath: explicit) - try? FileManager.default.removeItem(atPath: preferred) - } - - let resolved = try service.resolvedShell(explicitShell: explicit, preferredShell: preferred) - XCTAssertEqual(resolved, explicit) - } - - func testInvalidExplicitShellThrows() throws { - let preferred = try makeExecutableFile() - defer { try? FileManager.default.removeItem(atPath: preferred) } - - XCTAssertThrowsError( - try service.resolvedShell(explicitShell: "/tmp/idx0-shell-does-not-exist", preferredShell: preferred) - ) { error in - guard case ShellIntegrationHealthError.invalidShellPath = error else { - XCTFail("Expected invalidShellPath error, got: \(error)") - return - } - } - } - - func testPreferredShellUsedWhenExplicitMissing() throws { - let preferred = try makeExecutableFile() - defer { try? FileManager.default.removeItem(atPath: preferred) } - - let resolved = try service.resolvedShell(explicitShell: nil, preferredShell: preferred) - XCTAssertEqual(resolved, preferred) - } - - func makeExecutableFile() throws -> String { - let path = FileManager.default.temporaryDirectory - .appendingPathComponent("idx0-shell-\(UUID().uuidString)") - .path - - let script = "#!/bin/sh\nexit 0\n" - try script.write(toFile: path, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path) - return path - } + private let service = ShellIntegrationHealthService() + + func testExplicitShellTakesPrecedenceWhenExecutable() throws { + let explicit = try makeExecutableFile() + let preferred = try makeExecutableFile() + defer { + try? FileManager.default.removeItem(atPath: explicit) + try? FileManager.default.removeItem(atPath: preferred) + } + + let resolved = try service.resolvedShell(explicitShell: explicit, preferredShell: preferred) + XCTAssertEqual(resolved, explicit) + } + + func testInvalidExplicitShellThrows() throws { + let preferred = try makeExecutableFile() + defer { try? FileManager.default.removeItem(atPath: preferred) } + + XCTAssertThrowsError( + try service.resolvedShell(explicitShell: "/tmp/idx0-shell-does-not-exist", preferredShell: preferred) + ) { error in + guard case ShellIntegrationHealthError.invalidShellPath = error else { + XCTFail("Expected invalidShellPath error, got: \(error)") + return + } + } + } + + func testPreferredShellUsedWhenExplicitMissing() throws { + let preferred = try makeExecutableFile() + defer { try? FileManager.default.removeItem(atPath: preferred) } + + let resolved = try service.resolvedShell(explicitShell: nil, preferredShell: preferred) + XCTAssertEqual(resolved, preferred) + } + + func makeExecutableFile() throws -> String { + let path = FileManager.default.temporaryDirectory + .appendingPathComponent("idx0-shell-\(UUID().uuidString)") + .path + + let script = "#!/bin/sh\nexit 0\n" + try script.write(toFile: path, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path) + return path + } } final class VibeCLIDiscoveryServiceTests: XCTestCase { - func testGeminiCliToolDetectsGeminiBinaryAlias() throws { - let binDirectory = FileManager.default.temporaryDirectory - .appendingPathComponent("idx0-gemini-alias-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: binDirectory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: binDirectory) } - - let geminiPath = binDirectory.appendingPathComponent("gemini", isDirectory: false).path - try "#!/bin/sh\nexit 0\n".write(toFile: geminiPath, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: geminiPath) - - let service = VibeCLIDiscoveryService( - environment: ["PATH": binDirectory.path], - shellLookup: { _ in nil } - ) - - let tool = service.tool(withID: "gemini-cli") - XCTAssertEqual(tool?.isInstalled, true) - XCTAssertEqual(tool?.resolvedPath, geminiPath) - } - - func testCodexToolDetectsNvmVersionBinWhenPathMissingCodex() throws { - let temporaryHome = FileManager.default.temporaryDirectory - .appendingPathComponent("idx0-home-\(UUID().uuidString)", isDirectory: true) - let nvmBinDirectory = temporaryHome - .appendingPathComponent(".nvm/versions/node/v22.22.0/bin", isDirectory: true) - try FileManager.default.createDirectory(at: nvmBinDirectory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: temporaryHome) } - - let codexPath = nvmBinDirectory.appendingPathComponent("codex", isDirectory: false).path - try "#!/bin/sh\nexit 0\n".write(toFile: codexPath, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: codexPath) - - let service = VibeCLIDiscoveryService( - environment: [ - "PATH": "/usr/bin:/bin", - "HOME": temporaryHome.path, - "ZDOTDIR": temporaryHome.path - ], - shellLookup: { _ in nil }, - homeDirectory: temporaryHome.path - ) - - let tool = service.tool(withID: "codex") - XCTAssertEqual(tool?.isInstalled, true) - XCTAssertEqual( - tool?.resolvedPath.map { URL(fileURLWithPath: $0).resolvingSymlinksInPath().path }, - URL(fileURLWithPath: codexPath).resolvingSymlinksInPath().path - ) - } - + func testGeminiCliToolDetectsGeminiBinaryAlias() throws { + let binDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("idx0-gemini-alias-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: binDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: binDirectory) } + + let geminiPath = binDirectory.appendingPathComponent("gemini", isDirectory: false).path + try "#!/bin/sh\nexit 0\n".write(toFile: geminiPath, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: geminiPath) + + let service = VibeCLIDiscoveryService( + environment: ["PATH": binDirectory.path], + shellLookup: { _ in nil } + ) + + let tool = service.tool(withID: "gemini-cli") + XCTAssertEqual(tool?.isInstalled, true) + XCTAssertEqual(tool?.resolvedPath, geminiPath) + } + + func testCodexToolDetectsNvmVersionBinWhenPathMissingCodex() throws { + let temporaryHome = FileManager.default.temporaryDirectory + .appendingPathComponent("idx0-home-\(UUID().uuidString)", isDirectory: true) + let nvmBinDirectory = temporaryHome + .appendingPathComponent(".nvm/versions/node/v22.22.0/bin", isDirectory: true) + try FileManager.default.createDirectory(at: nvmBinDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: temporaryHome) } + + let codexPath = nvmBinDirectory.appendingPathComponent("codex", isDirectory: false).path + try "#!/bin/sh\nexit 0\n".write(toFile: codexPath, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: codexPath) + + let service = VibeCLIDiscoveryService( + environment: [ + "PATH": "/usr/bin:/bin", + "HOME": temporaryHome.path, + "ZDOTDIR": temporaryHome.path, + ], + shellLookup: { _ in nil }, + homeDirectory: temporaryHome.path + ) + + let tool = service.tool(withID: "codex") + XCTAssertEqual(tool?.isInstalled, true) + XCTAssertEqual( + tool?.resolvedPath.map { URL(fileURLWithPath: $0).resolvingSymlinksInPath().path }, + URL(fileURLWithPath: codexPath).resolvingSymlinksInPath().path + ) + } }