diff --git a/Assets.xcassets/AgentIcons/Claude.imageset/Claude.png b/Assets.xcassets/AgentIcons/Claude.imageset/Claude.png new file mode 100644 index 000000000000..8f68dd19f36c Binary files /dev/null and b/Assets.xcassets/AgentIcons/Claude.imageset/Claude.png differ diff --git a/Assets.xcassets/AgentIcons/Claude.imageset/Claude@2x.png b/Assets.xcassets/AgentIcons/Claude.imageset/Claude@2x.png new file mode 100644 index 000000000000..a771853bb039 Binary files /dev/null and b/Assets.xcassets/AgentIcons/Claude.imageset/Claude@2x.png differ diff --git a/Assets.xcassets/AgentIcons/Claude.imageset/Claude@3x.png b/Assets.xcassets/AgentIcons/Claude.imageset/Claude@3x.png new file mode 100644 index 000000000000..d5ae20d5f136 Binary files /dev/null and b/Assets.xcassets/AgentIcons/Claude.imageset/Claude@3x.png differ diff --git a/Assets.xcassets/AgentIcons/Claude.imageset/Contents.json b/Assets.xcassets/AgentIcons/Claude.imageset/Contents.json new file mode 100644 index 000000000000..18671fcea742 --- /dev/null +++ b/Assets.xcassets/AgentIcons/Claude.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "filename" : "Claude.png", "scale" : "1x" }, + { "idiom" : "universal", "filename" : "Claude@2x.png", "scale" : "2x" }, + { "idiom" : "universal", "filename" : "Claude@3x.png", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Assets.xcassets/AgentIcons/Codex.imageset/Codex-dark.png b/Assets.xcassets/AgentIcons/Codex.imageset/Codex-dark.png new file mode 100644 index 000000000000..49b13dca3bf0 Binary files /dev/null and b/Assets.xcassets/AgentIcons/Codex.imageset/Codex-dark.png differ diff --git a/Assets.xcassets/AgentIcons/Codex.imageset/Codex-dark@2x.png b/Assets.xcassets/AgentIcons/Codex.imageset/Codex-dark@2x.png new file mode 100644 index 000000000000..206c5cfbb3bd Binary files /dev/null and b/Assets.xcassets/AgentIcons/Codex.imageset/Codex-dark@2x.png differ diff --git a/Assets.xcassets/AgentIcons/Codex.imageset/Codex-dark@3x.png b/Assets.xcassets/AgentIcons/Codex.imageset/Codex-dark@3x.png new file mode 100644 index 000000000000..06a7d337f55b Binary files /dev/null and b/Assets.xcassets/AgentIcons/Codex.imageset/Codex-dark@3x.png differ diff --git a/Assets.xcassets/AgentIcons/Codex.imageset/Codex.png b/Assets.xcassets/AgentIcons/Codex.imageset/Codex.png new file mode 100644 index 000000000000..41648e60c9ce Binary files /dev/null and b/Assets.xcassets/AgentIcons/Codex.imageset/Codex.png differ diff --git a/Assets.xcassets/AgentIcons/Codex.imageset/Codex@2x.png b/Assets.xcassets/AgentIcons/Codex.imageset/Codex@2x.png new file mode 100644 index 000000000000..42ab2f7429f7 Binary files /dev/null and b/Assets.xcassets/AgentIcons/Codex.imageset/Codex@2x.png differ diff --git a/Assets.xcassets/AgentIcons/Codex.imageset/Codex@3x.png b/Assets.xcassets/AgentIcons/Codex.imageset/Codex@3x.png new file mode 100644 index 000000000000..e3f91996fb35 Binary files /dev/null and b/Assets.xcassets/AgentIcons/Codex.imageset/Codex@3x.png differ diff --git a/Assets.xcassets/AgentIcons/Codex.imageset/Contents.json b/Assets.xcassets/AgentIcons/Codex.imageset/Contents.json new file mode 100644 index 000000000000..a480c5db6c00 --- /dev/null +++ b/Assets.xcassets/AgentIcons/Codex.imageset/Contents.json @@ -0,0 +1,11 @@ +{ + "images" : [ + { "idiom" : "universal", "filename" : "Codex.png", "scale" : "1x" }, + { "idiom" : "universal", "filename" : "Codex@2x.png", "scale" : "2x" }, + { "idiom" : "universal", "filename" : "Codex@3x.png", "scale" : "3x" }, + { "idiom" : "universal", "filename" : "Codex-dark.png", "scale" : "1x", "appearances" : [{"appearance":"luminosity","value":"dark"}] }, + { "idiom" : "universal", "filename" : "Codex-dark@2x.png", "scale" : "2x", "appearances" : [{"appearance":"luminosity","value":"dark"}] }, + { "idiom" : "universal", "filename" : "Codex-dark@3x.png", "scale" : "3x", "appearances" : [{"appearance":"luminosity","value":"dark"}] } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Assets.xcassets/AgentIcons/Contents.json b/Assets.xcassets/AgentIcons/Contents.json new file mode 100644 index 000000000000..e377ccd6c1f2 --- /dev/null +++ b/Assets.xcassets/AgentIcons/Contents.json @@ -0,0 +1 @@ +{ "info": { "author": "xcode", "version": 1 }, "properties": { "provides-namespace": true } } diff --git a/Assets.xcassets/AgentIcons/OpenCode.imageset/Contents.json b/Assets.xcassets/AgentIcons/OpenCode.imageset/Contents.json new file mode 100644 index 000000000000..6a8dd1eb11af --- /dev/null +++ b/Assets.xcassets/AgentIcons/OpenCode.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "filename" : "OpenCode.png", "scale" : "1x" }, + { "idiom" : "universal", "filename" : "OpenCode@2x.png", "scale" : "2x" }, + { "idiom" : "universal", "filename" : "OpenCode@3x.png", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Assets.xcassets/AgentIcons/OpenCode.imageset/OpenCode.png b/Assets.xcassets/AgentIcons/OpenCode.imageset/OpenCode.png new file mode 100644 index 000000000000..9c1d94ad2ca3 Binary files /dev/null and b/Assets.xcassets/AgentIcons/OpenCode.imageset/OpenCode.png differ diff --git a/Assets.xcassets/AgentIcons/OpenCode.imageset/OpenCode@2x.png b/Assets.xcassets/AgentIcons/OpenCode.imageset/OpenCode@2x.png new file mode 100644 index 000000000000..61b2e23aef95 Binary files /dev/null and b/Assets.xcassets/AgentIcons/OpenCode.imageset/OpenCode@2x.png differ diff --git a/Assets.xcassets/AgentIcons/OpenCode.imageset/OpenCode@3x.png b/Assets.xcassets/AgentIcons/OpenCode.imageset/OpenCode@3x.png new file mode 100644 index 000000000000..f95f02a5be04 Binary files /dev/null and b/Assets.xcassets/AgentIcons/OpenCode.imageset/OpenCode@3x.png differ diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 7e410de4afeb..5873bf3c15d5 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -9,6 +9,9 @@ /* Begin PBXBuildFile section */ FE001101 /* FileExplorerStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE001001 /* FileExplorerStore.swift */; }; FE001102 /* FileExplorerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE001002 /* FileExplorerView.swift */; }; + FE003101 /* SessionIndexStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003001 /* SessionIndexStore.swift */; }; + FE003102 /* SessionIndexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003002 /* SessionIndexView.swift */; }; + FE003103 /* RightSidebarPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003003 /* RightSidebarPanelView.swift */; }; FE002101 /* FileExplorerRootResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE002001 /* FileExplorerRootResolverTests.swift */; }; FE002102 /* FileExplorerStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE002002 /* FileExplorerStoreTests.swift */; }; A5001001 /* cmuxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001011 /* cmuxApp.swift */; }; @@ -213,6 +216,9 @@ /* Begin PBXFileReference section */ FE001001 /* FileExplorerStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerStore.swift; sourceTree = ""; }; FE001002 /* FileExplorerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerView.swift; sourceTree = ""; }; + FE003001 /* SessionIndexStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionIndexStore.swift; sourceTree = ""; }; + FE003002 /* SessionIndexView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionIndexView.swift; sourceTree = ""; }; + FE003003 /* RightSidebarPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarPanelView.swift; sourceTree = ""; }; FE002001 /* FileExplorerRootResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerRootResolverTests.swift; sourceTree = ""; }; FE002002 /* FileExplorerStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerStoreTests.swift; sourceTree = ""; }; A5001000 /* cmux.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = cmux.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -533,6 +539,9 @@ A5001655 /* CmuxDirectoryTrust.swift */, FE001001 /* FileExplorerStore.swift */, FE001002 /* FileExplorerView.swift */, + FE003001 /* SessionIndexStore.swift */, + FE003002 /* SessionIndexView.swift */, + FE003003 /* RightSidebarPanelView.swift */, ); path = Sources; sourceTree = ""; @@ -864,6 +873,9 @@ A5001654 /* CmuxDirectoryTrust.swift in Sources */, FE001101 /* FileExplorerStore.swift in Sources */, FE001102 /* FileExplorerView.swift in Sources */, + FE003101 /* SessionIndexStore.swift in Sources */, + FE003102 /* SessionIndexView.swift in Sources */, + FE003103 /* RightSidebarPanelView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index e2d1e2701913..4c1253748a7a 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -89686,6 +89686,516 @@ } } } + }, + "rightSidebar.mode.files": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Files" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイル" + } + } + } + }, + "rightSidebar.mode.sessions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sessions" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッション" + } + } + } + }, + "sessionIndex.agent.claude": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Claude Code" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Claude Code" + } + } + } + }, + "sessionIndex.agent.codex": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Codex" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Codex" + } + } + } + }, + "sessionIndex.agent.opencode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "OpenCode" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "OpenCode" + } + } + } + }, + "sessionIndex.untitled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Untitled session" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無題のセッション" + } + } + } + }, + "sessionIndex.scope.thisFolder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This folder only" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このフォルダのみ" + } + } + } + }, + "sessionIndex.reload.tooltip": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reload sessions" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッションを再読み込み" + } + } + } + }, + "sessionIndex.loading": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scanning sessions…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッションをスキャン中…" + } + } + } + }, + "sessionIndex.empty.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No sessions found" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッションが見つかりません" + } + } + } + }, + "sessionIndex.empty.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sessions from Claude Code, Codex, and OpenCode will appear here." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Claude Code、Codex、OpenCode のセッションがここに表示されます。" + } + } + } + }, + "sessionIndex.section.empty": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No sessions" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッションなし" + } + } + } + }, + "sessionIndex.row.open": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "開く" + } + } + } + }, + "sessionIndex.row.reveal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reveal in Finder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Finder で表示" + } + } + } + }, + "sessionIndex.row.copyPath": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy File Path" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルパスをコピー" + } + } + } + }, + "sessionIndex.row.openCwd": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Working Directory" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "作業ディレクトリを開く" + } + } + } + }, + "sessionIndex.row.openPR": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Pull Request" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プルリクエストを開く" + } + } + } + }, + "sessionIndex.group.directory": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "By folder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダ別" + } + } + } + }, + "sessionIndex.group.agent": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "By agent" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェント別" + } + } + } + }, + "sessionIndex.directory.unknown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "(no folder)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "(フォルダなし)" + } + } + } + }, + "sessionIndex.row.resume": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resume in New Tab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しいタブで再開" + } + } + } + }, + "sessionIndex.row.copyResume": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy Resume Command" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再開コマンドをコピー" + } + } + } + }, + "sessionIndex.section.noChats": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No chats" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チャットなし" + } + } + } + }, + "sessionIndex.section.showMore": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show more" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "もっと表示" + } + } + } + }, + "sessionIndex.section.showLess": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show less" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "折りたたむ" + } + } + } + }, + "sessionIndex.section.showMoreCount": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show %lld more" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "あと %lld 件を表示" + } + } + } + }, + "sessionIndex.popover.searchPlaceholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search sessions" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッションを検索" + } + } + } + }, + "sessionIndex.popover.noMatches": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No matches" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一致なし" + } + } + } + }, + "sessionIndex.popover.searching": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Searching…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索中…" + } + } + } + }, + "sessionIndex.popover.loading": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "読み込み中…" + } + } + } } } } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 3a7791784b95..0a5cb6e40a4f 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5761,6 +5761,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard isMainTerminalWindow(window) else { return } guard window.attachedSheet == nil else { return } guard !isCommandPaletteEffectivelyVisible(in: window) else { return } + // If the active first responder is a text-field editor (e.g. a popover's + // search field whose field editor is borrowed from the parent window), + // never re-route the keystroke to the terminal. Symmetric with + // applyFirstResponderIfNeeded's NSText guard. Terminals use GhosttyNSView, + // never NSText, so this can't suppress legitimate terminal repair. + if window.firstResponder is NSText { + return + } guard let context = contextForMainWindow(window) ?? contextForMainTerminalWindow(window), let workspace = context.tabManager.selectedWorkspace, let panelId = workspace.focusedPanelId, diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index d90e52592971..530c434fe7df 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1844,6 +1844,7 @@ struct ContentView: View { @State private var observedWindow: NSWindow? @StateObject private var fullscreenControlsViewModel = TitlebarControlsViewModel() @StateObject private var fileExplorerStore = FileExplorerStore() + @StateObject private var sessionIndexStore = SessionIndexStore() @State private var fileExplorerWidth: CGFloat = 220 @State private var fileExplorerDragStartWidth: CGFloat? @State private var previousSelectedWorkspaceId: UUID? @@ -2775,7 +2776,14 @@ struct ContentView: View { if explorerVisible { Divider() } - FileExplorerPanelView(store: fileExplorerStore, state: fileExplorerState) + RightSidebarPanelView( + fileExplorerStore: fileExplorerStore, + fileExplorerState: fileExplorerState, + sessionIndexStore: sessionIndexStore, + onResumeSession: { entry in + resumeSession(entry: entry) + } + ) .frame(width: explorerVisible ? fileExplorerWidth : 0) .clipped() .allowsHitTesting(explorerVisible) @@ -2964,16 +2972,69 @@ struct ContentView: View { ) } + private func resumeSession(entry: SessionEntry) { + let inputWithReturn = entry.resumeCommand + "\n" + let targetCwd = entry.cwd + + // Smart placement: if the focused workspace's tracked cwd matches, open a + // new tab inside that workspace. Otherwise create a new workspace. + // Remote workspaces are excluded from cwd-match: a session indexed from + // the local filesystem must not be resumed inside a remote shell just + // because the path string happens to coincide. + let selected = tabManager.selectedWorkspace + let selectedTab = tabManager.selectedTabId.flatMap { id in + tabManager.tabs.first(where: { $0.id == id }) + } + let isRemoteSelection = selectedTab?.isRemoteWorkspace ?? false + let workspaceCwd = selected?.currentDirectory + let pwdMatches: Bool = { + guard !isRemoteSelection, + let targetCwd, !targetCwd.isEmpty, + let workspaceCwd, !workspaceCwd.isEmpty else { return false } + let lhs = (targetCwd as NSString).standardizingPath + let rhs = (workspaceCwd as NSString).standardizingPath + return lhs == rhs + }() + + if pwdMatches, + let workspace = selected, + let paneId = workspace.bonsplitController.focusedPaneId { + workspace.newTerminalSurface( + inPane: paneId, + focus: true, + workingDirectory: targetCwd, + initialInput: inputWithReturn + ) + return + } + + tabManager.addWorkspace( + workingDirectory: targetCwd, + initialTerminalInput: inputWithReturn + ) + } + private func syncFileExplorerDirectory() { guard let selectedId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == selectedId }) else { + // No selection means we have no local cwd to scope by; clear so the + // sessions panel doesn't keep filtering by a stale previous tab. + sessionIndexStore.currentDirectory = nil return } let dir = tab.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) - guard !dir.isEmpty else { return } + guard !dir.isEmpty else { + sessionIndexStore.currentDirectory = nil + return + } fileExplorerStore.showHiddenFiles = true + if !tab.isRemoteWorkspace { + sessionIndexStore.currentDirectory = dir + } else { + sessionIndexStore.currentDirectory = nil + } if tab.isRemoteWorkspace { let config = tab.remoteConfiguration diff --git a/Sources/FileExplorerStore.swift b/Sources/FileExplorerStore.swift index a6459f6c219a..411856a88663 100644 --- a/Sources/FileExplorerStore.swift +++ b/Sources/FileExplorerStore.swift @@ -440,6 +440,11 @@ final class FileExplorerState: ObservableObject { didSet { UserDefaults.standard.set(showHiddenFiles, forKey: "fileExplorer.showHidden") } } + /// Active mode for the right sidebar (file tree or session index). + @Published var mode: RightSidebarMode { + didSet { UserDefaults.standard.set(mode.rawValue, forKey: "rightSidebar.mode") } + } + init() { let defaults = UserDefaults.standard self.isVisible = defaults.bool(forKey: "fileExplorer.isVisible") @@ -449,6 +454,8 @@ final class FileExplorerState: ObservableObject { self.dividerPosition = storedPosition > 0 ? CGFloat(storedPosition) : 0.6 let storedShowHidden = defaults.object(forKey: "fileExplorer.showHidden") self.showHiddenFiles = storedShowHidden == nil ? true : defaults.bool(forKey: "fileExplorer.showHidden") + let storedMode = defaults.string(forKey: "rightSidebar.mode") ?? RightSidebarMode.files.rawValue + self.mode = RightSidebarMode(rawValue: storedMode) ?? .files } func toggle() { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 32a50628e2db..4d9855bfde47 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3611,6 +3611,7 @@ final class TerminalSurface: Identifiable, ObservableObject { private let configTemplate: CmuxSurfaceConfigTemplate? private let workingDirectory: String? private let initialCommand: String? + private let initialInput: String? private let initialEnvironmentOverrides: [String: String] var requestedWorkingDirectory: String? { workingDirectory } private var additionalEnvironment: [String: String] @@ -3703,6 +3704,7 @@ final class TerminalSurface: Identifiable, ObservableObject { configTemplate: CmuxSurfaceConfigTemplate?, workingDirectory: String? = nil, initialCommand: String? = nil, + initialInput: String? = nil, initialEnvironmentOverrides: [String: String] = [:], additionalEnvironment: [String: String] = [:] ) { @@ -3713,6 +3715,8 @@ final class TerminalSurface: Identifiable, ObservableObject { self.workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedCommand = initialCommand?.trimmingCharacters(in: .whitespacesAndNewlines) self.initialCommand = (trimmedCommand?.isEmpty == false) ? trimmedCommand : nil + let trimmedInput = initialInput?.isEmpty == false ? initialInput : nil + self.initialInput = trimmedInput self.initialEnvironmentOverrides = Self.mergedNormalizedEnvironment(base: [:], overrides: initialEnvironmentOverrides) self.additionalEnvironment = Self.mergedNormalizedEnvironment(base: [:], overrides: additionalEnvironment) // Match Ghostty's own SurfaceView: ensure a non-zero initial frame so the backing layer @@ -4462,7 +4466,12 @@ final class TerminalSurface: Identifiable, ObservableObject { } return baseConfig.command }() - let resolvedInitialInput = baseConfig.initialInput + let resolvedInitialInput: String? = { + if let initialInput, !initialInput.isEmpty { + return initialInput + } + return baseConfig.initialInput + }() func withOptionalCString(_ value: String?, _ body: (UnsafePointer?) -> T) -> T { guard let value else { return body(nil) @@ -8609,6 +8618,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { dlog("terminal.draggingEntered surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") types=\(types.map(\.rawValue))") #endif guard let types = sender.draggingPasteboard.types else { return [] } + // Defer to bonsplit when a tab/session drag is in flight: bonsplit's pane + // drop overlays should win over the terminal's text/file drop handling. + if types.contains(Self.tabTransferPasteboardType) || types.contains(Self.sidebarTabReorderPasteboardType) { + return [] + } if Set(types).isDisjoint(with: Self.dropTypes) { return [] } @@ -8621,6 +8635,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { dlog("terminal.draggingUpdated surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") types=\(types.map(\.rawValue))") #endif guard let types = sender.draggingPasteboard.types else { return [] } + if types.contains(Self.tabTransferPasteboardType) || types.contains(Self.sidebarTabReorderPasteboardType) { + return [] + } if Set(types).isDisjoint(with: Self.dropTypes) { return [] } @@ -8628,6 +8645,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + let types = sender.draggingPasteboard.types ?? [] + if types.contains(Self.tabTransferPasteboardType) || types.contains(Self.sidebarTabReorderPasteboardType) { + return false + } #if DEBUG dlog("terminal.fileDrop surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")") #endif @@ -10857,6 +10878,17 @@ final class GhosttySurfaceScrollView: NSView { if let fr = window.firstResponder, isSearchOverlayOrDescendant(fr) { #if DEBUG dlog("find.applyFirstResponder SKIP surface=\(surfaceShort) reason=searchOverlayFocused") +#endif + return + } + // Don't steal focus from an active text editor (NSTextField/NSTextView/field editor). + // The terminal surface uses its own GhosttyNSView for input, never NSText, so this is + // safe. Without this guard, a popover (e.g. session-index search) loses its text-field + // focus instantly because applyFirstResponderIfNeeded runs on a deferred async tick and + // sees the field editor as a "foreign" responder to clobber. + if window.firstResponder is NSText { +#if DEBUG + dlog("find.applyFirstResponder SKIP surface=\(surfaceShort) reason=textEditorFocused") #endif return } diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index c5d3a79f2596..417b45e81da7 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -94,6 +94,7 @@ final class TerminalPanel: Panel, ObservableObject { workingDirectory: String? = nil, portOrdinal: Int = 0, initialCommand: String? = nil, + initialInput: String? = nil, initialEnvironmentOverrides: [String: String] = [:], additionalEnvironment: [String: String] = [:] ) { @@ -103,6 +104,7 @@ final class TerminalPanel: Panel, ObservableObject { configTemplate: configTemplate, workingDirectory: workingDirectory, initialCommand: initialCommand, + initialInput: initialInput, initialEnvironmentOverrides: initialEnvironmentOverrides, additionalEnvironment: additionalEnvironment ) diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift new file mode 100644 index 000000000000..c5feec908e83 --- /dev/null +++ b/Sources/RightSidebarPanelView.swift @@ -0,0 +1,122 @@ +import AppKit +import SwiftUI + +/// Mode shown in the right sidebar (the panel toggled by ⌘⌥B). +enum RightSidebarMode: String, CaseIterable { + case files + case sessions + + var label: String { + switch self { + case .files: return String(localized: "rightSidebar.mode.files", defaultValue: "Files") + case .sessions: return String(localized: "rightSidebar.mode.sessions", defaultValue: "Sessions") + } + } + + var symbolName: String { + switch self { + case .files: return "folder" + case .sessions: return "bubble.left.and.text.bubble.right" + } + } +} + +/// Right sidebar root view. Hosts a segmented mode picker plus the active panel. +struct RightSidebarPanelView: View { + @ObservedObject var fileExplorerStore: FileExplorerStore + @ObservedObject var fileExplorerState: FileExplorerState + @ObservedObject var sessionIndexStore: SessionIndexStore + let onResumeSession: ((SessionEntry) -> Void)? + + var body: some View { + VStack(spacing: 0) { + modeBar + Divider() + contentForMode + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private var modeBar: some View { + HStack(spacing: 4) { + ForEach(RightSidebarMode.allCases, id: \.rawValue) { mode in + ModeBarButton( + mode: mode, + isSelected: fileExplorerState.mode == mode + ) { + if fileExplorerState.mode != mode { + fileExplorerState.mode = mode + if mode == .sessions { + sessionIndexStore.currentDirectory = fileExplorerStore.rootPath.isEmpty + ? nil + : fileExplorerStore.rootPath + if sessionIndexStore.entries.isEmpty { + sessionIndexStore.reload() + } + } + } + } + } + Spacer(minLength: 0) + } + .padding(.leading, 4) + .padding(.trailing, 6) + .padding(.vertical, 4) + .frame(height: 31) + } + + @ViewBuilder + private var contentForMode: some View { + switch fileExplorerState.mode { + case .files: + FileExplorerPanelView(store: fileExplorerStore, state: fileExplorerState) + case .sessions: + SessionIndexView(store: sessionIndexStore, onResume: onResumeSession) + .onAppear { + sessionIndexStore.currentDirectory = fileExplorerStore.rootPath.isEmpty + ? nil + : fileExplorerStore.rootPath + } + } + } +} + +private struct ModeBarButton: View { + let mode: RightSidebarMode + let isSelected: Bool + let action: () -> Void + + @State private var isHovered: Bool = false + + var body: some View { + Button(action: action) { + HStack(spacing: 4) { + Image(systemName: mode.symbolName) + .font(.system(size: 11, weight: .medium)) + Text(mode.label) + .font(.system(size: 11, weight: .medium)) + } + .foregroundColor(isSelected ? .primary : .secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(backgroundColor) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { isHovered = $0 } + .help(mode.label) + } + + private var backgroundColor: Color { + if isSelected { + return Color.primary.opacity(0.10) + } + if isHovered { + return Color.primary.opacity(0.05) + } + return Color.clear + } +} diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift new file mode 100644 index 000000000000..2e0ad13100d5 --- /dev/null +++ b/Sources/SessionIndexStore.swift @@ -0,0 +1,1434 @@ +import AppKit +import Bonsplit +import Combine +import Foundation +import SQLite3 + +// MARK: - Agents + +enum SessionAgent: String, CaseIterable, Identifiable, Hashable, Codable { + case claude + case codex + case opencode + + var id: String { rawValue } + + var displayName: String { + switch self { + case .claude: return String(localized: "sessionIndex.agent.claude", defaultValue: "Claude Code") + case .codex: return String(localized: "sessionIndex.agent.codex", defaultValue: "Codex") + case .opencode: return String(localized: "sessionIndex.agent.opencode", defaultValue: "OpenCode") + } + } + + /// Asset catalog image name for the agent's brand mark. + var assetName: String { + switch self { + case .claude: return "AgentIcons/Claude" + case .codex: return "AgentIcons/Codex" + case .opencode: return "AgentIcons/OpenCode" + } + } +} + +// MARK: - Session entry + +struct PullRequestLink: Hashable { + let number: Int + let url: String + let repository: String? +} + +/// Agent-specific fields used to build the resume command with appropriate flags. +enum AgentSpecifics: Hashable { + case claude(model: String?, permissionMode: String?) + case codex(model: String?, approvalPolicy: String?, sandboxMode: String?, effort: String?) + case opencode(providerModel: String?, agentName: String?) +} + +struct SessionEntry: Identifiable, Hashable { + let id: String + let agent: SessionAgent + /// Native session identifier for the agent's CLI (used to build the resume command). + let sessionId: String + let title: String + let cwd: String? + let gitBranch: String? + let pullRequest: PullRequestLink? + let modified: Date + let fileURL: URL? + let specifics: AgentSpecifics + + /// Shell command that resumes this session in a new terminal, with the agent's + /// known per-session settings injected as CLI flags. + var resumeCommand: String { + switch specifics { + case let .claude(model, permissionMode): + var parts = ["claude --resume \(sessionId)"] + if let model, !model.isEmpty { + parts.append("--model \(Self.shellQuote(model))") + } + if let permissionMode, !permissionMode.isEmpty { + parts.append("--permission-mode \(Self.shellQuote(permissionMode))") + } + return parts.joined(separator: " ") + case let .codex(model, approval, sandbox, effort): + var parts = ["codex resume \(sessionId)"] + if let model, !model.isEmpty { + parts.append("-m \(Self.shellQuote(model))") + } + if let approval, !approval.isEmpty { + parts.append("-a \(Self.shellQuote(approval))") + } + if let sandbox, !sandbox.isEmpty { + parts.append("-s \(Self.shellQuote(sandbox))") + } + if let effort, !effort.isEmpty { + parts.append("-c model_reasoning_effort=\(Self.shellQuote(effort))") + } + return parts.joined(separator: " ") + case let .opencode(providerModel, agentName): + var parts = ["opencode --session \(sessionId)"] + if let providerModel, !providerModel.isEmpty { + parts.append("-m \(Self.shellQuote(providerModel))") + } + if let agentName, !agentName.isEmpty { + parts.append("--agent \(Self.shellQuote(agentName))") + } + return parts.joined(separator: " ") + } + } + + /// Single-quote a value for safe shell injection. Escapes embedded single quotes. + private static func shellQuote(_ value: String) -> String { + if value.range(of: "[^A-Za-z0-9_./:=+-]", options: .regularExpression) == nil { + return value + } + let escaped = value.replacingOccurrences(of: "'", with: #"'\''"#) + return "'\(escaped)'" + } + + var displayTitle: String { + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + return String(localized: "sessionIndex.untitled", defaultValue: "Untitled session") + } + return trimmed + } + + var cwdLabel: String? { + guard let cwd, !cwd.isEmpty else { return nil } + let home = NSHomeDirectory() + // Compare on a path boundary so /Users/al doesn't get matched by a + // home of /Users/alice (would render as "~ice/foo"). + if cwd == home { + return "~" + } + if cwd.hasPrefix(home + "/") { + return "~" + cwd.dropFirst(home.count) + } + return cwd + } + + var cwdBasename: String? { + guard let cwd, !cwd.isEmpty else { return nil } + return (cwd as NSString).lastPathComponent + } +} + +// MARK: - Parsed metadata cache + +/// Process-wide cache for parsed Claude session metadata, keyed by file URL with +/// mtime as the freshness check. Avoids re-reading and re-parsing the same +/// jsonls across pagination calls. Bounded by `maxEntries` to keep memory in +/// check (LRU on insert). +final class ClaudeMetadataCache: @unchecked Sendable { + static let shared = ClaudeMetadataCache() + private let maxEntries = 1000 + private let lock = NSLock() + private var entries: [URL: (mtime: Date, entry: SessionEntry)] = [:] + + func get(url: URL, mtime: Date) -> SessionEntry? { + lock.lock() + defer { lock.unlock() } + guard let cached = entries[url], cached.mtime == mtime else { return nil } + return cached.entry + } + + func put(url: URL, mtime: Date, entry: SessionEntry) { + lock.lock() + defer { lock.unlock() } + entries[url] = (mtime, entry) + if entries.count > maxEntries { + // Evict ~10% (oldest mtimes) to amortize cleanup cost. + let evictCount = entries.count / 10 + let oldestKeys = entries + .sorted { $0.value.mtime < $1.value.mtime } + .prefix(evictCount) + .map(\.key) + for k in oldestKeys { entries.removeValue(forKey: k) } + } + } +} + +// MARK: - Drag registry + +/// Process-wide registry that pairs a synthetic drag UUID with a SessionEntry. +/// Used to forward sessions through bonsplit's external-tab-drop hook (which only +/// carries UUIDs in its payload). Workspace.handleExternalTabDrop consults this +/// to decide whether a drop should spawn a brand new terminal vs. move an existing tab. +final class SessionDragRegistry { + static let shared = SessionDragRegistry() + + private let lock = NSLock() + private var pending: [UUID: SessionEntry] = [:] + + func register(_ entry: SessionEntry) -> UUID { + let id = UUID() + lock.lock() + pending[id] = entry + lock.unlock() + // Auto-expire so a cancelled drag doesn't leak forever. + DispatchQueue.global().asyncAfter(deadline: .now() + 60) { [weak self] in + self?.lock.lock() + self?.pending.removeValue(forKey: id) + self?.lock.unlock() + } + return id + } + + func consume(id: UUID) -> SessionEntry? { + lock.lock() + defer { lock.unlock() } + return pending.removeValue(forKey: id) + } +} + +// MARK: - Store + +enum SessionGrouping: String, CaseIterable, Identifiable, Codable { + case directory + case agent + + var id: String { rawValue } + + var label: String { + switch self { + case .directory: return String(localized: "sessionIndex.group.directory", defaultValue: "By folder") + case .agent: return String(localized: "sessionIndex.group.agent", defaultValue: "By agent") + } + } + + var symbolName: String { + switch self { + case .directory: return "folder" + case .agent: return "person.2" + } + } +} + +/// Identifier for a section in the index. For agent grouping, raw value is `agent:`; +/// for directory grouping, `dir:` (or `dir:` for unknown). +struct SectionKey: Hashable { + let raw: String + + static func agent(_ a: SessionAgent) -> SectionKey { SectionKey(raw: "agent:" + a.rawValue) } + static func directory(_ path: String?) -> SectionKey { SectionKey(raw: "dir:" + (path ?? "")) } +} + +struct IndexSection: Identifiable { + let key: SectionKey + let title: String + let icon: SectionIcon + let entries: [SessionEntry] + + var id: SectionKey { key } +} + +enum SectionIcon { + case agent(SessionAgent) + case folder +} + +@MainActor +final class SessionIndexStore: ObservableObject { + @Published private(set) var entries: [SessionEntry] = [] + @Published private(set) var isLoading: Bool = false + @Published var scopeToCurrentDirectory: Bool = false + @Published var currentDirectory: String? = nil + + @Published var grouping: SessionGrouping { + didSet { UserDefaults.standard.set(grouping.rawValue, forKey: Self.groupingKey) } + } + + /// Persisted order for agent sections. + @Published var agentOrder: [SessionAgent] { + didSet { Self.persistAgentOrder(agentOrder) } + } + + /// Persisted order for directory sections (absolute paths; "" means "no folder"). + @Published var directoryOrder: [String] { + didSet { Self.persistDirectoryOrder(directoryOrder) } + } + + /// The section currently being dragged, if any. Drives "hide adjacent drop slots". + @Published var draggedKey: SectionKey? = nil + + private static let groupingKey = "sessionIndex.grouping" + private static let agentOrderDefaultsKey = "sessionIndex.agentOrder" + private static let directoryOrderDefaultsKey = "sessionIndex.directoryOrder" + + init() { + self.agentOrder = Self.loadAgentOrder() + self.directoryOrder = Self.loadDirectoryOrder() + let storedGrouping = UserDefaults.standard.string(forKey: Self.groupingKey) + self.grouping = SessionGrouping(rawValue: storedGrouping ?? "") ?? .directory + } + + /// Returns the sections for the current grouping mode, in the user-saved order. + func sectionsForCurrentGrouping() -> [IndexSection] { + let visible = filteredEntriesForCurrentScope() + switch grouping { + case .agent: + return agentOrder.map { agent in + IndexSection( + key: .agent(agent), + title: agent.displayName, + icon: .agent(agent), + entries: visible.filter { $0.agent == agent } + ) + } + case .directory: + let buckets = Dictionary(grouping: visible) { $0.cwd ?? "" } + // Discover any directories not yet in saved order; append by most-recent activity. + let knownPaths = Set(directoryOrder) + let unknownSorted = buckets.keys + .filter { !knownPaths.contains($0) } + .sorted { lhs, rhs in + let lMax = buckets[lhs]?.map(\.modified).max() ?? .distantPast + let rMax = buckets[rhs]?.map(\.modified).max() ?? .distantPast + return lMax > rMax + } + if !unknownSorted.isEmpty { + let nextOrder = directoryOrder + unknownSorted + Task { @MainActor in self.directoryOrder = nextOrder } + } + return (directoryOrder + unknownSorted) + .filter { buckets[$0] != nil } + .map { path in + IndexSection( + key: .directory(path.isEmpty ? nil : path), + title: directoryDisplayName(path), + icon: .folder, + entries: buckets[path] ?? [] + ) + } + } + } + + private func filteredEntriesForCurrentScope() -> [SessionEntry] { + guard scopeToCurrentDirectory, let dir = normalizedDirectory(currentDirectory) else { + return entries + } + return entries.filter { entry in + guard let cwd = normalizedDirectory(entry.cwd) else { return false } + return cwd == dir || cwd.hasPrefix(dir + "/") + } + } + + private func directoryDisplayName(_ path: String) -> String { + if path.isEmpty { + return String(localized: "sessionIndex.directory.unknown", defaultValue: "(no folder)") + } + return (path as NSString).lastPathComponent + } + + /// Move `key` so it lands immediately before `referenceKey` in the + /// persisted order (or at the end if `referenceKey` is nil). Anchoring + /// to a neighbor key (rather than a positional index) means scope filters + /// can hide some sections without corrupting reorders: hidden sections + /// keep their relative position to their visible neighbors. + func moveSection(_ key: SectionKey, before referenceKey: SectionKey?) { + switch grouping { + case .agent: + guard key.raw.hasPrefix("agent:"), + let agent = SessionAgent(rawValue: String(key.raw.dropFirst("agent:".count))) else { return } + guard let oldIndex = agentOrder.firstIndex(of: agent) else { return } + var next = agentOrder + next.remove(at: oldIndex) + if let referenceKey, + referenceKey.raw.hasPrefix("agent:"), + let refAgent = SessionAgent(rawValue: String(referenceKey.raw.dropFirst("agent:".count))), + let refIndex = next.firstIndex(of: refAgent) { + next.insert(agent, at: refIndex) + } else { + next.append(agent) + } + if next != agentOrder { agentOrder = next } + case .directory: + guard key.raw.hasPrefix("dir:") else { return } + let path = String(key.raw.dropFirst("dir:".count)) + guard let oldIndex = directoryOrder.firstIndex(of: path) else { return } + var next = directoryOrder + next.remove(at: oldIndex) + if let referenceKey, + referenceKey.raw.hasPrefix("dir:") { + let refPath = String(referenceKey.raw.dropFirst("dir:".count)) + if let refIndex = next.firstIndex(of: refPath) { + next.insert(path, at: refIndex) + } else { + next.append(path) + } + } else { + next.append(path) + } + if next != directoryOrder { directoryOrder = next } + } + } + + private static func loadAgentOrder() -> [SessionAgent] { + let stored = UserDefaults.standard.array(forKey: agentOrderDefaultsKey) as? [String] ?? [] + var ordered: [SessionAgent] = stored.compactMap { SessionAgent(rawValue: $0) } + for agent in SessionAgent.allCases where !ordered.contains(agent) { + ordered.append(agent) + } + var seen = Set() + ordered = ordered.filter { seen.insert($0).inserted } + return ordered + } + + private static func loadDirectoryOrder() -> [String] { + UserDefaults.standard.array(forKey: directoryOrderDefaultsKey) as? [String] ?? [] + } + + private static func persistAgentOrder(_ order: [SessionAgent]) { + UserDefaults.standard.set(order.map { $0.rawValue }, forKey: agentOrderDefaultsKey) + } + + private static func persistDirectoryOrder(_ order: [String]) { + UserDefaults.standard.set(order, forKey: directoryOrderDefaultsKey) + } + + private var loadTask: Task? + + func reload() { + loadTask?.cancel() + isLoading = true + loadTask = Task.detached(priority: .userInitiated) { [weak self] in + let scanned = await Self.scanAll() + await MainActor.run { + guard let self else { return } + if Task.isCancelled { return } + self.entries = scanned + self.isLoading = false + } + } + } + + private func normalizedDirectory(_ value: String?) -> String? { + guard let value, !value.isEmpty else { return nil } + var path = (value as NSString).standardizingPath + if path.count > 1 && path.hasSuffix("/") { + path.removeLast() + } + return path + } + + // MARK: - Scanning + + private static let perAgentLimit = 30 + private static let headByteCap = 64 * 1024 + private static let tailByteCap = 32 * 1024 + /// Hard cap on candidate files inspected per call to keep deep-page searches bounded. + private static let searchMaxFiles = 1500 + + private static func scanAll() async -> [SessionEntry] { + // Initial scan errors are silently ignored — UI just shows the cached + // entries we did get. Errors get surfaced when the user actively + // searches via the popover. + let bag = ErrorBag() + async let claude = loadClaudeEntries(needle: "", cwdFilter: nil, offset: 0, limit: perAgentLimit) + async let codex = loadCodexEntries(needle: "", cwdFilter: nil, offset: 0, limit: perAgentLimit, errorBag: bag) + async let opencode = loadOpenCodeEntries(needle: "", cwdFilter: nil, offset: 0, limit: perAgentLimit, errorBag: bag) + let combined = await claude + codex + opencode + return combined.sorted { $0.modified > $1.modified } + } + + private struct ClaudeParsed { + var title: String = "" + var cwd: String? + var branch: String? + var pr: PullRequestLink? + var model: String? + var permissionMode: String? + } + + nonisolated private static func extractClaudeMetadata(head: String, tail: String, projectDir: String) -> ClaudeParsed { + var out = ClaudeParsed() + out.cwd = decodeClaudeProjectDir(projectDir) + + for line in head.split(separator: "\n", omittingEmptySubsequences: true) { + guard let data = line.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue } + if let cwdField = obj["cwd"] as? String, !cwdField.isEmpty { + out.cwd = cwdField + } + if let branchField = obj["gitBranch"] as? String, !branchField.isEmpty { + out.branch = branchField + } + if let mode = obj["permissionMode"] as? String, !mode.isEmpty { + out.permissionMode = mode + } + if (obj["type"] as? String) == "assistant", + let message = obj["message"] as? [String: Any], + let model = message["model"] as? String, !model.isEmpty { + out.model = model + } + if out.title.isEmpty, + (obj["type"] as? String) == "user", + let message = obj["message"] as? [String: Any], + (message["role"] as? String) == "user" { + if let content = message["content"] as? String, !content.isEmpty { + out.title = content + } else if let parts = message["content"] as? [[String: Any]] { + for part in parts { + if (part["type"] as? String) == "text", + let text = part["text"] as? String, !text.isEmpty { + out.title = text + break + } + } + } + } + } + + for line in tail.split(separator: "\n", omittingEmptySubsequences: true) { + guard let data = line.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue } + let type = obj["type"] as? String + if type == "pr-link", let number = obj["prNumber"] as? Int, + let url = obj["prUrl"] as? String { + out.pr = PullRequestLink( + number: number, + url: url, + repository: obj["prRepository"] as? String + ) + } + if let branchField = obj["gitBranch"] as? String, !branchField.isEmpty { + out.branch = branchField + } + if let mode = obj["permissionMode"] as? String, !mode.isEmpty { + out.permissionMode = mode + } + if (obj["type"] as? String) == "assistant", + let message = obj["message"] as? [String: Any], + let model = message["model"] as? String, !model.isEmpty { + out.model = model + } + } + // Strip the [1m] suffix some Claude internal model IDs carry (claude-opus-4-7[1m]). + if let m = out.model, let bracket = m.firstIndex(of: "[") { + out.model = String(m[.....`, ``, + /// ``, AGENTS.md preamble) that we don't want to surface as a + /// session title. + nonisolated private static func realCodexUserMessage(_ raw: String) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let envelopePrefixes = [ + " String? { + // Claude encodes cwd by replacing "/" with "-" and prefixing "-" + // e.g. "-Users-lawrence-fun-cmuxterm-hq" -> "/Users/lawrence/fun/cmuxterm-hq". + // The encoding is lossy: a real path segment containing "-" + // (e.g. "my-cool-project") collapses to multiple segments + // ("/my/cool/project") on decode, which is wrong. Only return the + // candidate if it actually exists on disk; otherwise let the caller + // fall back to the JSONL `cwd` field. + guard !raw.isEmpty else { return nil } + let stripped = raw.hasPrefix("-") ? String(raw.dropFirst()) : raw + let candidate = "/" + stripped.replacingOccurrences(of: "-", with: "/") + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: candidate, isDirectory: &isDir), + isDir.boolValue else { + return nil + } + return candidate + } + + /// Inverse of `decodeClaudeProjectDir`. Used as a fast path: when filtering + /// by cwd we can skip enumerating other project dirs entirely. + nonisolated private static func encodeClaudeProjectDir(_ path: String) -> String { + // "/Users/x/y" -> "-Users-x-y" + return path.replacingOccurrences(of: "/", with: "-") + } + + // MARK: Codex + + private struct CodexParsed { + var sessionId: String = "" + /// First user message — used only if Codex never assigns a thread_name. + var firstUserMessage: String = "" + /// Codex-generated session title (`event_msg.thread_name_updated`). Wins over firstUserMessage. + var threadName: String = "" + var cwd: String? + var branch: String? + var model: String? + var approvalPolicy: String? + var sandboxMode: String? + var effort: String? + + var title: String { + threadName.isEmpty ? firstUserMessage : threadName + } + } + + /// Cheap cwd peek for Codex rollouts. `session_meta` is always the first line + /// of the file, but the line itself can be 30+ KB (it embeds the full system + /// prompt). Read up to 64 KB to cover that, parse the JSON, return cwd. + nonisolated private static func peekCodexSessionMetaCwd(url: URL) -> String? { + let head = readFileHead(url: url, byteCap: headByteCap) + guard let nl = head.firstIndex(of: "\n") else { return nil } + let firstLine = head[.. CodexParsed { + var out = CodexParsed() + let maxBytes = 4 * 1024 * 1024 + forEachJSONLine(url: url, maxBytes: maxBytes) { obj in + let type = obj["type"] as? String + let payload = obj["payload"] as? [String: Any] + if type == "session_meta", let p = payload { + if let c = p["cwd"] as? String, !c.isEmpty { out.cwd = c } + if let id = p["id"] as? String, !id.isEmpty { out.sessionId = id } + if let git = p["git"] as? [String: Any], + let branch = git["branch"] as? String, !branch.isEmpty { + out.branch = branch + } + } + if type == "turn_context", let p = payload { + if let m = p["model"] as? String, !m.isEmpty { out.model = m } + if let a = p["approval_policy"] as? String, !a.isEmpty { out.approvalPolicy = a } + if let sandbox = p["sandbox_policy"] as? [String: Any], + let s = sandbox["type"] as? String, !s.isEmpty { + out.sandboxMode = s + } + if let e = p["effort"] as? String, !e.isEmpty { out.effort = e } + } + if type == "event_msg", let p = payload, + (p["type"] as? String) == "thread_name_updated", + let name = p["thread_name"] as? String, !name.isEmpty { + out.threadName = name + } + if out.firstUserMessage.isEmpty, type == "event_msg", let p = payload, + (p["type"] as? String) == "user_message", + let msg = p["message"] as? String, + let real = realCodexUserMessage(msg) { + out.firstUserMessage = real + } + if out.firstUserMessage.isEmpty, type == "response_item", let p = payload, + (p["type"] as? String) == "message", + (p["role"] as? String) == "user", + let content = p["content"] as? [[String: Any]] { + for part in content { + guard (part["type"] as? String) == "input_text", + let text = part["text"] as? String, + let real = realCodexUserMessage(text) else { continue } + out.firstUserMessage = real + break + } + } + // Stop early once we have a real thread name + the launch metadata. If no + // thread name appears we keep streaming until we at least have a user + // message — Codex emits thread_name_updated late in newer versions but it's + // still typically within the first few KB of events. + return !out.threadName.isEmpty + && out.cwd != nil + && out.branch != nil + && !out.sessionId.isEmpty + && out.model != nil + } + return out + } + + /// Stream JSON-lines from the start of `url`. `body` returns true to stop early. + /// Caps total bytes read at `maxBytes`. + nonisolated private static func forEachJSONLine( + url: URL, + maxBytes: Int, + body: ([String: Any]) -> Bool + ) { + guard let handle = try? FileHandle(forReadingFrom: url) else { return } + defer { try? handle.close() } + var leftover = Data() + var totalRead = 0 + let chunkSize = 64 * 1024 + while totalRead < maxBytes { + let chunk: Data + if #available(macOS 10.15.4, *) { + chunk = (try? handle.read(upToCount: chunkSize)) ?? Data() + } else { + chunk = handle.readData(ofLength: chunkSize) + } + if chunk.isEmpty { break } + totalRead += chunk.count + leftover.append(chunk) + while let nl = leftover.firstIndex(of: 0x0a) { + let lineData = leftover.subdata(in: 0.. (String?, String?) { + guard let raw, let data = raw.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return (nil, nil) + } + let modelID = obj["modelID"] as? String + let providerID = obj["providerID"] as? String + let agentName = obj["agent"] as? String + let providerModel: String? = { + switch (providerID, modelID) { + case let (p?, m?) where !p.isEmpty && !m.isEmpty: return "\(p)/\(m)" + case let (_, m?) where !m.isEmpty: return m + default: return nil + } + }() + return (providerModel, agentName?.isEmpty == false ? agentName : nil) + } + + nonisolated private static func sqliteText(_ stmt: OpaquePointer, _ index: Int32) -> String? { + guard let cString = sqlite3_column_text(stmt, index) else { return nil } + return String(cString: cString) + } + + nonisolated private static func sqliteMessage(_ db: OpaquePointer?) -> String? { + guard let db, let cString = sqlite3_errmsg(db) else { return nil } + return String(cString: cString) + } + + // MARK: - Deep search (popover "Show more") + + enum SearchScope { + case agent(SessionAgent) + /// Filter by absolute cwd; nil/"" = unknown-folder bucket. + case directory(String?) + } + + /// What the popover gets back. `errors` is non-empty when one or more + /// agents failed to read their data source (schema mismatch, file missing, + /// SQL error). UI should surface them so users see why the list looks + /// short or empty rather than thinking nothing matched. + struct SearchOutcome: Sendable { + var entries: [SessionEntry] + var errors: [String] + } + + /// Thread-safe accumulator passed down to per-agent helpers so they can + /// report failures (e.g. SQL prepare errors when an agent bumps its + /// schema) without requiring the helpers to throw across actor boundaries. + final class ErrorBag: @unchecked Sendable { + private let lock = NSLock() + private var messages: [String] = [] + func add(_ msg: String) { + lock.lock(); defer { lock.unlock() } + messages.append(msg) + } + func snapshot() -> [String] { + lock.lock(); defer { lock.unlock() } + return messages + } + } + + /// Paginated on-demand search across the full filesystem (Claude/Codex) and + /// SQLite (OpenCode). Empty query is allowed and returns the most-recent + /// entries (used when the user just opens the popover and scrolls). + /// Returns up to `limit` entries sorted by mtime desc, skipping the first + /// `offset` matches. + func searchSessions( + query: String, + scope: SearchScope, + offset: Int, + limit: Int + ) async -> SearchOutcome { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + let needle = trimmed.lowercased() + let bag = ErrorBag() + #if DEBUG + let totalStart = ProcessInfo.processInfo.systemUptime + defer { + let totalMs = (ProcessInfo.processInfo.systemUptime - totalStart) * 1000 + dlog("session.search.total ms=\(String(format: "%.0f", totalMs)) needle=\"\(trimmed.prefix(20))\" offset=\(offset) limit=\(limit) errors=\(bag.snapshot().count)") + } + #endif + let entries: [SessionEntry] + switch scope { + case .agent(let a): + entries = await Self.searchAgent( + needle: needle, agent: a, cwdFilter: nil, + offset: offset, limit: limit, errorBag: bag + ) + case .directory(let path): + let cwdFilter = (path?.isEmpty == false) ? path : nil + // Multi-agent merge: fetch the union of (offset+limit) per agent so the + // merge-sort can produce a stable global ordering, then slice. + let target = offset + limit + async let c = Self.timedAgent( + needle: needle, agent: .claude, cwdFilter: cwdFilter, + offset: 0, limit: target, errorBag: bag + ) + async let x = Self.timedAgent( + needle: needle, agent: .codex, cwdFilter: cwdFilter, + offset: 0, limit: target, errorBag: bag + ) + async let o = Self.timedAgent( + needle: needle, agent: .opencode, cwdFilter: cwdFilter, + offset: 0, limit: target, errorBag: bag + ) + let merged = (await c) + (await x) + (await o) + let sorted = merged.sorted { $0.modified > $1.modified } + entries = Array(sorted.dropFirst(offset).prefix(limit)) + } + return SearchOutcome(entries: entries, errors: bag.snapshot()) + } + + nonisolated private static func timedAgent( + needle: String, agent: SessionAgent, cwdFilter: String?, + offset: Int, limit: Int, errorBag: ErrorBag + ) async -> [SessionEntry] { + #if DEBUG + let start = ProcessInfo.processInfo.systemUptime + let result = await searchAgent(needle: needle, agent: agent, cwdFilter: cwdFilter, offset: offset, limit: limit, errorBag: errorBag) + let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 + dlog("session.search.agent agent=\(agent.rawValue) ms=\(String(format: "%.0f", ms)) results=\(result.count) cwd=\(cwdFilter?.suffix(40) ?? "nil")") + return result + #else + return await searchAgent(needle: needle, agent: agent, cwdFilter: cwdFilter, offset: offset, limit: limit, errorBag: errorBag) + #endif + } + + nonisolated private static func searchAgent( + needle: String, agent: SessionAgent, cwdFilter: String?, + offset: Int, limit: Int, errorBag: ErrorBag + ) async -> [SessionEntry] { + switch agent { + case .claude: return await loadClaudeEntries(needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit) + case .codex: return await loadCodexEntries(needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit, errorBag: errorBag) + case .opencode: return loadOpenCodeEntries(needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit, errorBag: errorBag) + } + } + + /// Path to `rg` (ripgrep), if installed. Resolved once. nil when not found — + /// the search code falls back to the Foundation substring scan. + nonisolated private static let cachedRipgrepPath: String? = { + let fm = FileManager.default + let common = [ + "/opt/homebrew/bin/rg", + "/usr/local/bin/rg", + "/usr/bin/rg", + "/opt/local/bin/rg", + ] + for path in common where fm.isExecutableFile(atPath: path) { + return path + } + if let pathEnv = ProcessInfo.processInfo.environment["PATH"] { + for dir in pathEnv.split(separator: ":") { + let full = String(dir) + "/rg" + if fm.isExecutableFile(atPath: full) { return full } + } + } + return nil + }() + + /// Run `rg --files-with-matches --ignore-case --fixed-strings` for `needle` + /// under `root`, restricted to `glob` (e.g. `*.jsonl`). Returns matched file + /// URLs, or nil if rg isn't available or the run failed (caller falls back). + /// + /// Async by design so we can wire cancellation: when the awaiting Task is + /// cancelled (e.g. user types another key), `onCancel` calls + /// `process.terminate()`, killing the in-flight rg instead of letting it + /// grind to completion. Wait is also async (via `terminationHandler`) so we + /// don't tie up a cooperative-pool thread on `waitUntilExit`. + nonisolated private static func ripgrepMatchingPaths( + needle: String, root: String, fileGlob: String + ) async -> [URL]? { + guard let rg = cachedRipgrepPath else { return nil } + let process = Process() + process.executableURL = URL(fileURLWithPath: rg) + process.arguments = [ + "--files-with-matches", + "--ignore-case", + "--fixed-strings", + "--no-messages", + "--no-ignore", + "--hidden", + "--glob", fileGlob, + "--", + needle, + root, + ] + let outPipe = Pipe() + process.standardOutput = outPipe + // Discard stderr to /dev/null so its pipe can never deadlock either. + if let nullDev = FileHandle(forWritingAtPath: "/dev/null") { + process.standardError = nullDev + } + + return await withTaskCancellationHandler { + do { try process.run() } catch { return nil as [URL]? } + // Drain stdout BEFORE waitUntilExit. With many matches rg writes + // more than the ~64 KB pipe buffer; reading until EOF lets rg + // make progress and EOF arrives when rg closes its stdout on exit. + // Once readDataToEndOfFile returns, the process is already exiting, + // so waitUntilExit is essentially instant — we just need it to make + // terminationStatus observable. (Setting terminationHandler here + // would race: if rg already exited, the handler is registered too + // late and never fires → deadlock.) + let data = outPipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + // rg exit codes: 0 = matches, 1 = no matches, 2 = error/terminated. + switch process.terminationStatus { + case 0: + guard let str = String(data: data, encoding: .utf8) else { return nil as [URL]? } + return str.split(separator: "\n", omittingEmptySubsequences: true) + .map { URL(fileURLWithPath: String($0)) } + case 1: + return [] + default: + return nil + } + } onCancel: { + // Fires synchronously when the awaiting Task is cancelled. Sends + // SIGTERM to rg, which closes stdout, lets readDataToEndOfFile + // return, and unblocks the body so this call can complete cleanly. + process.terminate() + } + } + + /// Returns Claude session entries paginated by mtime desc. + /// - When `needle` is empty: fast path. Skips rg, enumerates `~/.claude/projects`, + /// takes the top `offset+limit` by mtime, parses metadata, returns the slice. + /// - When `needle` is non-empty and rg is on PATH: rg pre-filters the candidate + /// set; we only parse files that actually contain the needle. + /// - When `needle` is non-empty and rg is missing/failed: falls back to the + /// Foundation enumeration + 64 KB head + 32 KB tail substring scan. + nonisolated private static func loadClaudeEntries( + needle: String, cwdFilter: String?, offset: Int, limit: Int + ) async -> [SessionEntry] { + let projectsRoot = ("~/.claude/projects" as NSString).expandingTildeInPath + let fm = FileManager.default + + // Pre-filter via rg when we have a needle — rg is parallel, mmaps the + // file, and scans the WHOLE file (not just our 128 KB head), so it both + // speeds the scan up and finds matches deeper in long transcripts. + var rgFiltered = false + var candidates: [(URL, Date, String)] = [] + if !needle.isEmpty, + let rgPaths = await ripgrepMatchingPaths(needle: needle, root: projectsRoot, fileGlob: "*.jsonl") { + rgFiltered = true + for url in rgPaths { + guard let attrs = try? fm.attributesOfItem(atPath: url.path), + let mtime = attrs[.modificationDate] as? Date else { continue } + let dirName = url.deletingLastPathComponent().lastPathComponent + candidates.append((url, mtime, dirName)) + } + } else if let cwdFilter { + // Fast path: the project directory name encodes the cwd. We can skip + // enumerating every other project entirely. + let dirName = encodeClaudeProjectDir(cwdFilter) + let dirPath = (projectsRoot as NSString).appendingPathComponent(dirName) + var isDir: ObjCBool = false + if fm.fileExists(atPath: dirPath, isDirectory: &isDir), isDir.boolValue, + let contents = try? fm.contentsOfDirectory(atPath: dirPath) { + for name in contents where name.hasSuffix(".jsonl") { + let filePath = (dirPath as NSString).appendingPathComponent(name) + let url = URL(fileURLWithPath: filePath) + guard let attrs = try? fm.attributesOfItem(atPath: filePath), + let mtime = attrs[.modificationDate] as? Date else { continue } + candidates.append((url, mtime, dirName)) + } + } + } else { + guard let projectDirs = try? fm.contentsOfDirectory(atPath: projectsRoot) else { return [] } + for dirName in projectDirs { + let dirPath = (projectsRoot as NSString).appendingPathComponent(dirName) + var isDir: ObjCBool = false + guard fm.fileExists(atPath: dirPath, isDirectory: &isDir), isDir.boolValue else { continue } + guard let contents = try? fm.contentsOfDirectory(atPath: dirPath) else { continue } + for name in contents where name.hasSuffix(".jsonl") { + let filePath = (dirPath as NSString).appendingPathComponent(name) + let url = URL(fileURLWithPath: filePath) + guard let attrs = try? fm.attributesOfItem(atPath: filePath), + let mtime = attrs[.modificationDate] as? Date else { continue } + candidates.append((url, mtime, dirName)) + } + } + } + candidates.sort { $0.1 > $1.1 } + + // Take a generous window of candidates to inspect in parallel. We need + // enough to cover both targets and skipped files; we'll trim to + // (offset+limit) matches afterwards. Cap at searchMaxFiles. + let target = offset + limit + let workSize = min(target * 2, candidates.count, searchMaxFiles) + let workCandidates = Array(candidates.prefix(workSize)) + + #if DEBUG + let loopStart = ProcessInfo.processInfo.systemUptime + #endif + + // Parallelize per-file work. Each file's read + parse is independent; + // running them in a TaskGroup lets the cooperative pool fan I/O out + // across cores instead of one-file-at-a-time blocking on disk. + let processed: [(Int, SessionEntry?, Bool)] = await withTaskGroup( + of: (Int, SessionEntry?, Bool).self + ) { group in + for (idx, candidate) in workCandidates.enumerated() { + let (url, mtime, dirName) = candidate + group.addTask { + // Cache hit + if let cached = ClaudeMetadataCache.shared.get(url: url, mtime: mtime) { + if let cwdFilter, cached.cwd != cwdFilter { return (idx, nil, true) } + return (idx, cached, true) + } + let head = readFileHead(url: url, byteCap: headByteCap) + let tail = readFileTail(url: url, byteCap: tailByteCap) + if !needle.isEmpty && !rgFiltered { + let combined = head + "\n" + tail + if combined.range(of: needle, options: [.caseInsensitive, .literal]) == nil { + return (idx, nil, false) + } + } + let parsed = extractClaudeMetadata(head: head, tail: tail, projectDir: dirName) + if let cwdFilter, parsed.cwd != cwdFilter { return (idx, nil, false) } + let sid = url.deletingPathExtension().lastPathComponent + let entry = SessionEntry( + id: "claude:" + url.path, + agent: .claude, + sessionId: sid, + title: parsed.title, + cwd: parsed.cwd, + gitBranch: parsed.branch, + pullRequest: parsed.pr, + modified: mtime, + fileURL: url, + specifics: .claude(model: parsed.model, permissionMode: parsed.permissionMode) + ) + if needle.isEmpty { + ClaudeMetadataCache.shared.put(url: url, mtime: mtime, entry: entry) + } + return (idx, entry, false) + } + } + var collected: [(Int, SessionEntry?, Bool)] = [] + collected.reserveCapacity(workCandidates.count) + for await item in group { collected.append(item) } + return collected + } + // Restore original mtime ordering (TaskGroup completes out-of-order). + let sorted = processed.sorted { $0.0 < $1.0 } + let matched = sorted.compactMap { $0.1 } + #if DEBUG + let cachedCount = sorted.filter { $0.2 }.count + let skippedCount = sorted.filter { $0.1 == nil && !$0.2 }.count + sorted.filter { $0.1 == nil && $0.2 }.count + let totalMs = (ProcessInfo.processInfo.systemUptime - loopStart) * 1000 + dlog("session.claude.detail target=\(target) workSize=\(workSize) matched=\(matched.count) cachedHits=\(cachedCount) skipped=\(skippedCount) parallelMs=\(Int(totalMs))") + #endif + return Array(matched.prefix(target).dropFirst(offset).prefix(limit)) + } + + /// Returns Codex session entries paginated by mtime desc. + /// Primary path: query Codex's own `~/.codex/state_5.sqlite` (`threads` + /// table) — Codex pre-extracts cwd, title, model, branch, approval, sandbox, + /// effort, and rollout_path so we don't need to read jsonl files at all. + /// Fallback (DB missing): the file-scan path below. + nonisolated private static func loadCodexEntries( + needle: String, cwdFilter: String?, offset: Int, limit: Int, + errorBag: ErrorBag + ) async -> [SessionEntry] { + if let viaSQL = loadCodexEntriesViaSQL( + needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit, + errorBag: errorBag + ) { + return viaSQL + } + return await loadCodexEntriesFromDisk( + needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit + ) + } + + /// SQL-backed Codex loader. Returns nil if `state_5.sqlite` doesn't exist + /// (signaling caller to fall back to the disk scan). On schema mismatch or + /// other SQL errors, appends a user-facing message to `errorBag` and still + /// returns nil so the caller can fall back. + nonisolated private static func loadCodexEntriesViaSQL( + needle: String, cwdFilter: String?, offset: Int, limit: Int, + errorBag: ErrorBag + ) -> [SessionEntry]? { + let dbPath = ("~/.codex/state_5.sqlite" as NSString).expandingTildeInPath + let fm = FileManager.default + guard fm.fileExists(atPath: dbPath) else { return nil } + + // Snapshot DB + WAL/SHM to avoid contention with a running Codex. + let snapshotDir = fm.temporaryDirectory.appendingPathComponent( + "cmux-codex-search-\(UUID().uuidString)", isDirectory: true + ) + do { try fm.createDirectory(at: snapshotDir, withIntermediateDirectories: true) } catch { return nil } + defer { try? fm.removeItem(at: snapshotDir) } + let snapshotDB = snapshotDir.appendingPathComponent("state.db") + do { try fm.copyItem(atPath: dbPath, toPath: snapshotDB.path) } catch { return nil } + for sidecar in ["-wal", "-shm"] { + let src = dbPath + sidecar + let dst = snapshotDB.path + sidecar + if fm.fileExists(atPath: src) { try? fm.copyItem(atPath: src, toPath: dst) } + } + + var db: OpaquePointer? + guard sqlite3_open_v2(snapshotDB.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK, let db else { + errorBag.add("Codex: cannot open state_5.sqlite (\(sqliteMessage(db) ?? "unknown error"))") + sqlite3_close(db) + return nil + } + defer { sqlite3_close(db) } + + var sql = """ + SELECT id, rollout_path, cwd, title, model, git_branch, + approval_mode, sandbox_policy, reasoning_effort, + first_user_message, updated_at_ms + FROM threads + WHERE archived = 0 + """ + var conditions: [String] = [] + if !needle.isEmpty { + // Match against title, first_user_message, cwd, and git_branch. + conditions.append("(LOWER(title) LIKE ?1 OR LOWER(first_user_message) LIKE ?1 OR LOWER(cwd) LIKE ?1 OR LOWER(git_branch) LIKE ?1)") + } + if cwdFilter != nil { + conditions.append("cwd = ?\(needle.isEmpty ? 1 : 2)") + } + if !conditions.isEmpty { + sql += " AND " + conditions.joined(separator: " AND ") + } + sql += " ORDER BY updated_at_ms DESC LIMIT \(limit) OFFSET \(offset)" + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK, let stmt else { + errorBag.add("Codex: schema unsupported — \(sqliteMessage(db) ?? "prepare failed"). Falling back to file scan.") + sqlite3_finalize(stmt) + return nil + } + defer { sqlite3_finalize(stmt) } + + let SQLITE_TRANSIENT_FN = unsafeBitCast(OpaquePointer(bitPattern: -1), to: sqlite3_destructor_type.self) + if !needle.isEmpty { + sqlite3_bind_text(stmt, 1, "%\(needle)%", -1, SQLITE_TRANSIENT_FN) + } + if let cwdFilter { + sqlite3_bind_text(stmt, needle.isEmpty ? 1 : 2, cwdFilter, -1, SQLITE_TRANSIENT_FN) + } + + var results: [SessionEntry] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + let sid = sqliteText(stmt, 0) ?? "" + let rollout = sqliteText(stmt, 1) ?? "" + let cwd = sqliteText(stmt, 2) + let titleField = sqliteText(stmt, 3) ?? "" + let model = sqliteText(stmt, 4) + let branch = sqliteText(stmt, 5) + let approval = sqliteText(stmt, 6) + let sandboxJSON = sqliteText(stmt, 7) + let effort = sqliteText(stmt, 8) + let firstMsg = sqliteText(stmt, 9) ?? "" + let updatedMs = sqlite3_column_int64(stmt, 10) + let modified = Date(timeIntervalSince1970: TimeInterval(updatedMs) / 1000.0) + + // Codex stores sandbox_policy as JSON like {"type":"danger-full-access"}. + let sandboxMode = sandboxJSON + .flatMap { $0.data(using: .utf8) } + .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } + .flatMap { $0["type"] as? String } + + // Prefer Codex's curated `title`. Fall back to first_user_message, + // skipping envelope wrappers (, etc.). + let displayTitle: String + if !titleField.isEmpty { + displayTitle = titleField + } else if let real = realCodexUserMessage(firstMsg) { + displayTitle = real + } else { + displayTitle = "" + } + + let fileURL: URL? = rollout.isEmpty ? nil : URL(fileURLWithPath: rollout) + results.append(SessionEntry( + id: "codex:" + (fileURL?.path ?? sid), + agent: .codex, + sessionId: sid, + title: displayTitle, + cwd: cwd, + gitBranch: branch?.isEmpty == false ? branch : nil, + pullRequest: nil, + modified: modified, + fileURL: fileURL, + specifics: .codex( + model: model?.isEmpty == false ? model : nil, + approvalPolicy: approval?.isEmpty == false ? approval : nil, + sandboxMode: sandboxMode, + effort: effort?.isEmpty == false ? effort : nil + ) + )) + } + return results + } + + /// Disk-scan fallback for Codex when state_5.sqlite isn't present (very old + /// Codex installs, or non-default config). Same shape as the original loader. + nonisolated private static func loadCodexEntriesFromDisk( + needle: String, cwdFilter: String?, offset: Int, limit: Int + ) async -> [SessionEntry] { + let root = ("~/.codex/sessions" as NSString).expandingTildeInPath + let fm = FileManager.default + + var rgFiltered = false + var candidates: [(URL, Date)] = [] + if !needle.isEmpty, + let rgPaths = await ripgrepMatchingPaths(needle: needle, root: root, fileGlob: "*.jsonl") { + rgFiltered = true + for url in rgPaths { + guard let attrs = try? fm.attributesOfItem(atPath: url.path), + let mtime = attrs[.modificationDate] as? Date else { continue } + candidates.append((url, mtime)) + } + } else { + let rootURL = URL(fileURLWithPath: root) + guard let enumerator = fm.enumerator( + at: rootURL, + includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { return [] } + for case let url as URL in enumerator { + guard url.pathExtension == "jsonl" else { continue } + let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .isRegularFileKey]) + guard values?.isRegularFile == true, + let mtime = values?.contentModificationDate else { continue } + candidates.append((url, mtime)) + } + } + candidates.sort { $0.1 > $1.1 } + + let target = offset + limit + var matches: [SessionEntry] = [] + var scanned = 0 + for (url, mtime) in candidates { + if Task.isCancelled { break } + if matches.count >= target { break } + if scanned >= searchMaxFiles { break } + scanned += 1 + if !needle.isEmpty && !rgFiltered { + let head = readFileHead(url: url, byteCap: headByteCap) + guard head.range(of: needle, options: [.caseInsensitive, .literal]) != nil else { continue } + } + // Fast cwd reject: session_meta is the FIRST line of every Codex + // rollout. Pull just that line and bail before streaming the + // (potentially MB-sized) rest of the file looking for title/branch. + if let cwdFilter, + let firstLineCwd = peekCodexSessionMetaCwd(url: url), + firstLineCwd != cwdFilter { + continue + } + let parsed = extractCodexMetadata(url: url) + if let cwdFilter, parsed.cwd != cwdFilter { continue } + matches.append(SessionEntry( + id: "codex:" + url.path, + agent: .codex, + sessionId: parsed.sessionId, + title: parsed.title, + cwd: parsed.cwd, + gitBranch: parsed.branch, + pullRequest: nil, + modified: mtime, + fileURL: url, + specifics: .codex( + model: parsed.model, + approvalPolicy: parsed.approvalPolicy, + sandboxMode: parsed.sandboxMode, + effort: parsed.effort + ) + )) + } + return Array(matches.dropFirst(offset).prefix(limit)) + } + + /// Returns OpenCode session entries paginated by `time_updated` desc. + /// Empty needle skips the `LIKE` clause entirely so it's just `ORDER BY … LIMIT/OFFSET`. + /// Sync because the SQL pass is fast and SQLite's API is sync; the caller + /// awaits the wrapping `searchSessions`/`scanAll` boundaries. + nonisolated private static func loadOpenCodeEntries( + needle: String, cwdFilter: String?, offset: Int, limit: Int, + errorBag: ErrorBag + ) -> [SessionEntry] { + let dbPath = ("~/.local/share/opencode/opencode.db" as NSString).expandingTildeInPath + let fm = FileManager.default + guard fm.fileExists(atPath: dbPath) else { return [] } + let snapshotDir = fm.temporaryDirectory.appendingPathComponent("cmux-opencode-search-\(UUID().uuidString)", isDirectory: true) + do { try fm.createDirectory(at: snapshotDir, withIntermediateDirectories: true) } catch { return [] } + defer { try? fm.removeItem(at: snapshotDir) } + let snapshotDB = snapshotDir.appendingPathComponent("opencode.db") + do { try fm.copyItem(atPath: dbPath, toPath: snapshotDB.path) } catch { return [] } + for sidecar in ["-wal", "-shm"] { + let src = dbPath + sidecar + let dst = snapshotDB.path + sidecar + if fm.fileExists(atPath: src) { try? fm.copyItem(atPath: src, toPath: dst) } + } + var db: OpaquePointer? + guard sqlite3_open_v2(snapshotDB.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK, let db else { + errorBag.add("OpenCode: cannot open opencode.db (\(sqliteMessage(db) ?? "unknown error"))") + sqlite3_close(db) + return [] + } + defer { sqlite3_close(db) } + + var sql = """ + SELECT s.id, s.title, s.directory, s.time_updated, ( + SELECT data FROM message + WHERE session_id = s.id AND data LIKE '%"role":"assistant"%' + ORDER BY time_created DESC LIMIT 1 + ) AS last_assistant + FROM session s + """ + var conditions: [String] = [] + if !needle.isEmpty { + conditions.append("(LOWER(s.title) LIKE ? OR LOWER(s.directory) LIKE ?)") + } + if cwdFilter != nil { + conditions.append("s.directory = ?") + } + if !conditions.isEmpty { + sql += " WHERE " + conditions.joined(separator: " AND ") + } + sql += " ORDER BY s.time_updated DESC LIMIT \(limit) OFFSET \(offset)" + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK, let stmt else { + errorBag.add("OpenCode: schema unsupported — \(sqliteMessage(db) ?? "prepare failed")") + sqlite3_finalize(stmt) + return [] + } + defer { sqlite3_finalize(stmt) } + + let SQLITE_TRANSIENT_FN = unsafeBitCast(OpaquePointer(bitPattern: -1), to: sqlite3_destructor_type.self) + var bindIndex: Int32 = 1 + if !needle.isEmpty { + let likePattern = "%\(needle)%" + sqlite3_bind_text(stmt, bindIndex, likePattern, -1, SQLITE_TRANSIENT_FN); bindIndex += 1 + sqlite3_bind_text(stmt, bindIndex, likePattern, -1, SQLITE_TRANSIENT_FN); bindIndex += 1 + } + if let cwdFilter { + sqlite3_bind_text(stmt, bindIndex, cwdFilter, -1, SQLITE_TRANSIENT_FN); bindIndex += 1 + } + + var results: [SessionEntry] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + let sid = sqliteText(stmt, 0) ?? "" + let title = sqliteText(stmt, 1) ?? "" + let directory = sqliteText(stmt, 2) + let updatedMs = sqlite3_column_int64(stmt, 3) + let modified = Date(timeIntervalSince1970: TimeInterval(updatedMs) / 1000.0) + let lastJSON = sqliteText(stmt, 4) + let (providerModel, agentName) = parseOpenCodeAssistant(lastJSON) + results.append(SessionEntry( + id: "opencode:" + sid, + agent: .opencode, + sessionId: sid, + title: title, + cwd: directory, + gitBranch: nil, + pullRequest: nil, + modified: modified, + fileURL: nil, + specifics: .opencode(providerModel: providerModel, agentName: agentName) + )) + } + return results + } + + // MARK: Helpers + + /// Read up to `byteCap` bytes from the start of the file as UTF-8. + nonisolated private static func readFileHead(url: URL, byteCap: Int) -> String { + guard let handle = try? FileHandle(forReadingFrom: url) else { return "" } + defer { try? handle.close() } + let data: Data + if #available(macOS 10.15.4, *) { + data = (try? handle.read(upToCount: byteCap)) ?? Data() + } else { + data = handle.readData(ofLength: byteCap) + } + return String(data: data, encoding: .utf8) ?? String(decoding: data, as: UTF8.self) + } + + /// Read up to `byteCap` bytes from the end of the file as UTF-8. + /// Used to find late-arriving events like pr-link without scanning the whole file. + nonisolated private static func readFileTail(url: URL, byteCap: Int) -> String { + guard let handle = try? FileHandle(forReadingFrom: url) else { return "" } + defer { try? handle.close() } + let size: UInt64 + do { size = try handle.seekToEnd() } catch { return "" } + if size == 0 { return "" } + let cap = UInt64(byteCap) + let offset: UInt64 = size > cap ? size - cap : 0 + do { try handle.seek(toOffset: offset) } catch { return "" } + let data: Data + if #available(macOS 10.15.4, *) { + data = (try? handle.read(upToCount: byteCap)) ?? Data() + } else { + data = handle.readData(ofLength: byteCap) + } + // Trim leading partial line (we likely cut mid-record). + if offset > 0, let nl = data.firstIndex(of: 0x0a) { + return String(data: data[(nl + 1)...], encoding: .utf8) ?? "" + } + return String(data: data, encoding: .utf8) ?? "" + } +} diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift new file mode 100644 index 000000000000..13dc188f4d5d --- /dev/null +++ b/Sources/SessionIndexView.swift @@ -0,0 +1,1077 @@ +import AppKit +import Bonsplit +import SwiftUI +import UniformTypeIdentifiers + +struct SessionIndexView: View { + @ObservedObject var store: SessionIndexStore + /// Sections the user has explicitly collapsed (default is expanded). + @State private var collapsedSections: Set = [] + /// Section whose "Show more" popover is currently open. + @State private var openPopoverSection: SectionKey? = nil + let onResume: ((SessionEntry) -> Void)? + + /// Rows shown per section before "Show more" is tapped. + private static let collapsedRowLimit = 5 + + static let relativeFormatter: RelativeDateTimeFormatter = { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .abbreviated + return f + }() + + static let absoluteFormatter: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .medium + f.timeStyle = .short + return f + }() + + var body: some View { + VStack(spacing: 0) { + controlBar + Divider() + if store.isLoading && store.entries.isEmpty { + loadingView + } else if store.entries.isEmpty { + emptyView + } else { + sessionsList + } + } + .onAppear { + // RightSidebarPanelView's mode toggle also kicks reload() when + // entries are empty, so guard against the double-reload that + // would otherwise cancel and restart the in-flight scan. + if store.entries.isEmpty && !store.isLoading { + store.reload() + } + } + } + + private var controlBar: some View { + HStack(spacing: 6) { + ForEach(SessionGrouping.allCases) { mode in + GroupingButton( + mode: mode, + isSelected: store.grouping == mode + ) { + if store.grouping != mode { + store.grouping = mode + } + } + } + + Spacer(minLength: 4) + + Toggle(isOn: $store.scopeToCurrentDirectory) { + Text(String(localized: "sessionIndex.scope.thisFolder", defaultValue: "This folder only")) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + .toggleStyle(.checkbox) + .controlSize(.small) + .disabled(store.currentDirectory == nil) + + Button { + store.reload() + } label: { + Image(systemName: "arrow.clockwise") + .font(.system(size: 10, weight: .medium)) + } + .buttonStyle(.borderless) + .help(String(localized: "sessionIndex.reload.tooltip", defaultValue: "Reload sessions")) + .disabled(store.isLoading) + } + .padding(.horizontal, 8) + .padding(.vertical, 3) + .frame(height: 29) + } + + private var loadingView: some View { + VStack(spacing: 6) { + ProgressView().controlSize(.small) + Text(String(localized: "sessionIndex.loading", defaultValue: "Scanning sessions…")) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var emptyView: some View { + VStack(spacing: 4) { + Text(String(localized: "sessionIndex.empty.title", defaultValue: "No sessions found")) + .font(.system(size: 12)) + .foregroundColor(.secondary) + Text(String(localized: "sessionIndex.empty.subtitle", + defaultValue: "Sessions from Claude Code, Codex, and OpenCode will appear here.")) + .font(.system(size: 11)) + .foregroundColor(.secondary.opacity(0.7)) + .multilineTextAlignment(.center) + .padding(.horizontal, 16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var sessionsList: some View { + let sections = store.sectionsForCurrentGrouping() + return ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(Array(sections.enumerated()), id: \.element.key) { index, section in + // Drop above this row → insert dragged section BEFORE this section's key. + SectionReorderGap(beforeKey: section.key, store: store) + IndexSectionView( + section: section, + rowLimit: Self.collapsedRowLimit, + isCollapsed: Binding( + get: { collapsedSections.contains(section.key) }, + set: { newValue in + if newValue { + collapsedSections.insert(section.key) + } else { + collapsedSections.remove(section.key) + } + } + ), + isPopoverOpen: Binding( + get: { openPopoverSection == section.key }, + set: { newValue in + openPopoverSection = newValue ? section.key : nil + } + ), + store: store, + onResume: onResume + ) + let _ = index + } + // Trailing gap → append. + SectionReorderGap(beforeKey: nil, store: store) + } + .padding(.bottom, 8) + } + } +} + +private struct GroupingButton: View { + let mode: SessionGrouping + let isSelected: Bool + let action: () -> Void + @State private var isHovered: Bool = false + + var body: some View { + Button(action: action) { + HStack(spacing: 3) { + Image(systemName: mode.symbolName) + .font(.system(size: 10, weight: .medium)) + Text(mode.label) + .font(.system(size: 11, weight: .medium)) + } + .foregroundColor(isSelected ? .primary : .secondary) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(isSelected ? Color.primary.opacity(0.10) + : (isHovered ? Color.primary.opacity(0.05) : Color.clear)) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { isHovered = $0 } + .help(mode.label) + } +} + +private struct IndexSectionView: View { + let section: IndexSection + let rowLimit: Int + @Binding var isCollapsed: Bool + @Binding var isPopoverOpen: Bool + @ObservedObject var store: SessionIndexStore + let onResume: ((SessionEntry) -> Void)? + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + sectionHeader + if !isCollapsed { + if section.entries.isEmpty { + Text(String(localized: "sessionIndex.section.noChats", defaultValue: "No chats")) + .font(.system(size: 12)) + .foregroundColor(.secondary.opacity(0.6)) + .padding(.leading, 32) + .padding(.vertical, 4) + } else { + ForEach(Array(section.entries.prefix(rowLimit))) { entry in + SessionRow(entry: entry, onResume: onResume) + .equatable() + } + if section.entries.count > rowLimit { + showMoreButton + } + } + Spacer(minLength: 2) + } + } + .opacity(store.draggedKey == section.key ? 0.45 : 1.0) + } + + private var showMoreButton: some View { + Button { + isPopoverOpen = true + } label: { + Text(String(localized: "sessionIndex.section.showMore", defaultValue: "Show more")) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary.opacity(0.7)) + .padding(.leading, 32) + .padding(.vertical, 4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background( + SectionPopoverHost( + isPresented: $isPopoverOpen, + section: section, + store: store, + onResume: onResume + ) + ) + } + + private var sectionHeader: some View { + Button { + isCollapsed.toggle() + } label: { + HStack(spacing: 8) { + sectionIconView + Text(section.title) + .font(.system(size: 13, weight: .regular)) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(.secondary.opacity(0.6)) + .rotationEffect(.degrees(isCollapsed ? -90 : 0)) + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 3) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onDrag { + DispatchQueue.main.async { store.draggedKey = section.key } + return NSItemProvider(object: section.key.raw as NSString) + } preview: { + HStack(spacing: 8) { + sectionIconView + Text(section.title) + .font(.system(size: 13)) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 6, style: .continuous)) + } + } + + @ViewBuilder + private var sectionIconView: some View { + switch section.icon { + case .agent(let agent): + Image(agent.assetName) + .resizable() + .interpolation(.high) + .aspectRatio(contentMode: .fit) + .frame(width: 14, height: 14) + case .folder: + Image(systemName: "folder") + .font(.system(size: 12, weight: .regular)) + .foregroundColor(.secondary) + .frame(width: 14, height: 14) + } + } +} + +private struct SectionReorderGap: View { + /// Section the dragged item should land BEFORE if dropped here. `nil` for + /// the trailing gap (drop appends to the end of persisted order). + let beforeKey: SectionKey? + @ObservedObject var store: SessionIndexStore + @State private var isDropTarget: Bool = false + + var body: some View { + let isValid = isValidDrop + Rectangle() + .fill(Color.clear) + .frame(height: 4) + .overlay(alignment: .center) { + if isDropTarget && isValid { + Capsule() + .fill(Color.accentColor) + .frame(height: 3) + .padding(.horizontal, 10) + } + } + .onDrop( + of: [.text], + delegate: SectionGapDropDelegate( + beforeKey: beforeKey, + store: store, + isDropTarget: $isDropTarget + ) + ) + } + + private var isValidDrop: Bool { + guard let dragged = store.draggedKey else { return true } + // Skip no-op drops: dropping on the gap immediately above yourself, or + // the trailing gap when you're already last. + if dragged == beforeKey { return false } + return true + } +} + +private struct SectionGapDropDelegate: DropDelegate { + let beforeKey: SectionKey? + let store: SessionIndexStore + @Binding var isDropTarget: Bool + + func validateDrop(info: DropInfo) -> Bool { + guard info.hasItemsConforming(to: [.text]) else { return false } + guard let dragged = store.draggedKey else { return true } + return dragged != beforeKey + } + + func dropEntered(info: DropInfo) { isDropTarget = true } + func dropExited(info: DropInfo) { isDropTarget = false } + + func performDrop(info: DropInfo) -> Bool { + isDropTarget = false + guard let provider = info.itemProviders(for: [.text]).first else { + store.draggedKey = nil + return false + } + let beforeKey = self.beforeKey + provider.loadObject(ofClass: NSString.self) { object, _ in + DispatchQueue.main.async { + defer { store.draggedKey = nil } + guard let raw = object as? String else { return } + let key = SectionKey(raw: raw) + store.moveSection(key, before: beforeKey) + } + } + return true + } +} + +private struct SessionRow: View, Equatable { + let entry: SessionEntry + let onResume: ((SessionEntry) -> Void)? + @State private var isHovered: Bool = false + + static func == (lhs: SessionRow, rhs: SessionRow) -> Bool { + // Skip body re-eval during scroll when the entry is unchanged. + // The closure isn't compared (it comes from stable parent state). + lhs.entry == rhs.entry + } + + var body: some View { + HStack(spacing: 6) { + Image(entry.agent.assetName) + .resizable() + .interpolation(.high) + .aspectRatio(contentMode: .fit) + .frame(width: 12, height: 12) + Text(entry.displayTitle) + .font(.system(size: 13)) + .foregroundColor(.primary.opacity(0.92)) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 8) + Text(relativeTime(entry.modified)) + .font(.system(size: 12).monospacedDigit()) + .foregroundColor(.secondary.opacity(0.65)) + .fixedSize() + } + .padding(.leading, 32) + .padding(.trailing, 12) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(isHovered ? Color.primary.opacity(0.05) : Color.clear) + .padding(.horizontal, 6) + ) + .onHover { isHovered = $0 } + .help(helpText) + .onTapGesture(count: 2) { + if let onResume { onResume(entry) } + } + .onDrag { + sessionDragItemProvider(for: entry) + } preview: { + HStack(spacing: 6) { + Image(entry.agent.assetName) + .resizable() + .interpolation(.high) + .aspectRatio(contentMode: .fit) + .frame(width: 12, height: 12) + Text(entry.displayTitle) + .font(.system(size: 12, weight: .medium)) + .lineLimit(1) + .truncationMode(.tail) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 6, style: .continuous)) + } + .contextMenu { + sessionRowMenuItems(entry: entry, onResume: onResume) + } + } + + private var helpText: String { + var lines: [String] = [entry.displayTitle] + if let cwd = entry.cwdLabel { + lines.append(cwd) + } + lines.append(absoluteTime(entry.modified)) + return lines.joined(separator: "\n") + } + + private func relativeTime(_ date: Date) -> String { + SessionIndexView.relativeFormatter.localizedString(for: date, relativeTo: Date()) + } + + private func absoluteTime(_ date: Date) -> String { + SessionIndexView.absoluteFormatter.string(from: date) + } +} + +// MARK: - Shared row actions + +/// Right-click menu items for any session row (full or popover). Built as a +/// free `@ViewBuilder` so SessionRow and PopoverRow both attach the same set +/// without duplicating the button list or the action helpers. +@ViewBuilder +private func sessionRowMenuItems(entry: SessionEntry, onResume: ((SessionEntry) -> Void)?) -> some View { + if let onResume { + Button { + onResume(entry) + } label: { + Text(String(localized: "sessionIndex.row.resume", defaultValue: "Resume in New Tab")) + } + Divider() + } + if let url = entry.fileURL { + Button { + NSWorkspace.shared.open(url) + } label: { + Text(String(localized: "sessionIndex.row.open", defaultValue: "Open")) + } + Button { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } label: { + Text(String(localized: "sessionIndex.row.reveal", defaultValue: "Reveal in Finder")) + } + Divider() + Button { + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(url.path, forType: .string) + } label: { + Text(String(localized: "sessionIndex.row.copyPath", defaultValue: "Copy File Path")) + } + } + Button { + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(entry.resumeCommand, forType: .string) + } label: { + Text(String(localized: "sessionIndex.row.copyResume", defaultValue: "Copy Resume Command")) + } + if let cwd = entry.cwd, !cwd.isEmpty { + Button { + NSWorkspace.shared.open(URL(fileURLWithPath: cwd)) + } label: { + Text(String(localized: "sessionIndex.row.openCwd", defaultValue: "Open Working Directory")) + } + } + if let pr = entry.pullRequest, let url = URL(string: pr.url) { + Divider() + Button { + NSWorkspace.shared.open(url) + } label: { + Text(String(localized: "sessionIndex.row.openPR", defaultValue: "Open Pull Request")) + } + } +} + + +// MARK: - "Show more" popover with search + +private struct SectionPopoverView: View { + let section: IndexSection + let store: SessionIndexStore + let onResume: ((SessionEntry) -> Void)? + let onDismiss: () -> Void + + @State private var query: String = "" + @FocusState private var searchFocused: Bool + + /// Pages of results loaded so far. Each page is `pageSize` rows from the store's + /// paginated search. + @State private var loaded: [SessionEntry] = [] + @State private var hasMore: Bool = true + @State private var isLoading: Bool = false + @State private var activeQuery: String = "" + @State private var loadTask: Task? + @State private var errorMessages: [String] = [] + /// Bumped on each query reset so an in-flight task knows it's been superseded + /// even if cancellation hasn't propagated yet. + @State private var loadGeneration: Int = 0 + + private static let pageSize = 30 + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + sectionIconView + Text(section.title) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.primary) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.top, 10) + .padding(.bottom, 6) + + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.secondary) + TextField( + String(localized: "sessionIndex.popover.searchPlaceholder", + defaultValue: "Search sessions"), + text: $query + ) + .textFieldStyle(.plain) + .font(.system(size: 12)) + .focused($searchFocused) + if !query.isEmpty { + Button { + query = "" + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(0.06)) + ) + .padding(.horizontal, 10) + .padding(.bottom, 8) + + Divider() + + if !errorMessages.isEmpty { + VStack(alignment: .leading, spacing: 2) { + ForEach(errorMessages, id: \.self) { msg in + HStack(alignment: .top, spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 10)) + .foregroundColor(.orange) + Text(msg) + .font(.system(size: 11)) + .foregroundColor(.primary.opacity(0.85)) + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.orange.opacity(0.10)) + } + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + if isLoading && loaded.isEmpty { + loadingRow + } else if loaded.isEmpty { + Text(String(localized: "sessionIndex.popover.noMatches", + defaultValue: "No matches")) + .font(.system(size: 12)) + .foregroundColor(.secondary) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + ForEach(loaded) { entry in + PopoverRow(entry: entry) { + onResume?(entry) + onDismiss() + } + .equatable() + } + if hasMore { + loadingRow + .onAppear { loadMore() } + } + } + } + .padding(.top, 4) + .padding(.bottom, 10) + } + .frame(maxHeight: 420) + } + .frame(width: 360) + .background( + EscapeKeyCatcher { onDismiss() } + ) + .onAppear { + resetAndLoad(query: "") + DispatchQueue.main.async { searchFocused = true } + } + .onChange(of: query) { newValue in + resetAndLoad(query: newValue) + } + .onDisappear { + loadTask?.cancel() + loadTask = nil + isLoading = false + } + } + + private var loadingRow: some View { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text(String(localized: "sessionIndex.popover.loading", defaultValue: "Loading…")) + .font(.system(size: 11)) + .foregroundColor(.secondary) + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + } + + /// Reset the page and load page 0. + /// - Empty query: synchronous fast path. Show the cached top-N from + /// `section.entries` immediately so opening the popover never flashes a + /// loading spinner. The sentinel row's loadMore will then fetch any + /// additional pages from disk/SQL when the user scrolls past them. + /// - Non-empty query: 200ms debounce then deep search via the store. + private func resetAndLoad(query newValue: String) { + loadTask?.cancel() + loadGeneration += 1 + let generation = loadGeneration + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + activeQuery = trimmed + + if trimmed.isEmpty { + loaded = section.entries + // Optimistic: assume there might be more on disk; loadMore will + // discover the truth and flip hasMore off if a fetch returns nothing. + hasMore = !section.entries.isEmpty + isLoading = false + errorMessages = [] + return + } + + loaded = [] + hasMore = true + isLoading = true + errorMessages = [] + let scope = sectionSearchScope + let store = self.store + loadTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 200_000_000) + if Task.isCancelled || generation != loadGeneration { return } + let outcome = await store.searchSessions( + query: trimmed, scope: scope, + offset: 0, limit: Self.pageSize + ) + if Task.isCancelled || generation != loadGeneration { return } + applyOutcome(outcome, append: false) + } + } + + /// Append the next page to `loaded`. Triggered by the sentinel row's onAppear. + private func loadMore() { + guard !isLoading, hasMore else { return } + isLoading = true + let generation = loadGeneration + let scope = sectionSearchScope + let store = self.store + let query = activeQuery + let offset = loaded.count + loadTask = Task { @MainActor in + let outcome = await store.searchSessions( + query: query, scope: scope, + offset: offset, limit: Self.pageSize + ) + if Task.isCancelled || generation != loadGeneration { return } + applyOutcome(outcome, append: true) + } + } + + /// Merge a fetch result into the popover's display state. Both the + /// initial-page and load-more paths converge here so the count/hasMore/ + /// error/loading bookkeeping lives in one place. + @MainActor + private func applyOutcome(_ outcome: SessionIndexStore.SearchOutcome, append: Bool) { + if append { + loaded.append(contentsOf: outcome.entries) + } else { + loaded = outcome.entries + } + hasMore = outcome.entries.count >= Self.pageSize + errorMessages = outcome.errors + isLoading = false + } + + private var sectionSearchScope: SessionIndexStore.SearchScope { + let raw = section.key.raw + if raw.hasPrefix("agent:"), + let agent = SessionAgent(rawValue: String(raw.dropFirst("agent:".count))) { + return .agent(agent) + } + if raw.hasPrefix("dir:") { + let path = String(raw.dropFirst("dir:".count)) + return .directory(path.isEmpty ? nil : path) + } + return .directory(nil) + } + + @ViewBuilder + private var sectionIconView: some View { + switch section.icon { + case .agent(let agent): + Image(agent.assetName) + .resizable() + .interpolation(.high) + .aspectRatio(contentMode: .fit) + .frame(width: 14, height: 14) + case .folder: + Image(systemName: "folder") + .font(.system(size: 12, weight: .regular)) + .foregroundColor(.secondary) + .frame(width: 14, height: 14) + } + } +} + +private struct PopoverRow: View, Equatable { + let entry: SessionEntry + let onActivate: () -> Void + + @State private var isHovered: Bool = false + + static func == (lhs: PopoverRow, rhs: PopoverRow) -> Bool { + lhs.entry == rhs.entry + } + + var body: some View { + HStack(spacing: 6) { + Image(entry.agent.assetName) + .resizable() + .interpolation(.high) + .aspectRatio(contentMode: .fit) + .frame(width: 12, height: 12) + Text(entry.displayTitle) + .font(.system(size: 12)) + .foregroundColor(.primary.opacity(0.92)) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 8) + Text(SessionIndexView.relativeFormatter.localizedString(for: entry.modified, relativeTo: Date())) + .font(.system(size: 11).monospacedDigit()) + .foregroundColor(.secondary.opacity(0.7)) + .fixedSize() + } + .padding(.horizontal, 12) + .padding(.vertical, 5) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .background(isHovered ? Color.primary.opacity(0.06) : Color.clear) + .onHover { isHovered = $0 } + .onTapGesture(count: 2) { onActivate() } + .onDrag { + sessionDragItemProvider(for: entry) + } + .help(entry.cwdLabel ?? entry.displayTitle) + .contextMenu { + sessionRowMenuItems(entry: entry, onResume: { _ in onActivate() }) + } + } +} + +// MARK: - Drag payload + +/// Mirrors `Bonsplit.TabItem`'s Codable shape so we can produce a JSON payload +/// that bonsplit's external-drop path will decode and accept. +private struct MirrorTabItem: Codable { + let id: UUID + let title: String + let hasCustomTitle: Bool + let icon: String? + let iconImageData: Data? + let kind: String? + let isDirty: Bool + let showsNotificationBadge: Bool + let isLoading: Bool + let isPinned: Bool +} + +/// Mirrors `Bonsplit.TabTransferData` exactly. +private struct MirrorTabTransferData: Codable { + let tab: MirrorTabItem + let sourcePaneId: UUID + let sourceProcessId: Int32 +} + +/// Build the encoded payload bonsplit's external-drop decoder accepts. +private func sessionTabTransferData(for entry: SessionEntry, dragId: UUID) -> Data? { + let mirror = MirrorTabTransferData( + tab: MirrorTabItem( + id: dragId, + title: entry.displayTitle, + hasCustomTitle: false, + icon: "terminal.fill", + iconImageData: nil, + kind: "terminal", + isDirty: false, + showsNotificationBadge: false, + isLoading: false, + isPinned: false + ), + sourcePaneId: UUID(), + sourceProcessId: Int32(ProcessInfo.processInfo.processIdentifier) + ) + return try? JSONEncoder().encode(mirror) +} + +/// NSItemProvider used by `.onDrag {}`. Registers ONLY +/// `com.splittabbar.tabtransfer` so the terminal's NSDraggingDestination +/// (which accepts `.string` / `public.utf8-plain-text`) is not hit-tested +/// for our drag. With the terminal out of the way, bonsplit's SwiftUI +/// `.onDrop(of: [.tabTransfer])` overlay can render the blue insert/split +/// zones across the entire pane (including its center). +/// +/// Also mirrors the encoded blob onto NSPasteboard(name: .drag) since +/// bonsplit's external-drop decoder reads from that pasteboard directly +/// and SwiftUI's NSItemProvider bridge doesn't always surface custom +/// UTTypes there reliably. +private func sessionDragItemProvider(for entry: SessionEntry) -> NSItemProvider { + let dragId = SessionDragRegistry.shared.register(entry) + let provider = NSItemProvider() + + if let data = sessionTabTransferData(for: entry, dragId: dragId) { + provider.registerDataRepresentation( + forTypeIdentifier: "com.splittabbar.tabtransfer", + visibility: .ownProcess + ) { completion in + completion(data, nil) + return nil + } + DispatchQueue.main.async { + let pb = NSPasteboard(name: .drag) + let type = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer") + pb.addTypes([type], owner: nil) + pb.setData(data, forType: type) + } + } + + provider.suggestedName = entry.displayTitle + return provider +} + +// MARK: - NSPopover host + +/// Hosts SectionPopoverView in a real NSPopover. SwiftUI's native `.popover()` +/// doesn't reliably let the embedded TextField become first responder in cmux's +/// focus-managed environment — the terminal keeps grabbing focus back. +private struct SectionPopoverHost: NSViewRepresentable { + @Binding var isPresented: Bool + let section: IndexSection + let store: SessionIndexStore + let onResume: ((SessionEntry) -> Void)? + + func makeCoordinator() -> Coordinator { Coordinator(isPresented: $isPresented) } + + func makeNSView(context: Context) -> NSView { + let view = NSView() + view.translatesAutoresizingMaskIntoConstraints = false + context.coordinator.anchorView = view + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + let coordinator = context.coordinator + coordinator.anchorView = nsView + coordinator.update( + section: section, + store: store, + onResume: onResume + ) + if isPresented { + coordinator.present() + } else { + coordinator.dismiss() + } + } + + static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { + coordinator.dismiss() + } + + final class Coordinator: NSObject, NSPopoverDelegate { + @Binding var isPresented: Bool + weak var anchorView: NSView? + + private let hostingController = NSHostingController(rootView: AnyView(EmptyView())) + private var popover: NSPopover? + private var currentSection: IndexSection? + private var currentStore: SessionIndexStore? + private var currentOnResume: ((SessionEntry) -> Void)? + /// Bumped on every present(). Used as the SwiftUI view identity so each + /// open gets fresh @State (empty query, fresh focus, no stale results). + private var presentationCount = 0 + + init(isPresented: Binding) { + _isPresented = isPresented + } + + func update(section: IndexSection, store: SessionIndexStore, onResume: ((SessionEntry) -> Void)?) { + currentSection = section + currentStore = store + currentOnResume = onResume + refreshContent() + } + + private func refreshContent() { + guard let section = currentSection, let store = currentStore else { return } + let onResume = currentOnResume + let identity = presentationCount + hostingController.rootView = AnyView( + SectionPopoverView(section: section, store: store, onResume: onResume) { [weak self] in + self?.closeFromContent() + } + // Tied to presentationCount so reopening the popover discards + // the prior open's @State (typed query, scrolled position, etc.). + .id(identity) + ) + hostingController.view.invalidateIntrinsicContentSize() + hostingController.view.layoutSubtreeIfNeeded() + updateContentSize() + } + + func present() { + guard let anchorView, anchorView.window != nil else { + isPresented = false + return + } + anchorView.superview?.layoutSubtreeIfNeeded() + let popover = popover ?? makePopover() + // Only bump identity on a hidden→shown transition. Bumping on every + // updateNSView (which fires on parent re-renders, e.g. ObservedObject + // store changes) would reset SectionPopoverView's @State on every + // tick — typed query gone, loaded reset, looks like infinite loading. + if !popover.isShown { + presentationCount += 1 + refreshContent() + } + updateContentSize() + guard !popover.isShown else { return } + popover.show(relativeTo: anchorView.bounds, of: anchorView, preferredEdge: .maxX) + } + + func dismiss() { + popover?.performClose(nil) + } + + func closeFromContent() { + isPresented = false + dismiss() + } + + func popoverDidClose(_ notification: Notification) { + popover = nil + if isPresented { + isPresented = false + } + } + + private func makePopover() -> NSPopover { + let p = NSPopover() + p.behavior = .transient + p.animates = true + p.contentViewController = hostingController + p.delegate = self + self.popover = p + return p + } + + private func updateContentSize() { + let fitting = hostingController.view.fittingSize + guard fitting.width > 0, fitting.height > 0 else { return } + popover?.contentSize = NSSize( + width: ceil(max(fitting.width, 360)), + height: ceil(min(fitting.height, 480)) + ) + } + } +} + +// MARK: - Escape key catcher + +/// Invisible AppKit view that fires `onEscape` when Escape is pressed while +/// the popover content is key. Lives in the popover's view tree so it inherits +/// the popover's responder chain. +private struct EscapeKeyCatcher: NSViewRepresentable { + let onEscape: () -> Void + + func makeNSView(context: Context) -> NSView { + let view = EscapeMonitorView() + view.onEscape = onEscape + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + (nsView as? EscapeMonitorView)?.onEscape = onEscape + } + + private final class EscapeMonitorView: NSView { + var onEscape: (() -> Void)? + private var monitor: Any? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if let monitor { + NSEvent.removeMonitor(monitor) + self.monitor = nil + } + guard window != nil else { return } + monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self, let win = self.window, win.isKeyWindow else { return event } + if event.keyCode == 53 { // kVK_Escape + self.onEscape?() + return nil + } + return event + } + } + + deinit { + if let monitor { NSEvent.removeMonitor(monitor) } + } + } +} diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index a5f066b37893..103ec922dc18 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1891,6 +1891,7 @@ class TabManager: ObservableObject { portOrdinal: Int, configTemplate: CmuxSurfaceConfigTemplate?, initialTerminalCommand: String?, + initialTerminalInput: String? = nil, initialTerminalEnvironment: [String: String] ) -> Workspace { Workspace( @@ -1899,6 +1900,7 @@ class TabManager: ObservableObject { portOrdinal: portOrdinal, configTemplate: configTemplate, initialTerminalCommand: initialTerminalCommand, + initialTerminalInput: initialTerminalInput, initialTerminalEnvironment: initialTerminalEnvironment ) } @@ -1950,6 +1952,7 @@ class TabManager: ObservableObject { title: String? = nil, workingDirectory overrideWorkingDirectory: String? = nil, initialTerminalCommand: String? = nil, + initialTerminalInput: String? = nil, initialTerminalEnvironment: [String: String] = [:], select: Bool = true, eagerLoadTerminal: Bool = false, @@ -1997,6 +2000,7 @@ class TabManager: ObservableObject { portOrdinal: ordinal, configTemplate: inheritedConfig, initialTerminalCommand: initialTerminalCommand, + initialTerminalInput: initialTerminalInput, initialTerminalEnvironment: initialTerminalEnvironment ) applyCreationChromeInheritance( diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index d5250f6782d3..48576da7e09e 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -6862,6 +6862,7 @@ final class Workspace: Identifiable, ObservableObject { portOrdinal: Int = 0, configTemplate: CmuxSurfaceConfigTemplate? = nil, initialTerminalCommand: String? = nil, + initialTerminalInput: String? = nil, initialTerminalEnvironment: [String: String] = [:] ) { self.id = UUID() @@ -6912,6 +6913,7 @@ final class Workspace: Identifiable, ObservableObject { workingDirectory: hasWorkingDirectory ? trimmedWorkingDirectory : nil, portOrdinal: portOrdinal, initialCommand: initialTerminalCommand, + initialInput: initialTerminalInput, initialEnvironmentOverrides: initialTerminalEnvironment ) configureTerminalPanel(terminalPanel) @@ -8938,6 +8940,7 @@ final class Workspace: Identifiable, ObservableObject { inPane paneId: PaneID, focus: Bool? = nil, workingDirectory: String? = nil, + initialInput: String? = nil, startupEnvironment: [String: String] = [:] ) -> TerminalPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) @@ -8955,6 +8958,7 @@ final class Workspace: Identifiable, ObservableObject { workingDirectory: workingDirectory, portOrdinal: portOrdinal, initialCommand: remoteTerminalStartupCommand, + initialInput: initialInput, additionalEnvironment: startupEnvironment ) configureTerminalPanel(newPanel) @@ -11131,7 +11135,94 @@ final class Workspace: Identifiable, ObservableObject { } } + private func handleSessionDrop( + entry: SessionEntry, + destination: BonsplitController.ExternalTabDropRequest.Destination + ) -> Bool { + let inputWithReturn = entry.resumeCommand + "\n" + switch destination { + case .insert(let paneId, _): + let panel = newTerminalSurface( + inPane: paneId, + focus: true, + workingDirectory: entry.cwd, + initialInput: inputWithReturn + ) + return panel != nil + case .split(let paneId, let orientation, let insertFirst): + let panel = splitPaneWithNewTerminal( + targetPane: paneId, + orientation: orientation, + insertFirst: insertFirst, + workingDirectory: entry.cwd, + initialInput: inputWithReturn + ) + return panel != nil + } + } + + /// Split `paneId` and place a brand-new terminal in the resulting pane. + /// Used by the session-index drop path; mirrors `newTerminalSplit(from:...)` but + /// targets a destination pane directly rather than inheriting from a source panel. + @discardableResult + func splitPaneWithNewTerminal( + targetPane paneId: PaneID, + orientation: SplitOrientation, + insertFirst: Bool, + workingDirectory: String?, + initialInput: String? + ) -> TerminalPanel? { + let inheritedConfig = inheritedTerminalConfig(inPane: paneId) + + let newPanel = TerminalPanel( + workspaceId: id, + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: inheritedConfig, + workingDirectory: workingDirectory, + portOrdinal: portOrdinal, + initialInput: initialInput + ) + configureTerminalPanel(newPanel) + panels[newPanel.id] = newPanel + panelTitles[newPanel.id] = newPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) + + let newTab = Bonsplit.Tab( + title: newPanel.displayTitle, + icon: newPanel.displayIcon, + kind: SurfaceKind.terminal, + isDirty: newPanel.isDirty, + isPinned: false + ) + surfaceIdToPanelId[newTab.id] = newPanel.id + + isProgrammaticSplit = true + defer { isProgrammaticSplit = false } + guard bonsplitController.splitPane( + paneId, + orientation: orientation, + withTab: newTab, + insertFirst: insertFirst + ) != nil else { + panels.removeValue(forKey: newPanel.id) + panelTitles.removeValue(forKey: newPanel.id) + surfaceIdToPanelId.removeValue(forKey: newTab.id) + terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) + return nil + } + + bonsplitController.selectTab(newTab.id) + newPanel.focus() + return newPanel + } + private func handleExternalTabDrop(_ request: BonsplitController.ExternalTabDropRequest) -> Bool { + // Session-index drag → spawn a brand new terminal at the destination instead + // of moving an existing tab. + if let entry = SessionDragRegistry.shared.consume(id: request.tabId.uuid) { + return handleSessionDrop(entry: entry, destination: request.destination) + } + guard let app = AppDelegate.shared else { return false } #if DEBUG let dropStart = ProcessInfo.processInfo.systemUptime diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index da365d54b845..ce8c4c9c84f9 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -1553,6 +1553,7 @@ final class WorkspaceCreationPlacementTests: XCTestCase { portOrdinal: Int, configTemplate: CmuxSurfaceConfigTemplate?, initialTerminalCommand: String?, + initialTerminalInput: String?, initialTerminalEnvironment: [String: String] ) -> Workspace { beforeCreateWorkspace?() @@ -1562,6 +1563,7 @@ final class WorkspaceCreationPlacementTests: XCTestCase { portOrdinal: portOrdinal, configTemplate: configTemplate, initialTerminalCommand: initialTerminalCommand, + initialTerminalInput: initialTerminalInput, initialTerminalEnvironment: initialTerminalEnvironment ) } @@ -1812,6 +1814,7 @@ final class WorkspaceCreationConfigSanitizationTests: XCTestCase { portOrdinal: Int, configTemplate: CmuxSurfaceConfigTemplate?, initialTerminalCommand: String?, + initialTerminalInput: String?, initialTerminalEnvironment: [String: String] ) -> Workspace { capturedConfigTemplate = configTemplate @@ -1821,6 +1824,7 @@ final class WorkspaceCreationConfigSanitizationTests: XCTestCase { portOrdinal: portOrdinal, configTemplate: configTemplate, initialTerminalCommand: initialTerminalCommand, + initialTerminalInput: initialTerminalInput, initialTerminalEnvironment: initialTerminalEnvironment ) }