diff --git a/Deckard.xcodeproj/project.pbxproj b/Deckard.xcodeproj/project.pbxproj index 0b89a08..7ab24da 100644 --- a/Deckard.xcodeproj/project.pbxproj +++ b/Deckard.xcodeproj/project.pbxproj @@ -21,7 +21,7 @@ 432752F9187D2E815F617B96 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88AA91CDBC6DDE958543FB29 /* main.swift */; }; 63A33F81DE38406C15D1259A /* SettingsWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD37F7C8CC5243DCF0A1346E /* SettingsWindow.swift */; }; TC100001TC100001TC100001 /* ThemeCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = TC100002TC100002TC100002 /* ThemeCardView.swift */; }; - 70265FBBA52FB6E60AE4EFFE /* ProjectPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE07F0C0F9D62925EF195F7 /* ProjectPicker.swift */; }; + 70265FBBA52FB6E60AE4EFFE /* WorkspacePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE07F0C0F9D62925EF195F7 /* WorkspacePicker.swift */; }; 7E500DC8ECF3F7D073A098A4 /* ControlSocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08100E22096E7498D571A20 /* ControlSocket.swift */; }; A0FF9B9E99BC8B33FDCAB7E5 /* HookHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2B375A023FEBCB272DCFD7 /* HookHandler.swift */; }; @@ -54,8 +54,8 @@ AA1C0001AA1C0001AA1C0001 /* DeckardHooksInstallerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1C0002AA1C0002AA1C0002 /* DeckardHooksInstallerTests.swift */; }; AA1D0001AA1D0001AA1D0001 /* CrashReporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1D0002AA1D0002AA1D0002 /* CrashReporterTests.swift */; }; AA1E0001AA1E0001AA1E0001 /* ControlSocketTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1E0002AA1E0002AA1E0002 /* ControlSocketTests.swift */; }; - AA1F0001AA1F0001AA1F0001 /* SidebarFolderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1F0002AA1F0002AA1F0002 /* SidebarFolderTests.swift */; }; - AA200001AA200001AA200001 /* SidebarFolderViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA200002AA200002AA200002 /* SidebarFolderViewTests.swift */; }; + AA1F0001AA1F0001AA1F0001 /* SidebarGroupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1F0002AA1F0002AA1F0002 /* SidebarGroupTests.swift */; }; + AA200001AA200001AA200001 /* SidebarGroupViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA200002AA200002AA200002 /* SidebarGroupViewTests.swift */; }; QA200002QA200002QA200002 /* QuotaMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = QA200001QA200001QA200001 /* QuotaMonitor.swift */; }; QA200004QA200004QA200004 /* QuotaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = QA200003QA200003QA200003 /* QuotaView.swift */; }; QA200006QA200006QA200006 /* QuotaMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = QA200005QA200005QA200005 /* QuotaMonitorTests.swift */; }; @@ -66,13 +66,14 @@ CF000001CF000001CF000001 /* ClaudeCLIFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF000002CF000002CF000002 /* ClaudeCLIFlags.swift */; }; CAF00001CAF00001CAF00001 /* ClaudeArgsField.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF00002CAF00002CAF00002 /* ClaudeArgsField.swift */; }; CF000003CF000003CF000003 /* ClaudeCLIFlagsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF000004CF000004CF000004 /* ClaudeCLIFlagsTests.swift */; }; + 5C000001500000015C000001 /* ShortcutMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C000002500000025C000002 /* ShortcutMigrationTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 4A2B375A023FEBCB272DCFD7 /* HookHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HookHandler.swift; sourceTree = ""; }; - 4BE07F0C0F9D62925EF195F7 /* ProjectPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectPicker.swift; sourceTree = ""; }; + 4BE07F0C0F9D62925EF195F7 /* WorkspacePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePicker.swift; sourceTree = ""; }; 6C2CC70A6932271D0EE46154 /* Deckard-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Deckard-Bridging-Header.h"; sourceTree = ""; }; 7BE2AA0719D32ACCD1549184 /* SessionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionState.swift; sourceTree = ""; }; 7F505D5B7348F40C2CBD6F83 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -116,8 +117,8 @@ AA1C0002AA1C0002AA1C0002 /* DeckardHooksInstallerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeckardHooksInstallerTests.swift; sourceTree = ""; }; AA1D0002AA1D0002AA1D0002 /* CrashReporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashReporterTests.swift; sourceTree = ""; }; AA1E0002AA1E0002AA1E0002 /* ControlSocketTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlSocketTests.swift; sourceTree = ""; }; - AA1F0002AA1F0002AA1F0002 /* SidebarFolderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarFolderTests.swift; sourceTree = ""; }; - AA200002AA200002AA200002 /* SidebarFolderViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarFolderViewTests.swift; sourceTree = ""; }; + AA1F0002AA1F0002AA1F0002 /* SidebarGroupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarGroupTests.swift; sourceTree = ""; }; + AA200002AA200002AA200002 /* SidebarGroupViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarGroupViewTests.swift; sourceTree = ""; }; QA200001QA200001QA200001 /* QuotaMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotaMonitor.swift; sourceTree = ""; }; QA200003QA200003QA200003 /* QuotaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotaView.swift; sourceTree = ""; }; QA200005QA200005QA200005 /* QuotaMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotaMonitorTests.swift; sourceTree = ""; }; @@ -127,6 +128,7 @@ SE00000ASE00000ASE00000A /* SessionExplorerTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionExplorerTimelineView.swift; sourceTree = ""; }; CF000002CF000002CF000002 /* ClaudeCLIFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeCLIFlags.swift; sourceTree = ""; }; CF000004CF000004CF000004 /* ClaudeCLIFlagsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeCLIFlagsTests.swift; sourceTree = ""; }; + 5C000002500000025C000002 /* ShortcutMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutMigrationTests.swift; sourceTree = ""; }; CAF00002CAF00002CAF00002 /* ClaudeArgsField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeArgsField.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -214,7 +216,7 @@ QA200003QA200003QA200003 /* QuotaView.swift */, SV000002SV000002SV000002 /* SidebarViews.swift */, TV000002TV000002TV000002 /* TabBarViews.swift */, - 4BE07F0C0F9D62925EF195F7 /* ProjectPicker.swift */, + 4BE07F0C0F9D62925EF195F7 /* WorkspacePicker.swift */, DD37F7C8CC5243DCF0A1346E /* SettingsWindow.swift */, TC100002TC100002TC100002 /* ThemeCardView.swift */, CAF00002CAF00002CAF00002 /* ClaudeArgsField.swift */, @@ -280,8 +282,8 @@ AA180002AA180002AA180002 /* ProcessMonitorTests.swift */, QA200005QA200005QA200005 /* QuotaMonitorTests.swift */, AA130002AA130002AA130002 /* SessionStateTests.swift */, - AA1F0002AA1F0002AA1F0002 /* SidebarFolderTests.swift */, - AA200002AA200002AA200002 /* SidebarFolderViewTests.swift */, + AA1F0002AA1F0002AA1F0002 /* SidebarGroupTests.swift */, + AA200002AA200002AA200002 /* SidebarGroupViewTests.swift */, TE000001TE000001TE000001 /* SmokeTests.swift */, AA120002AA120002AA120002 /* TerminalColorSchemeTests.swift */, AA1A0002AA1A0002AA1A0002 /* TerminalSurfaceTests.swift */, @@ -289,6 +291,7 @@ AA140002AA140002AA140002 /* ThemeManagerTests.swift */, AA1B0002AA1B0002AA1B0002 /* WindowControllerLogicTests.swift */, CF000004CF000004CF000004 /* ClaudeCLIFlagsTests.swift */, + 5C000002500000025C000002 /* ShortcutMigrationTests.swift */, ); path = Tests; sourceTree = ""; @@ -426,7 +429,7 @@ SV000001SV000001SV000001 /* SidebarViews.swift in Sources */, TV000001TV000001TV000001 /* TabBarViews.swift in Sources */, A0FF9B9E99BC8B33FDCAB7E5 /* HookHandler.swift in Sources */, - 70265FBBA52FB6E60AE4EFFE /* ProjectPicker.swift in Sources */, + 70265FBBA52FB6E60AE4EFFE /* WorkspacePicker.swift in Sources */, 2D322E22D0679FD39D688539 /* SessionState.swift in Sources */, 63A33F81DE38406C15D1259A /* SettingsWindow.swift in Sources */, TC100001TC100001TC100001 /* ThemeCardView.swift in Sources */, @@ -471,10 +474,11 @@ AA1C0001AA1C0001AA1C0001 /* DeckardHooksInstallerTests.swift in Sources */, AA1D0001AA1D0001AA1D0001 /* CrashReporterTests.swift in Sources */, AA1E0001AA1E0001AA1E0001 /* ControlSocketTests.swift in Sources */, - AA1F0001AA1F0001AA1F0001 /* SidebarFolderTests.swift in Sources */, - AA200001AA200001AA200001 /* SidebarFolderViewTests.swift in Sources */, + AA1F0001AA1F0001AA1F0001 /* SidebarGroupTests.swift in Sources */, + AA200001AA200001AA200001 /* SidebarGroupViewTests.swift in Sources */, QA200006QA200006QA200006 /* QuotaMonitorTests.swift in Sources */, CF000003CF000003CF000003 /* ClaudeCLIFlagsTests.swift in Sources */, + 5C000001500000015C000001 /* ShortcutMigrationTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index c5f8923..e32d9ab 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -31,6 +31,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { ThemeManager.shared.applySavedTheme() log.log("startup", "Loaded \(ThemeManager.shared.availableThemes.count) themes, current: \(ThemeManager.shared.currentThemeName ?? "default")") + // Migrate any user shortcut overrides from old identifier names before + // anything reads from KeyboardShortcuts. + DeckardShortcutMigration.migrate() + // Set up the main menu. log.log("startup", "Setting up main menu...") setupMainMenu() @@ -72,7 +76,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { if TerminalSurface.tmuxAvailable { let savedState = SessionManager.shared.load() let activeSessions = Set( - (savedState?.projects ?? []).flatMap(\.tabs).compactMap(\.tmuxSessionName) + (savedState?.workspaces ?? []).flatMap(\.tabs).compactMap(\.tmuxSessionName) ) DispatchQueue.global(qos: .utility).async { TerminalSurface.cleanupOrphanedTmuxSessions(activeSessions: activeSessions) @@ -152,11 +156,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { } @objc private func handleNewTab() { - windowController?.addTabToCurrentProject(kind: .claude) + windowController?.addTabToCurrentWorkspace(kind: .claude) } @objc private func handleNewCodexTab() { - windowController?.addTabToCurrentProject(kind: .codex) + windowController?.addTabToCurrentWorkspace(kind: .codex) } @objc private func handleCloseTab() { @@ -192,8 +196,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { let fileMenuItem = NSMenuItem() let fileMenu = NSMenu(title: "File") - let openItem = NSMenuItem(title: "Open Folder...", action: #selector(openProject), keyEquivalent: "") - openItem.setShortcut(for: .openFolder) + let openItem = NSMenuItem(title: "Open Workspace...", action: #selector(openWorkspace), keyEquivalent: "") + openItem.setShortcut(for: .openWorkspace) fileMenu.addItem(openItem) fileMenu.addItem(.separator()) @@ -215,13 +219,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { closeItem.setShortcut(for: .closeTab) fileMenu.addItem(closeItem) - let newFolderItem = NSMenuItem(title: "New Sidebar Folder", action: #selector(createNewSidebarFolder), keyEquivalent: "") - newFolderItem.setShortcut(for: .newSidebarFolder) - newFolderItem.target = self - fileMenu.addItem(newFolderItem) + let newGroupItem = NSMenuItem(title: "New Group", action: #selector(createNewSidebarGroup), keyEquivalent: "") + newGroupItem.setShortcut(for: .newGroup) + newGroupItem.target = self + fileMenu.addItem(newGroupItem) - let moveOutItem = NSMenuItem(title: "Move Out of Folder", action: #selector(moveCurrentProjectOutOfFolder), keyEquivalent: "") - moveOutItem.setShortcut(for: .moveOutOfFolder) + let moveOutItem = NSMenuItem(title: "Move Out of Group", action: #selector(moveCurrentWorkspaceOutOfGroup), keyEquivalent: "") + moveOutItem.setShortcut(for: .moveOutOfGroup) moveOutItem.target = self fileMenu.addItem(moveOutItem) @@ -229,9 +233,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { exploreSessionsItem.setShortcut(for: .exploreSessions) fileMenu.addItem(exploreSessionsItem) - let closeProjectItem = NSMenuItem(title: "Close Folder", action: #selector(closeCurrentProject), keyEquivalent: "") - closeProjectItem.setShortcut(for: .closeFolder) - fileMenu.addItem(closeProjectItem) + let closeWorkspaceItem = NSMenuItem(title: "Close Workspace", action: #selector(closeCurrentWorkspace), keyEquivalent: "") + closeWorkspaceItem.setShortcut(for: .closeWorkspace) + fileMenu.addItem(closeWorkspaceItem) fileMenu.addItem(.separator()) let nextTabItem = NSMenuItem(title: "Next Tab", action: #selector(selectNextTab), keyEquivalent: "") @@ -242,19 +246,19 @@ class AppDelegate: NSObject, NSApplicationDelegate { prevTabItem.setShortcut(for: .previousTab) fileMenu.addItem(prevTabItem) - let nextProjectItem = NSMenuItem(title: "Next Project", action: #selector(selectNextProject), keyEquivalent: "") - nextProjectItem.setShortcut(for: .nextProject) - fileMenu.addItem(nextProjectItem) + let nextWorkspaceItem = NSMenuItem(title: "Next Workspace", action: #selector(selectNextWorkspace), keyEquivalent: "") + nextWorkspaceItem.setShortcut(for: .nextWorkspace) + fileMenu.addItem(nextWorkspaceItem) - let prevProjectItem = NSMenuItem(title: "Previous Project", action: #selector(selectPrevProject), keyEquivalent: "") - prevProjectItem.setShortcut(for: .previousProject) - fileMenu.addItem(prevProjectItem) + let prevWorkspaceItem = NSMenuItem(title: "Previous Workspace", action: #selector(selectPrevWorkspace), keyEquivalent: "") + prevWorkspaceItem.setShortcut(for: .previousWorkspace) + fileMenu.addItem(prevWorkspaceItem) fileMenu.addItem(.separator()) - // Cmd+1-9, Cmd+0 for direct project access + // Cmd+1-9, Cmd+0 for direct workspace access for i in 0.. = [ "--help", "--version", - // Deckard launches Codex in the project directory already; suggesting - // --cd as a persistent default would make tabs ignore their project root. + // Deckard launches Codex in the workspace directory already; suggesting + // --cd as a persistent default would make tabs ignore their workspace root. "--cd", ] diff --git a/Sources/App/ShortcutNames.swift b/Sources/App/ShortcutNames.swift index 02d607a..fb995ea 100644 --- a/Sources/App/ShortcutNames.swift +++ b/Sources/App/ShortcutNames.swift @@ -2,20 +2,20 @@ import AppKit import KeyboardShortcuts extension KeyboardShortcuts.Name { - static let openFolder = Self("openFolder", default: .init(.o, modifiers: .command)) + static let openWorkspace = Self("openWorkspace", default: .init(.o, modifiers: .command)) static let newClaudeTab = Self("newClaudeTab", default: .init(.t, modifiers: .command)) static let newCodexTab = Self("newCodexTab", default: .init(.t, modifiers: [.command, .option])) static let newTerminalTab = Self("newTerminalTab", default: .init(.t, modifiers: [.command, .shift])) static let closeTab = Self("closeTab", default: .init(.w, modifiers: .command)) - static let closeFolder = Self("closeFolder", default: .init(.w, modifiers: [.command, .shift])) + static let closeWorkspace = Self("closeWorkspace", default: .init(.w, modifiers: [.command, .shift])) static let nextTab = Self("nextTab", default: .init(.rightBracket, modifiers: [.command, .shift])) static let previousTab = Self("previousTab", default: .init(.leftBracket, modifiers: [.command, .shift])) - static let nextProject = Self("nextProject", default: .init(.rightBracket, modifiers: [.command, .option])) - static let previousProject = Self("previousProject", default: .init(.leftBracket, modifiers: [.command, .option])) + static let nextWorkspace = Self("nextWorkspace", default: .init(.rightBracket, modifiers: [.command, .option])) + static let previousWorkspace = Self("previousWorkspace", default: .init(.leftBracket, modifiers: [.command, .option])) static let toggleSidebar = Self("toggleSidebar", default: .init(.s, modifiers: [.command, .control])) static let exploreSessions = Self("exploreSessions", default: .init(.e, modifiers: [.command, .shift])) - static let newSidebarFolder = Self("newSidebarFolder", default: .init(.n, modifiers: [.command, .option])) - static let moveOutOfFolder = Self("moveOutOfFolder", default: .init(.u, modifiers: [.command, .option])) + static let newGroup = Self("newGroup", default: .init(.n, modifiers: [.command, .option])) + static let moveOutOfGroup = Self("moveOutOfGroup", default: .init(.u, modifiers: [.command, .option])) static let settings = Self("settings", default: .init(.comma, modifiers: .command)) static let tab1 = Self("tab1", default: .init(.one, modifiers: .command)) static let tab2 = Self("tab2", default: .init(.two, modifiers: .command)) @@ -36,37 +36,73 @@ struct ShortcutEntry { } let configurableShortcuts: [ShortcutEntry] = [ - ShortcutEntry(name: .openFolder, label: "Open Folder"), + ShortcutEntry(name: .openWorkspace, label: "Open Workspace"), ShortcutEntry(name: .newClaudeTab, label: "New Claude Tab"), ShortcutEntry(name: .newCodexTab, label: "New Codex Tab"), ShortcutEntry(name: .newTerminalTab, label: "New Terminal Tab"), ShortcutEntry(name: .closeTab, label: "Close Tab"), - ShortcutEntry(name: .closeFolder, label: "Close Folder"), + ShortcutEntry(name: .closeWorkspace, label: "Close Workspace"), ShortcutEntry(name: .nextTab, label: "Next Tab"), ShortcutEntry(name: .previousTab, label: "Previous Tab"), - ShortcutEntry(name: .nextProject, label: "Next Project"), - ShortcutEntry(name: .previousProject, label: "Previous Project"), + ShortcutEntry(name: .nextWorkspace, label: "Next Workspace"), + ShortcutEntry(name: .previousWorkspace, label: "Previous Workspace"), ShortcutEntry(name: .toggleSidebar, label: "Toggle Sidebar"), ShortcutEntry(name: .exploreSessions, label: "Explore Sessions"), - ShortcutEntry(name: .newSidebarFolder, label: "New Sidebar Folder"), - ShortcutEntry(name: .moveOutOfFolder, label: "Move Out of Folder"), + ShortcutEntry(name: .newGroup, label: "New Group"), + ShortcutEntry(name: .moveOutOfGroup, label: "Move Out of Group"), ShortcutEntry(name: .settings, label: "Settings"), - ShortcutEntry(name: .tab1, label: "Project 1"), - ShortcutEntry(name: .tab2, label: "Project 2"), - ShortcutEntry(name: .tab3, label: "Project 3"), - ShortcutEntry(name: .tab4, label: "Project 4"), - ShortcutEntry(name: .tab5, label: "Project 5"), - ShortcutEntry(name: .tab6, label: "Project 6"), - ShortcutEntry(name: .tab7, label: "Project 7"), - ShortcutEntry(name: .tab8, label: "Project 8"), - ShortcutEntry(name: .tab9, label: "Project 9"), - ShortcutEntry(name: .tab0, label: "Project 10"), + ShortcutEntry(name: .tab1, label: "Workspace 1"), + ShortcutEntry(name: .tab2, label: "Workspace 2"), + ShortcutEntry(name: .tab3, label: "Workspace 3"), + ShortcutEntry(name: .tab4, label: "Workspace 4"), + ShortcutEntry(name: .tab5, label: "Workspace 5"), + ShortcutEntry(name: .tab6, label: "Workspace 6"), + ShortcutEntry(name: .tab7, label: "Workspace 7"), + ShortcutEntry(name: .tab8, label: "Workspace 8"), + ShortcutEntry(name: .tab9, label: "Workspace 9"), + ShortcutEntry(name: .tab0, label: "Workspace 10"), ] let tabShortcutNames: [KeyboardShortcuts.Name] = [ .tab1, .tab2, .tab3, .tab4, .tab5, .tab6, .tab7, .tab8, .tab9, .tab0, ] +/// One-shot migration that copies user shortcut overrides from old identifier +/// names to new ones, then deletes the old keys. Guarded by a UserDefaults +/// flag so it only runs once. KeyboardShortcuts persists each override under +/// `KeyboardShortcuts_` (see KeyboardShortcuts.swift in the upstream). +enum DeckardShortcutMigration { + static let migrationFlagKey = "shortcutsMigratedToWorkspaceAndGroupNames" + + /// Old identifier → new identifier renames covering both the folder→group + /// and project→workspace renames in the folder/project terminology refactor. + static let renames: [(oldName: String, newName: String)] = [ + ("newSidebarFolder", "newGroup"), + ("moveOutOfFolder", "moveOutOfGroup"), + ("openFolder", "openWorkspace"), + ("closeFolder", "closeWorkspace"), + ("nextProject", "nextWorkspace"), + ("previousProject", "previousWorkspace"), + ] + + static func migrate(defaults: UserDefaults = .standard) { + guard !defaults.bool(forKey: migrationFlagKey) else { return } + for (oldName, newName) in renames { + let oldKey = "KeyboardShortcuts_\(oldName)" + let newKey = "KeyboardShortcuts_\(newName)" + // Only carry over if the user actually set an override on the old name + // and hasn't already set one on the new name. + guard defaults.object(forKey: oldKey) != nil else { continue } + if defaults.object(forKey: newKey) == nil, + let value = defaults.object(forKey: oldKey) { + defaults.set(value, forKey: newKey) + } + defaults.removeObject(forKey: oldKey) + } + defaults.set(true, forKey: migrationFlagKey) + } +} + enum DeckardShortcutPolicy { static func disableGlobalHotKeys() { KeyboardShortcuts.disable(configurableShortcuts.map(\.name)) diff --git a/Sources/Detection/ContextMonitor.swift b/Sources/Detection/ContextMonitor.swift index 95efd0b..07e30da 100644 --- a/Sources/Detection/ContextMonitor.swift +++ b/Sources/Detection/ContextMonitor.swift @@ -93,9 +93,9 @@ class ContextMonitor { } } - /// Lists all Claude sessions for a project, sorted by most recent first. - func listSessions(forProjectPath projectPath: String) -> [SessionInfo] { - let encoded = projectPath.claudeProjectDirName + /// Lists all Claude sessions for a workspace, sorted by most recent first. + func listSessions(forWorkspacePath workspacePath: String) -> [SessionInfo] { + let encoded = workspacePath.claudeProjectDirName let dir = NSHomeDirectory() + "/.claude/projects/\(encoded)" let fm = FileManager.default @@ -153,25 +153,25 @@ class ContextMonitor { return results } - /// Lists Claude and Codex sessions for a project, sorted by most recent first. - func listAllSessions(forProjectPath projectPath: String) -> [SessionInfo] { - (listSessions(forProjectPath: projectPath) + listCodexSessions(forProjectPath: projectPath)) + /// Lists Claude and Codex sessions for a workspace, sorted by most recent first. + func listAllSessions(forWorkspacePath workspacePath: String) -> [SessionInfo] { + (listSessions(forWorkspacePath: workspacePath) + listCodexSessions(forWorkspacePath: workspacePath)) .sorted { $0.modificationDate > $1.modificationDate } } - /// Lists Codex sessions for a project by scanning ~/.codex/sessions. - func listCodexSessions(forProjectPath projectPath: String) -> [SessionInfo] { + /// Lists Codex sessions for a workspace by scanning ~/.codex/sessions. + func listCodexSessions(forWorkspacePath workspacePath: String) -> [SessionInfo] { let root = codexSessionsRoot let fm = FileManager.default guard let enumerator = fm.enumerator(at: root, includingPropertiesForKeys: [.contentModificationDateKey], options: [.skipsHiddenFiles]) else { return [] } - let resolvedProjectPath = (projectPath as NSString).resolvingSymlinksInPath + let resolvedWorkspacePath = (workspacePath as NSString).resolvingSymlinksInPath var results: [SessionInfo] = [] for case let fileURL as URL in enumerator where fileURL.pathExtension == "jsonl" { - guard let info = parseCodexSessionInfo(fileURL: fileURL, projectPath: resolvedProjectPath) else { continue } + guard let info = parseCodexSessionInfo(fileURL: fileURL, workspacePath: resolvedWorkspacePath) else { continue } results.append(info) } @@ -199,13 +199,13 @@ class ContextMonitor { return nil } - func codexSessionInfo(openedByProcessId processId: pid_t, projectPath: String) -> SessionInfo? { + func codexSessionInfo(openedByProcessId processId: pid_t, workspacePath: String) -> SessionInfo? { guard let fileURL = codexOpenRolloutFileURL(processId: processId) else { return nil } - let resolvedProjectPath = (projectPath as NSString).resolvingSymlinksInPath - return parseCodexSessionInfo(fileURL: fileURL, projectPath: resolvedProjectPath) + let resolvedWorkspacePath = (workspacePath as NSString).resolvingSymlinksInPath + return parseCodexSessionInfo(fileURL: fileURL, workspacePath: resolvedWorkspacePath) } private func codexOpenRolloutFileURL(processId: pid_t) -> URL? { @@ -249,8 +249,8 @@ class ContextMonitor { } } - func latestCodexSession(forProjectPath projectPath: String, after date: Date, excluding excludedIds: Set = []) -> SessionInfo? { - listCodexSessions(forProjectPath: projectPath) + func latestCodexSession(forWorkspacePath workspacePath: String, after date: Date, excluding excludedIds: Set = []) -> SessionInfo? { + listCodexSessions(forWorkspacePath: workspacePath) .first { $0.modificationDate >= date && !excludedIds.contains($0.sessionId) } } @@ -603,7 +603,7 @@ class ContextMonitor { return nil } - private func parseCodexSessionInfo(fileURL: URL, projectPath: String) -> SessionInfo? { + private func parseCodexSessionInfo(fileURL: URL, workspacePath: String) -> SessionInfo? { guard let data = try? Data(contentsOf: fileURL), let content = String(data: data, encoding: .utf8) else { return nil } @@ -644,7 +644,7 @@ class ContextMonitor { } } - guard let sessionId, cwd == projectPath else { return nil } + guard let sessionId, cwd == workspacePath else { return nil } let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path) let modDate = attrs?[.modificationDate] as? Date ?? metaTimestamp ?? Date.distantPast @@ -757,12 +757,12 @@ class ContextMonitor { /// Parses a session JSONL file and returns an ordered list of user turns. /// Deduplicates by promptId — only the first occurrence with non-empty content is kept. - func parseTimeline(sessionId: String, projectPath: String, kind: TabKind = .claude) -> [TimelineEntry] { + func parseTimeline(sessionId: String, workspacePath: String, kind: TabKind = .claude) -> [TimelineEntry] { if kind == .codex { - return parseCodexTimeline(sessionId: sessionId, projectPath: projectPath) + return parseCodexTimeline(sessionId: sessionId, workspacePath: workspacePath) } - let encoded = projectPath.claudeProjectDirName + let encoded = workspacePath.claudeProjectDirName let jsonlPath = NSHomeDirectory() + "/.claude/projects/\(encoded)/\(sessionId).jsonl" guard let data = try? Data(contentsOf: URL(fileURLWithPath: jsonlPath)), @@ -814,7 +814,7 @@ class ContextMonitor { return entries } - private func parseCodexTimeline(sessionId: String, projectPath: String) -> [TimelineEntry] { + private func parseCodexTimeline(sessionId: String, workspacePath: String) -> [TimelineEntry] { guard let fileURL = codexSessionFileURL(sessionId: sessionId), let data = try? Data(contentsOf: fileURL), let content = String(data: data, encoding: .utf8) else { return [] } @@ -847,20 +847,20 @@ class ContextMonitor { /// Creates a truncated copy of a session JSONL, keeping everything up to (and including /// the full response for) the Nth unique user turn. Returns the new session ID. - func truncateSession(sessionId: String, projectPath: String, afterTurnIndex: Int, kind: TabKind = .claude) -> String? { + func truncateSession(sessionId: String, workspacePath: String, afterTurnIndex: Int, kind: TabKind = .claude) -> String? { switch kind { case .claude: - return truncateClaudeSession(sessionId: sessionId, projectPath: projectPath, afterTurnIndex: afterTurnIndex) + return truncateClaudeSession(sessionId: sessionId, workspacePath: workspacePath, afterTurnIndex: afterTurnIndex) case .codex: - return truncateCodexSession(sessionId: sessionId, projectPath: projectPath, afterTurnIndex: afterTurnIndex) + return truncateCodexSession(sessionId: sessionId, workspacePath: workspacePath, afterTurnIndex: afterTurnIndex) case .terminal: return nil } } - private func truncateClaudeSession(sessionId: String, projectPath: String, afterTurnIndex: Int) -> String? { + private func truncateClaudeSession(sessionId: String, workspacePath: String, afterTurnIndex: Int) -> String? { - let encoded = projectPath.claudeProjectDirName + let encoded = workspacePath.claudeProjectDirName let dir = NSHomeDirectory() + "/.claude/projects/\(encoded)" let jsonlPath = dir + "/\(sessionId).jsonl" @@ -917,7 +917,7 @@ class ContextMonitor { } } - private func truncateCodexSession(sessionId: String, projectPath: String, afterTurnIndex: Int) -> String? { + private func truncateCodexSession(sessionId: String, workspacePath: String, afterTurnIndex: Int) -> String? { guard let sourceURL = codexSessionFileURL(sessionId: sessionId), let data = try? Data(contentsOf: sourceURL), let content = String(data: data, encoding: .utf8) else { return nil } @@ -1139,8 +1139,8 @@ class ContextMonitor { /// Get context usage for a session by reading its JSONL file. /// Only reads the tail of the file to find the most recent usage entry. /// Falls back to a cached value when the tail doesn't contain a usage entry. - func getUsage(sessionId: String, projectPath: String) -> ContextUsage? { - let encoded = projectPath.claudeProjectDirName + func getUsage(sessionId: String, workspacePath: String) -> ContextUsage? { + let encoded = workspacePath.claudeProjectDirName let jsonlPath = NSHomeDirectory() + "/.claude/projects/\(encoded)/\(sessionId).jsonl" if let usage = getUsageFromFile(at: jsonlPath) { diff --git a/Sources/Detection/HookHandler.swift b/Sources/Detection/HookHandler.swift index 537bc84..f274504 100644 --- a/Sources/Detection/HookHandler.swift +++ b/Sources/Detection/HookHandler.swift @@ -82,7 +82,7 @@ class HookHandler { case "create-tab": if let dir = message.workingDirectory { - windowController?.openProject(path: dir) + windowController?.openWorkspace(path: dir) } reply(ControlResponse(ok: true)) diff --git a/Sources/Detection/ProcessMonitor.swift b/Sources/Detection/ProcessMonitor.swift index baa20e1..b45b8d7 100644 --- a/Sources/Detection/ProcessMonitor.swift +++ b/Sources/Detection/ProcessMonitor.swift @@ -16,19 +16,19 @@ class ProcessMonitor { let surfaceId: UUID let kind: TabKind let name: String - let projectPath: String + let workspacePath: String var isClaude: Bool { kind == .claude } - init(surfaceId: UUID, kind: TabKind, name: String, projectPath: String) { + init(surfaceId: UUID, kind: TabKind, name: String, workspacePath: String) { self.surfaceId = surfaceId self.kind = kind self.name = name - self.projectPath = projectPath + self.workspacePath = workspacePath } - init(surfaceId: UUID, isClaude: Bool, name: String, projectPath: String) { - self.init(surfaceId: surfaceId, kind: isClaude ? .claude : .terminal, name: name, projectPath: projectPath) + init(surfaceId: UUID, isClaude: Bool, name: String, workspacePath: String) { + self.init(surfaceId: surfaceId, kind: isClaude ? .claude : .terminal, name: name, workspacePath: workspacePath) } } @@ -132,7 +132,7 @@ class ProcessMonitor { let lines = tabs.map { tab -> String in let prefix = tab.kind.rawValue.prefix(1).uppercased() let pid = cachedPids[tab.surfaceId].map { "login=\($0.login) shell=\($0.shell)" } ?? "?" - return " \(prefix):\(tab.name)@\(tab.projectPath) → \(pid)" + return " \(prefix):\(tab.name)@\(tab.workspacePath) → \(pid)" } DiagnosticLog.shared.log("processmon", "PID mapping (\(cachedPids.count)/\(tabs.count) matched):\n" + @@ -196,7 +196,7 @@ class ProcessMonitor { lastDiskBytes[key] = diskBytes let result = ActivityInfo(cpu: true) DiagnosticLog.shared.log("processmon", - "ACTIVE: project=\(tab.projectPath) tab=\"\(tab.name)\" " + + "ACTIVE: workspace=\(tab.workspacePath) tab=\"\(tab.name)\" " + "shell=\(key) fg=\(fgPid) reason=fg_changed") return result } @@ -217,7 +217,7 @@ class ProcessMonitor { if cpuActive { reasons.append("cpu=\(cpuDelta)ns") } if diskActive { reasons.append("disk=+\(diskDelta)B") } DiagnosticLog.shared.log("processmon", - "ACTIVE: project=\(tab.projectPath) tab=\"\(tab.name)\" " + + "ACTIVE: workspace=\(tab.workspacePath) tab=\"\(tab.name)\" " + "shell=\(key) fg=\(fgPid) \(reasons.joined(separator: " "))") } diff --git a/Sources/Detection/QuotaMonitor.swift b/Sources/Detection/QuotaMonitor.swift index b276dc8..b76bf24 100644 --- a/Sources/Detection/QuotaMonitor.swift +++ b/Sources/Detection/QuotaMonitor.swift @@ -114,7 +114,7 @@ class QuotaMonitor { /// Compute tokens-per-minute from the single most recently written session JSONL. /// Only considers the one file most recently modified (the active conversation), /// and only counts output_tokens with timestamps in the last 5 minutes. - func computeTokenRate(projectPaths: [String]) -> TokenRate? { + func computeTokenRate(workspacePaths: [String]) -> TokenRate? { let now = Date() let cutoff = now.addingTimeInterval(-300) // 5 minutes ago let recentCutoff = now.addingTimeInterval(-120) // 2 minutes ago (must be very recent) @@ -123,12 +123,12 @@ class QuotaMonitor { let isoFormatter = ISO8601DateFormatter() isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - // Find the single most recently modified JSONL across all projects + // Find the single most recently modified JSONL across all workspaces var bestFile: String? var bestDate: Date = .distantPast - for projectPath in projectPaths { - let encoded = projectPath.claudeProjectDirName + for workspacePath in workspacePaths { + let encoded = workspacePath.claudeProjectDirName let dir = NSHomeDirectory() + "/.claude/projects/\(encoded)" guard let files = try? fm.contentsOfDirectory(atPath: dir) else { continue } diff --git a/Sources/Session/BookmarkManager.swift b/Sources/Session/BookmarkManager.swift index 35b3792..980d912 100644 --- a/Sources/Session/BookmarkManager.swift +++ b/Sources/Session/BookmarkManager.swift @@ -1,7 +1,7 @@ import Foundation /// Manages session bookmarks persisted to ~/Library/Application Support/Deckard/session-bookmarks.json. -/// Stores a set of bookmarked session IDs per project path. +/// Stores a set of bookmarked session IDs per workspace path. class BookmarkManager { static let shared = BookmarkManager() @@ -12,38 +12,38 @@ class BookmarkManager { return deckardDir.appendingPathComponent("session-bookmarks.json") }() - private var cache: [String: [String]]? // projectKey -> [sessionId] + private var cache: [String: [String]]? // workspaceKey -> [sessionId] - /// Returns all bookmarked session IDs for a project. - func bookmarkedSessionIds(forProjectPath projectPath: String) -> Set { - bookmarkedSessionIds(forProjectPath: projectPath, kind: .claude) + /// Returns all bookmarked session IDs for a workspace. + func bookmarkedSessionIds(forWorkspacePath workspacePath: String) -> Set { + bookmarkedSessionIds(forWorkspacePath: workspacePath, kind: .claude) } - func bookmarkedSessionIds(forProjectPath projectPath: String, kind: TabKind) -> Set { + func bookmarkedSessionIds(forWorkspacePath workspacePath: String, kind: TabKind) -> Set { let all = loadAll() - let key = projectKey(projectPath: projectPath, kind: kind) + let key = workspaceKey(workspacePath: workspacePath, kind: kind) return Set(all[key] ?? []) } /// Checks if a session is bookmarked. - func isBookmarked(projectPath: String, sessionId: String) -> Bool { - isBookmarked(projectPath: projectPath, sessionId: sessionId, kind: .claude) + func isBookmarked(workspacePath: String, sessionId: String) -> Bool { + isBookmarked(workspacePath: workspacePath, sessionId: sessionId, kind: .claude) } - func isBookmarked(projectPath: String, sessionId: String, kind: TabKind) -> Bool { - bookmarkedSessionIds(forProjectPath: projectPath, kind: kind).contains(sessionId) + func isBookmarked(workspacePath: String, sessionId: String, kind: TabKind) -> Bool { + bookmarkedSessionIds(forWorkspacePath: workspacePath, kind: kind).contains(sessionId) } /// Toggles the bookmark state for a session. Returns the new state. @discardableResult - func toggleBookmark(projectPath: String, sessionId: String) -> Bool { - toggleBookmark(projectPath: projectPath, sessionId: sessionId, kind: .claude) + func toggleBookmark(workspacePath: String, sessionId: String) -> Bool { + toggleBookmark(workspacePath: workspacePath, sessionId: sessionId, kind: .claude) } @discardableResult - func toggleBookmark(projectPath: String, sessionId: String, kind: TabKind) -> Bool { + func toggleBookmark(workspacePath: String, sessionId: String, kind: TabKind) -> Bool { var all = loadAll() - let key = projectKey(projectPath: projectPath, kind: kind) + let key = workspaceKey(workspacePath: workspacePath, kind: kind) var ids = all[key] ?? [] if let idx = ids.firstIndex(of: sessionId) { @@ -61,8 +61,8 @@ class BookmarkManager { // MARK: - Private - private func projectKey(projectPath: String, kind: TabKind) -> String { - let encoded = projectPath.claudeProjectDirName + private func workspaceKey(workspacePath: String, kind: TabKind) -> String { + let encoded = workspacePath.claudeProjectDirName return kind == .claude ? encoded : "\(kind.rawValue):\(encoded)" } diff --git a/Sources/Session/SessionExplorerWindowController.swift b/Sources/Session/SessionExplorerWindowController.swift index 3a1b43e..297e211 100644 --- a/Sources/Session/SessionExplorerWindowController.swift +++ b/Sources/Session/SessionExplorerWindowController.swift @@ -1,17 +1,17 @@ import AppKit -/// Displays all Claude Code sessions for a project in a dedicated window. +/// Displays all Claude Code sessions for a workspace in a dedicated window. /// Left pane: search + session list with star toggles. Right pane: conversation timeline. class SessionExplorerWindowController: NSWindowController, NSSplitViewDelegate, NSSearchFieldDelegate { - private let projectPath: String - private let projectName: String + private let workspacePath: String + private let workspaceName: String /// Callback invoked when the user picks an action (resume/fork). /// Parameters: kind, sessionId, forkSession flag, tab name. var onSessionAction: ((TabKind, String, Bool, String?) -> Void)? - /// Session IDs currently open in the project's tabs. + /// Session IDs currently open in the workspace's tabs. var openSessionIds = Set() // --- Data --- @@ -38,9 +38,9 @@ class SessionExplorerWindowController: NSWindowController, NSSplitViewDelegate, return f }() - init(projectPath: String, projectName: String) { - self.projectPath = projectPath - self.projectName = projectName + init(workspacePath: String, workspaceName: String) { + self.workspacePath = workspacePath + self.workspaceName = workspaceName let colors = ThemeManager.shared.currentColors let window = NSWindow( @@ -49,7 +49,7 @@ class SessionExplorerWindowController: NSWindowController, NSSplitViewDelegate, backing: .buffered, defer: false ) - window.title = "Sessions — \(projectName)" + window.title = "Sessions — \(workspaceName)" window.minSize = NSSize(width: 700, height: 500) window.backgroundColor = colors.background window.titlebarAppearsTransparent = true @@ -169,17 +169,17 @@ class SessionExplorerWindowController: NSWindowController, NSSplitViewDelegate, // MARK: - Data Loading private func loadData() { - let rawSessions = ContextMonitor.shared.listAllSessions(forProjectPath: projectPath) + let rawSessions = ContextMonitor.shared.listAllSessions(forWorkspacePath: workspacePath) let savedNames = SessionManager.shared.loadSessionNames() allSessions = rawSessions.map { session in let cacheKey = SessionManager.sessionCacheKey(sessionId: session.sessionId, kind: session.kind) let name = savedNames[cacheKey] - let bookmarkedIds = BookmarkManager.shared.bookmarkedSessionIds(forProjectPath: projectPath, kind: session.kind) + let bookmarkedIds = BookmarkManager.shared.bookmarkedSessionIds(forWorkspacePath: workspacePath, kind: session.kind) return ExplorerSessionInfo( agentKind: session.kind, sessionId: session.sessionId, - filePath: session.filePath ?? URL(fileURLWithPath: NSHomeDirectory() + "/.claude/projects/\(projectPath.claudeProjectDirName)/\(session.sessionId).jsonl"), + filePath: session.filePath ?? URL(fileURLWithPath: NSHomeDirectory() + "/.claude/projects/\(workspacePath.claudeProjectDirName)/\(session.sessionId).jsonl"), modificationDate: session.modificationDate, messageCount: session.messageCount, firstUserMessage: session.firstUserMessage, @@ -253,7 +253,7 @@ class SessionExplorerWindowController: NSWindowController, NSSplitViewDelegate, guard let session = allSessions.first(where: { $0.cacheKey == selectedSessionId || $0.sessionId == sessionId }), let newSessionId = ContextMonitor.shared.truncateSession( sessionId: sessionId, - projectPath: projectPath, + workspacePath: workspacePath, afterTurnIndex: turnIndex, kind: session.agentKind ) else { return } @@ -268,7 +268,7 @@ class SessionExplorerWindowController: NSWindowController, NSSplitViewDelegate, guard row < filteredSessions.count else { return } let session = filteredSessions[row] let sessionId = session.sessionId - let newState = BookmarkManager.shared.toggleBookmark(projectPath: projectPath, sessionId: sessionId, kind: session.agentKind) + let newState = BookmarkManager.shared.toggleBookmark(workspacePath: workspacePath, sessionId: sessionId, kind: session.agentKind) if let idx = allSessions.firstIndex(where: { $0.cacheKey == session.cacheKey }) { allSessions[idx].isBookmarked = newState } @@ -304,7 +304,7 @@ class SessionExplorerWindowController: NSWindowController, NSSplitViewDelegate, guard let session = allSessions.first(where: { $0.cacheKey == cacheKey }) else { return } let sessionId = session.sessionId - let entries = ContextMonitor.shared.parseTimeline(sessionId: sessionId, projectPath: projectPath, kind: session.agentKind) + let entries = ContextMonitor.shared.parseTimeline(sessionId: sessionId, workspacePath: workspacePath, kind: session.agentKind) if let idx = allSessions.firstIndex(where: { $0.cacheKey == cacheKey }) { allSessions[idx].messageCount = entries.count diff --git a/Sources/Session/SessionState.swift b/Sources/Session/SessionState.swift index e2370b9..e518f9a 100644 --- a/Sources/Session/SessionState.swift +++ b/Sources/Session/SessionState.swift @@ -20,8 +20,8 @@ enum TabKind: String, Codable, CaseIterable { /// Persisted state for Deckard — saved to ~/Library/Application Support/Deckard/state.json struct DeckardState: Codable { - var version: Int = 2 - var selectedTabIndex: Int = 0 // selected project index + var version: Int = 3 + var selectedTabIndex: Int = 0 // selected workspace index var defaultWorkingDirectory: String? // Legacy (v1) — kept for backward compat @@ -30,12 +30,54 @@ struct DeckardState: Codable { var terminalTabCounter: Int? var masterSessionId: String? - // v2: project-based - var projects: [ProjectState]? + // Workspaces (the on-disk key was "projects" in v2; CodingKeys reads both) + var workspaces: [WorkspaceState]? - // v3: sidebar folders - var sidebarFolders: [SidebarFolderState]? + // v3: sidebar groups (was "sidebarFolders" in v2-era state.json) + var sidebarGroups: [SidebarGroupState]? var sidebarOrder: [SidebarOrderItem]? + + init() {} + + private enum CodingKeys: String, CodingKey { + case version, selectedTabIndex, defaultWorkingDirectory + case tabs, claudeTabCounter, terminalTabCounter, masterSessionId + case workspaces + case sidebarGroups, sidebarOrder + // Legacy keys — read on decode, never written. + case projects // v2 name for workspaces + case sidebarFolders // v2 name for sidebarGroups + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + version = try c.decodeIfPresent(Int.self, forKey: .version) ?? 2 + selectedTabIndex = try c.decodeIfPresent(Int.self, forKey: .selectedTabIndex) ?? 0 + defaultWorkingDirectory = try c.decodeIfPresent(String.self, forKey: .defaultWorkingDirectory) + tabs = try c.decodeIfPresent([TabState].self, forKey: .tabs) + claudeTabCounter = try c.decodeIfPresent(Int.self, forKey: .claudeTabCounter) + terminalTabCounter = try c.decodeIfPresent(Int.self, forKey: .terminalTabCounter) + masterSessionId = try c.decodeIfPresent(String.self, forKey: .masterSessionId) + workspaces = try c.decodeIfPresent([WorkspaceState].self, forKey: .workspaces) + ?? c.decodeIfPresent([WorkspaceState].self, forKey: .projects) + sidebarGroups = try c.decodeIfPresent([SidebarGroupState].self, forKey: .sidebarGroups) + ?? c.decodeIfPresent([SidebarGroupState].self, forKey: .sidebarFolders) + sidebarOrder = try c.decodeIfPresent([SidebarOrderItem].self, forKey: .sidebarOrder) + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(version, forKey: .version) + try c.encode(selectedTabIndex, forKey: .selectedTabIndex) + try c.encodeIfPresent(defaultWorkingDirectory, forKey: .defaultWorkingDirectory) + try c.encodeIfPresent(tabs, forKey: .tabs) + try c.encodeIfPresent(claudeTabCounter, forKey: .claudeTabCounter) + try c.encodeIfPresent(terminalTabCounter, forKey: .terminalTabCounter) + try c.encodeIfPresent(masterSessionId, forKey: .masterSessionId) + try c.encodeIfPresent(workspaces, forKey: .workspaces) + try c.encodeIfPresent(sidebarGroups, forKey: .sidebarGroups) + try c.encodeIfPresent(sidebarOrder, forKey: .sidebarOrder) + } } struct TabState: Codable { @@ -48,17 +90,17 @@ struct TabState: Codable { var workingDirectory: String? } -struct ProjectState: Codable { +struct WorkspaceState: Codable { var id: String var path: String var name: String var selectedTabIndex: Int - var tabs: [ProjectTabState] + var tabs: [WorkspaceTabState] var defaultArgs: String? var defaultCodexArgs: String? } -struct ProjectTabState: Codable { +struct WorkspaceTabState: Codable { var id: String var name: String var kind: TabKind @@ -111,17 +153,49 @@ struct ProjectTabState: Codable { } } -struct SidebarFolderState: Codable { +struct SidebarGroupState: Codable { var id: String var name: String var isCollapsed: Bool - var projectIds: [String] + var workspaceIds: [String] + + init(id: String, name: String, isCollapsed: Bool, workspaceIds: [String]) { + self.id = id + self.name = name + self.isCollapsed = isCollapsed + self.workspaceIds = workspaceIds + } + + private enum CodingKeys: String, CodingKey { + case id, name, isCollapsed + case workspaceIds + // Legacy key — read on decode, never written. + case projectIds + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + name = try c.decode(String.self, forKey: .name) + isCollapsed = try c.decode(Bool.self, forKey: .isCollapsed) + workspaceIds = try c.decodeIfPresent([String].self, forKey: .workspaceIds) + ?? c.decodeIfPresent([String].self, forKey: .projectIds) + ?? [] + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(id, forKey: .id) + try c.encode(name, forKey: .name) + try c.encode(isCollapsed, forKey: .isCollapsed) + try c.encode(workspaceIds, forKey: .workspaceIds) + } } -/// A tagged union for sidebar ordering — either a folder or an ungrouped project. +/// A tagged union for sidebar ordering — either a group or an ungrouped workspace. enum SidebarOrderItem: Codable { - case folder(String) // folder id - case project(String) // project id + case group(String) // group id + case workspace(String) // workspace id private enum CodingKeys: String, CodingKey { case type, id @@ -130,10 +204,13 @@ enum SidebarOrderItem: Codable { func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { - case .folder(let id): - try container.encode("folder", forKey: .type) + case .group(let id): + try container.encode("group", forKey: .type) try container.encode(id, forKey: .id) - case .project(let id): + case .workspace(let id): + // Keep the on-disk discriminator as "project" — that is what every + // existing state.json contains. Users on this build still encode it + // unchanged so a downgrade keeps loading their workspaces. try container.encode("project", forKey: .type) try container.encode(id, forKey: .id) } @@ -144,10 +221,10 @@ enum SidebarOrderItem: Codable { let type = try container.decode(String.self, forKey: .type) let id = try container.decode(String.self, forKey: .id) switch type { - case "folder": - self = .folder(id) + case "group", "folder": // "folder" is the v2 legacy discriminator + self = .group(id) case "project": - self = .project(id) + self = .workspace(id) default: throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unknown sidebar order item type: \(type)") diff --git a/Sources/Window/DeckardWindowController.swift b/Sources/Window/DeckardWindowController.swift index 54641d5..56bb814 100644 --- a/Sources/Window/DeckardWindowController.swift +++ b/Sources/Window/DeckardWindowController.swift @@ -1,7 +1,7 @@ import AppKit import KeyboardShortcuts -/// Format a tooltip with the current shortcut, e.g. "Open Folder (Cmd+O)" +/// Format a tooltip with the current shortcut, e.g. "Open Workspace (Cmd+O)" @MainActor func shortcutTooltip(_ label: String, for name: KeyboardShortcuts.Name) -> String { if let shortcut = KeyboardShortcuts.getShortcut(for: name) { @@ -12,7 +12,7 @@ func shortcutTooltip(_ label: String, for name: KeyboardShortcuts.Name) -> Strin // MARK: - Data Models -/// A horizontal tab within a project (agent session or terminal). +/// A horizontal tab within a workspace (agent session or terminal). class TabItem { let id: UUID var surface: TerminalSurface @@ -80,8 +80,8 @@ class TabItem { } } -/// A project in the vertical sidebar — contains horizontal tabs. -class ProjectItem { +/// A workspace in the vertical sidebar — contains horizontal tabs. +class WorkspaceItem { let id: UUID var path: String var name: String // basename of path @@ -97,34 +97,34 @@ class ProjectItem { } } -// MARK: - Sidebar Folder Model +// MARK: - Sidebar Group Model -/// A folder in the sidebar that groups projects. -class SidebarFolder { +/// A group in the sidebar that groups workspaces. +class SidebarGroup { let id: UUID var name: String var isCollapsed: Bool - var projectIds: [UUID] // references to ProjectItem.id + var workspaceIds: [UUID] // references to WorkspaceItem.id init(name: String) { self.id = UUID() self.name = name self.isCollapsed = false - self.projectIds = [] + self.workspaceIds = [] } - init(id: UUID, name: String, isCollapsed: Bool, projectIds: [UUID]) { + init(id: UUID, name: String, isCollapsed: Bool, workspaceIds: [UUID]) { self.id = id self.name = name self.isCollapsed = isCollapsed - self.projectIds = projectIds + self.workspaceIds = workspaceIds } } -/// Ordered sidebar items: either a folder or an ungrouped project reference. +/// Ordered sidebar items: either a group or an ungrouped workspace reference. enum SidebarItem { - case folder(SidebarFolder) - case project(UUID) // ProjectItem.id + case group(SidebarGroup) + case workspace(UUID) // WorkspaceItem.id } // MARK: - Default Tab Configuration @@ -149,9 +149,9 @@ struct DefaultTabConfig { // MARK: - Window Controller -let deckardProjectDragType = NSPasteboard.PasteboardType("com.deckard.project-reorder") +let deckardWorkspaceDragType = NSPasteboard.PasteboardType("com.deckard.workspace-reorder") let deckardSidebarDragType = NSPasteboard.PasteboardType("com.deckard.sidebar-drag") -let deckardFolderDragType = NSPasteboard.PasteboardType("com.deckard.folder-reorder") +let deckardGroupDragType = NSPasteboard.PasteboardType("com.deckard.group-reorder") private class CollapsibleSplitView: NSSplitView { @@ -165,11 +165,11 @@ private class CollapsibleSplitView: NSSplitView { } class DeckardWindowController: NSWindowController, NSSplitViewDelegate { - var projects: [ProjectItem] = [] - var selectedProjectIndex: Int = -1 + var workspaces: [WorkspaceItem] = [] + var selectedWorkspaceIndex: Int = -1 - // Sidebar folders - var sidebarFolders: [SidebarFolder] = [] + // Sidebar groups + var sidebarGroups: [SidebarGroup] = [] var sidebarOrder: [SidebarItem] = [] // Theme @@ -189,7 +189,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { private var contextTimer: Timer? private var processMonitorTimer: Timer? var currentTerminalView: NSView? - /// Opaque overlay shown when a project has no tabs, covering any surfaces underneath. + /// Opaque overlay shown when a workspace has no tabs, covering any surfaces underneath. private var emptyStateView: NSView? let sidebarDropZone = SidebarDropZone() @@ -198,8 +198,8 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { private let sidebarWidth: CGFloat = 210 private var sidebarInitialized = false private var sidebarWidthBeforeCollapse: CGFloat = 210 - /// Recently closed projects — stored so reopening the same path restores tabs. - private var recentlyClosedProjects: [ProjectState] = [] + /// Recently closed workspaces — stored so reopening the same path restores tabs. + private var recentlyClosedWorkspaces: [WorkspaceState] = [] var isRestoring = false /// Tabs in the order they were created (for ProcessMonitor PID matching). var tabCreationOrder: [UUID] = [] @@ -272,10 +272,10 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { return event } - // If no projects after restore, auto-show the project picker - if projects.isEmpty { + // If no workspaces after restore, auto-show the workspace picker + if workspaces.isEmpty { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - AppDelegate.shared?.openProjectPicker() + AppDelegate.shared?.openWorkspacePicker() } } @@ -368,10 +368,10 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } private func restoreFirstResponderAfterWake() { - guard let project = currentProject else { return } - let idx = project.selectedTabIndex - guard idx >= 0, idx < project.tabs.count else { return } - let tab = project.tabs[idx] + guard let workspace = currentWorkspace else { return } + let idx = workspace.selectedTabIndex + guard idx >= 0, idx < workspace.tabs.count else { return } + let tab = workspace.tabs[idx] let fr = window?.firstResponder DiagnosticLog.shared.log("sleep", "wake recovery: firstResponder=\(type(of: fr)) surfaceId=\(tab.id)") @@ -410,7 +410,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { // Drop zone covers the entire sidebar area below the stack sidebarDropZone.translatesAutoresizingMaskIntoConstraints = false - sidebarDropZone.registerForDraggedTypes([deckardProjectDragType, deckardFolderDragType]) + sidebarDropZone.registerForDraggedTypes([deckardWorkspaceDragType, deckardGroupDragType]) sidebarView.addSubview(sidebarDropZone) sidebarStackView.orientation = .vertical @@ -471,12 +471,12 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { splitView.addArrangedSubview(sidebarView) splitView.addArrangedSubview(rightPane) - // Opaque empty-state overlay — covers all surfaces when a project has no tabs. + // Opaque empty-state overlay — covers all surfaces when a workspace has no tabs. let emptyBg = NSView() emptyBg.wantsLayer = true emptyBg.layer?.backgroundColor = colors.background.cgColor emptyBg.translatesAutoresizingMaskIntoConstraints = false - let welcome = NSTextField(labelWithString: "Press \u{2318}O to open a project") + let welcome = NSTextField(labelWithString: "Press \u{2318}O to open a workspace") welcome.font = .systemFont(ofSize: 16, weight: .light) welcome.textColor = colors.secondaryText welcome.alignment = .center @@ -561,87 +561,87 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } } - // MARK: - Project Management + // MARK: - Workspace Management - func openProjectPaths() -> [String] { - return projects.map { $0.path } + func openWorkspacePaths() -> [String] { + return workspaces.map { $0.path } } - func openProject(path: String) { - let project = ProjectItem(path: path) + func openWorkspace(path: String) { + let workspace = WorkspaceItem(path: path) // Check if we have a recently closed snapshot — restore tabs from it - // Use project.path (symlinks resolved) so symlinked paths match canonical ones. - if let snapshot = recentlyClosedProjects.first(where: { $0.path == project.path }) { - recentlyClosedProjects.removeAll { $0.path == project.path } - project.name = snapshot.name - project.defaultArgs = snapshot.defaultArgs - project.defaultCodexArgs = snapshot.defaultCodexArgs + // Use workspace.path (symlinks resolved) so symlinked paths match canonical ones. + if let snapshot = recentlyClosedWorkspaces.first(where: { $0.path == workspace.path }) { + recentlyClosedWorkspaces.removeAll { $0.path == workspace.path } + workspace.name = snapshot.name + workspace.defaultArgs = snapshot.defaultArgs + workspace.defaultCodexArgs = snapshot.defaultCodexArgs for ts in snapshot.tabs { - createTabInProject(project, kind: ts.kind, name: ts.name, + createTabInWorkspace(workspace, kind: ts.kind, name: ts.name, sessionIdToResume: ts.kind.isAgent ? ts.sessionId : nil, tmuxSessionToResume: ts.tmuxSessionName) } - project.selectedTabIndex = min(snapshot.selectedTabIndex, project.tabs.count - 1) + workspace.selectedTabIndex = min(snapshot.selectedTabIndex, workspace.tabs.count - 1) } // If no tabs restored, create defaults - if project.tabs.isEmpty { + if workspace.tabs.isEmpty { let config = DefaultTabConfig.current for entry in config.entries { - createTabInProject(project, kind: entry.kind) + createTabInWorkspace(workspace, kind: entry.kind) } } - projects.append(project) - sidebarOrder.append(.project(project.id)) + workspaces.append(workspace) + sidebarOrder.append(.workspace(workspace.id)) rebuildSidebar() - selectProject(at: projects.count - 1) + selectWorkspace(at: workspaces.count - 1) if !isRestoring { saveState() } } - func closeCurrentProject() { - guard selectedProjectIndex >= 0, selectedProjectIndex < projects.count else { return } - closeProject(at: selectedProjectIndex) + func closeCurrentWorkspace() { + guard selectedWorkspaceIndex >= 0, selectedWorkspaceIndex < workspaces.count else { return } + closeWorkspace(at: selectedWorkspaceIndex) } - func exploreCurrentProjectSessions() { - guard selectedProjectIndex >= 0, selectedProjectIndex < projects.count else { return } - let project = projects[selectedProjectIndex] + func exploreCurrentWorkspaceSessions() { + guard selectedWorkspaceIndex >= 0, selectedWorkspaceIndex < workspaces.count else { return } + let workspace = workspaces[selectedWorkspaceIndex] let fakeMenuItem = NSMenuItem() - fakeMenuItem.representedObject = project + fakeMenuItem.representedObject = workspace exploreSessionsMenuAction(fakeMenuItem) } - func moveCurrentProjectOutOfFolder() { - guard selectedProjectIndex >= 0, selectedProjectIndex < projects.count else { return } - let project = projects[selectedProjectIndex] - moveProjectOutOfFolder(projectId: project.id) + func moveCurrentWorkspaceOutOfGroup() { + guard selectedWorkspaceIndex >= 0, selectedWorkspaceIndex < workspaces.count else { return } + let workspace = workspaces[selectedWorkspaceIndex] + moveWorkspaceOutOfGroup(workspaceId: workspace.id) } - func closeProject(at index: Int) { - guard index >= 0, index < projects.count else { return } - let project = projects[index] + func closeWorkspace(at index: Int) { + guard index >= 0, index < workspaces.count else { return } + let workspace = workspaces[index] - // Save project state for potential restoration - let snapshot = ProjectState( - id: project.id.uuidString, - path: project.path, - name: project.name, - selectedTabIndex: project.selectedTabIndex, - tabs: project.tabs.map { tab in - ProjectTabState(id: tab.id.uuidString, name: tab.name, + // Save workspace state for potential restoration + let snapshot = WorkspaceState( + id: workspace.id.uuidString, + path: workspace.path, + name: workspace.name, + selectedTabIndex: workspace.selectedTabIndex, + tabs: workspace.tabs.map { tab in + WorkspaceTabState(id: tab.id.uuidString, name: tab.name, kind: tab.kind, sessionId: tab.sessionId, tmuxSessionName: tab.surface.tmuxSessionName) }, - defaultArgs: project.defaultArgs, - defaultCodexArgs: project.defaultCodexArgs + defaultArgs: workspace.defaultArgs, + defaultCodexArgs: workspace.defaultCodexArgs ) - recentlyClosedProjects.removeAll { $0.path == project.path } - recentlyClosedProjects.append(snapshot) + recentlyClosedWorkspaces.removeAll { $0.path == workspace.path } + recentlyClosedWorkspaces.append(snapshot) // Persist session names for agent tabs so they survive app restarts - for tab in project.tabs where tab.isAgent { + for tab in workspace.tabs where tab.isAgent { if let sid = tab.sessionId, !sid.isEmpty { SessionManager.shared.saveSessionName(sessionId: sid, kind: tab.kind, name: tab.name) } @@ -649,9 +649,9 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { // Detach terminal tabs so their tmux sessions survive for re-open; // terminate agent tabs (they use their own resume mechanism). - let closedIds = Set(project.tabs.map { $0.id }) + let closedIds = Set(workspace.tabs.map { $0.id }) tabCreationOrder.removeAll { closedIds.contains($0) } - for tab in project.tabs { + for tab in workspace.tabs { if tab.isTerminal && tab.surface.tmuxSessionName != nil { tab.surface.detach() } else { @@ -659,20 +659,20 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } } - projects.remove(at: index) - removeSidebarReference(projectId: project.id) + workspaces.remove(at: index) + removeSidebarReference(workspaceId: workspace.id) rebuildSidebar() - if projects.isEmpty { - selectedProjectIndex = -1 + if workspaces.isEmpty { + selectedWorkspaceIndex = -1 currentTerminalView?.removeFromSuperview() currentTerminalView = nil rebuildTabBar() - } else if let next = nextVisibleProjectIndex(near: index) { - selectProject(at: next, autoExpandFolder: false) + } else if let next = nextVisibleWorkspaceIndex(near: index) { + selectWorkspace(at: next, autoExpandGroup: false) } else { - // All remaining projects are inside collapsed folders — show empty state. - selectedProjectIndex = -1 + // All remaining workspaces are inside collapsed groups — show empty state. + selectedWorkspaceIndex = -1 currentTerminalView?.removeFromSuperview() currentTerminalView = nil rebuildTabBar() @@ -682,52 +682,52 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { saveState() } - /// Returns the index of the nearest project that is visible in the sidebar - /// (i.e. top-level or inside a non-collapsed folder), or nil if none. - private func nextVisibleProjectIndex(near index: Int) -> Int? { - let collapsedProjectIds = Set(sidebarFolders.filter(\.isCollapsed).flatMap(\.projectIds)) - let clamped = min(index, projects.count - 1) + /// Returns the index of the nearest workspace that is visible in the sidebar + /// (i.e. top-level or inside a non-collapsed group), or nil if none. + private func nextVisibleWorkspaceIndex(near index: Int) -> Int? { + let collapsedWorkspaceIds = Set(sidebarGroups.filter(\.isCollapsed).flatMap(\.workspaceIds)) + let clamped = min(index, workspaces.count - 1) // Search outward from `clamped`: check clamped, clamped-1, clamped+1, ... var lo = clamped, hi = clamped + 1 - while lo >= 0 || hi < projects.count { - if lo >= 0, !collapsedProjectIds.contains(projects[lo].id) { return lo } - if hi < projects.count, !collapsedProjectIds.contains(projects[hi].id) { return hi } + while lo >= 0 || hi < workspaces.count { + if lo >= 0, !collapsedWorkspaceIds.contains(workspaces[lo].id) { return lo } + if hi < workspaces.count, !collapsedWorkspaceIds.contains(workspaces[hi].id) { return hi } lo -= 1; hi += 1 } return nil } - func selectProject(at index: Int, autoExpandFolder: Bool = true) { - guard index >= 0, index < projects.count else { return } - selectedProjectIndex = index + func selectWorkspace(at index: Int, autoExpandGroup: Bool = true) { + guard index >= 0, index < workspaces.count else { return } + selectedWorkspaceIndex = index - let project = projects[index] + let workspace = workspaces[index] - // Auto-expand folder if the selected project is inside a collapsed one - if autoExpandFolder { - for folder in sidebarFolders where folder.isCollapsed && folder.projectIds.contains(project.id) { - folder.isCollapsed = false + // Auto-expand group if the selected workspace is inside a collapsed one + if autoExpandGroup { + for group in sidebarGroups where group.isCollapsed && group.workspaceIds.contains(workspace.id) { + group.isCollapsed = false rebuildSidebar() } } rebuildTabBar() - if project.tabs.isEmpty { + if workspace.tabs.isEmpty { currentTerminalView = nil showEmptyState() } else { // Always clamp for safe array access, even during restore - let safeIdx = max(0, min(project.selectedTabIndex, project.tabs.count - 1)) - clearUnseenIfNeeded(project.tabs[safeIdx]) - showTab(project.tabs[safeIdx]) + let safeIdx = max(0, min(workspace.selectedTabIndex, workspace.tabs.count - 1)) + clearUnseenIfNeeded(workspace.tabs[safeIdx]) + showTab(workspace.tabs[safeIdx]) } - // Show folder path in title bar + // Show group path in title bar let home = NSHomeDirectory() - let displayPath = project.path.hasPrefix(home) - ? "~" + project.path.dropFirst(home.count) - : project.path + let displayPath = workspace.path.hasPrefix(home) + ? "~" + workspace.path.dropFirst(home.count) + : workspace.path #if DEBUG window?.title = "\(displayPath) [DEV]" #else @@ -737,7 +737,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { updateSidebarSelection() } - // MARK: - Tab Management (within a project) + // MARK: - Tab Management (within a workspace) private func initialBadgeState(for kind: TabKind) -> TabItem.BadgeState { switch kind { @@ -750,11 +750,11 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } } - func createTabInProject(_ project: ProjectItem, isClaude: Bool, name: String? = nil, sessionIdToResume: String? = nil, forkSession: Bool = false, tmuxSessionToResume: String? = nil, extraArgs: String? = nil) { - createTabInProject(project, kind: isClaude ? .claude : .terminal, name: name, sessionIdToResume: sessionIdToResume, forkSession: forkSession, tmuxSessionToResume: tmuxSessionToResume, extraArgs: extraArgs) + func createTabInWorkspace(_ workspace: WorkspaceItem, isClaude: Bool, name: String? = nil, sessionIdToResume: String? = nil, forkSession: Bool = false, tmuxSessionToResume: String? = nil, extraArgs: String? = nil) { + createTabInWorkspace(workspace, kind: isClaude ? .claude : .terminal, name: name, sessionIdToResume: sessionIdToResume, forkSession: forkSession, tmuxSessionToResume: tmuxSessionToResume, extraArgs: extraArgs) } - func createTabInProject(_ project: ProjectItem, kind: TabKind, name: String? = nil, sessionIdToResume: String? = nil, forkSession: Bool = false, tmuxSessionToResume: String? = nil, extraArgs: String? = nil) { + func createTabInWorkspace(_ workspace: WorkspaceItem, kind: TabKind, name: String? = nil, sessionIdToResume: String? = nil, forkSession: Bool = false, tmuxSessionToResume: String? = nil, extraArgs: String? = nil) { let surface = TerminalSurface() let tabName: String if let name = name { @@ -763,7 +763,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { let base = kind.displayName // Find the highest existing number for this tab type to avoid duplicates let prefix = "\(base) #" - let maxNum = project.tabs + let maxNum = workspace.tabs .filter { $0.kind == kind } .compactMap { tab -> Int? in guard tab.name.hasPrefix(prefix) else { return nil } @@ -786,11 +786,11 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { let initialInput: String? if kind == .claude { - let resolvedArgs = extraArgs ?? project.defaultArgs ?? UserDefaults.standard.string(forKey: "claudeExtraArgs") ?? "" + let resolvedArgs = extraArgs ?? workspace.defaultArgs ?? UserDefaults.standard.string(forKey: "claudeExtraArgs") ?? "" let extraArgsSuffix = resolvedArgs.isEmpty ? "" : " \(resolvedArgs)" var claudeArgs = extraArgsSuffix if let sessionIdToResume { - let encoded = project.path.claudeProjectDirName + let encoded = workspace.path.claudeProjectDirName let jsonlPath = NSHomeDirectory() + "/.claude/projects/\(encoded)/\(sessionIdToResume).jsonl" if FileManager.default.fileExists(atPath: jsonlPath) { let forkFlag = forkSession ? " --fork-session" : "" @@ -804,7 +804,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { // clear hides the echoed command; exec replaces the shell. initialInput = "clear && exec claude\(claudeArgs)\n" } else if kind == .codex { - let resolvedArgs = extraArgs ?? project.defaultCodexArgs ?? UserDefaults.standard.string(forKey: "codexExtraArgs") ?? "" + let resolvedArgs = extraArgs ?? workspace.defaultCodexArgs ?? UserDefaults.standard.string(forKey: "codexExtraArgs") ?? "" let codexOptions = resolvedArgs.isEmpty ? "" : " \(resolvedArgs)" var codexArgs = "" if let sessionIdToResume { @@ -826,7 +826,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { DiagnosticLog.shared.log("surface", "createTab: \(kind.rawValue) surfaceId=\(surface.surfaceId)") surface.startShell( - workingDirectory: project.path, + workingDirectory: workspace.path, envVars: envVars, initialInput: initialInput, tmuxSession: tmuxSessionToResume @@ -838,15 +838,15 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } } - project.tabs.append(tab) + workspace.tabs.append(tab) tabCreationOrder.append(tab.id) if kind == .codex && (tab.sessionId == nil || forkSession) { - scheduleCodexSessionDiscovery(forSurfaceId: tab.id, projectPath: project.path) + scheduleCodexSessionDiscovery(forSurfaceId: tab.id, workspacePath: workspace.path) } } - private func scheduleCodexSessionDiscovery(forSurfaceId surfaceId: UUID, projectPath: String) { + private func scheduleCodexSessionDiscovery(forSurfaceId surfaceId: UUID, workspacePath: String) { for delay in [1.0, 3.0, 8.0] { DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in guard let self, @@ -857,7 +857,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { guard let processId = ProcessMonitor.shared.shellPid(forSurface: surfaceId), let session = ContextMonitor.shared.codexSessionInfo( openedByProcessId: processId, - projectPath: projectPath + workspacePath: workspacePath ) else { return } self.updateSessionId(forSurfaceId: surfaceId.uuidString, sessionId: session.sessionId) @@ -868,60 +868,60 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { /// Guards against rapid duplicate tab creation from key repeat. var isCreatingTab = false - func addTabToCurrentProject(isClaude: Bool) { - addTabToCurrentProject(kind: isClaude ? .claude : .terminal) + func addTabToCurrentWorkspace(isClaude: Bool) { + addTabToCurrentWorkspace(kind: isClaude ? .claude : .terminal) } - func addTabToCurrentProject(kind: TabKind) { + func addTabToCurrentWorkspace(kind: TabKind) { guard !isCreatingTab else { return } isCreatingTab = true - guard selectedProjectIndex >= 0, selectedProjectIndex < projects.count else { + guard selectedWorkspaceIndex >= 0, selectedWorkspaceIndex < workspaces.count else { isCreatingTab = false return } - let project = projects[selectedProjectIndex] + let workspace = workspaces[selectedWorkspaceIndex] if kind == .claude && UserDefaults.standard.bool(forKey: "promptForSessionArgs") { - promptForClaudeArgs(for: project) { [weak self] args in + promptForClaudeArgs(for: workspace) { [weak self] args in guard let self else { return } guard let args else { // User cancelled self.isCreatingTab = false return } - guard self.projects.contains(where: { $0 === project }) else { + guard self.workspaces.contains(where: { $0 === workspace }) else { self.isCreatingTab = false return } - self.createTabInProject(project, kind: .claude, extraArgs: args) - self.finalizeTabCreation(in: project) + self.createTabInWorkspace(workspace, kind: .claude, extraArgs: args) + self.finalizeTabCreation(in: workspace) } } else if kind == .codex && UserDefaults.standard.bool(forKey: "promptForCodexSessionArgs") { - promptForCodexArgs(for: project) { [weak self] args in + promptForCodexArgs(for: workspace) { [weak self] args in guard let self else { return } guard let args else { self.isCreatingTab = false return } - guard self.projects.contains(where: { $0 === project }) else { + guard self.workspaces.contains(where: { $0 === workspace }) else { self.isCreatingTab = false return } - self.createTabInProject(project, kind: .codex, extraArgs: args) - self.finalizeTabCreation(in: project) + self.createTabInWorkspace(workspace, kind: .codex, extraArgs: args) + self.finalizeTabCreation(in: workspace) } } else { - createTabInProject(project, kind: kind) - finalizeTabCreation(in: project) + createTabInWorkspace(workspace, kind: kind) + finalizeTabCreation(in: workspace) } } - private func finalizeTabCreation(in project: ProjectItem) { - project.selectedTabIndex = project.tabs.count - 1 + private func finalizeTabCreation(in workspace: WorkspaceItem) { + workspace.selectedTabIndex = workspace.tabs.count - 1 rebuildTabBar() rebuildSidebar() - showTab(project.tabs[project.selectedTabIndex]) + showTab(workspace.tabs[workspace.selectedTabIndex]) saveState() DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in @@ -929,7 +929,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } } - private func promptForClaudeArgs(for project: ProjectItem, completion: @escaping (String?) -> Void) { + private func promptForClaudeArgs(for workspace: WorkspaceItem, completion: @escaping (String?) -> Void) { let alert = NSAlert() alert.messageText = "Claude Code Arguments" alert.informativeText = "Arguments passed to this session:" @@ -937,7 +937,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { alert.addButton(withTitle: "Cancel") let field = ClaudeArgsField(frame: NSRect(x: 0, y: 0, width: 400, height: 60)) - field.stringValue = project.defaultArgs ?? UserDefaults.standard.string(forKey: "claudeExtraArgs") ?? "" + field.stringValue = workspace.defaultArgs ?? UserDefaults.standard.string(forKey: "claudeExtraArgs") ?? "" alert.accessoryView = field guard let window else { @@ -954,7 +954,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } } - private func promptForCodexArgs(for project: ProjectItem, completion: @escaping (String?) -> Void) { + private func promptForCodexArgs(for workspace: WorkspaceItem, completion: @escaping (String?) -> Void) { let alert = NSAlert() alert.messageText = "Codex Arguments" alert.informativeText = "Arguments passed to this session:" @@ -965,7 +965,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { frame: NSRect(x: 0, y: 0, width: 400, height: 60), flagSource: .codex ) - field.stringValue = project.defaultCodexArgs ?? UserDefaults.standard.string(forKey: "codexExtraArgs") ?? "" + field.stringValue = workspace.defaultCodexArgs ?? UserDefaults.standard.string(forKey: "codexExtraArgs") ?? "" alert.accessoryView = field guard let window else { @@ -983,28 +983,28 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } func closeCurrentTab() { - guard let project = currentProject else { return } - let idx = project.selectedTabIndex - guard idx >= 0, idx < project.tabs.count else { return } + guard let workspace = currentWorkspace else { return } + let idx = workspace.selectedTabIndex + guard idx >= 0, idx < workspace.tabs.count else { return } - let tab = project.tabs[idx] + let tab = workspace.tabs[idx] tab.surface.terminate() tabCreationOrder.removeAll { $0 == tab.id } - project.tabs.remove(at: idx) + workspace.tabs.remove(at: idx) - if project.tabs.isEmpty { - // Keep the project in the sidebar with just the "+" button + if workspace.tabs.isEmpty { + // Keep the workspace in the sidebar with just the "+" button currentTerminalView = nil showEmptyState() rebuildTabBar() rebuildSidebar() } else { - project.selectedTabIndex = min(idx, project.tabs.count - 1) + workspace.selectedTabIndex = min(idx, workspace.tabs.count - 1) rebuildTabBar() rebuildSidebar() - clearUnseenIfNeeded(project.tabs[project.selectedTabIndex]) - showTab(project.tabs[project.selectedTabIndex]) + clearUnseenIfNeeded(workspace.tabs[workspace.selectedTabIndex]) + showTab(workspace.tabs[workspace.selectedTabIndex]) } saveState() } @@ -1029,40 +1029,40 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } } - func selectTabInProject(at tabIndex: Int) { - guard let project = currentProject else { return } - guard tabIndex >= 0, tabIndex < project.tabs.count else { return } - project.selectedTabIndex = tabIndex - clearUnseenIfNeeded(project.tabs[tabIndex]) + func selectTabInWorkspace(at tabIndex: Int) { + guard let workspace = currentWorkspace else { return } + guard tabIndex >= 0, tabIndex < workspace.tabs.count else { return } + workspace.selectedTabIndex = tabIndex + clearUnseenIfNeeded(workspace.tabs[tabIndex]) rebuildTabBar() - showTab(project.tabs[tabIndex]) + showTab(workspace.tabs[tabIndex]) } /// Switch to a tab without rebuilding the tab bar. /// Called from HorizontalTabView.mouseDown so the terminal switch /// is not lost if an async rebuild destroys the view before mouseUp. func switchToTab(at tabIndex: Int) { - guard let project = currentProject else { return } - guard tabIndex >= 0, tabIndex < project.tabs.count else { return } - guard tabIndex != project.selectedTabIndex else { return } - project.selectedTabIndex = tabIndex - clearUnseenIfNeeded(project.tabs[tabIndex]) - showTab(project.tabs[tabIndex]) + guard let workspace = currentWorkspace else { return } + guard tabIndex >= 0, tabIndex < workspace.tabs.count else { return } + guard tabIndex != workspace.selectedTabIndex else { return } + workspace.selectedTabIndex = tabIndex + clearUnseenIfNeeded(workspace.tabs[tabIndex]) + showTab(workspace.tabs[tabIndex]) } func selectNextTab() { - guard let project = currentProject, !project.tabs.isEmpty else { return } - selectTabInProject(at: (project.selectedTabIndex + 1) % project.tabs.count) + guard let workspace = currentWorkspace, !workspace.tabs.isEmpty else { return } + selectTabInWorkspace(at: (workspace.selectedTabIndex + 1) % workspace.tabs.count) } func selectPrevTab() { - guard let project = currentProject, !project.tabs.isEmpty else { return } - selectTabInProject(at: (project.selectedTabIndex - 1 + project.tabs.count) % project.tabs.count) + guard let workspace = currentWorkspace, !workspace.tabs.isEmpty else { return } + selectTabInWorkspace(at: (workspace.selectedTabIndex - 1 + workspace.tabs.count) % workspace.tabs.count) } - var currentProject: ProjectItem? { - guard selectedProjectIndex >= 0, selectedProjectIndex < projects.count else { return nil } - return projects[selectedProjectIndex] + var currentWorkspace: WorkspaceItem? { + guard selectedWorkspaceIndex >= 0, selectedWorkspaceIndex < workspaces.count else { return nil } + return workspaces[selectedWorkspaceIndex] } func showTab(_ tab: TabItem) { @@ -1100,7 +1100,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { refreshContextBar(for: tab) } - /// Show the empty-state overlay (project has no tabs). + /// Show the empty-state overlay (workspace has no tabs). func showEmptyState() { currentTerminalView?.removeFromSuperview() emptyStateView?.isHidden = false @@ -1138,25 +1138,25 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { private func updateContextUsage(for tab: TabItem) { guard let sessionId = tab.sessionId, - let project = currentProject else { + let workspace = currentWorkspace else { DiagnosticLog.shared.log("context", - "updateContextUsage: skipped — sessionId=\(tab.sessionId ?? "nil") project=\(currentProject != nil)") + "updateContextUsage: skipped — sessionId=\(tab.sessionId ?? "nil") workspace=\(currentWorkspace != nil)") quotaView.updateContext(usage: nil, tabName: nil) return } let tabName = tab.name let tabId = tab.id - let projectPath = project.path - let allPaths = projects.map { $0.path } + let workspacePath = workspace.path + let allPaths = workspaces.map { $0.path } DispatchQueue.global(qos: .utility).async { - let usage = ContextMonitor.shared.getUsage(sessionId: sessionId, projectPath: projectPath) - let rate = QuotaMonitor.shared.computeTokenRate(projectPaths: allPaths) + let usage = ContextMonitor.shared.getUsage(sessionId: sessionId, workspacePath: workspacePath) + let rate = QuotaMonitor.shared.computeTokenRate(workspacePaths: allPaths) DispatchQueue.main.async { [weak self] in guard let self = self else { return } // Only update if this tab is still the active one - guard let project = self.currentProject, - let activeTab = project.tabs[safe: project.selectedTabIndex], + guard let workspace = self.currentWorkspace, + let activeTab = workspace.tabs[safe: workspace.selectedTabIndex], activeTab.id == tabId else { DiagnosticLog.shared.log("context", "updateContextUsage: stale callback for \(tabName), ignoring") @@ -1173,7 +1173,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } private func updateCodexUsage(for tab: TabItem) { - guard let project = currentProject else { + guard let workspace = currentWorkspace else { quotaView.clear() return } @@ -1181,21 +1181,21 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { let tabName = tab.name let tabId = tab.id let initialSessionId = tab.sessionId - let projectPath = project.path + let workspacePath = workspace.path DispatchQueue.global(qos: .utility).async { var sessionId = initialSessionId if sessionId == nil, let processId = ProcessMonitor.shared.shellPid(forSurface: tabId) { sessionId = ContextMonitor.shared.codexSessionInfo( openedByProcessId: processId, - projectPath: projectPath + workspacePath: workspacePath )?.sessionId } let usage = ContextMonitor.shared.getCodexUsage(sessionId: sessionId) DispatchQueue.main.async { [weak self] in guard let self = self else { return } - guard let project = self.currentProject, - let activeTab = project.tabs[safe: project.selectedTabIndex], + guard let workspace = self.currentWorkspace, + let activeTab = workspace.tabs[safe: workspace.selectedTabIndex], activeTab.id == tabId else { DiagnosticLog.shared.log("context", "updateCodexUsage: stale callback for \(tabName), ignoring") @@ -1225,7 +1225,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { private struct CodexBadgePollTarget { let surfaceId: UUID - let projectPath: String + let workspacePath: String let sessionId: String? let processId: pid_t? } @@ -1242,15 +1242,15 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { // is done via control socket registration, not sorted order. var tabInfos: [ProcessMonitor.TabInfo] = [] var codexTargets: [CodexBadgePollTarget] = [] - for project in self.projects { - for tab in project.tabs { + for workspace in self.workspaces { + for tab in workspace.tabs { tabInfos.append(ProcessMonitor.TabInfo( surfaceId: tab.id, kind: tab.kind, - name: tab.name, projectPath: project.path)) + name: tab.name, workspacePath: workspace.path)) if tab.kind == .codex { codexTargets.append(CodexBadgePollTarget( surfaceId: tab.id, - projectPath: project.path, + workspacePath: workspace.path, sessionId: tab.sessionId, processId: ProcessMonitor.shared.shellPid(forSurface: tab.id))) } @@ -1278,7 +1278,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { let processId = target.processId, let session = ContextMonitor.shared.codexSessionInfo( openedByProcessId: processId, - projectPath: target.projectPath + workspacePath: target.workspacePath ), !discoveredSessionIds.values.contains(session.sessionId) { sessionId = session.sessionId @@ -1302,8 +1302,8 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { private func applyCodexBadgeStates(_ states: [UUID: ContextMonitor.CodexActivityInfo]) { var changed = false - for project in projects { - for tab in project.tabs where tab.kind == .codex { + for workspace in workspaces { + for tab in workspace.tabs where tab.kind == .codex { guard let state = states[tab.id] else { continue } let newBadge: TabItem.BadgeState @@ -1322,7 +1322,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { if tab.badgeState != newBadge { DiagnosticLog.shared.log("badge", - "codex badge: project=\(project.path) tab=\"\(tab.name)\" busy=\(state.isBusy) error=\(state.isError) -> \(newBadge)") + "codex badge: workspace=\(workspace.path) tab=\"\(tab.name)\" busy=\(state.isBusy) error=\(state.isError) -> \(newBadge)") tab.badgeState = newBadge changed = true } @@ -1336,8 +1336,8 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { private func applyTerminalBadgeStates(_ states: [UUID: ProcessMonitor.ActivityInfo]) { var changed = false - for project in projects { - for tab in project.tabs where tab.isTerminal { + for workspace in workspaces { + for tab in workspace.tabs where tab.isTerminal { let activity = states[tab.id] ?? ProcessMonitor.ActivityInfo() // Require 2 consecutive active polls to transition to terminalActive. @@ -1365,7 +1365,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { if tab.badgeState != newBadge { if newBadge == .terminalActive { DiagnosticLog.shared.log("processmon", - "badge -> terminalActive: project=\(project.path) tab=\"\(tab.name)\"") + "badge -> terminalActive: workspace=\(workspace.path) tab=\"\(tab.name)\"") } tab.badgeState = newBadge changed = true @@ -1379,8 +1379,8 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } func setTitle(_ title: String, forSurfaceId surfaceId: UUID) { - for project in projects { - for tab in project.tabs where tab.surface.surfaceId == surfaceId { + for workspace in workspaces { + for tab in workspace.tabs where tab.surface.surfaceId == surfaceId { guard tab.surface.title != title else { return } tab.surface.title = title return @@ -1389,9 +1389,9 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } func handleSurfaceClosedById(_ surfaceId: UUID) { - for (pi, project) in projects.enumerated() { - if let ti = project.tabs.firstIndex(where: { $0.id == surfaceId }) { - let tab = project.tabs[ti] + for (pi, workspace) in workspaces.enumerated() { + if let ti = workspace.tabs.firstIndex(where: { $0.id == surfaceId }) { + let tab = workspace.tabs[ti] // Terminal tabs: restart shell instead of removing the tab. // Reconnects to the tmux session if it still exists, otherwise @@ -1399,27 +1399,27 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { if tab.isTerminal && tab.surface.canRestart { DiagnosticLog.shared.log("surface", "restarting shell for surfaceId=\(surfaceId)") - tab.surface.restartShell(workingDirectory: project.path) + tab.surface.restartShell(workingDirectory: workspace.path) return } tab.surface.terminate() tabCreationOrder.removeAll { $0 == tab.id } - project.tabs.remove(at: ti) + workspace.tabs.remove(at: ti) - if project.tabs.isEmpty && pi == selectedProjectIndex { + if workspace.tabs.isEmpty && pi == selectedWorkspaceIndex { currentTerminalView?.removeFromSuperview() currentTerminalView = nil rebuildTabBar() rebuildSidebar() - } else if project.tabs.isEmpty { + } else if workspace.tabs.isEmpty { rebuildSidebar() - } else if pi == selectedProjectIndex { - project.selectedTabIndex = min(project.selectedTabIndex, project.tabs.count - 1) + } else if pi == selectedWorkspaceIndex { + workspace.selectedTabIndex = min(workspace.selectedTabIndex, workspace.tabs.count - 1) rebuildTabBar() rebuildSidebar() - showTab(project.tabs[project.selectedTabIndex]) + showTab(workspace.tabs[workspace.selectedTabIndex]) } else { rebuildSidebar() } @@ -1433,8 +1433,8 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { func tabForSurfaceId(_ surfaceIdStr: String) -> TabItem? { guard let surfaceId = UUID(uuidString: surfaceIdStr) else { return nil } - for project in projects { - if let tab = project.tabs.first(where: { $0.id == surfaceId }) { + for workspace in workspaces { + if let tab = workspace.tabs.first(where: { $0.id == surfaceId }) { return tab } } @@ -1448,27 +1448,27 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { func isTabFocused(_ surfaceIdStr: String) -> Bool { guard let surfaceId = UUID(uuidString: surfaceIdStr) else { return false } - guard let project = currentProject else { return false } - let idx = project.selectedTabIndex - guard idx >= 0, idx < project.tabs.count else { return false } - return project.tabs[idx].id == surfaceId && (window?.isKeyWindow ?? false) + guard let workspace = currentWorkspace else { return false } + let idx = workspace.selectedTabIndex + guard idx >= 0, idx < workspace.tabs.count else { return false } + return workspace.tabs[idx].id == surfaceId && (window?.isKeyWindow ?? false) } - /// Whether the tab is currently visible (selected tab in the active project), + /// Whether the tab is currently visible (selected tab in the active workspace), /// regardless of whether the Deckard window is in the foreground. func isTabVisible(_ surfaceIdStr: String) -> Bool { guard let surfaceId = UUID(uuidString: surfaceIdStr) else { return false } - guard let project = currentProject else { return false } - let idx = project.selectedTabIndex - guard idx >= 0, idx < project.tabs.count else { return false } - return project.tabs[idx].id == surfaceId + guard let workspace = currentWorkspace else { return false } + let idx = workspace.selectedTabIndex + guard idx >= 0, idx < workspace.tabs.count else { return false } + return workspace.tabs[idx].id == surfaceId } func focusTabById(_ tabId: UUID) { - for (pi, project) in projects.enumerated() { - if let ti = project.tabs.firstIndex(where: { $0.id == tabId }) { - selectProject(at: pi) - selectTabInProject(at: ti) + for (pi, workspace) in workspaces.enumerated() { + if let ti = workspace.tabs.firstIndex(where: { $0.id == tabId }) { + selectWorkspace(at: pi) + selectTabInWorkspace(at: ti) window?.makeKeyAndOrderFront(nil) return } @@ -1484,9 +1484,9 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { SessionManager.shared.saveSessionName(sessionId: sessionId, kind: tab.kind, name: tab.name) saveState() // Start watching if this is the currently displayed tab - if let project = currentProject, - let idx = project.tabs.firstIndex(where: { $0.id == tab.id }), - idx == project.selectedTabIndex { + if let workspace = currentWorkspace, + let idx = workspace.tabs.firstIndex(where: { $0.id == tab.id }), + idx == workspace.selectedTabIndex { refreshContextBar(for: tab) } } @@ -1520,17 +1520,17 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { func listTabInfo() -> [TabInfo] { var result: [TabInfo] = [] - for project in projects { - for tab in project.tabs { + for workspace in workspaces { + for tab in workspace.tabs { result.append(TabInfo( id: tab.id.uuidString, - name: "\(project.name)/\(tab.name)", + name: "\(workspace.name)/\(tab.name)", isClaude: tab.isClaude, kind: tab.kind.rawValue, isMaster: false, sessionId: tab.sessionId, badgeState: tab.badgeState.rawValue, - workingDirectory: project.path + workingDirectory: workspace.path )) } } @@ -1558,28 +1558,28 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { func captureState() -> DeckardState { var state = DeckardState() - state.selectedTabIndex = selectedProjectIndex - state.tabs = projects.map { project in - // Store project-level info; individual tabs stored in a new field + state.selectedTabIndex = selectedWorkspaceIndex + state.tabs = workspaces.map { workspace in + // Store workspace-level info; individual tabs stored in a new field TabState( - id: project.id.uuidString, + id: workspace.id.uuidString, sessionId: nil, - name: project.name, + name: workspace.name, nameOverride: false, isMaster: false, isClaude: false, - workingDirectory: project.path + workingDirectory: workspace.path ) } - // Store full project data in the new projects field - state.projects = projects.map { project in - ProjectState( - id: project.id.uuidString, - path: project.path, - name: project.name, - selectedTabIndex: project.selectedTabIndex, - tabs: project.tabs.map { tab in - ProjectTabState( + // Store full workspace data in the new workspaces field + state.workspaces = workspaces.map { workspace in + WorkspaceState( + id: workspace.id.uuidString, + path: workspace.path, + name: workspace.name, + selectedTabIndex: workspace.selectedTabIndex, + tabs: workspace.tabs.map { tab in + WorkspaceTabState( id: tab.id.uuidString, name: tab.name, kind: tab.kind, @@ -1587,28 +1587,28 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { tmuxSessionName: tab.surface.tmuxSessionName ) }, - defaultArgs: project.defaultArgs, - defaultCodexArgs: project.defaultCodexArgs + defaultArgs: workspace.defaultArgs, + defaultCodexArgs: workspace.defaultCodexArgs ) } - // Persist sidebar folders - state.sidebarFolders = sidebarFolders.map { folder in - SidebarFolderState( - id: folder.id.uuidString, - name: folder.name, - isCollapsed: folder.isCollapsed, - projectIds: folder.projectIds.map { $0.uuidString } + // Persist sidebar groups + state.sidebarGroups = sidebarGroups.map { group in + SidebarGroupState( + id: group.id.uuidString, + name: group.name, + isCollapsed: group.isCollapsed, + workspaceIds: group.workspaceIds.map { $0.uuidString } ) } // Persist sidebar order state.sidebarOrder = sidebarOrder.compactMap { item in switch item { - case .folder(let folder): - return .folder(folder.id.uuidString) - case .project(let pid): - return .project(pid.uuidString) + case .group(let group): + return .group(group.id.uuidString) + case .workspace(let pid): + return .workspace(pid.uuidString) } } @@ -1621,7 +1621,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { private func restoreOrCreateInitial() { guard let state = SessionManager.shared.load(), - let projectStates = state.projects, !projectStates.isEmpty else { + let workspaceStates = state.workspaces, !workspaceStates.isEmpty else { // Nothing to restore — start autosave immediately SessionManager.shared.startAutosave { [weak self] in self?.captureState() ?? DeckardState() @@ -1631,28 +1631,28 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { isRestoring = true - // Pre-flight: touch each unique project directory to trigger a single - // TCC prompt per protected folder category (Documents, Desktop, etc.) + // Pre-flight: touch each unique workspace directory to trigger a single + // TCC prompt per protected group category (Documents, Desktop, etc.) // before mass-creating tabs. Without this, each forkpty queues its // own TCC request and the user sees one dialog per tab. - let uniquePaths = Set(projectStates.map(\.path)) + let uniquePaths = Set(workspaceStates.map(\.path)) for path in uniquePaths { _ = FileManager.default.isReadableFile(atPath: path) } - let selectedIdx = min(max(state.selectedTabIndex, 0), projectStates.count - 1) + let selectedIdx = min(max(state.selectedTabIndex, 0), workspaceStates.count - 1) var codexRestoreCandidatesByPath: [String: [String]] = [:] - var usedCodexSessionIds = Set(projectStates.flatMap { project in - project.tabs.compactMap { tab in + var usedCodexSessionIds = Set(workspaceStates.flatMap { workspace in + workspace.tabs.compactMap { tab in tab.kind == .codex ? tab.sessionId : nil } }) - func recoverCodexSessionId(for projectPath: String, tabName: String) -> String? { - let resolvedPath = (projectPath as NSString).resolvingSymlinksInPath + func recoverCodexSessionId(for workspacePath: String, tabName: String) -> String? { + let resolvedPath = (workspacePath as NSString).resolvingSymlinksInPath if codexRestoreCandidatesByPath[resolvedPath] == nil { codexRestoreCandidatesByPath[resolvedPath] = ContextMonitor.shared - .listCodexSessions(forProjectPath: resolvedPath) + .listCodexSessions(forWorkspacePath: resolvedPath) .map(\.sessionId) } @@ -1668,15 +1668,15 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { return nil } - // Phase 1: Create the active project's active tab immediately so the user + // Phase 1: Create the active workspace's active tab immediately so the user // sees a working terminal right away. Collect remaining tabs for Phase 2. - var pending: [(project: ProjectItem, tab: ProjectTabState, originalIndex: Int)] = [] + var pending: [(workspace: WorkspaceItem, tab: WorkspaceTabState, originalIndex: Int)] = [] - for (i, ps) in projectStates.enumerated() { - let project = ProjectItem(path: ps.path) - project.name = ps.name - project.defaultArgs = ps.defaultArgs - project.defaultCodexArgs = ps.defaultCodexArgs + for (i, ps) in workspaceStates.enumerated() { + let workspace = WorkspaceItem(path: ps.path) + workspace.name = ps.name + workspace.defaultArgs = ps.defaultArgs + workspace.defaultCodexArgs = ps.defaultCodexArgs let selTab = min(max(ps.selectedTabIndex, 0), max(ps.tabs.count - 1, 0)) @@ -1688,56 +1688,56 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { if i == selectedIdx && t == selTab { // Create the active tab's surface synchronously - createTabInProject(project, kind: restoredTab.kind, name: restoredTab.name, + createTabInWorkspace(workspace, kind: restoredTab.kind, name: restoredTab.name, sessionIdToResume: restoredTab.kind.isAgent ? restoredTab.sessionId : nil, tmuxSessionToResume: restoredTab.tmuxSessionName) } else { - pending.append((project: project, tab: restoredTab, originalIndex: t)) + pending.append((workspace: workspace, tab: restoredTab, originalIndex: t)) } } - project.selectedTabIndex = selTab - projects.append(project) + workspace.selectedTabIndex = selTab + workspaces.append(workspace) } - // Keep isRestoring = true until Phase 2 finishes, so selectProject + // Keep isRestoring = true until Phase 2 finishes, so selectWorkspace // won't clamp selectedTabIndex before all tabs are inserted. - // Restore sidebar folders - restoreSidebarFolders(from: state) + // Restore sidebar groups + restoreSidebarGroups(from: state) rebuildSidebar() - if selectedIdx >= 0 && selectedIdx < projects.count { - selectProject(at: selectedIdx) + if selectedIdx >= 0 && selectedIdx < workspaces.count { + selectWorkspace(at: selectedIdx) } // Phase 2: Create remaining surfaces progressively with small delays for UX. createTabsProgressively(pending) } - private func restoreSidebarFolders(from state: DeckardState) { - // During restore, ProjectItem gets a new UUID. Build a map from saved-id -> live ProjectItem. - // Match by index (projects are created in the same order as projectStates) rather than - // by path, because multiple projects can share the same path (e.g. ~/Downloads). - guard let projectStates = state.projects else { return } - var savedIdToProject: [String: ProjectItem] = [:] - for (i, ps) in projectStates.enumerated() { - guard i < projects.count else { continue } - savedIdToProject[ps.id] = projects[i] - } - - // Restore folders - if let folderStates = state.sidebarFolders { - for fs in folderStates { - guard let folderId = UUID(uuidString: fs.id) else { continue } - let resolvedIds = fs.projectIds.compactMap { savedIdToProject[$0]?.id } - let folder = SidebarFolder( - id: folderId, + private func restoreSidebarGroups(from state: DeckardState) { + // During restore, WorkspaceItem gets a new UUID. Build a map from saved-id -> live WorkspaceItem. + // Match by index (workspaces are created in the same order as workspaceStates) rather than + // by path, because multiple workspaces can share the same path (e.g. ~/Downloads). + guard let workspaceStates = state.workspaces else { return } + var savedIdToWorkspace: [String: WorkspaceItem] = [:] + for (i, ps) in workspaceStates.enumerated() { + guard i < workspaces.count else { continue } + savedIdToWorkspace[ps.id] = workspaces[i] + } + + // Restore groups + if let groupStates = state.sidebarGroups { + for fs in groupStates { + guard let groupId = UUID(uuidString: fs.id) else { continue } + let resolvedIds = fs.workspaceIds.compactMap { savedIdToWorkspace[$0]?.id } + let group = SidebarGroup( + id: groupId, name: fs.name, isCollapsed: fs.isCollapsed, - projectIds: resolvedIds + workspaceIds: resolvedIds ) - sidebarFolders.append(folder) + sidebarGroups.append(group) } } @@ -1745,24 +1745,24 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { if let orderItems = state.sidebarOrder { sidebarOrder = orderItems.compactMap { item in switch item { - case .folder(let idStr): - if let folder = sidebarFolders.first(where: { $0.id.uuidString == idStr }) { - return .folder(folder) + case .group(let idStr): + if let group = sidebarGroups.first(where: { $0.id.uuidString == idStr }) { + return .group(group) } return nil - case .project(let idStr): - if let project = savedIdToProject[idStr] { - return .project(project.id) + case .workspace(let idStr): + if let workspace = savedIdToWorkspace[idStr] { + return .workspace(workspace.id) } return nil } } } - // If no saved order, ensureSidebarOrder() will build one from projects + // If no saved order, ensureSidebarOrder() will build one from workspaces } - private func createTabsProgressively(_ remaining: [(project: ProjectItem, tab: ProjectTabState, originalIndex: Int)]) { + private func createTabsProgressively(_ remaining: [(workspace: WorkspaceItem, tab: WorkspaceTabState, originalIndex: Int)]) { guard let first = remaining.first else { // All tabs created — rebuild UI to reflect the full state isRestoring = false @@ -1779,9 +1779,9 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { // Dump tab creation order -> PID mapping for diagnostics let mapping = tabCreationOrder.enumerated().map { (i, id) -> String in var label = "?" - for project in projects { - if let tab = project.tabs.first(where: { $0.id == id }) { - label = "\(tab.kind.rawValue.prefix(1).uppercased()):\(tab.name)@\(project.name)" + for workspace in workspaces { + if let tab = workspace.tabs.first(where: { $0.id == id }) { + label = "\(tab.kind.rawValue.prefix(1).uppercased()):\(tab.name)@\(workspace.name)" break } } @@ -1793,18 +1793,18 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } let ts = first.tab - let project = first.project + let workspace = first.workspace let insertAt = first.originalIndex - // Create the tab (appends to project.tabs) - createTabInProject(project, kind: ts.kind, name: ts.name, + // Create the tab (appends to workspace.tabs) + createTabInWorkspace(workspace, kind: ts.kind, name: ts.name, sessionIdToResume: ts.kind.isAgent ? ts.sessionId : nil, tmuxSessionToResume: ts.tmuxSessionName) // Move it from the end to its original position - if insertAt < project.tabs.count - 1 { - let tab = project.tabs.removeLast() - project.tabs.insert(tab, at: min(insertAt, project.tabs.count)) + if insertAt < workspace.tabs.count - 1 { + let tab = workspace.tabs.removeLast() + workspace.tabs.insert(tab, at: min(insertAt, workspace.tabs.count)) } // Small delay between tab creations for smoother UX during restore. @@ -1832,8 +1832,8 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { @objc private func quotaDidChange() { DispatchQueue.main.async { [weak self] in guard let self = self else { return } - guard let project = self.currentProject, - let activeTab = project.tabs[safe: project.selectedTabIndex], + guard let workspace = self.currentWorkspace, + let activeTab = workspace.tabs[safe: workspace.selectedTabIndex], activeTab.kind == .claude else { return } self.quotaView.update( snapshot: QuotaMonitor.shared.latest, @@ -1858,8 +1858,8 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { rebuildTabBar() // Apply color scheme to all terminal surfaces - for project in projects { - for tab in project.tabs { + for workspace in workspaces { + for tab in workspace.tabs { tab.surface.applyColorScheme(scheme) } } @@ -1867,45 +1867,45 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { // MARK: - Navigation - /// Project indices matching visible sidebar rows (skips collapsed folders). - func projectIndicesInSidebarOrder() -> [Int] { + /// Workspace indices matching visible sidebar rows (skips collapsed groups). + func workspaceIndicesInSidebarOrder() -> [Int] { var indices: [Int] = [] for item in sidebarOrder { switch item { - case .project(let id): - if let i = projects.firstIndex(where: { $0.id == id }) { indices.append(i) } - case .folder(let folder): - guard !folder.isCollapsed else { continue } - for id in folder.projectIds { - if let i = projects.firstIndex(where: { $0.id == id }) { indices.append(i) } + case .workspace(let id): + if let i = workspaces.firstIndex(where: { $0.id == id }) { indices.append(i) } + case .group(let group): + guard !group.isCollapsed else { continue } + for id in group.workspaceIds { + if let i = workspaces.firstIndex(where: { $0.id == id }) { indices.append(i) } } } } return indices } - func selectNextProject() { - let ordered = projectIndicesInSidebarOrder() + func selectNextWorkspace() { + let ordered = workspaceIndicesInSidebarOrder() guard !ordered.isEmpty else { return } - let cur = ordered.firstIndex(of: selectedProjectIndex) ?? -1 - selectProject(at: ordered[(cur + 1) % ordered.count]) + let cur = ordered.firstIndex(of: selectedWorkspaceIndex) ?? -1 + selectWorkspace(at: ordered[(cur + 1) % ordered.count]) } - func selectPrevProject() { - let ordered = projectIndicesInSidebarOrder() + func selectPrevWorkspace() { + let ordered = workspaceIndicesInSidebarOrder() guard !ordered.isEmpty else { return } - let cur = ordered.firstIndex(of: selectedProjectIndex) ?? ordered.count - selectProject(at: ordered[(cur - 1 + ordered.count) % ordered.count]) + let cur = ordered.firstIndex(of: selectedWorkspaceIndex) ?? ordered.count + selectWorkspace(at: ordered[(cur - 1 + ordered.count) % ordered.count]) } - func selectProject(byNumber n: Int) { - let ordered = projectIndicesInSidebarOrder() + func selectWorkspace(byNumber n: Int) { + let ordered = workspaceIndicesInSidebarOrder() guard n >= 0, n < ordered.count else { return } - selectProject(at: ordered[n]) + selectWorkspace(at: ordered[n]) } func updateShortcutIndicators(commandHeld: Bool) { - let ordered = commandHeld ? projectIndicesInSidebarOrder() : [] + let ordered = commandHeld ? workspaceIndicesInSidebarOrder() : [] for view in sidebarStackView.arrangedSubviews { guard let row = view as? VerticalTabRowView else { continue } if let pos = ordered.firstIndex(of: row.index), pos < 10 { diff --git a/Sources/Window/SettingsWindow.swift b/Sources/Window/SettingsWindow.swift index b56ab25..580faf7 100644 --- a/Sources/Window/SettingsWindow.swift +++ b/Sources/Window/SettingsWindow.swift @@ -194,7 +194,7 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie fieldLabel: "Default arguments:", defaultsKey: "claudeExtraArgs", flagSource: .claude, - help: "Arguments passed to every new Claude Code session. Can be overridden per project.", + help: "Arguments passed to every new Claude Code session. Can be overridden per workspace.", checkboxTitle: "Customize Claude arguments per session", checkboxHelp: "Show a dialog to set arguments when creating a new Claude tab.", checkboxDefaultsKey: "promptForSessionArgs", @@ -206,7 +206,7 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie fieldLabel: "Default parameters:", defaultsKey: "codexExtraArgs", flagSource: .codex, - help: "Codex CLI parameters passed to every new Codex session, such as model, effort, sandbox, and approval settings. Can be overridden per project.", + help: "Codex CLI parameters passed to every new Codex session, such as model, effort, sandbox, and approval settings. Can be overridden per workspace.", checkboxTitle: "Customize Codex parameters per session", checkboxHelp: "Show a dialog to set parameters when creating a new Codex tab.", checkboxDefaultsKey: "promptForCodexSessionArgs", @@ -1075,7 +1075,7 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie grid.column(at: 2).width = 120 grid.column(at: 3).width = 100 - // Build right-column entries: reveal toggle first, then the project shortcuts + // Build right-column entries: reveal toggle first, then the workspace shortcuts let revealLabel = NSTextField(labelWithString: "Show Numbers") revealLabel.alignment = .right let revealToggle = RevealShortcutView() diff --git a/Sources/Window/SidebarController.swift b/Sources/Window/SidebarController.swift index e7864a8..f7498c4 100644 --- a/Sources/Window/SidebarController.swift +++ b/Sources/Window/SidebarController.swift @@ -7,31 +7,31 @@ extension DeckardWindowController { // MARK: - Sidebar Helpers - /// Build `sidebarOrder` from the flat projects array when no order exists yet (migration). + /// Build `sidebarOrder` from the flat workspaces array when no order exists yet (migration). func ensureSidebarOrder() { - guard sidebarOrder.isEmpty, !projects.isEmpty else { return } - sidebarOrder = projects.map { .project($0.id) } + guard sidebarOrder.isEmpty, !workspaces.isEmpty else { return } + sidebarOrder = workspaces.map { .workspace($0.id) } } - /// Remove a project from sidebarOrder and all folders' projectIds. - func removeSidebarReference(projectId: UUID) { + /// Remove a workspace from sidebarOrder and all groups' workspaceIds. + func removeSidebarReference(workspaceId: UUID) { sidebarOrder.removeAll { item in - if case .project(let id) = item, id == projectId { return true } + if case .workspace(let id) = item, id == workspaceId { return true } return false } - for folder in sidebarFolders { - folder.projectIds.removeAll { $0 == projectId } + for group in sidebarGroups { + group.workspaceIds.removeAll { $0 == workspaceId } } } - /// Look up a ProjectItem by id. - func projectById(_ id: UUID) -> ProjectItem? { - projects.first { $0.id == id } + /// Look up a WorkspaceItem by id. + func workspaceById(_ id: UUID) -> WorkspaceItem? { + workspaces.first { $0.id == id } } - /// Returns the flat index into `projects` for a given project id, or -1. - func projectIndex(forId id: UUID) -> Int { - projects.firstIndex { $0.id == id } ?? -1 + /// Returns the flat index into `workspaces` for a given workspace id, or -1. + func workspaceIndex(forId id: UUID) -> Int { + workspaces.firstIndex { $0.id == id } ?? -1 } // MARK: - Sidebar Rebuild @@ -39,7 +39,7 @@ extension DeckardWindowController { /// True when any sidebar row is being inline-edited (rename). private var isSidebarEditing: Bool { sidebarStackView.arrangedSubviews.contains { view in - if let fv = view as? SidebarFolderView, fv.isEditingName { return true } + if let fv = view as? SidebarGroupView, fv.isEditingName { return true } if let rv = view as? VerticalTabRowView, rv.isEditingName { return true } return false } @@ -65,162 +65,162 @@ extension DeckardWindowController { // Check current modifier state to pre-set shortcut indicators on new rows let revealMods = revealNumbersModifiers() let cmdHeld = !revealMods.isEmpty && NSEvent.modifierFlags.contains(revealMods) - var shortcutForProjectIndex: [Int: String] = [:] + var shortcutForWorkspaceIndex: [Int: String] = [:] if cmdHeld { - for (pos, pi) in projectIndicesInSidebarOrder().prefix(10).enumerated() { - shortcutForProjectIndex[pi] = "\((pos + 1) % 10)" + for (pos, pi) in workspaceIndicesInSidebarOrder().prefix(10).enumerated() { + shortcutForWorkspaceIndex[pi] = "\((pos + 1) % 10)" } } - // Map from arranged-subview index to flat project index (for selection highlight). + // Map from arranged-subview index to flat workspace index (for selection highlight). // Also used for drag-drop: we store a "sidebar row index" in the pasteboard. - var sidebarRowToProjectIndex: [Int: Int] = [:] + var sidebarRowToWorkspaceIndex: [Int: Int] = [:] var rowIndex = 0 for sidebarItem in sidebarOrder { switch sidebarItem { - case .project(let projectId): - guard let project = projectById(projectId) else { continue } - let pi = projectIndex(forId: projectId) - let row = VerticalTabRowView(title: project.name, bold: false, index: pi, - target: self, action: #selector(projectRowClicked(_:))) - row.shortcutBadge = shortcutForProjectIndex[pi] - row.badgeInfos = project.tabs.filter { $0.badgeState != .none }.map { tab in + case .workspace(let workspaceId): + guard let workspace = workspaceById(workspaceId) else { continue } + let pi = workspaceIndex(forId: workspaceId) + let row = VerticalTabRowView(title: workspace.name, bold: false, index: pi, + target: self, action: #selector(workspaceRowClicked(_:))) + row.shortcutBadge = shortcutForWorkspaceIndex[pi] + row.badgeInfos = workspace.tabs.filter { $0.badgeState != .none }.map { tab in (state: tab.badgeState, name: tab.name, activity: self.terminalActivity[tab.id]) } row.onRename = { [weak self] newName in guard let self = self else { return } - project.name = newName + workspace.name = newName self.saveState() } row.onClearName = { [weak self] in guard let self = self else { return } - project.name = (project.path as NSString).lastPathComponent + workspace.name = (workspace.path as NSString).lastPathComponent self.rebuildSidebar() self.saveState() } row.onContextMenu = { [weak self] event in guard let self = self else { return nil } - return self.buildProjectContextMenu(for: project) + return self.buildWorkspaceContextMenu(for: workspace) } sidebarStackView.addArrangedSubview(row) row.leadingAnchor.constraint(equalTo: sidebarStackView.leadingAnchor).isActive = true row.trailingAnchor.constraint(equalTo: sidebarStackView.trailingAnchor).isActive = true - sidebarRowToProjectIndex[rowIndex] = pi + sidebarRowToWorkspaceIndex[rowIndex] = pi rowIndex += 1 - case .folder(let folder): - // Folder header - let folderView = SidebarFolderView( - folder: folder, - projectCount: folder.projectIds.count + case .group(let group): + // Group header + let groupView = SidebarGroupView( + group: group, + workspaceCount: group.workspaceIds.count ) - folderView.onToggle = { [weak self] fv in - self?.folderToggleClicked(fv) + groupView.onToggle = { [weak self] fv in + self?.groupToggleClicked(fv) } - folderView.onDrop = { [weak self] fv, fromIndex in + groupView.onDrop = { [weak self] fv, fromIndex in guard let self else { return } - guard fromIndex >= 0, fromIndex < self.projects.count else { return } - let project = self.projects[fromIndex] - self.moveProjectIntoFolder(projectId: project.id, folder: fv.folder) + guard fromIndex >= 0, fromIndex < self.workspaces.count else { return } + let workspace = self.workspaces[fromIndex] + self.moveWorkspaceIntoGroup(workspaceId: workspace.id, group: fv.group) } - // Aggregate badge infos from all projects in the folder + // Aggregate badge infos from all workspaces in the group var aggregatedBadges: [(state: TabItem.BadgeState, name: String, activity: ProcessMonitor.ActivityInfo?)] = [] - for pid in folder.projectIds { - if let project = projectById(pid) { - for tab in project.tabs where tab.badgeState != .none { + for pid in group.workspaceIds { + if let workspace = workspaceById(pid) { + for tab in workspace.tabs where tab.badgeState != .none { aggregatedBadges.append((state: tab.badgeState, name: tab.name, activity: self.terminalActivity[tab.id])) } } } - folderView.badgeInfos = aggregatedBadges + groupView.badgeInfos = aggregatedBadges - folderView.onRename = { [weak self] newName in + groupView.onRename = { [weak self] newName in guard let self = self else { return } - folder.name = newName + group.name = newName self.saveState() } - folderView.onContextMenu = { [weak self] event in + groupView.onContextMenu = { [weak self] event in guard let self = self else { return nil } - return self.buildFolderContextMenu(for: folder) + return self.buildGroupContextMenu(for: group) } - folderView.rowIndex = rowIndex - sidebarStackView.addArrangedSubview(folderView) - folderView.leadingAnchor.constraint(equalTo: sidebarStackView.leadingAnchor).isActive = true - folderView.trailingAnchor.constraint(equalTo: sidebarStackView.trailingAnchor).isActive = true + groupView.rowIndex = rowIndex + sidebarStackView.addArrangedSubview(groupView) + groupView.leadingAnchor.constraint(equalTo: sidebarStackView.leadingAnchor).isActive = true + groupView.trailingAnchor.constraint(equalTo: sidebarStackView.trailingAnchor).isActive = true rowIndex += 1 - // Render projects inside the folder (if not collapsed) - if !folder.isCollapsed { - for projectId in folder.projectIds { - guard let project = projectById(projectId) else { continue } - let pi = projectIndex(forId: projectId) - let row = VerticalTabRowView(title: project.name, bold: false, index: pi, - target: self, action: #selector(projectRowClicked(_:))) + // Render workspaces inside the group (if not collapsed) + if !group.isCollapsed { + for workspaceId in group.workspaceIds { + guard let workspace = workspaceById(workspaceId) else { continue } + let pi = workspaceIndex(forId: workspaceId) + let row = VerticalTabRowView(title: workspace.name, bold: false, index: pi, + target: self, action: #selector(workspaceRowClicked(_:))) row.indent = 16 - row.shortcutBadge = shortcutForProjectIndex[pi] - row.badgeInfos = project.tabs.filter { $0.badgeState != .none }.map { tab in + row.shortcutBadge = shortcutForWorkspaceIndex[pi] + row.badgeInfos = workspace.tabs.filter { $0.badgeState != .none }.map { tab in (state: tab.badgeState, name: tab.name, activity: self.terminalActivity[tab.id]) } row.onRename = { [weak self] newName in guard let self = self else { return } - project.name = newName + workspace.name = newName self.saveState() } row.onClearName = { [weak self] in guard let self = self else { return } - project.name = (project.path as NSString).lastPathComponent + workspace.name = (workspace.path as NSString).lastPathComponent self.rebuildSidebar() self.saveState() } row.onContextMenu = { [weak self] event in guard let self = self else { return nil } - return self.buildProjectContextMenu(for: project) + return self.buildWorkspaceContextMenu(for: workspace) } sidebarStackView.addArrangedSubview(row) row.leadingAnchor.constraint(equalTo: sidebarStackView.leadingAnchor).isActive = true row.trailingAnchor.constraint(equalTo: sidebarStackView.trailingAnchor).isActive = true - sidebarRowToProjectIndex[rowIndex] = pi + sidebarRowToWorkspaceIndex[rowIndex] = pi rowIndex += 1 } } } } - sidebarStackView.registerForDraggedTypes([deckardProjectDragType, deckardSidebarDragType, deckardFolderDragType]) + sidebarStackView.registerForDraggedTypes([deckardWorkspaceDragType, deckardSidebarDragType, deckardGroupDragType]) sidebarStackView.onReorder = { [weak self] from, to, forceTopLevel in - self?.handleSidebarDragReorder(fromProjectIndex: from, toRow: to, forceTopLevel: forceTopLevel) + self?.handleSidebarDragReorder(fromWorkspaceIndex: from, toRow: to, forceTopLevel: forceTopLevel) } - sidebarStackView.onDropOntoFolder = { [weak self] folderView, fromIndex in - folderView.onDrop?(folderView, fromIndex) + sidebarStackView.onDropOntoGroup = { [weak self] groupView, fromIndex in + groupView.onDrop?(groupView, fromIndex) } - sidebarStackView.onFolderReorder = { [weak self] fromRow, toRow in - self?.handleFolderDragReorder(fromRow: fromRow, toRow: toRow) + sidebarStackView.onGroupReorder = { [weak self] fromRow, toRow in + self?.handleGroupDragReorder(fromRow: fromRow, toRow: toRow) } sidebarDropZone.onDrop = { [weak self] fromIndex in - guard let self = self, fromIndex >= 0, fromIndex < self.projects.count else { return } - let project = self.projects[fromIndex] - // If the project was inside a folder, move it out first - if self.sidebarFolders.contains(where: { $0.projectIds.contains(project.id) }) { - self.moveProjectOutOfFolder(projectId: project.id) + guard let self = self, fromIndex >= 0, fromIndex < self.workspaces.count else { return } + let workspace = self.workspaces[fromIndex] + // If the workspace was inside a group, move it out first + if self.sidebarGroups.contains(where: { $0.workspaceIds.contains(workspace.id) }) { + self.moveWorkspaceOutOfGroup(workspaceId: workspace.id) } // Move the sidebarOrder item to the end self.sidebarOrder.removeAll { item in - if case .project(let id) = item, id == project.id { return true } + if case .workspace(let id) = item, id == workspace.id { return true } return false } - self.sidebarOrder.append(.project(project.id)) - self.reorderProject(from: fromIndex, to: self.projects.count) + self.sidebarOrder.append(.workspace(workspace.id)) + self.reorderWorkspace(from: fromIndex, to: self.workspaces.count) } - sidebarDropZone.onFolderDrop = { [weak self] fromRow in + sidebarDropZone.onGroupDrop = { [weak self] fromRow in guard let self else { return } - // Move folder to end of sidebarOrder + // Move group to end of sidebarOrder let infos = self.sidebarRowInfos() - guard fromRow >= 0, fromRow < infos.count, infos[fromRow].isFolder, - let folderId = infos[fromRow].folderId else { return } + guard fromRow >= 0, fromRow < infos.count, infos[fromRow].isGroup, + let groupId = infos[fromRow].groupId else { return } guard let orderIdx = self.sidebarOrder.firstIndex(where: { - if case .folder(let f) = $0, f.id == folderId { return true } + if case .group(let f) = $0, f.id == groupId { return true } return false }) else { return } let item = self.sidebarOrder.remove(at: orderIdx) @@ -231,7 +231,7 @@ extension DeckardWindowController { sidebarDropZone.sidebarStackView = sidebarStackView sidebarDropZone.onContextMenu = { [weak self] event in let menu = NSMenu() - let item = NSMenuItem(title: "New Folder", action: #selector(self?.sidebarEmptyContextNewFolder), keyEquivalent: "") + let item = NSMenuItem(title: "New Group", action: #selector(self?.sidebarEmptyContextNewGroup), keyEquivalent: "") item.target = self menu.addItem(item) return menu @@ -240,22 +240,22 @@ extension DeckardWindowController { updateSidebarSelection() } - func reorderProject(from fromIndex: Int, to toIndex: Int) { + func reorderWorkspace(from fromIndex: Int, to toIndex: Int) { guard fromIndex != toIndex, - fromIndex >= 0, fromIndex < projects.count, - toIndex >= 0, toIndex <= projects.count else { return } + fromIndex >= 0, fromIndex < workspaces.count, + toIndex >= 0, toIndex <= workspaces.count else { return } - let project = projects.remove(at: fromIndex) + let workspace = workspaces.remove(at: fromIndex) let insertAt = toIndex > fromIndex ? toIndex - 1 : toIndex - projects.insert(project, at: min(insertAt, projects.count)) + workspaces.insert(workspace, at: min(insertAt, workspaces.count)) // Update selected index - if selectedProjectIndex == fromIndex { - selectedProjectIndex = insertAt - } else if fromIndex < selectedProjectIndex && insertAt >= selectedProjectIndex { - selectedProjectIndex -= 1 - } else if fromIndex > selectedProjectIndex && insertAt <= selectedProjectIndex { - selectedProjectIndex += 1 + if selectedWorkspaceIndex == fromIndex { + selectedWorkspaceIndex = insertAt + } else if fromIndex < selectedWorkspaceIndex && insertAt >= selectedWorkspaceIndex { + selectedWorkspaceIndex -= 1 + } else if fromIndex > selectedWorkspaceIndex && insertAt <= selectedWorkspaceIndex { + selectedWorkspaceIndex += 1 } rebuildSidebar() @@ -265,36 +265,36 @@ extension DeckardWindowController { // MARK: - Sidebar Row Info /// Maps a sidebar stack view row index to a sidebarOrder-aware identifier. - /// Returns (sidebarOrderIndex, isFolder, isFolderChild, parentFolder, childIndex) + /// Returns (sidebarOrderIndex, isGroup, isGroupChild, parentGroup, childIndex) struct SidebarRowInfo { var sidebarOrderIndex: Int - var isFolder: Bool - var parentFolder: SidebarFolder? - var childIndexInFolder: Int? - var projectId: UUID? - var folderId: UUID? + var isGroup: Bool + var parentGroup: SidebarGroup? + var childIndexInGroup: Int? + var workspaceId: UUID? + var groupId: UUID? } func sidebarRowInfos() -> [SidebarRowInfo] { var infos: [SidebarRowInfo] = [] for (orderIdx, item) in sidebarOrder.enumerated() { switch item { - case .project(let pid): + case .workspace(let pid): infos.append(SidebarRowInfo( - sidebarOrderIndex: orderIdx, isFolder: false, - parentFolder: nil, childIndexInFolder: nil, - projectId: pid, folderId: nil)) - case .folder(let folder): + sidebarOrderIndex: orderIdx, isGroup: false, + parentGroup: nil, childIndexInGroup: nil, + workspaceId: pid, groupId: nil)) + case .group(let group): infos.append(SidebarRowInfo( - sidebarOrderIndex: orderIdx, isFolder: true, - parentFolder: nil, childIndexInFolder: nil, - projectId: nil, folderId: folder.id)) - if !folder.isCollapsed { - for (ci, pid) in folder.projectIds.enumerated() { + sidebarOrderIndex: orderIdx, isGroup: true, + parentGroup: nil, childIndexInGroup: nil, + workspaceId: nil, groupId: group.id)) + if !group.isCollapsed { + for (ci, pid) in group.workspaceIds.enumerated() { infos.append(SidebarRowInfo( - sidebarOrderIndex: orderIdx, isFolder: false, - parentFolder: folder, childIndexInFolder: ci, - projectId: pid, folderId: nil)) + sidebarOrderIndex: orderIdx, isGroup: false, + parentGroup: group, childIndexInGroup: ci, + workspaceId: pid, groupId: nil)) } } } @@ -305,18 +305,18 @@ extension DeckardWindowController { // MARK: - Sidebar Drag Handling /// Handle drag reorder in the sidebar. - /// `fromProjectIndex` is the flat projects array index (from the pasteboard). + /// `fromWorkspaceIndex` is the flat workspaces array index (from the pasteboard). /// `toRow` is the stack view row index of the drop target. - func handleSidebarDragReorder(fromProjectIndex: Int, toRow: Int, forceTopLevel: Bool = false) { - guard fromProjectIndex >= 0, fromProjectIndex < projects.count else { return } - let draggedProject = projects[fromProjectIndex] + func handleSidebarDragReorder(fromWorkspaceIndex: Int, toRow: Int, forceTopLevel: Bool = false) { + guard fromWorkspaceIndex >= 0, fromWorkspaceIndex < workspaces.count else { return } + let draggedWorkspace = workspaces[fromWorkspaceIndex] let infos = sidebarRowInfos() guard toRow >= 0, toRow < infos.count else { // Drop past the end — move to top level at the end - let wasInFolder = sidebarFolders.contains { $0.projectIds.contains(draggedProject.id) } - if wasInFolder { moveProjectOutOfFolder(projectId: draggedProject.id) } - sidebarOrder.removeAll { if case .project(let id) = $0, id == draggedProject.id { return true }; return false } - sidebarOrder.append(.project(draggedProject.id)) + let wasInGroup = sidebarGroups.contains { $0.workspaceIds.contains(draggedWorkspace.id) } + if wasInGroup { moveWorkspaceOutOfGroup(workspaceId: draggedWorkspace.id) } + sidebarOrder.removeAll { if case .workspace(let id) = $0, id == draggedWorkspace.id { return true }; return false } + sidebarOrder.append(.workspace(draggedWorkspace.id)) rebuildSidebar() saveState() return @@ -324,53 +324,53 @@ extension DeckardWindowController { let toInfo = infos[toRow] - // Note: dropping directly *onto* a folder header (with highlight) is - // handled separately via onDropOntoFolder in performDragOperation. + // Note: dropping directly *onto* a group header (with highlight) is + // handled separately via onDropOntoGroup in performDragOperation. // Here we only handle line-indicator (between-items) drops. - // Determine the target folder: either the row itself is a folder child, - // or the row above is (dropping after the last child in a folder). - let effectiveFolder: SidebarFolder? + // Determine the target group: either the row itself is a group child, + // or the row above is (dropping after the last child in a group). + let effectiveGroup: SidebarGroup? let effectiveChildIndex: Int? - if let pf = toInfo.parentFolder { - effectiveFolder = pf - effectiveChildIndex = toInfo.childIndexInFolder - } else if !forceTopLevel, toRow > 0, toRow - 1 < infos.count, let prevFolder = infos[toRow - 1].parentFolder { - // The previous row is a folder child — we're inserting at the end of that folder - effectiveFolder = prevFolder - effectiveChildIndex = prevFolder.projectIds.count + if let pf = toInfo.parentGroup { + effectiveGroup = pf + effectiveChildIndex = toInfo.childIndexInGroup + } else if !forceTopLevel, toRow > 0, toRow - 1 < infos.count, let prevGroup = infos[toRow - 1].parentGroup { + // The previous row is a group child — we're inserting at the end of that group + effectiveGroup = prevGroup + effectiveChildIndex = prevGroup.workspaceIds.count } else { - effectiveFolder = nil + effectiveGroup = nil effectiveChildIndex = nil } - // Dropping between items inside the same folder → reorder within folder - let sourceFolder = sidebarFolders.first { $0.projectIds.contains(draggedProject.id) } - if let targetFolder = effectiveFolder, let sf = sourceFolder, sf.id == targetFolder.id { - // Reorder within the same folder - guard let fromIdx = sf.projectIds.firstIndex(of: draggedProject.id), + // Dropping between items inside the same group → reorder within group + let sourceGroup = sidebarGroups.first { $0.workspaceIds.contains(draggedWorkspace.id) } + if let targetGroup = effectiveGroup, let sf = sourceGroup, sf.id == targetGroup.id { + // Reorder within the same group + guard let fromIdx = sf.workspaceIds.firstIndex(of: draggedWorkspace.id), let toIdx = effectiveChildIndex else { return } - sf.projectIds.remove(at: fromIdx) - let insertAt = toIdx > fromIdx ? min(toIdx - 1, sf.projectIds.count) : toIdx - sf.projectIds.insert(draggedProject.id, at: insertAt) + sf.workspaceIds.remove(at: fromIdx) + let insertAt = toIdx > fromIdx ? min(toIdx - 1, sf.workspaceIds.count) : toIdx + sf.workspaceIds.insert(draggedWorkspace.id, at: insertAt) rebuildSidebar() saveState() return } - // Dropping between items inside a different folder → move into that folder at position - if let targetFolder = effectiveFolder { - // Remove from source folder if needed - if let sf = sourceFolder { - sf.projectIds.removeAll { $0 == draggedProject.id } + // Dropping between items inside a different group → move into that group at position + if let targetGroup = effectiveGroup { + // Remove from source group if needed + if let sf = sourceGroup { + sf.workspaceIds.removeAll { $0 == draggedWorkspace.id } } else { // Remove from top-level sidebarOrder - sidebarOrder.removeAll { if case .project(let id) = $0, id == draggedProject.id { return true }; return false } + sidebarOrder.removeAll { if case .workspace(let id) = $0, id == draggedWorkspace.id { return true }; return false } } - // Insert at position in target folder - let insertAt = toInfo.childIndexInFolder ?? targetFolder.projectIds.count - if !targetFolder.projectIds.contains(draggedProject.id) { - targetFolder.projectIds.insert(draggedProject.id, at: min(insertAt, targetFolder.projectIds.count)) + // Insert at position in target group + let insertAt = toInfo.childIndexInGroup ?? targetGroup.workspaceIds.count + if !targetGroup.workspaceIds.contains(draggedWorkspace.id) { + targetGroup.workspaceIds.insert(draggedWorkspace.id, at: min(insertAt, targetGroup.workspaceIds.count)) } rebuildSidebar() saveState() @@ -378,19 +378,19 @@ extension DeckardWindowController { } // Dropping at top level — reorder in sidebarOrder - if let sf = sourceFolder { - sf.projectIds.removeAll { $0 == draggedProject.id } - // Add as top-level project in sidebarOrder at the target position + if let sf = sourceGroup { + sf.workspaceIds.removeAll { $0 == draggedWorkspace.id } + // Add as top-level workspace in sidebarOrder at the target position let targetOrderIdx = toInfo.sidebarOrderIndex // Remove existing top-level entry if any - sidebarOrder.removeAll { if case .project(let id) = $0, id == draggedProject.id { return true }; return false } - sidebarOrder.insert(.project(draggedProject.id), at: min(targetOrderIdx, sidebarOrder.count)) - } else if let targetPid = toInfo.projectId { + sidebarOrder.removeAll { if case .workspace(let id) = $0, id == draggedWorkspace.id { return true }; return false } + sidebarOrder.insert(.workspace(draggedWorkspace.id), at: min(targetOrderIdx, sidebarOrder.count)) + } else if let targetPid = toInfo.workspaceId { // Both are top-level — reorder sidebarOrder if let fromOrderIdx = sidebarOrder.firstIndex(where: { - if case .project(let id) = $0, id == draggedProject.id { return true }; return false + if case .workspace(let id) = $0, id == draggedWorkspace.id { return true }; return false }), let targetOrderIdx = sidebarOrder.firstIndex(where: { - if case .project(let id) = $0, id == targetPid { return true }; return false + if case .workspace(let id) = $0, id == targetPid { return true }; return false }) { let item = sidebarOrder.remove(at: fromOrderIdx) let insertIdx = targetOrderIdx > fromOrderIdx ? targetOrderIdx - 1 : targetOrderIdx @@ -398,124 +398,124 @@ extension DeckardWindowController { } } - // Also reorder in the flat projects array - let fromPi = fromProjectIndex - if let pid = toInfo.projectId, let toPi = projects.firstIndex(where: { $0.id == pid }), fromPi != toPi { - reorderProject(from: fromPi, to: toPi) + // Also reorder in the flat workspaces array + let fromPi = fromWorkspaceIndex + if let pid = toInfo.workspaceId, let toPi = workspaces.firstIndex(where: { $0.id == pid }), fromPi != toPi { + reorderWorkspace(from: fromPi, to: toPi) } else { rebuildSidebar() saveState() } } - // MARK: - Folder Management + // MARK: - Group Management - @objc func sidebarEmptyContextNewFolder() { - createSidebarFolder() + @objc func sidebarEmptyContextNewGroup() { + createSidebarGroup() } - func createSidebarFolder(name: String = "New Folder") { - let folder = SidebarFolder(name: name) - sidebarFolders.append(folder) - sidebarOrder.append(.folder(folder)) + func createSidebarGroup(name: String = "New Group") { + let group = SidebarGroup(name: name) + sidebarGroups.append(group) + sidebarOrder.append(.group(group)) rebuildSidebar() saveState() // Start editing the name immediately - if let folderView = sidebarStackView.arrangedSubviews.compactMap({ $0 as? SidebarFolderView }).last { - folderView.startEditing() + if let groupView = sidebarStackView.arrangedSubviews.compactMap({ $0 as? SidebarGroupView }).last { + groupView.startEditing() } } - func deleteSidebarFolder(_ folder: SidebarFolder) { - // Move all projects inside the folder back to top level (ungrouped) + func deleteSidebarGroup(_ group: SidebarGroup) { + // Move all workspaces inside the group back to top level (ungrouped) let orderIndex = sidebarOrder.firstIndex(where: { - if case .folder(let f) = $0, f.id == folder.id { return true } + if case .group(let f) = $0, f.id == group.id { return true } return false }) - // Insert ungrouped project items in place of the folder + // Insert ungrouped workspace items in place of the group if let idx = orderIndex { sidebarOrder.remove(at: idx) var insertIdx = idx - for pid in folder.projectIds { - sidebarOrder.insert(.project(pid), at: insertIdx) + for pid in group.workspaceIds { + sidebarOrder.insert(.workspace(pid), at: insertIdx) insertIdx += 1 } } - sidebarFolders.removeAll { $0.id == folder.id } + sidebarGroups.removeAll { $0.id == group.id } rebuildSidebar() saveState() } - func moveProjectIntoFolder(projectId: UUID, folder: SidebarFolder) { - // Remove project from current location (top-level or another folder) + func moveWorkspaceIntoGroup(workspaceId: UUID, group: SidebarGroup) { + // Remove workspace from current location (top-level or another group) sidebarOrder.removeAll { item in - if case .project(let id) = item, id == projectId { return true } + if case .workspace(let id) = item, id == workspaceId { return true } return false } - for f in sidebarFolders where f.id != folder.id { - f.projectIds.removeAll { $0 == projectId } + for f in sidebarGroups where f.id != group.id { + f.workspaceIds.removeAll { $0 == workspaceId } } - // Add to target folder - if !folder.projectIds.contains(projectId) { - folder.projectIds.append(projectId) + // Add to target group + if !group.workspaceIds.contains(workspaceId) { + group.workspaceIds.append(workspaceId) } - // Auto-expand folder when adding projects - folder.isCollapsed = false + // Auto-expand group when adding workspaces + group.isCollapsed = false rebuildSidebar() saveState() } - func moveProjectOutOfFolder(projectId: UUID) { - // Find which folder contains this project - guard let folder = sidebarFolders.first(where: { $0.projectIds.contains(projectId) }) else { return } - folder.projectIds.removeAll { $0 == projectId } + func moveWorkspaceOutOfGroup(workspaceId: UUID) { + // Find which group contains this workspace + guard let group = sidebarGroups.first(where: { $0.workspaceIds.contains(workspaceId) }) else { return } + group.workspaceIds.removeAll { $0 == workspaceId } - // Insert as ungrouped project right after the folder in sidebarOrder - if let folderIdx = sidebarOrder.firstIndex(where: { - if case .folder(let f) = $0, f.id == folder.id { return true } + // Insert as ungrouped workspace right after the group in sidebarOrder + if let groupIdx = sidebarOrder.firstIndex(where: { + if case .group(let f) = $0, f.id == group.id { return true } return false }) { - sidebarOrder.insert(.project(projectId), at: folderIdx + 1) + sidebarOrder.insert(.workspace(workspaceId), at: groupIdx + 1) } else { - sidebarOrder.append(.project(projectId)) + sidebarOrder.append(.workspace(workspaceId)) } rebuildSidebar() saveState() } - func folderToggleClicked(_ sender: SidebarFolderView) { - let wasCollapsed = sender.folder.isCollapsed - sender.folder.isCollapsed.toggle() + func groupToggleClicked(_ sender: SidebarGroupView) { + let wasCollapsed = sender.group.isCollapsed + sender.group.isCollapsed.toggle() - // If collapsing a folder that contains the selected project, auto-expand it instead - if sender.folder.isCollapsed, let current = currentProject, - sender.folder.projectIds.contains(current.id) { - sender.folder.isCollapsed = false + // If collapsing a group that contains the selected workspace, auto-expand it instead + if sender.group.isCollapsed, let current = currentWorkspace, + sender.group.workspaceIds.contains(current.id) { + sender.group.isCollapsed = false } DiagnosticLog.shared.log("sidebar", - "folderToggle: \(sender.folder.name) was=\(wasCollapsed) now=\(sender.folder.isCollapsed) projects=\(sender.folder.projectIds.count)") + "groupToggle: \(sender.group.name) was=\(wasCollapsed) now=\(sender.group.isCollapsed) workspaces=\(sender.group.workspaceIds.count)") rebuildSidebar() saveState() } - /// Handle drag-reorder of a folder row. - /// `fromRow` is the row index of the dragged folder, `toRow` is the drop target row. - func handleFolderDragReorder(fromRow: Int, toRow: Int) { + /// Handle drag-reorder of a group row. + /// `fromRow` is the row index of the dragged group, `toRow` is the drop target row. + func handleGroupDragReorder(fromRow: Int, toRow: Int) { let infos = sidebarRowInfos() - guard fromRow >= 0, fromRow < infos.count, infos[fromRow].isFolder, - let folderId = infos[fromRow].folderId else { return } + guard fromRow >= 0, fromRow < infos.count, infos[fromRow].isGroup, + let groupId = infos[fromRow].groupId else { return } - // Find the folder's index in sidebarOrder + // Find the group's index in sidebarOrder guard let fromOrderIdx = sidebarOrder.firstIndex(where: { - if case .folder(let f) = $0, f.id == folderId { return true } + if case .group(let f) = $0, f.id == groupId { return true } return false }) else { return } @@ -537,81 +537,81 @@ extension DeckardWindowController { saveState() } - // MARK: - Folder Context Menu + // MARK: - Group Context Menu - func buildFolderContextMenu(for folder: SidebarFolder) -> NSMenu { + func buildGroupContextMenu(for group: SidebarGroup) -> NSMenu { let menu = NSMenu() - let renameItem = NSMenuItem(title: "Rename Folder", action: #selector(renameFolderMenuAction(_:)), keyEquivalent: "") + let renameItem = NSMenuItem(title: "Rename Group", action: #selector(renameGroupMenuAction(_:)), keyEquivalent: "") renameItem.target = self - renameItem.representedObject = folder + renameItem.representedObject = group menu.addItem(renameItem) menu.addItem(.separator()) - let deleteItem = NSMenuItem(title: "Delete Folder", action: #selector(deleteFolderMenuAction(_:)), keyEquivalent: "") + let deleteItem = NSMenuItem(title: "Delete Group", action: #selector(deleteGroupMenuAction(_:)), keyEquivalent: "") deleteItem.target = self - deleteItem.representedObject = folder + deleteItem.representedObject = group menu.addItem(deleteItem) return menu } - @objc func renameFolderMenuAction(_ sender: NSMenuItem) { - guard let folder = sender.representedObject as? SidebarFolder else { return } - // Find the SidebarFolderView for this folder and start editing + @objc func renameGroupMenuAction(_ sender: NSMenuItem) { + guard let group = sender.representedObject as? SidebarGroup else { return } + // Find the SidebarGroupView for this group and start editing for view in sidebarStackView.arrangedSubviews { - if let fv = view as? SidebarFolderView, fv.folder.id == folder.id { + if let fv = view as? SidebarGroupView, fv.group.id == group.id { fv.startEditing() break } } } - @objc func deleteFolderMenuAction(_ sender: NSMenuItem) { - guard let folder = sender.representedObject as? SidebarFolder else { return } - deleteSidebarFolder(folder) + @objc func deleteGroupMenuAction(_ sender: NSMenuItem) { + guard let group = sender.representedObject as? SidebarGroup else { return } + deleteSidebarGroup(group) } - // MARK: - Project Context Menu + // MARK: - Workspace Context Menu - func buildProjectContextMenu(for project: ProjectItem) -> NSMenu { + func buildWorkspaceContextMenu(for workspace: WorkspaceItem) -> NSMenu { let menu = NSMenu() let exploreItem = NSMenuItem(title: "Explore Sessions", action: #selector(exploreSessionsMenuAction(_:)), keyEquivalent: "") exploreItem.setShortcut(for: .exploreSessions) exploreItem.target = self - exploreItem.representedObject = project + exploreItem.representedObject = workspace menu.addItem(exploreItem) let defaultArgsItem = NSMenuItem(title: "Default Claude Arguments\u{2026}", action: #selector(defaultArgsMenuAction(_:)), keyEquivalent: "") defaultArgsItem.target = self - defaultArgsItem.representedObject = project + defaultArgsItem.representedObject = workspace menu.addItem(defaultArgsItem) let defaultCodexArgsItem = NSMenuItem(title: "Default Codex Arguments\u{2026}", action: #selector(defaultCodexArgsMenuAction(_:)), keyEquivalent: "") defaultCodexArgsItem.target = self - defaultCodexArgsItem.representedObject = project + defaultCodexArgsItem.representedObject = workspace menu.addItem(defaultCodexArgsItem) menu.addItem(.separator()) - // Folder options - let isInFolder = sidebarFolders.contains { $0.projectIds.contains(project.id) } + // Group options + let isInGroup = sidebarGroups.contains { $0.workspaceIds.contains(workspace.id) } - if isInFolder { - let moveOutItem = NSMenuItem(title: "Move Out of Folder", action: #selector(moveProjectOutOfFolderAction(_:)), keyEquivalent: "") - moveOutItem.setShortcut(for: .moveOutOfFolder) + if isInGroup { + let moveOutItem = NSMenuItem(title: "Move Out of Group", action: #selector(moveWorkspaceOutOfGroupAction(_:)), keyEquivalent: "") + moveOutItem.setShortcut(for: .moveOutOfGroup) moveOutItem.target = self - moveOutItem.representedObject = project + moveOutItem.representedObject = workspace menu.addItem(moveOutItem) - } else if !sidebarFolders.isEmpty { - let moveToItem = NSMenuItem(title: "Move to Folder", action: nil, keyEquivalent: "") + } else if !sidebarGroups.isEmpty { + let moveToItem = NSMenuItem(title: "Move to Group", action: nil, keyEquivalent: "") let moveSubmenu = NSMenu() - for folder in sidebarFolders { - let item = NSMenuItem(title: folder.name, action: #selector(moveProjectToFolderAction(_:)), keyEquivalent: "") + for group in sidebarGroups { + let item = NSMenuItem(title: group.name, action: #selector(moveWorkspaceToGroupAction(_:)), keyEquivalent: "") item.target = self - item.representedObject = MoveToFolderInfo(project: project, folder: folder) + item.representedObject = MoveToGroupInfo(workspace: workspace, group: group) moveSubmenu.addItem(item) } moveToItem.submenu = moveSubmenu @@ -620,56 +620,56 @@ extension DeckardWindowController { menu.addItem(.separator()) - let newFolderItem = NSMenuItem(title: "New Folder", action: #selector(newFolderMenuAction), keyEquivalent: "") - newFolderItem.setShortcut(for: .newSidebarFolder) - newFolderItem.target = self - menu.addItem(newFolderItem) + let newGroupItem = NSMenuItem(title: "New Group", action: #selector(newGroupMenuAction), keyEquivalent: "") + newGroupItem.setShortcut(for: .newGroup) + newGroupItem.target = self + menu.addItem(newGroupItem) menu.addItem(.separator()) - let closeItem = NSMenuItem(title: "Close Folder", action: #selector(closeProjectMenuAction(_:)), keyEquivalent: "") - closeItem.setShortcut(for: .closeFolder) + let closeItem = NSMenuItem(title: "Close Workspace", action: #selector(closeWorkspaceMenuAction(_:)), keyEquivalent: "") + closeItem.setShortcut(for: .closeWorkspace) closeItem.target = self - closeItem.representedObject = project + closeItem.representedObject = workspace menu.addItem(closeItem) return menu } - class MoveToFolderInfo { - let project: ProjectItem - let folder: SidebarFolder - init(project: ProjectItem, folder: SidebarFolder) { - self.project = project - self.folder = folder + class MoveToGroupInfo { + let workspace: WorkspaceItem + let group: SidebarGroup + init(workspace: WorkspaceItem, group: SidebarGroup) { + self.workspace = workspace + self.group = group } } - @objc func moveProjectToFolderAction(_ sender: NSMenuItem) { - guard let info = sender.representedObject as? MoveToFolderInfo else { return } - moveProjectIntoFolder(projectId: info.project.id, folder: info.folder) + @objc func moveWorkspaceToGroupAction(_ sender: NSMenuItem) { + guard let info = sender.representedObject as? MoveToGroupInfo else { return } + moveWorkspaceIntoGroup(workspaceId: info.workspace.id, group: info.group) } - @objc func moveProjectOutOfFolderAction(_ sender: NSMenuItem) { - guard let project = sender.representedObject as? ProjectItem else { return } - moveProjectOutOfFolder(projectId: project.id) + @objc func moveWorkspaceOutOfGroupAction(_ sender: NSMenuItem) { + guard let workspace = sender.representedObject as? WorkspaceItem else { return } + moveWorkspaceOutOfGroup(workspaceId: workspace.id) } - @objc func newFolderMenuAction() { - createSidebarFolder() + @objc func newGroupMenuAction() { + createSidebarGroup() } - @objc func closeProjectMenuAction(_ sender: NSMenuItem) { - guard let project = sender.representedObject as? ProjectItem, - let pi = projects.firstIndex(where: { $0.id == project.id }) else { return } - closeProject(at: pi) + @objc func closeWorkspaceMenuAction(_ sender: NSMenuItem) { + guard let workspace = sender.representedObject as? WorkspaceItem, + let pi = workspaces.firstIndex(where: { $0.id == workspace.id }) else { return } + closeWorkspace(at: pi) } @objc func exploreSessionsMenuAction(_ sender: NSMenuItem) { - guard let project = sender.representedObject as? ProjectItem else { return } + guard let workspace = sender.representedObject as? WorkspaceItem else { return } - // If an explorer window already exists for this project, bring it to front - let expectedTitle = "Sessions — \(project.name)" + // If an explorer window already exists for this workspace, bring it to front + let expectedTitle = "Sessions — \(workspace.name)" for window in NSApp.windows { if window.title == expectedTitle, objc_getAssociatedObject(window, "explorerController") is SessionExplorerWindowController { @@ -680,16 +680,16 @@ extension DeckardWindowController { } let explorer = SessionExplorerWindowController( - projectPath: project.path, - projectName: project.name + workspacePath: workspace.path, + workspaceName: workspace.name ) - explorer.openSessionIds = Set(project.tabs.compactMap { $0.sessionCacheKey }) + explorer.openSessionIds = Set(workspace.tabs.compactMap { $0.sessionCacheKey }) explorer.onSessionAction = { [weak self] kind, sessionId, fork, tabName in guard let self else { return } - self.createTabInProject(project, kind: kind, name: tabName, sessionIdToResume: sessionId, forkSession: fork) - project.selectedTabIndex = project.tabs.count - 1 - if let idx = self.projects.firstIndex(where: { $0 === project }) { - self.selectProject(at: idx) + self.createTabInWorkspace(workspace, kind: kind, name: tabName, sessionIdToResume: sessionId, forkSession: fork) + workspace.selectedTabIndex = workspace.tabs.count - 1 + if let idx = self.workspaces.firstIndex(where: { $0 === workspace }) { + self.selectWorkspace(at: idx) } self.rebuildTabBar() self.saveState() @@ -704,34 +704,34 @@ extension DeckardWindowController { } @objc func defaultArgsMenuAction(_ sender: NSMenuItem) { - guard let project = sender.representedObject as? ProjectItem, + guard let workspace = sender.representedObject as? WorkspaceItem, let window else { return } let alert = NSAlert() - alert.messageText = "Default Arguments for \(project.name)" - alert.informativeText = "These arguments will be used for new Claude tabs in this project, overriding global defaults. Leave empty to clear." + alert.messageText = "Default Arguments for \(workspace.name)" + alert.informativeText = "These arguments will be used for new Claude tabs in this workspace, overriding global defaults. Leave empty to clear." alert.addButton(withTitle: "Save") alert.addButton(withTitle: "Cancel") let field = ClaudeArgsField(frame: NSRect(x: 0, y: 0, width: 400, height: 60)) - field.stringValue = project.defaultArgs ?? "" + field.stringValue = workspace.defaultArgs ?? "" alert.accessoryView = field alert.beginSheetModal(for: window) { [weak self] response in guard response == .alertFirstButtonReturn else { return } let value = field.stringValue.trimmingCharacters(in: .whitespaces) - project.defaultArgs = value.isEmpty ? nil : value + workspace.defaultArgs = value.isEmpty ? nil : value self?.saveState() } } @objc func defaultCodexArgsMenuAction(_ sender: NSMenuItem) { - guard let project = sender.representedObject as? ProjectItem, + guard let workspace = sender.representedObject as? WorkspaceItem, let window else { return } let alert = NSAlert() - alert.messageText = "Default Codex Arguments for \(project.name)" - alert.informativeText = "These arguments will be used for new Codex tabs in this project, overriding global defaults. Leave empty to clear." + alert.messageText = "Default Codex Arguments for \(workspace.name)" + alert.informativeText = "These arguments will be used for new Codex tabs in this workspace, overriding global defaults. Leave empty to clear." alert.addButton(withTitle: "Save") alert.addButton(withTitle: "Cancel") @@ -739,13 +739,13 @@ extension DeckardWindowController { frame: NSRect(x: 0, y: 0, width: 400, height: 60), flagSource: .codex ) - field.stringValue = project.defaultCodexArgs ?? "" + field.stringValue = workspace.defaultCodexArgs ?? "" alert.accessoryView = field alert.beginSheetModal(for: window) { [weak self] response in guard response == .alertFirstButtonReturn else { return } let value = field.stringValue.trimmingCharacters(in: .whitespaces) - project.defaultCodexArgs = value.isEmpty ? nil : value + workspace.defaultCodexArgs = value.isEmpty ? nil : value self?.saveState() } } @@ -753,7 +753,7 @@ extension DeckardWindowController { // MARK: - Sidebar Selection func updateSidebarSelection() { - guard let currentProjectId = currentProject?.id else { + guard let currentWorkspaceId = currentWorkspace?.id else { for view in sidebarStackView.arrangedSubviews { if let row = view as? VerticalTabRowView { row.isSelected = false @@ -763,19 +763,19 @@ extension DeckardWindowController { } for view in sidebarStackView.arrangedSubviews { if let row = view as? VerticalTabRowView { - row.isSelected = (row.index == selectedProjectIndex) - } else if let fv = view as? SidebarFolderView { - // Highlight folder if it contains the selected project - fv.isContainingSelected = fv.folder.projectIds.contains(currentProjectId) && fv.folder.isCollapsed + row.isSelected = (row.index == selectedWorkspaceIndex) + } else if let fv = view as? SidebarGroupView { + // Highlight group if it contains the selected workspace + fv.isContainingSelected = fv.group.workspaceIds.contains(currentWorkspaceId) && fv.group.isCollapsed } } } - @objc func openProjectClicked() { - AppDelegate.shared?.openProjectPicker() + @objc func openWorkspaceClicked() { + AppDelegate.shared?.openWorkspacePicker() } - @objc func projectRowClicked(_ sender: VerticalTabRowView) { - selectProject(at: sender.index) + @objc func workspaceRowClicked(_ sender: VerticalTabRowView) { + selectWorkspace(at: sender.index) } } diff --git a/Sources/Window/SidebarViews.swift b/Sources/Window/SidebarViews.swift index 39075a4..586387b 100644 --- a/Sources/Window/SidebarViews.swift +++ b/Sources/Window/SidebarViews.swift @@ -9,7 +9,7 @@ class VerticalTabRowView: NSView, NSTextFieldDelegate, NSDraggingSource { var isSelected: Bool = false { didSet { needsDisplay = true } } - /// Badge info for each Claude tab in this project, shown as right-aligned dots. + /// Badge info for each Claude tab in this workspace, shown as right-aligned dots. var badgeInfos: [(state: TabItem.BadgeState, name: String, activity: ProcessMonitor.ActivityInfo?)] = [] { didSet { updateBadgeDots() } } @@ -26,7 +26,7 @@ class VerticalTabRowView: NSView, NSTextFieldDelegate, NSDraggingSource { private var dragStartPoint: NSPoint? private var leadingConstraint: NSLayoutConstraint? - /// Leading indent (used for projects inside folders). + /// Leading indent (used for workspaces inside groups). var indent: CGFloat = 0 { didSet { leadingConstraint?.constant = 8 + indent } } @@ -72,7 +72,7 @@ class VerticalTabRowView: NSView, NSTextFieldDelegate, NSDraggingSource { wantsLayer = true label.translatesAutoresizingMaskIntoConstraints = false - label.toolTip = shortcutTooltip("Close Folder", for: .closeFolder) + label.toolTip = shortcutTooltip("Close Workspace", for: .closeWorkspace) badgeContainer.translatesAutoresizingMaskIntoConstraints = false shortcutOverlay.translatesAutoresizingMaskIntoConstraints = false addSubview(label) @@ -215,7 +215,7 @@ class VerticalTabRowView: NSView, NSTextFieldDelegate, NSDraggingSource { dragStartPoint = nil let pb = NSPasteboardItem() - pb.setString("\(index)", forType: deckardProjectDragType) + pb.setString("\(index)", forType: deckardWorkspaceDragType) let item = NSDraggingItem(pasteboardWriter: pb) item.setDraggingFrame(bounds, contents: snapshot()) beginDraggingSession(with: [item], event: event, source: self) @@ -236,7 +236,7 @@ class VerticalTabRowView: NSView, NSTextFieldDelegate, NSDraggingSource { return image } - /// True while the project name text field is being edited. + /// True while the workspace name text field is being edited. var isEditingName: Bool { label.isEditable } private func startEditing() { @@ -283,50 +283,50 @@ class VerticalTabRowView: NSView, NSTextFieldDelegate, NSDraggingSource { } } -// MARK: - SidebarFolderView +// MARK: - SidebarGroupView -/// A folder header row in the sidebar with disclosure triangle and name. -class SidebarFolderView: NSView, NSTextFieldDelegate, NSDraggingSource { - let folder: SidebarFolder +/// A group header row in the sidebar with disclosure triangle and name. +class SidebarGroupView: NSView, NSTextFieldDelegate, NSDraggingSource { + let group: SidebarGroup private let disclosureImageView: NSImageView private let label: NSTextField private let badgeContainer: NSStackView - var onToggle: ((SidebarFolderView) -> Void)? + var onToggle: ((SidebarGroupView) -> Void)? var onRename: ((String) -> Void)? var onContextMenu: ((NSEvent) -> NSMenu?)? - var onDrop: ((SidebarFolderView, Int) -> Void)? // folder, project index + var onDrop: ((SidebarGroupView, Int) -> Void)? // group, workspace index /// Row index in the sidebar stack view (set during rebuildSidebar). var rowIndex: Int = 0 private var dragStartPoint: NSPoint? private var didDrag = false - /// Highlight when a dragged item hovers over this folder. + /// Highlight when a dragged item hovers over this group. var isDropTarget: Bool = false { didSet { needsDisplay = true } } - /// Badge info aggregated from all projects in the folder. + /// Badge info aggregated from all workspaces in the group. var badgeInfos: [(state: TabItem.BadgeState, name: String, activity: ProcessMonitor.ActivityInfo?)] = [] { didSet { updateBadgeDots() } } - /// True when the folder is collapsed and contains the selected project. + /// True when the group is collapsed and contains the selected workspace. var isContainingSelected: Bool = false { didSet { needsDisplay = true } } - init(folder: SidebarFolder, projectCount: Int) { - self.folder = folder + init(group: SidebarGroup, workspaceCount: Int) { + self.group = group disclosureImageView = NSImageView() - disclosureImageView.image = NSImage(systemSymbolName: folder.isCollapsed ? "chevron.right" : "chevron.down", - accessibilityDescription: "Toggle folder") + disclosureImageView.image = NSImage(systemSymbolName: group.isCollapsed ? "chevron.right" : "chevron.down", + accessibilityDescription: "Toggle group") disclosureImageView.contentTintColor = ThemeManager.shared.currentColors.secondaryText disclosureImageView.imageAlignment = .alignCenter - label = NSTextField(labelWithString: folder.name) + label = NSTextField(labelWithString: group.name) label.font = .systemFont(ofSize: 11, weight: .semibold) label.textColor = ThemeManager.shared.currentColors.secondaryText label.lineBreakMode = .byTruncatingTail @@ -368,7 +368,7 @@ class SidebarFolderView: NSView, NSTextFieldDelegate, NSDraggingSource { required init?(coder: NSCoder) { fatalError() } override func hitTest(_ point: NSPoint) -> NSView? { - // While editing the folder name, let the field editor handle events normally. + // While editing the group name, let the field editor handle events normally. // Otherwise, always route clicks to self so subviews (image, label) don't swallow them. if isEditingName { return super.hitTest(point) } return frame.contains(point) ? self : nil @@ -385,8 +385,8 @@ class SidebarFolderView: NSView, NSTextFieldDelegate, NSDraggingSource { } func updateChevron() { - disclosureImageView.image = NSImage(systemSymbolName: folder.isCollapsed ? "chevron.right" : "chevron.down", - accessibilityDescription: "Toggle folder") + disclosureImageView.image = NSImage(systemSymbolName: group.isCollapsed ? "chevron.right" : "chevron.down", + accessibilityDescription: "Toggle group") } override func mouseDown(with event: NSEvent) { @@ -422,7 +422,7 @@ class SidebarFolderView: NSView, NSTextFieldDelegate, NSDraggingSource { dragStartPoint = nil let pb = NSPasteboardItem() - pb.setString("\(rowIndex)", forType: deckardFolderDragType) + pb.setString("\(rowIndex)", forType: deckardGroupDragType) let item = NSDraggingItem(pasteboardWriter: pb) item.setDraggingFrame(bounds, contents: snapshot()) beginDraggingSession(with: [item], event: event, source: self) @@ -448,7 +448,7 @@ class SidebarFolderView: NSView, NSTextFieldDelegate, NSDraggingSource { } } - /// True while the folder name text field is being edited. + /// True while the group name text field is being edited. var isEditingName: Bool { label.isEditable } func startEditing() { @@ -464,11 +464,11 @@ class SidebarFolderView: NSView, NSTextFieldDelegate, NSDraggingSource { label.isEditable = false label.isSelectable = false let newName = label.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) - if !newName.isEmpty, newName != folder.name { - folder.name = newName + if !newName.isEmpty, newName != group.name { + group.name = newName onRename?(newName) } else { - label.stringValue = folder.name + label.stringValue = group.name } } @@ -483,7 +483,7 @@ class SidebarFolderView: NSView, NSTextFieldDelegate, NSDraggingSource { return true } if sel == #selector(cancelOperation(_:)) { - label.stringValue = folder.name + label.stringValue = group.name label.isEditable = false window?.makeFirstResponder(nil) return true @@ -497,8 +497,8 @@ class SidebarFolderView: NSView, NSTextFieldDelegate, NSDraggingSource { $0.removeFromSuperview() } // When collapsed, show aggregated badges; when expanded, hide them - // (individual project rows show their own badges) - guard folder.isCollapsed else { return } + // (individual workspace rows show their own badges) + guard group.isCollapsed else { return } for info in badgeInfos where info.state != .none { let dot = BadgeShapeView( shape: VerticalTabRowView.shapeForBadge(info.state), @@ -515,10 +515,10 @@ class SidebarFolderView: NSView, NSTextFieldDelegate, NSDraggingSource { // MARK: - SidebarDropZone -/// Covers the empty area below the project list; dropping here moves to end. +/// Covers the empty area below the workspace list; dropping here moves to end. class SidebarDropZone: NSView { var onDrop: ((Int) -> Void)? - var onFolderDrop: ((Int) -> Void)? // folder row index dropped to bottom + var onGroupDrop: ((Int) -> Void)? // group row index dropped to bottom var onContextMenu: ((NSEvent) -> NSMenu?)? weak var sidebarStackView: ReorderableStackView? @@ -530,7 +530,7 @@ class SidebarDropZone: NSView { private func acceptsDrag(_ sender: NSDraggingInfo) -> Bool { let types = sender.draggingPasteboard.types ?? [] - return types.contains(deckardProjectDragType) || types.contains(deckardFolderDragType) + return types.contains(deckardWorkspaceDragType) || types.contains(deckardGroupDragType) } override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { @@ -555,14 +555,14 @@ class SidebarDropZone: NSView { override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { sidebarStackView?.hideIndicator() - if let fromStr = sender.draggingPasteboard.string(forType: deckardProjectDragType), + if let fromStr = sender.draggingPasteboard.string(forType: deckardWorkspaceDragType), let fromIndex = Int(fromStr) { onDrop?(fromIndex) return true } - if let fromStr = sender.draggingPasteboard.string(forType: deckardFolderDragType), + if let fromStr = sender.draggingPasteboard.string(forType: deckardGroupDragType), let fromRow = Int(fromStr) { - onFolderDrop?(fromRow) + onGroupDrop?(fromRow) return true } return false @@ -572,11 +572,11 @@ class SidebarDropZone: NSView { // MARK: - ReorderableStackView /// NSStackView subclass that accepts drops for reordering. -/// Supports project drag (reorder/drop onto folder) and folder drag (reorder folders). +/// Supports workspace drag (reorder/drop onto group) and group drag (reorder groups). class ReorderableStackView: NSStackView { var onReorder: ((Int, Int, Bool) -> Void)? - var onDropOntoFolder: ((SidebarFolderView, Int) -> Void)? - var onFolderReorder: ((Int, Int) -> Void)? + var onDropOntoGroup: ((SidebarGroupView, Int) -> Void)? + var onGroupReorder: ((Int, Int) -> Void)? private let dropIndicator: NSView = { let v = NSView() @@ -587,7 +587,7 @@ class ReorderableStackView: NSStackView { }() private var currentDropIndex: Int = -1 private var currentDropForceFullWidth: Bool = false - private weak var highlightedFolder: SidebarFolderView? + private weak var highlightedGroup: SidebarGroupView? private func dropIndex(for sender: NSDraggingInfo) -> Int { let location = convert(sender.draggingLocation, from: nil) @@ -599,14 +599,14 @@ class ReorderableStackView: NSStackView { return arrangedSubviews.count } - /// Returns the SidebarFolderView at the drag location, if the cursor is - /// within the center region of a folder row. The top and bottom edges + /// Returns the SidebarGroupView at the drag location, if the cursor is + /// within the center region of a group row. The top and bottom edges /// (6px each) are reserved for between-item line indicator drops. - private func folderView(at sender: NSDraggingInfo) -> SidebarFolderView? { + private func groupView(at sender: NSDraggingInfo) -> SidebarGroupView? { let location = convert(sender.draggingLocation, from: nil) let edgeInset: CGFloat = 6 for view in arrangedSubviews { - guard let fv = view as? SidebarFolderView else { continue } + guard let fv = view as? SidebarGroupView else { continue } let innerTop = fv.frame.maxY - edgeInset let innerBottom = fv.frame.minY + edgeInset if location.y <= innerTop && location.y >= innerBottom { @@ -616,10 +616,10 @@ class ReorderableStackView: NSStackView { return nil } - private func clearFolderHighlight() { - if let prev = highlightedFolder { + private func clearGroupHighlight() { + if let prev = highlightedGroup { prev.isDropTarget = false - highlightedFolder = nil + highlightedGroup = nil } } @@ -644,7 +644,7 @@ class ReorderableStackView: NSStackView { yPos = bounds.maxY - 1 } - // Indent the indicator when between items inside a folder (project drags only) + // Indent the indicator when between items inside a group (workspace drags only) let leftInset: CGFloat if forceFullWidth { leftInset = 8 @@ -668,45 +668,45 @@ class ReorderableStackView: NSStackView { dropIndicator.isHidden = true currentDropIndex = -1 currentDropForceFullWidth = false - clearFolderHighlight() + clearGroupHighlight() } - private func acceptsProjectDrag(_ sender: NSDraggingInfo) -> Bool { - sender.draggingPasteboard.types?.contains(deckardProjectDragType) == true + private func acceptsWorkspaceDrag(_ sender: NSDraggingInfo) -> Bool { + sender.draggingPasteboard.types?.contains(deckardWorkspaceDragType) == true } - private func acceptsFolderDrag(_ sender: NSDraggingInfo) -> Bool { - sender.draggingPasteboard.types?.contains(deckardFolderDragType) == true + private func acceptsGroupDrag(_ sender: NSDraggingInfo) -> Bool { + sender.draggingPasteboard.types?.contains(deckardGroupDragType) == true } override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { - if acceptsProjectDrag(sender) { - return updateProjectDrag(sender) - } else if acceptsFolderDrag(sender) { - return updateFolderDrag(sender) + if acceptsWorkspaceDrag(sender) { + return updateWorkspaceDrag(sender) + } else if acceptsGroupDrag(sender) { + return updateGroupDrag(sender) } return [] } override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { - if acceptsProjectDrag(sender) { - return updateProjectDrag(sender) - } else if acceptsFolderDrag(sender) { - return updateFolderDrag(sender) + if acceptsWorkspaceDrag(sender) { + return updateWorkspaceDrag(sender) + } else if acceptsGroupDrag(sender) { + return updateGroupDrag(sender) } return [] } - /// Folder drag: only show indicator between top-level items (not inside folders). - private func updateFolderDrag(_ sender: NSDraggingInfo) -> NSDragOperation { + /// Group drag: only show indicator between top-level items (not inside groups). + private func updateGroupDrag(_ sender: NSDraggingInfo) -> NSDragOperation { let snapped = snapToTopLevel(for: sender) showIndicator(at: snapped, forceFullWidth: true) return .move } /// Snap drop position to the nearest top-level boundary. - /// Indented rows (inside folders) are skipped — the indicator jumps to - /// the folder header above or the next top-level item below. + /// Indented rows (inside groups) are skipped — the indicator jumps to + /// the group header above or the next top-level item below. private func snapToTopLevel(for sender: NSDraggingInfo) -> Int { let raw = dropIndex(for: sender) // If dropping at a top-level position, use it directly @@ -722,10 +722,10 @@ class ReorderableStackView: NSStackView { let isIndented = (view as? VerticalTabRowView)?.indent ?? 0 > 0 if !isIndented { // Snap to just after this top-level item's group - // (after the folder + all its children) + // (after the group + all its children) best = i - // Find end of this folder's children - if view is SidebarFolderView { + // Find end of this group's children + if view is SidebarGroupView { var end = i + 1 while end < arrangedSubviews.count, let r = arrangedSubviews[end] as? VerticalTabRowView, r.indent > 0 { @@ -739,24 +739,24 @@ class ReorderableStackView: NSStackView { return best } - /// Common logic for project drag: highlight folder or show line indicator. - private func updateProjectDrag(_ sender: NSDraggingInfo) -> NSDragOperation { - if let fv = folderView(at: sender) { - // Hovering over a folder row — highlight it, hide the line indicator + /// Common logic for workspace drag: highlight group or show line indicator. + private func updateWorkspaceDrag(_ sender: NSDraggingInfo) -> NSDragOperation { + if let fv = groupView(at: sender) { + // Hovering over a group row — highlight it, hide the line indicator dropIndicator.isHidden = true currentDropIndex = -1 - if highlightedFolder !== fv { - clearFolderHighlight() + if highlightedGroup !== fv { + clearGroupHighlight() fv.isDropTarget = true - highlightedFolder = fv + highlightedGroup = fv } } else { - // Not over a folder — show the line indicator - clearFolderHighlight() + // Not over a group — show the line indicator + clearGroupHighlight() let idx = dropIndex(for: sender) - // At the boundary between the last child of an expanded folder + // At the boundary between the last child of an expanded group // and the next non-indented row, use cursor Y to disambiguate: - // upper half (folder child territory) → indented indicator, + // upper half (group child territory) → indented indicator, // lower half (top-level territory) → full-width indicator. var forceFullWidth = false if idx > 0, idx < arrangedSubviews.count { @@ -781,16 +781,16 @@ class ReorderableStackView: NSStackView { } override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { - let wasOnFolder = highlightedFolder + let wasOnGroup = highlightedGroup let wasForceFullWidth = currentDropForceFullWidth hideIndicator() - // Handle project drag - if let fromStr = sender.draggingPasteboard.string(forType: deckardProjectDragType), + // Handle workspace drag + if let fromStr = sender.draggingPasteboard.string(forType: deckardWorkspaceDragType), let fromIndex = Int(fromStr) { - // If dropped on a highlighted folder, route to folder drop handler - if let fv = wasOnFolder { - onDropOntoFolder?(fv, fromIndex) + // If dropped on a highlighted group, route to group drop handler + if let fv = wasOnGroup { + onDropOntoGroup?(fv, fromIndex) return true } let toIndex = dropIndex(for: sender) @@ -798,12 +798,12 @@ class ReorderableStackView: NSStackView { return true } - // Handle folder drag - if let fromStr = sender.draggingPasteboard.string(forType: deckardFolderDragType), + // Handle group drag + if let fromStr = sender.draggingPasteboard.string(forType: deckardGroupDragType), let fromRow = Int(fromStr) { let toRow = dropIndex(for: sender) if toRow != fromRow { - onFolderReorder?(fromRow, toRow) + onGroupReorder?(fromRow, toRow) } return true } diff --git a/Sources/Window/TabBarController.swift b/Sources/Window/TabBarController.swift index bd32727..41bd2c4 100644 --- a/Sources/Window/TabBarController.swift +++ b/Sources/Window/TabBarController.swift @@ -4,7 +4,7 @@ import AppKit extension DeckardWindowController { - // MARK: - Tab Bar (horizontal tabs within selected project) + // MARK: - Tab Bar (horizontal tabs within selected workspace) var isTabEditing: Bool { tabBar.arrangedSubviews.contains { ($0 as? HorizontalTabView)?.isEditing == true } @@ -31,10 +31,10 @@ extension DeckardWindowController { savedFirstResponder = window?.firstResponder tabBar.arrangedSubviews.forEach { $0.removeFromSuperview() } - guard let project = currentProject else { return } + guard let workspace = currentWorkspace else { return } - for (i, tab) in project.tabs.enumerated() { - let isSelected = (i == project.selectedTabIndex) + for (i, tab) in workspace.tabs.enumerated() { + let isSelected = (i == workspace.selectedTabIndex) let title = " \(tab.name) " let tabView = HorizontalTabView( @@ -49,9 +49,9 @@ extension DeckardWindowController { clickAction: #selector(tabBarClicked(_:)) ) tabView.onRename = { [weak self] newName in - guard let self = self, let project = self.currentProject, - i < project.tabs.count else { return } - let tab = project.tabs[i] + guard let self = self, let workspace = self.currentWorkspace, + i < workspace.tabs.count else { return } + let tab = workspace.tabs[i] tab.name = newName if let sid = tab.sessionId, !sid.isEmpty { SessionManager.shared.saveSessionName(sessionId: sid, kind: tab.kind, name: newName) @@ -60,11 +60,11 @@ extension DeckardWindowController { self.saveState() } tabView.onClearName = { [weak self] in - guard let self = self, let project = self.currentProject, - i < project.tabs.count else { return } - let tab = project.tabs[i] + guard let self = self, let workspace = self.currentWorkspace, + i < workspace.tabs.count else { return } + let tab = workspace.tabs[i] let base = tab.kind.displayName - let sameType = project.tabs.filter { $0.kind == tab.kind } + let sameType = workspace.tabs.filter { $0.kind == tab.kind } tab.name = sameType.count <= 1 ? base : "\(base) #\(i + 1)" self.rebuildTabBar() self.saveState() @@ -76,13 +76,13 @@ extension DeckardWindowController { self.tabBarCloseClicked(btn) } tabView.onNewClaude = { [weak self] in - self?.addTabToCurrentProject(kind: .claude) + self?.addTabToCurrentWorkspace(kind: .claude) } tabView.onNewCodex = { [weak self] in - self?.addTabToCurrentProject(kind: .codex) + self?.addTabToCurrentWorkspace(kind: .codex) } tabView.onNewTerminal = { [weak self] in - self?.addTabToCurrentProject(kind: .terminal) + self?.addTabToCurrentWorkspace(kind: .terminal) } tabView.onEditingFinished = { [weak self] in guard let self = self, self.needsTabBarRebuild else { return } @@ -93,7 +93,7 @@ extension DeckardWindowController { } // Set up drag-to-reorder - tabBar.tabCount = project.tabs.count + tabBar.tabCount = workspace.tabs.count tabBar.registerForDraggedTypes([deckardTabDragType]) tabBar.onReorder = { [weak self] from, to in self?.reorderTab(from: from, to: to) @@ -101,9 +101,9 @@ extension DeckardWindowController { // Add "+" button let addButton = AddTabButton( - claudeAction: { [weak self] in self?.addTabToCurrentProject(kind: .claude) }, - codexAction: { [weak self] in self?.addTabToCurrentProject(kind: .codex) }, - terminalAction: { [weak self] in self?.addTabToCurrentProject(kind: .terminal) } + claudeAction: { [weak self] in self?.addTabToCurrentWorkspace(kind: .claude) }, + codexAction: { [weak self] in self?.addTabToCurrentWorkspace(kind: .codex) }, + terminalAction: { [weak self] in self?.addTabToCurrentWorkspace(kind: .terminal) } ) tabBar.addArrangedSubview(addButton) @@ -115,21 +115,21 @@ extension DeckardWindowController { } func reorderTab(from fromIndex: Int, to toIndex: Int) { - guard let project = currentProject else { return } + guard let workspace = currentWorkspace else { return } guard fromIndex != toIndex, - fromIndex >= 0, fromIndex < project.tabs.count, - toIndex >= 0, toIndex <= project.tabs.count else { return } + fromIndex >= 0, fromIndex < workspace.tabs.count, + toIndex >= 0, toIndex <= workspace.tabs.count else { return } - let tab = project.tabs.remove(at: fromIndex) + let tab = workspace.tabs.remove(at: fromIndex) let insertAt = toIndex > fromIndex ? toIndex - 1 : toIndex - project.tabs.insert(tab, at: min(insertAt, project.tabs.count)) - - if project.selectedTabIndex == fromIndex { - project.selectedTabIndex = insertAt - } else if fromIndex < project.selectedTabIndex && insertAt >= project.selectedTabIndex { - project.selectedTabIndex -= 1 - } else if fromIndex > project.selectedTabIndex && insertAt <= project.selectedTabIndex { - project.selectedTabIndex += 1 + workspace.tabs.insert(tab, at: min(insertAt, workspace.tabs.count)) + + if workspace.selectedTabIndex == fromIndex { + workspace.selectedTabIndex = insertAt + } else if fromIndex < workspace.selectedTabIndex && insertAt >= workspace.selectedTabIndex { + workspace.selectedTabIndex -= 1 + } else if fromIndex > workspace.selectedTabIndex && insertAt <= workspace.selectedTabIndex { + workspace.selectedTabIndex += 1 } rebuildTabBar() @@ -138,31 +138,31 @@ extension DeckardWindowController { } @objc func tabBarClicked(_ sender: HorizontalTabView) { - selectTabInProject(at: sender.index) + selectTabInWorkspace(at: sender.index) } @objc func tabBarCloseClicked(_ sender: NSButton) { - guard let project = currentProject else { return } + guard let workspace = currentWorkspace else { return } let idx = sender.tag - guard idx >= 0, idx < project.tabs.count else { return } + guard idx >= 0, idx < workspace.tabs.count else { return } - let tab = project.tabs[idx] + let tab = workspace.tabs[idx] tab.surface.terminate() tabCreationOrder.removeAll { $0 == tab.id } - project.tabs.remove(at: idx) + workspace.tabs.remove(at: idx) - if project.tabs.isEmpty { + if workspace.tabs.isEmpty { currentTerminalView = nil showEmptyState() rebuildTabBar() rebuildSidebar() } else { - project.selectedTabIndex = min(idx, project.tabs.count - 1) + workspace.selectedTabIndex = min(idx, workspace.tabs.count - 1) rebuildTabBar() rebuildSidebar() - clearUnseenIfNeeded(project.tabs[project.selectedTabIndex]) - showTab(project.tabs[project.selectedTabIndex]) + clearUnseenIfNeeded(workspace.tabs[workspace.selectedTabIndex]) + showTab(workspace.tabs[workspace.selectedTabIndex]) } saveState() } diff --git a/Sources/Window/ProjectPicker.swift b/Sources/Window/WorkspacePicker.swift similarity index 86% rename from Sources/Window/ProjectPicker.swift rename to Sources/Window/WorkspacePicker.swift index ee6da1a..c9e183f 100644 --- a/Sources/Window/ProjectPicker.swift +++ b/Sources/Window/WorkspacePicker.swift @@ -1,9 +1,9 @@ import AppKit import Fuse -/// A Spotlight-style project picker that appears when creating a new Claude tab. -/// Shows recent projects from ~/.claude/projects/, sorted by recency. -class ProjectPicker: NSObject, NSTableViewDataSource, NSTableViewDelegate, NSTextFieldDelegate, NSWindowDelegate { +/// A Spotlight-style workspace picker that appears when creating a new Claude tab. +/// Shows recent workspaces from ~/.claude/projects/, sorted by recency. +class WorkspacePicker: NSObject, NSTableViewDataSource, NSTableViewDelegate, NSTextFieldDelegate, NSWindowDelegate { typealias Completion = (String?) -> Void // nil = cancelled, String = chosen path @@ -13,8 +13,8 @@ class ProjectPicker: NSObject, NSTableViewDataSource, NSTableViewDelegate, NSTex private let scrollView: NSScrollView private var completion: Completion? - private var allProjects: [(path: String, lastUsed: Date)] = [] - private var filteredProjects: [(path: String, lastUsed: Date)] = [] + private var allWorkspaces: [(path: String, lastUsed: Date)] = [] + private var filteredWorkspaces: [(path: String, lastUsed: Date)] = [] private var spotlightSearch: Process? private var spotlightPipe: Pipe? private var keyMonitor: Any? @@ -36,14 +36,14 @@ class ProjectPicker: NSObject, NSTableViewDataSource, NSTableViewDelegate, NSTex // Search field searchField = NSTextField() - searchField.placeholderString = "Open Folder..." + searchField.placeholderString = "Open Workspace..." searchField.font = .systemFont(ofSize: 16) searchField.translatesAutoresizingMaskIntoConstraints = false searchField.focusRingType = .none searchField.bezelStyle = .roundedBezel - // Table view for project list - let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("Project")) + // Table view for workspace list + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("Workspace")) column.title = "" tableView = NSTableView() @@ -92,16 +92,16 @@ class ProjectPicker: NSObject, NSTableViewDataSource, NSTableViewDelegate, NSTex } /// Show the picker centered on the given window. - /// `excludePaths` are already-open projects that should be hidden from the list. + /// `excludePaths` are already-open workspaces that should be hidden from the list. func show(relativeTo window: NSWindow?, completion: @escaping Completion) { self.completion = completion - allProjects = Self.loadRecentProjects() - filteredProjects = allProjects + allWorkspaces = Self.loadRecentWorkspaces() + filteredWorkspaces = allWorkspaces tableView.reloadData() // Select first row - if !filteredProjects.isEmpty { + if !filteredWorkspaces.isEmpty { tableView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) } @@ -165,8 +165,8 @@ class ProjectPicker: NSObject, NSTableViewDataSource, NSTableViewDelegate, NSTex removeKeyMonitor() let row = tableView.selectedRow let path: String - if row >= 0, row < filteredProjects.count { - path = filteredProjects[row].path + if row >= 0, row < filteredWorkspaces.count { + path = filteredWorkspaces[row].path } else { // Use the text field value as a raw path let text = searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) @@ -182,25 +182,25 @@ class ProjectPicker: NSObject, NSTableViewDataSource, NSTableViewDelegate, NSTex } private func moveSelection(by delta: Int) { - guard !filteredProjects.isEmpty else { return } + guard !filteredWorkspaces.isEmpty else { return } let current = tableView.selectedRow - let next = max(0, min(filteredProjects.count - 1, current + delta)) + let next = max(0, min(filteredWorkspaces.count - 1, current + delta)) tableView.selectRowIndexes(IndexSet(integer: next), byExtendingSelection: false) tableView.scrollRowToVisible(next) } private func autocompleteSelection() { var row = tableView.selectedRow - guard row >= 0, row < filteredProjects.count else { return } + guard row >= 0, row < filteredWorkspaces.count else { return } // If selected row is the typed directory itself, jump to first subfolder let currentInput = (searchField.stringValue as NSString).expandingTildeInPath .trimmingCharacters(in: CharacterSet(charactersIn: "/")) - let selectedPath = filteredProjects[row].path + let selectedPath = filteredWorkspaces[row].path if selectedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) == currentInput, - row + 1 < filteredProjects.count { + row + 1 < filteredWorkspaces.count { row += 1 } - let path = filteredProjects[row].path + "/" + let path = filteredWorkspaces[row].path + "/" searchField.stringValue = path // Move cursor to end searchField.currentEditor()?.selectedRange = NSRange(location: path.count, length: 0) @@ -239,38 +239,38 @@ class ProjectPicker: NSObject, NSTableViewDataSource, NSTableViewDelegate, NSTex func controlTextDidChange(_ obj: Notification) { let query = searchField.stringValue if query.isEmpty { - filteredProjects = allProjects + filteredWorkspaces = allWorkspaces cancelSpotlightSearch() } else if query.hasPrefix("/") || query.hasPrefix("~") { // Path-based autocomplete: list directories at the typed path cancelSpotlightSearch() - filteredProjects = listDirectories(at: query) + filteredWorkspaces = listDirectories(at: query) } else { // Fuzzy match on basename (primary) and full path (fallback) - var scored: [(project: (path: String, lastUsed: Date), score: Double)] = [] - for project in allProjects { - let basename = (project.path as NSString).lastPathComponent + var scored: [(workspace: (path: String, lastUsed: Date), score: Double)] = [] + for workspace in allWorkspaces { + let basename = (workspace.path as NSString).lastPathComponent let bResult = fuse.search(query, in: basename) - let pResult = fuse.search(query, in: project.path) + let pResult = fuse.search(query, in: workspace.path) let best: Double? = [bResult?.score, pResult?.score] .compactMap { $0 }.min() if let score = best { - scored.append((project: project, score: score)) + scored.append((workspace: workspace, score: score)) } } scored.sort { abs($0.score - $1.score) < 0.001 - ? $0.project.lastUsed > $1.project.lastUsed + ? $0.workspace.lastUsed > $1.workspace.lastUsed : $0.score < $1.score } - filteredProjects = scored.map { $0.project } + filteredWorkspaces = scored.map { $0.workspace } // Also search filesystem via mdfind (Spotlight) cancelSpotlightSearch() searchFilesystem(query: query) } tableView.reloadData() - if !filteredProjects.isEmpty { + if !filteredWorkspaces.isEmpty { tableView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) } } @@ -323,14 +323,14 @@ class ProjectPicker: NSObject, NSTableViewDataSource, NSTableViewDelegate, NSTex // Escape single quotes in the query to prevent Spotlight query injection. let escaped = query.replacingOccurrences(of: "'", with: "\\'") process.arguments = [ - "kMDItemContentType == public.folder && kMDItemFSName == '*\(escaped)*'cd" + "kMDItemContentType == public.group && kMDItemFSName == '*\(escaped)*'cd" ] let pipe = Pipe() process.standardOutput = pipe process.standardError = FileHandle.nullDevice - let knownPaths = Set(allProjects.map { $0.path }) + let knownPaths = Set(allWorkspaces.map { $0.path }) pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in let data = handle.availableData @@ -351,7 +351,7 @@ class ProjectPicker: NSObject, NSTableViewDataSource, NSTableViewDelegate, NSTex // Skip already-shown, already-open, or hidden paths if knownPaths.contains(path) { continue } if self.excludePaths.contains(path) { continue } - if self.filteredProjects.contains(where: { $0.path == path }) { continue } + if self.filteredWorkspaces.contains(where: { $0.path == path }) { continue } if path.contains("/.") || path.contains("/Library/") { continue } if path.contains("/node_modules/") || path.contains("/.git/") { continue } @@ -362,12 +362,12 @@ class ProjectPicker: NSObject, NSTableViewDataSource, NSTableViewDelegate, NSTex let best = [bResult?.score, pResult?.score].compactMap { $0 }.min() guard best != nil else { continue } - self.filteredProjects.append((path: path, lastUsed: .distantPast)) + self.filteredWorkspaces.append((path: path, lastUsed: .distantPast)) added = true } if added { self.tableView.reloadData() - if self.tableView.selectedRow < 0, !self.filteredProjects.isEmpty { + if self.tableView.selectedRow < 0, !self.filteredWorkspaces.isEmpty { self.tableView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) } } @@ -382,14 +382,14 @@ class ProjectPicker: NSObject, NSTableViewDataSource, NSTableViewDelegate, NSTex // MARK: - NSTableViewDataSource func numberOfRows(in tableView: NSTableView) -> Int { - return filteredProjects.count + return filteredWorkspaces.count } // MARK: - NSTableViewDelegate func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { - let id = NSUserInterfaceItemIdentifier("ProjectCell") - let project = filteredProjects[row] + let id = NSUserInterfaceItemIdentifier("WorkspaceCell") + let workspace = filteredWorkspaces[row] let cell: NSTableCellView if let recycled = tableView.makeView(withIdentifier: id, owner: nil) as? NSTableCellView { @@ -409,11 +409,11 @@ class ProjectPicker: NSObject, NSTableViewDataSource, NSTableViewDelegate, NSTex ]) } - // Show shortened path: ~/Documents/project instead of /Users/gilles/Documents/project + // Show shortened path: ~/Documents/workspace instead of /Users/gilles/Documents/workspace let home = NSHomeDirectory() - let displayPath = project.path.hasPrefix(home) - ? "~" + project.path.dropFirst(home.count) - : project.path + let displayPath = workspace.path.hasPrefix(home) + ? "~" + workspace.path.dropFirst(home.count) + : workspace.path cell.textField?.stringValue = displayPath cell.textField?.font = .systemFont(ofSize: 13) @@ -427,9 +427,9 @@ class ProjectPicker: NSObject, NSTableViewDataSource, NSTableViewDelegate, NSTex confirm() } - // MARK: - Load Projects + // MARK: - Load Workspaces - static func loadRecentProjects() -> [(path: String, lastUsed: Date)] { + static func loadRecentWorkspaces() -> [(path: String, lastUsed: Date)] { let projectsDir = NSHomeDirectory() + "/.claude/projects" let fm = FileManager.default @@ -469,7 +469,7 @@ class ProjectPicker: NSObject, NSTableViewDataSource, NSTableViewDelegate, NSTex return results.map { (path: $0.path, lastUsed: $0.lastUsed) } } - /// Decode a Claude Code project directory name back to a filesystem path. + /// Decode a Claude Code workspace directory name back to a filesystem path. /// Claude encodes every non-`[A-Za-z0-9-]` character (including "/", ".", "_", spaces) as "-", /// so "-Users-tibor-code-trogulja-trogulja-github-io" must be decoded by figuring out which /// hyphens are path separators and which are encoded characters inside a single path component. diff --git a/Tests/ContextMonitorTests.swift b/Tests/ContextMonitorTests.swift index 168a251..8bf45ed 100644 --- a/Tests/ContextMonitorTests.swift +++ b/Tests/ContextMonitorTests.swift @@ -347,7 +347,7 @@ final class ContextMonitorTests: XCTestCase { let withUsage = assistantUsageLine(model: "claude-opus-4-6", input: 400_000, cacheRead: 100_000) try (withUsage + "\n").write(toFile: jsonlPath, atomically: true, encoding: .utf8) - let usage1 = monitor.getUsage(sessionId: sessionId, projectPath: tempDir) + let usage1 = monitor.getUsage(sessionId: sessionId, workspacePath: tempDir) XCTAssertNotNil(usage1) XCTAssertEqual(usage1?.inputTokens, 400_000) @@ -356,7 +356,7 @@ final class ContextMonitorTests: XCTestCase { try noUsage.write(toFile: jsonlPath, atomically: true, encoding: .utf8) // Second call: file lacks usage → should return cached value - let usage2 = monitor.getUsage(sessionId: sessionId, projectPath: tempDir) + let usage2 = monitor.getUsage(sessionId: sessionId, workspacePath: tempDir) XCTAssertNotNil(usage2, "Should return cached value when file lacks usage") XCTAssertEqual(usage2?.inputTokens, 400_000) } @@ -377,14 +377,14 @@ final class ContextMonitorTests: XCTestCase { let line1 = assistantUsageLine(model: "claude-opus-4-6", input: 100_000, cacheRead: 50_000) try (line1 + "\n").write(toFile: jsonlPath, atomically: true, encoding: .utf8) - let usage1 = monitor.getUsage(sessionId: sessionId, projectPath: tempDir) + let usage1 = monitor.getUsage(sessionId: sessionId, workspacePath: tempDir) XCTAssertEqual(usage1?.inputTokens, 100_000) // Second: larger usage let line2 = assistantUsageLine(model: "claude-opus-4-6", input: 300_000, cacheRead: 200_000) try (line1 + "\n" + line2 + "\n").write(toFile: jsonlPath, atomically: true, encoding: .utf8) - let usage2 = monitor.getUsage(sessionId: sessionId, projectPath: tempDir) + let usage2 = monitor.getUsage(sessionId: sessionId, workspacePath: tempDir) XCTAssertEqual(usage2?.inputTokens, 300_000, "Cache should update to newest usage") } @@ -392,7 +392,7 @@ final class ContextMonitorTests: XCTestCase { let monitor = ContextMonitor() let usage = monitor.getUsage( sessionId: "never-seen-\(UUID().uuidString)", - projectPath: "/nonexistent/\(UUID().uuidString)") + workspacePath: "/nonexistent/\(UUID().uuidString)") XCTAssertNil(usage, "No cache for a session never queried before") } @@ -426,7 +426,7 @@ final class ContextMonitorTests: XCTestCase { func testListSessionsNonexistentPath() { let sessions = ContextMonitor.shared.listSessions( - forProjectPath: "/nonexistent/path/\(UUID().uuidString)" + forWorkspacePath: "/nonexistent/path/\(UUID().uuidString)" ) XCTAssertTrue(sessions.isEmpty) } @@ -436,7 +436,7 @@ final class ContextMonitorTests: XCTestCase { func testGetUsageNonexistentSession() { let usage = ContextMonitor.shared.getUsage( sessionId: "nonexistent-\(UUID().uuidString)", - projectPath: "/nonexistent/path/\(UUID().uuidString)" + workspacePath: "/nonexistent/path/\(UUID().uuidString)" ) XCTAssertNil(usage) } @@ -461,8 +461,8 @@ final class ContextMonitorTests: XCTestCase { func testClaudeProjectDirNameResolvesSymlinks() throws { let tempDir = NSTemporaryDirectory() + "deckard-dirname-\(UUID().uuidString)" - let realDir = tempDir + "/real-project" - let linkDir = tempDir + "/linked-project" + let realDir = tempDir + "/real-workspace" + let linkDir = tempDir + "/linked-workspace" try FileManager.default.createDirectory(atPath: realDir, withIntermediateDirectories: true) addTeardownBlock { try? FileManager.default.removeItem(atPath: tempDir) } try FileManager.default.createSymbolicLink(atPath: linkDir, withDestinationPath: realDir) @@ -472,8 +472,8 @@ final class ContextMonitorTests: XCTestCase { } func testClaudeProjectDirNameEncodesSlashes() { - let path = "/Users/test/my-project" - XCTAssertEqual(path.claudeProjectDirName, "-Users-test-my-project") + let path = "/Users/test/my-workspace" + XCTAssertEqual(path.claudeProjectDirName, "-Users-test-my-workspace") XCTAssertFalse(path.claudeProjectDirName.contains("/")) } @@ -484,13 +484,13 @@ final class ContextMonitorTests: XCTestCase { XCTAssertEqual(path.claudeProjectDirName, "-Volumes-Android-source-qcm8550-android13-0-ba01-r035") - let spaced = "/Users/test/My Project" - XCTAssertEqual(spaced.claudeProjectDirName, "-Users-test-My-Project") + let spaced = "/Users/test/My Workspace" + XCTAssertEqual(spaced.claudeProjectDirName, "-Users-test-My-Workspace") } func testClaudeProjectDirNameIdempotentOnCanonicalPath() throws { let tempDir = NSTemporaryDirectory() + "deckard-dirname-\(UUID().uuidString)" - let realDir = tempDir + "/project" + let realDir = tempDir + "/workspace" try FileManager.default.createDirectory(atPath: realDir, withIntermediateDirectories: true) addTeardownBlock { try? FileManager.default.removeItem(atPath: tempDir) } @@ -501,18 +501,18 @@ final class ContextMonitorTests: XCTestCase { "Double resolution should be idempotent") } - func testClaudeProjectDirNameConsistentWithProjectItem() throws { + func testClaudeProjectDirNameConsistentWithWorkspaceItem() throws { let tempDir = NSTemporaryDirectory() + "deckard-dirname-\(UUID().uuidString)" - let realDir = tempDir + "/real-project" - let linkDir = tempDir + "/linked-project" + let realDir = tempDir + "/real-workspace" + let linkDir = tempDir + "/linked-workspace" try FileManager.default.createDirectory(atPath: realDir, withIntermediateDirectories: true) addTeardownBlock { try? FileManager.default.removeItem(atPath: tempDir) } try FileManager.default.createSymbolicLink(atPath: linkDir, withDestinationPath: realDir) - // ProjectItem resolves symlinks; claudeProjectDirName should agree - let project = ProjectItem(path: linkDir) - let encoded = project.path.claudeProjectDirName + // WorkspaceItem resolves symlinks; claudeProjectDirName should agree + let workspace = WorkspaceItem(path: linkDir) + let encoded = workspace.path.claudeProjectDirName XCTAssertEqual(encoded, realDir.claudeProjectDirName, - "ProjectItem.path and claudeProjectDirName should agree on canonical encoding") + "WorkspaceItem.path and claudeProjectDirName should agree on canonical encoding") } } diff --git a/Tests/ControlMessageTests.swift b/Tests/ControlMessageTests.swift index c0c014b..bf008d8 100644 --- a/Tests/ControlMessageTests.swift +++ b/Tests/ControlMessageTests.swift @@ -63,11 +63,11 @@ final class ControlMessageTests: XCTestCase { func testDecodeCreateTab() throws { let json = """ - {"command": "create-tab", "workingDirectory": "/Users/test/project"} + {"command": "create-tab", "workingDirectory": "/Users/test/workspace"} """ let msg = try JSONDecoder().decode(ControlMessage.self, from: json.data(using: .utf8)!) XCTAssertEqual(msg.command, "create-tab") - XCTAssertEqual(msg.workingDirectory, "/Users/test/project") + XCTAssertEqual(msg.workingDirectory, "/Users/test/workspace") } func testDecodeHookNotification() throws { @@ -139,7 +139,7 @@ final class ControlMessageTests: XCTestCase { func testCodexTabInfoRoundtripIncludesKind() throws { let tab = TabInfo( id: "t-codex", - name: "Project/Codex", + name: "Workspace/Codex", isClaude: false, kind: "codex", isMaster: false, @@ -154,7 +154,7 @@ final class ControlMessageTests: XCTestCase { XCTAssertEqual(json?["kind"] as? String, "codex") XCTAssertEqual(decoded.id, "t-codex") - XCTAssertEqual(decoded.name, "Project/Codex") + XCTAssertEqual(decoded.name, "Workspace/Codex") XCTAssertFalse(decoded.isClaude) XCTAssertEqual(decoded.kind, "codex") XCTAssertEqual(decoded.sessionId, "codex-session") diff --git a/Tests/HookHandlerTests.swift b/Tests/HookHandlerTests.swift index 44c305e..32d364c 100644 --- a/Tests/HookHandlerTests.swift +++ b/Tests/HookHandlerTests.swift @@ -285,7 +285,7 @@ final class HookHandlerTests: XCTestCase { func testCreateTabReturnsOk() { var msg = ControlMessage(command: "create-tab") - msg.workingDirectory = "/Users/test/project" + msg.workingDirectory = "/Users/test/workspace" let expectation = expectation(description: "create-tab reply") diff --git a/Tests/ProcessMonitorTests.swift b/Tests/ProcessMonitorTests.swift index a158d90..8a9ee12 100644 --- a/Tests/ProcessMonitorTests.swift +++ b/Tests/ProcessMonitorTests.swift @@ -86,13 +86,13 @@ final class ProcessMonitorTests: XCTestCase { surfaceId: uuid, isClaude: true, name: "Claude", - projectPath: "/Users/test/project" + workspacePath: "/Users/test/workspace" ) XCTAssertEqual(tabInfo.surfaceId, uuid) XCTAssertTrue(tabInfo.isClaude) XCTAssertEqual(tabInfo.name, "Claude") - XCTAssertEqual(tabInfo.projectPath, "/Users/test/project") + XCTAssertEqual(tabInfo.workspacePath, "/Users/test/workspace") } func testCodexTabInfoConstruction() { @@ -101,13 +101,13 @@ final class ProcessMonitorTests: XCTestCase { surfaceId: uuid, kind: .codex, name: "Codex", - projectPath: "/Users/test/project" + workspacePath: "/Users/test/workspace" ) XCTAssertEqual(tabInfo.surfaceId, uuid) XCTAssertEqual(tabInfo.kind, .codex) XCTAssertFalse(tabInfo.isClaude) XCTAssertEqual(tabInfo.name, "Codex") - XCTAssertEqual(tabInfo.projectPath, "/Users/test/project") + XCTAssertEqual(tabInfo.workspacePath, "/Users/test/workspace") } } diff --git a/Tests/QuotaMonitorTests.swift b/Tests/QuotaMonitorTests.swift index 4848889..a787d75 100644 --- a/Tests/QuotaMonitorTests.swift +++ b/Tests/QuotaMonitorTests.swift @@ -95,7 +95,7 @@ final class QuotaMonitorTests: XCTestCase { // MARK: - Compute token rate with no files func testComputeTokenRateWithNonexistentPathReturnsNil() { - let rate = QuotaMonitor.shared.computeTokenRate(projectPaths: ["/nonexistent/path"]) + let rate = QuotaMonitor.shared.computeTokenRate(workspacePaths: ["/nonexistent/path"]) XCTAssertNil(rate) } } diff --git a/Tests/SessionStateTests.swift b/Tests/SessionStateTests.swift index c5e86f5..c25cb49 100644 --- a/Tests/SessionStateTests.swift +++ b/Tests/SessionStateTests.swift @@ -9,17 +9,17 @@ final class SessionStateTests: XCTestCase { var state = DeckardState() state.version = 2 state.selectedTabIndex = 3 - state.defaultWorkingDirectory = "/Users/test/project" - state.projects = [ - ProjectState( + state.defaultWorkingDirectory = "/Users/test/workspace" + state.workspaces = [ + WorkspaceState( id: "proj-1", - path: "/Users/test/project", - name: "project", + path: "/Users/test/workspace", + name: "workspace", selectedTabIndex: 0, tabs: [ - ProjectTabState(id: "tab-1", name: "Claude", isClaude: true, sessionId: "sess-1"), - ProjectTabState(id: "tab-2", name: "Codex", kind: .codex, sessionId: "codex-1"), - ProjectTabState(id: "tab-3", name: "Terminal", isClaude: false, sessionId: nil), + WorkspaceTabState(id: "tab-1", name: "Claude", isClaude: true, sessionId: "sess-1"), + WorkspaceTabState(id: "tab-2", name: "Codex", kind: .codex, sessionId: "codex-1"), + WorkspaceTabState(id: "tab-3", name: "Terminal", isClaude: false, sessionId: nil), ], defaultArgs: "--permission-mode acceptEdits", defaultCodexArgs: "--ask-for-approval never --sandbox workspace-write" @@ -32,18 +32,18 @@ final class SessionStateTests: XCTestCase { XCTAssertEqual(decoded.version, 2) XCTAssertEqual(decoded.selectedTabIndex, 3) - XCTAssertEqual(decoded.defaultWorkingDirectory, "/Users/test/project") - XCTAssertEqual(decoded.projects?.count, 1) - XCTAssertEqual(decoded.projects?[0].tabs.count, 3) - XCTAssertEqual(decoded.projects?[0].tabs[0].isClaude, true) - XCTAssertEqual(decoded.projects?[0].tabs[0].sessionId, "sess-1") - XCTAssertEqual(decoded.projects?[0].tabs[1].kind, .codex) - XCTAssertEqual(decoded.projects?[0].tabs[1].isClaude, false) - XCTAssertEqual(decoded.projects?[0].tabs[1].sessionId, "codex-1") - XCTAssertEqual(decoded.projects?[0].tabs[2].kind, .terminal) - XCTAssertNil(decoded.projects?[0].tabs[2].sessionId) - XCTAssertEqual(decoded.projects?[0].defaultArgs, "--permission-mode acceptEdits") - XCTAssertEqual(decoded.projects?[0].defaultCodexArgs, "--ask-for-approval never --sandbox workspace-write") + XCTAssertEqual(decoded.defaultWorkingDirectory, "/Users/test/workspace") + XCTAssertEqual(decoded.workspaces?.count, 1) + XCTAssertEqual(decoded.workspaces?[0].tabs.count, 3) + XCTAssertEqual(decoded.workspaces?[0].tabs[0].isClaude, true) + XCTAssertEqual(decoded.workspaces?[0].tabs[0].sessionId, "sess-1") + XCTAssertEqual(decoded.workspaces?[0].tabs[1].kind, .codex) + XCTAssertEqual(decoded.workspaces?[0].tabs[1].isClaude, false) + XCTAssertEqual(decoded.workspaces?[0].tabs[1].sessionId, "codex-1") + XCTAssertEqual(decoded.workspaces?[0].tabs[2].kind, .terminal) + XCTAssertNil(decoded.workspaces?[0].tabs[2].sessionId) + XCTAssertEqual(decoded.workspaces?[0].defaultArgs, "--permission-mode acceptEdits") + XCTAssertEqual(decoded.workspaces?[0].defaultCodexArgs, "--ask-for-approval never --sandbox workspace-write") } func testEmptyStateRoundtrip() throws { @@ -51,28 +51,28 @@ final class SessionStateTests: XCTestCase { let data = try JSONEncoder().encode(state) let decoded = try JSONDecoder().decode(DeckardState.self, from: data) - XCTAssertEqual(decoded.version, 2) + XCTAssertEqual(decoded.version, 3) XCTAssertEqual(decoded.selectedTabIndex, 0) XCTAssertNil(decoded.defaultWorkingDirectory) - XCTAssertNil(decoded.projects) + XCTAssertNil(decoded.workspaces) } - func testMultipleProjectsRoundtrip() throws { + func testMultipleWorkspacesRoundtrip() throws { var state = DeckardState() - state.projects = [ - ProjectState(id: "p1", path: "/path/a", name: "a", selectedTabIndex: 0, tabs: []), - ProjectState(id: "p2", path: "/path/b", name: "b", selectedTabIndex: 1, tabs: [ - ProjectTabState(id: "t1", name: "Claude", isClaude: true, sessionId: nil), + state.workspaces = [ + WorkspaceState(id: "p1", path: "/path/a", name: "a", selectedTabIndex: 0, tabs: []), + WorkspaceState(id: "p2", path: "/path/b", name: "b", selectedTabIndex: 1, tabs: [ + WorkspaceTabState(id: "t1", name: "Claude", isClaude: true, sessionId: nil), ]), - ProjectState(id: "p3", path: "/path/c", name: "c", selectedTabIndex: 0, tabs: []), + WorkspaceState(id: "p3", path: "/path/c", name: "c", selectedTabIndex: 0, tabs: []), ] let data = try JSONEncoder().encode(state) let decoded = try JSONDecoder().decode(DeckardState.self, from: data) - XCTAssertEqual(decoded.projects?.count, 3) - XCTAssertEqual(decoded.projects?[1].name, "b") - XCTAssertEqual(decoded.projects?[1].tabs.count, 1) + XCTAssertEqual(decoded.workspaces?.count, 3) + XCTAssertEqual(decoded.workspaces?[1].name, "b") + XCTAssertEqual(decoded.workspaces?[1].tabs.count, 1) } // MARK: - TabState (legacy v1) Codable @@ -100,12 +100,12 @@ final class SessionStateTests: XCTestCase { XCTAssertEqual(decoded.workingDirectory, "/tmp") } - // MARK: - ProjectTabState Codable + // MARK: - WorkspaceTabState Codable - func testProjectTabStateRoundtrip() throws { - let tab = ProjectTabState(id: "t1", name: "Claude", isClaude: true, sessionId: "s1") + func testWorkspaceTabStateRoundtrip() throws { + let tab = WorkspaceTabState(id: "t1", name: "Claude", isClaude: true, sessionId: "s1") let data = try JSONEncoder().encode(tab) - let decoded = try JSONDecoder().decode(ProjectTabState.self, from: data) + let decoded = try JSONDecoder().decode(WorkspaceTabState.self, from: data) XCTAssertEqual(decoded.id, "t1") XCTAssertEqual(decoded.name, "Claude") @@ -114,8 +114,8 @@ final class SessionStateTests: XCTestCase { XCTAssertEqual(decoded.sessionId, "s1") } - func testProjectTabStateCodexRoundtrip() throws { - let tab = ProjectTabState( + func testWorkspaceTabStateCodexRoundtrip() throws { + let tab = WorkspaceTabState( id: "t-codex", name: "Codex", kind: .codex, @@ -125,7 +125,7 @@ final class SessionStateTests: XCTestCase { let data = try JSONEncoder().encode(tab) let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] - let decoded = try JSONDecoder().decode(ProjectTabState.self, from: data) + let decoded = try JSONDecoder().decode(WorkspaceTabState.self, from: data) XCTAssertEqual(json?["kind"] as? String, "codex") XCTAssertEqual(json?["isClaude"] as? Bool, false) @@ -137,24 +137,24 @@ final class SessionStateTests: XCTestCase { XCTAssertEqual(decoded.tmuxSessionName, "deckard-codex") } - func testProjectTabStateDecodesCodexKindEvenWhenLegacyIsClaudeIsFalse() throws { + func testWorkspaceTabStateDecodesCodexKindEvenWhenLegacyIsClaudeIsFalse() throws { let json = """ {"id": "tab-codex", "name": "Codex", "kind": "codex", "isClaude": false, "sessionId": "codex-1"} """.data(using: .utf8)! - let decoded = try JSONDecoder().decode(ProjectTabState.self, from: json) + let decoded = try JSONDecoder().decode(WorkspaceTabState.self, from: json) XCTAssertEqual(decoded.kind, .codex) XCTAssertFalse(decoded.isClaude) XCTAssertEqual(decoded.sessionId, "codex-1") } - func testProjectTabStateLegacyClaudeDecodeWithoutKind() throws { + func testWorkspaceTabStateLegacyClaudeDecodeWithoutKind() throws { let json = """ {"id": "tab-claude", "name": "Claude", "isClaude": true, "sessionId": "claude-1"} """.data(using: .utf8)! - let decoded = try JSONDecoder().decode(ProjectTabState.self, from: json) + let decoded = try JSONDecoder().decode(WorkspaceTabState.self, from: json) XCTAssertEqual(decoded.kind, .claude) XCTAssertTrue(decoded.isClaude) @@ -180,8 +180,8 @@ final class SessionStateTests: XCTestCase { // Create a state, encode to JSON, write to temp file, read back var state = DeckardState() state.selectedTabIndex = 5 - state.projects = [ - ProjectState(id: "p1", path: "/test", name: "test", selectedTabIndex: 0, tabs: []) + state.workspaces = [ + WorkspaceState(id: "p1", path: "/test", name: "test", selectedTabIndex: 0, tabs: []) ] let encoder = JSONEncoder() @@ -193,7 +193,7 @@ final class SessionStateTests: XCTestCase { let loaded = try JSONDecoder().decode(DeckardState.self, from: loadedData) XCTAssertEqual(loaded.selectedTabIndex, 5) - XCTAssertEqual(loaded.projects?.count, 1) + XCTAssertEqual(loaded.workspaces?.count, 1) } // MARK: - State with legacy fields @@ -219,30 +219,30 @@ final class SessionStateTests: XCTestCase { func testDefaultValues() { let state = DeckardState() - XCTAssertEqual(state.version, 2) + XCTAssertEqual(state.version, 3) XCTAssertEqual(state.selectedTabIndex, 0) XCTAssertNil(state.defaultWorkingDirectory) XCTAssertNil(state.tabs) - XCTAssertNil(state.projects) + XCTAssertNil(state.workspaces) } // MARK: - Symlink path restoration - func testProjectStatePathSurvivesRoundtripViaProjectItem() throws { - // Simulate: save state with canonical path, restore via ProjectItem + func testWorkspaceStatePathSurvivesRoundtripViaWorkspaceItem() throws { + // Simulate: save state with canonical path, restore via WorkspaceItem let tempDir = NSTemporaryDirectory() + "deckard-state-\(UUID().uuidString)" - let realDir = tempDir + "/real-project" - let linkDir = tempDir + "/linked-project" + let realDir = tempDir + "/real-workspace" + let linkDir = tempDir + "/linked-workspace" try FileManager.default.createDirectory(atPath: realDir, withIntermediateDirectories: true) addTeardownBlock { try? FileManager.default.removeItem(atPath: tempDir) } try FileManager.default.createSymbolicLink(atPath: linkDir, withDestinationPath: realDir) // Save state using symlink path (as old Deckard would) var state = DeckardState() - state.projects = [ - ProjectState(id: "p1", path: linkDir, name: "linked-project", + state.workspaces = [ + WorkspaceState(id: "p1", path: linkDir, name: "linked-workspace", selectedTabIndex: 0, tabs: [ - ProjectTabState(id: "t1", name: "Claude", isClaude: true, sessionId: "sess-1") + WorkspaceTabState(id: "t1", name: "Claude", isClaude: true, sessionId: "sess-1") ]) ] @@ -250,73 +250,73 @@ final class SessionStateTests: XCTestCase { let data = try JSONEncoder().encode(state) let restored = try JSONDecoder().decode(DeckardState.self, from: data) - // Simulate restoreOrCreateInitial: ProjectItem resolves the path - let ps = restored.projects![0] - let project = ProjectItem(path: ps.path) + // Simulate restoreOrCreateInitial: WorkspaceItem resolves the path + let ps = restored.workspaces![0] + let workspace = WorkspaceItem(path: ps.path) // The resolved path should match the canonical path - XCTAssertEqual(project.path, realDir, - "ProjectItem should resolve symlink from old state.json") + XCTAssertEqual(workspace.path, realDir, + "WorkspaceItem should resolve symlink from old state.json") - // Sidebar folder restoration resolves ps.path before comparison + // Sidebar group restoration resolves ps.path before comparison let resolvedPsPath = (ps.path as NSString).resolvingSymlinksInPath - XCTAssertEqual(project.path, resolvedPsPath, - "Resolved ps.path should match ProjectItem.path for sidebar folder mapping") + XCTAssertEqual(workspace.path, resolvedPsPath, + "Resolved ps.path should match WorkspaceItem.path for sidebar group mapping") } - func testProjectStateSavedWithCanonicalPath() throws { - // When captureState() saves a project that was opened via symlink, - // the path should be canonical (because ProjectItem.init resolves) + func testWorkspaceStateSavedWithCanonicalPath() throws { + // When captureState() saves a workspace that was opened via symlink, + // the path should be canonical (because WorkspaceItem.init resolves) let tempDir = NSTemporaryDirectory() + "deckard-state-\(UUID().uuidString)" - let realDir = tempDir + "/real-project" - let linkDir = tempDir + "/linked-project" + let realDir = tempDir + "/real-workspace" + let linkDir = tempDir + "/linked-workspace" try FileManager.default.createDirectory(atPath: realDir, withIntermediateDirectories: true) addTeardownBlock { try? FileManager.default.removeItem(atPath: tempDir) } try FileManager.default.createSymbolicLink(atPath: linkDir, withDestinationPath: realDir) - let project = ProjectItem(path: linkDir) + let workspace = WorkspaceItem(path: linkDir) // Simulate what captureState() does - let saved = ProjectState( - id: project.id.uuidString, - path: project.path, - name: project.name, + let saved = WorkspaceState( + id: workspace.id.uuidString, + path: workspace.path, + name: workspace.name, selectedTabIndex: 0, tabs: [] ) XCTAssertEqual(saved.path, realDir, - "Saved ProjectState should contain canonical path, not symlink") + "Saved WorkspaceState should contain canonical path, not symlink") } func testOldAndNewStatePathsMatchAfterResolution() throws { // Simulate migration: old state has symlink path, new code resolves it let tempDir = NSTemporaryDirectory() + "deckard-state-\(UUID().uuidString)" - let realDir = tempDir + "/real-project" - let linkDir = tempDir + "/linked-project" + let realDir = tempDir + "/real-workspace" + let linkDir = tempDir + "/linked-workspace" try FileManager.default.createDirectory(atPath: realDir, withIntermediateDirectories: true) addTeardownBlock { try? FileManager.default.removeItem(atPath: tempDir) } try FileManager.default.createSymbolicLink(atPath: linkDir, withDestinationPath: realDir) // Old state (saved with symlink path) - let oldProjectState = ProjectState( - id: "p1", path: linkDir, name: "linked-project", + let oldWorkspaceState = WorkspaceState( + id: "p1", path: linkDir, name: "linked-workspace", selectedTabIndex: 0, tabs: [] ) - // New ProjectItem (opened via symlink, but path is resolved) - let project = ProjectItem(path: linkDir) + // New WorkspaceItem (opened via symlink, but path is resolved) + let workspace = WorkspaceItem(path: linkDir) - // restoreSidebarFolders resolves ps.path before comparison - let resolvedOldPath = (oldProjectState.path as NSString).resolvingSymlinksInPath - XCTAssertEqual(project.path, resolvedOldPath, - "Migration: resolved old state path must match new ProjectItem.path") + // restoreSidebarGroups resolves ps.path before comparison + let resolvedOldPath = (oldWorkspaceState.path as NSString).resolvingSymlinksInPath + XCTAssertEqual(workspace.path, resolvedOldPath, + "Migration: resolved old state path must match new WorkspaceItem.path") // New state (saved after fix) already has canonical path - let newProjectState = ProjectState( - id: "p2", path: project.path, name: project.name, + let newWorkspaceState = WorkspaceState( + id: "p2", path: workspace.path, name: workspace.name, selectedTabIndex: 0, tabs: [] ) - XCTAssertEqual(project.path, newProjectState.path, + XCTAssertEqual(workspace.path, newWorkspaceState.path, "Post-fix: saved path is already canonical, direct comparison works") } } diff --git a/Tests/ShortcutMigrationTests.swift b/Tests/ShortcutMigrationTests.swift new file mode 100644 index 0000000..5a80590 --- /dev/null +++ b/Tests/ShortcutMigrationTests.swift @@ -0,0 +1,98 @@ +import XCTest +@testable import Deckard + +final class ShortcutMigrationTests: XCTestCase { + + // Each test runs in an isolated UserDefaults suite to avoid touching the user's + // real shortcut configuration. + private var defaults: UserDefaults! + private var suiteName: String! + + override func setUp() { + super.setUp() + suiteName = "DeckardShortcutMigrationTests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + defaults.removePersistentDomain(forName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + func testMigratesOldIdentifierToNewKey() { + // Simulate a user override saved under the v2 identifier. + defaults.set("legacy-value", forKey: "KeyboardShortcuts_newSidebarFolder") + + DeckardShortcutMigration.migrate(defaults: defaults) + + XCTAssertEqual(defaults.string(forKey: "KeyboardShortcuts_newGroup"), "legacy-value") + XCTAssertNil(defaults.object(forKey: "KeyboardShortcuts_newSidebarFolder"), + "Old key must be removed after migration") + XCTAssertTrue(defaults.bool(forKey: DeckardShortcutMigration.migrationFlagKey), + "Migration flag must be set so it doesn't run again") + } + + func testMigratesAllRenamedIdentifiers() { + defaults.set("a", forKey: "KeyboardShortcuts_newSidebarFolder") + defaults.set("b", forKey: "KeyboardShortcuts_moveOutOfFolder") + defaults.set("c", forKey: "KeyboardShortcuts_openFolder") + defaults.set("d", forKey: "KeyboardShortcuts_closeFolder") + defaults.set("e", forKey: "KeyboardShortcuts_nextProject") + defaults.set("f", forKey: "KeyboardShortcuts_previousProject") + + DeckardShortcutMigration.migrate(defaults: defaults) + + XCTAssertEqual(defaults.string(forKey: "KeyboardShortcuts_newGroup"), "a") + XCTAssertEqual(defaults.string(forKey: "KeyboardShortcuts_moveOutOfGroup"), "b") + XCTAssertEqual(defaults.string(forKey: "KeyboardShortcuts_openWorkspace"), "c") + XCTAssertEqual(defaults.string(forKey: "KeyboardShortcuts_closeWorkspace"), "d") + XCTAssertEqual(defaults.string(forKey: "KeyboardShortcuts_nextWorkspace"), "e") + XCTAssertEqual(defaults.string(forKey: "KeyboardShortcuts_previousWorkspace"), "f") + // All old keys removed + for old in ["newSidebarFolder", "moveOutOfFolder", "openFolder", "closeFolder", "nextProject", "previousProject"] { + XCTAssertNil(defaults.object(forKey: "KeyboardShortcuts_\(old)"), + "Old key \(old) must be removed") + } + } + + func testDoesNotRunTwice() { + defaults.set("first-run", forKey: "KeyboardShortcuts_newSidebarFolder") + DeckardShortcutMigration.migrate(defaults: defaults) + + // Simulate the user later setting an override on the OLD key (e.g. by + // downgrading then upgrading again). The second migration call must be + // a no-op so the user's recent choice on the new key isn't clobbered. + defaults.set("late-legacy", forKey: "KeyboardShortcuts_newSidebarFolder") + defaults.set("user-choice", forKey: "KeyboardShortcuts_newGroup") + DeckardShortcutMigration.migrate(defaults: defaults) + + XCTAssertEqual(defaults.string(forKey: "KeyboardShortcuts_newGroup"), "user-choice") + XCTAssertEqual(defaults.string(forKey: "KeyboardShortcuts_newSidebarFolder"), "late-legacy", + "Second migration call must be a no-op — flag prevents re-run") + } + + func testNoLegacyKeysIsNoop() { + // First run with no legacy keys present: migration just sets the flag. + DeckardShortcutMigration.migrate(defaults: defaults) + + XCTAssertTrue(defaults.bool(forKey: DeckardShortcutMigration.migrationFlagKey)) + XCTAssertNil(defaults.object(forKey: "KeyboardShortcuts_newGroup")) + XCTAssertNil(defaults.object(forKey: "KeyboardShortcuts_moveOutOfGroup")) + } + + func testDoesNotOverwriteExistingNewKey() { + // If the user already has a value under the new key, don't clobber it + // with the legacy value. + defaults.set("legacy", forKey: "KeyboardShortcuts_newSidebarFolder") + defaults.set("already-set", forKey: "KeyboardShortcuts_newGroup") + + DeckardShortcutMigration.migrate(defaults: defaults) + + XCTAssertEqual(defaults.string(forKey: "KeyboardShortcuts_newGroup"), "already-set") + XCTAssertNil(defaults.object(forKey: "KeyboardShortcuts_newSidebarFolder"), + "Old key is still removed even when new key already had a value") + } +} diff --git a/Tests/SidebarFolderTests.swift b/Tests/SidebarFolderTests.swift deleted file mode 100644 index 6c8112f..0000000 --- a/Tests/SidebarFolderTests.swift +++ /dev/null @@ -1,419 +0,0 @@ -import XCTest -@testable import Deckard - -final class SidebarFolderTests: XCTestCase { - - // MARK: - SidebarFolderState Codable roundtrips - - func testSidebarFolderStateRoundtrip() throws { - let state = SidebarFolderState( - id: "folder-1", - name: "My Folder", - isCollapsed: true, - projectIds: ["proj-a", "proj-b", "proj-c"] - ) - - let data = try JSONEncoder().encode(state) - let decoded = try JSONDecoder().decode(SidebarFolderState.self, from: data) - - XCTAssertEqual(decoded.id, "folder-1") - XCTAssertEqual(decoded.name, "My Folder") - XCTAssertTrue(decoded.isCollapsed) - XCTAssertEqual(decoded.projectIds, ["proj-a", "proj-b", "proj-c"]) - } - - func testSidebarFolderStateEmptyProjectIds() throws { - let state = SidebarFolderState( - id: "folder-empty", - name: "Empty Folder", - isCollapsed: false, - projectIds: [] - ) - - let data = try JSONEncoder().encode(state) - let decoded = try JSONDecoder().decode(SidebarFolderState.self, from: data) - - XCTAssertEqual(decoded.id, "folder-empty") - XCTAssertEqual(decoded.name, "Empty Folder") - XCTAssertFalse(decoded.isCollapsed) - XCTAssertEqual(decoded.projectIds, []) - } - - // MARK: - SidebarOrderItem Codable roundtrips - - func testSidebarOrderItemFolderRoundtrip() throws { - let item = SidebarOrderItem.folder("folder-abc") - - let data = try JSONEncoder().encode(item) - let decoded = try JSONDecoder().decode(SidebarOrderItem.self, from: data) - - if case .folder(let id) = decoded { - XCTAssertEqual(id, "folder-abc") - } else { - XCTFail("Expected .folder case, got \(decoded)") - } - } - - func testSidebarOrderItemProjectRoundtrip() throws { - let item = SidebarOrderItem.project("proj-xyz") - - let data = try JSONEncoder().encode(item) - let decoded = try JSONDecoder().decode(SidebarOrderItem.self, from: data) - - if case .project(let id) = decoded { - XCTAssertEqual(id, "proj-xyz") - } else { - XCTFail("Expected .project case, got \(decoded)") - } - } - - func testSidebarOrderItemInvalidTypeThrows() throws { - let json = """ - {"type": "unknown", "id": "some-id"} - """.data(using: .utf8)! - - XCTAssertThrowsError(try JSONDecoder().decode(SidebarOrderItem.self, from: json)) { error in - guard case DecodingError.dataCorrupted(let context) = error else { - XCTFail("Expected DecodingError.dataCorrupted, got \(error)") - return - } - XCTAssertTrue(context.debugDescription.contains("Unknown sidebar order item type")) - } - } - - func testSidebarOrderItemEncodedShape() throws { - // Verify the JSON shape is {"type": "folder", "id": "..."} - let item = SidebarOrderItem.folder("f1") - let data = try JSONEncoder().encode(item) - let dict = try JSONSerialization.jsonObject(with: data) as? [String: String] - - XCTAssertEqual(dict?["type"], "folder") - XCTAssertEqual(dict?["id"], "f1") - } - - func testSidebarOrderItemProjectEncodedShape() throws { - let item = SidebarOrderItem.project("p1") - let data = try JSONEncoder().encode(item) - let dict = try JSONSerialization.jsonObject(with: data) as? [String: String] - - XCTAssertEqual(dict?["type"], "project") - XCTAssertEqual(dict?["id"], "p1") - } - - // MARK: - DeckardState with folders - - func testDeckardStateWithFoldersRoundtrip() throws { - var state = DeckardState() - state.sidebarFolders = [ - SidebarFolderState(id: "f1", name: "Work", isCollapsed: false, projectIds: ["p1", "p2"]), - SidebarFolderState(id: "f2", name: "Personal", isCollapsed: true, projectIds: ["p3"]), - ] - state.sidebarOrder = [ - .folder("f1"), - .project("p4"), - .folder("f2"), - ] - state.projects = [ - ProjectState(id: "p1", path: "/work/a", name: "a", selectedTabIndex: 0, tabs: []), - ProjectState(id: "p2", path: "/work/b", name: "b", selectedTabIndex: 0, tabs: []), - ProjectState(id: "p3", path: "/personal/c", name: "c", selectedTabIndex: 0, tabs: []), - ProjectState(id: "p4", path: "/other/d", name: "d", selectedTabIndex: 0, tabs: []), - ] - - let data = try JSONEncoder().encode(state) - let decoded = try JSONDecoder().decode(DeckardState.self, from: data) - - XCTAssertEqual(decoded.sidebarFolders?.count, 2) - XCTAssertEqual(decoded.sidebarFolders?[0].name, "Work") - XCTAssertEqual(decoded.sidebarFolders?[0].projectIds, ["p1", "p2"]) - XCTAssertEqual(decoded.sidebarFolders?[1].name, "Personal") - XCTAssertTrue(decoded.sidebarFolders?[1].isCollapsed == true) - XCTAssertEqual(decoded.sidebarOrder?.count, 3) - - // Verify order items - if case .folder(let id) = decoded.sidebarOrder?[0] { - XCTAssertEqual(id, "f1") - } else { - XCTFail("Expected .folder at index 0") - } - if case .project(let id) = decoded.sidebarOrder?[1] { - XCTAssertEqual(id, "p4") - } else { - XCTFail("Expected .project at index 1") - } - if case .folder(let id) = decoded.sidebarOrder?[2] { - XCTAssertEqual(id, "f2") - } else { - XCTFail("Expected .folder at index 2") - } - } - - func testDeckardStateNilFoldersBackwardCompat() throws { - // Simulate a v2 state without folder fields - var state = DeckardState() - state.projects = [ - ProjectState(id: "p1", path: "/test", name: "test", selectedTabIndex: 0, tabs: []) - ] - // sidebarFolders and sidebarOrder deliberately left nil - - let data = try JSONEncoder().encode(state) - let decoded = try JSONDecoder().decode(DeckardState.self, from: data) - - XCTAssertNil(decoded.sidebarFolders) - XCTAssertNil(decoded.sidebarOrder) - XCTAssertEqual(decoded.projects?.count, 1) - } - - func testDeckardStateMixedSidebarOrder() throws { - var state = DeckardState() - state.sidebarFolders = [ - SidebarFolderState(id: "f1", name: "Folder", isCollapsed: false, projectIds: []) - ] - state.sidebarOrder = [ - .project("p1"), - .folder("f1"), - .project("p2"), - .project("p3"), - .folder("f1"), // duplicate folder reference (edge case) - .project("p4"), - ] - - let data = try JSONEncoder().encode(state) - let decoded = try JSONDecoder().decode(DeckardState.self, from: data) - - XCTAssertEqual(decoded.sidebarOrder?.count, 6) - - // Verify alternating types - if case .project = decoded.sidebarOrder?[0] {} else { XCTFail("Expected .project at 0") } - if case .folder = decoded.sidebarOrder?[1] {} else { XCTFail("Expected .folder at 1") } - if case .project = decoded.sidebarOrder?[2] {} else { XCTFail("Expected .project at 2") } - if case .project = decoded.sidebarOrder?[3] {} else { XCTFail("Expected .project at 3") } - if case .folder = decoded.sidebarOrder?[4] {} else { XCTFail("Expected .folder at 4") } - if case .project = decoded.sidebarOrder?[5] {} else { XCTFail("Expected .project at 5") } - } - - func testDeckardStateEmptyFoldersAndOrder() throws { - var state = DeckardState() - state.sidebarFolders = [] - state.sidebarOrder = [] - - let data = try JSONEncoder().encode(state) - let decoded = try JSONDecoder().decode(DeckardState.self, from: data) - - XCTAssertEqual(decoded.sidebarFolders?.count, 0) - XCTAssertEqual(decoded.sidebarOrder?.count, 0) - } - - // MARK: - SidebarFolder data model - - func testSidebarFolderInitDefaults() { - let folder = SidebarFolder(name: "Test Folder") - - XCTAssertEqual(folder.name, "Test Folder") - XCTAssertFalse(folder.isCollapsed) - XCTAssertEqual(folder.projectIds, []) - XCTAssertNotEqual(folder.id, UUID()) // has a valid UUID - } - - func testSidebarFolderProjectIdsAddRemove() { - let folder = SidebarFolder(name: "Folder") - let id1 = UUID() - let id2 = UUID() - let id3 = UUID() - - folder.projectIds.append(id1) - folder.projectIds.append(id2) - folder.projectIds.append(id3) - XCTAssertEqual(folder.projectIds.count, 3) - XCTAssertEqual(folder.projectIds, [id1, id2, id3]) - - folder.projectIds.removeAll { $0 == id2 } - XCTAssertEqual(folder.projectIds.count, 2) - XCTAssertEqual(folder.projectIds, [id1, id3]) - - folder.projectIds.removeAll() - XCTAssertEqual(folder.projectIds.count, 0) - } - - func testSidebarFolderIsCollapsedToggle() { - let folder = SidebarFolder(name: "Folder") - XCTAssertFalse(folder.isCollapsed) - - folder.isCollapsed.toggle() - XCTAssertTrue(folder.isCollapsed) - - folder.isCollapsed.toggle() - XCTAssertFalse(folder.isCollapsed) - } - - func testSidebarFolderFullInit() { - let id = UUID() - let pid1 = UUID() - let pid2 = UUID() - let folder = SidebarFolder(id: id, name: "Custom", isCollapsed: true, projectIds: [pid1, pid2]) - - XCTAssertEqual(folder.id, id) - XCTAssertEqual(folder.name, "Custom") - XCTAssertTrue(folder.isCollapsed) - XCTAssertEqual(folder.projectIds, [pid1, pid2]) - } - - // MARK: - SidebarItem enum - - func testSidebarItemFolderCase() { - let folder = SidebarFolder(name: "Test") - let item = SidebarItem.folder(folder) - - if case .folder(let f) = item { - XCTAssertTrue(f === folder) // same reference - XCTAssertEqual(f.name, "Test") - } else { - XCTFail("Expected .folder case") - } - } - - func testSidebarItemProjectCase() { - let projectId = UUID() - let item = SidebarItem.project(projectId) - - if case .project(let id) = item { - XCTAssertEqual(id, projectId) - } else { - XCTFail("Expected .project case") - } - } - - func testSidebarItemFolderMutationThroughReference() { - let folder = SidebarFolder(name: "Before") - let item = SidebarItem.folder(folder) - - // Mutating the folder should be visible through the enum - folder.name = "After" - - if case .folder(let f) = item { - XCTAssertEqual(f.name, "After") - } else { - XCTFail("Expected .folder case") - } - } - - // MARK: - ProjectTabState with tmuxSessionName - - func testProjectTabStateWithTmuxSessionName() throws { - let tab = ProjectTabState( - id: "tab-1", - name: "Terminal", - isClaude: false, - sessionId: "sess-1", - tmuxSessionName: "deckard-main-1" - ) - - let data = try JSONEncoder().encode(tab) - let decoded = try JSONDecoder().decode(ProjectTabState.self, from: data) - - XCTAssertEqual(decoded.id, "tab-1") - XCTAssertEqual(decoded.name, "Terminal") - XCTAssertFalse(decoded.isClaude) - XCTAssertEqual(decoded.sessionId, "sess-1") - XCTAssertEqual(decoded.tmuxSessionName, "deckard-main-1") - } - - func testProjectTabStateWithNilTmuxSessionName() throws { - let tab = ProjectTabState( - id: "tab-2", - name: "Claude", - isClaude: true, - sessionId: "sess-2", - tmuxSessionName: nil - ) - - let data = try JSONEncoder().encode(tab) - let decoded = try JSONDecoder().decode(ProjectTabState.self, from: data) - - XCTAssertEqual(decoded.id, "tab-2") - XCTAssertEqual(decoded.name, "Claude") - XCTAssertTrue(decoded.isClaude) - XCTAssertEqual(decoded.sessionId, "sess-2") - XCTAssertNil(decoded.tmuxSessionName) - } - - func testProjectTabStateBackwardCompatNoTmuxField() throws { - // Simulate JSON without tmuxSessionName field (old format) - let json = """ - {"id": "tab-3", "name": "Terminal", "isClaude": false} - """.data(using: .utf8)! - - let decoded = try JSONDecoder().decode(ProjectTabState.self, from: json) - - XCTAssertEqual(decoded.id, "tab-3") - XCTAssertEqual(decoded.name, "Terminal") - XCTAssertFalse(decoded.isClaude) - XCTAssertNil(decoded.sessionId) - XCTAssertNil(decoded.tmuxSessionName) - } - - // MARK: - SidebarFolderState edge cases - - func testSidebarFolderStateSpecialCharactersInName() throws { - let state = SidebarFolderState( - id: "f-special", - name: "Work / Personal (2024) & More \u{1F4C1}", - isCollapsed: false, - projectIds: ["p1"] - ) - - let data = try JSONEncoder().encode(state) - let decoded = try JSONDecoder().decode(SidebarFolderState.self, from: data) - - XCTAssertEqual(decoded.name, "Work / Personal (2024) & More \u{1F4C1}") - } - - func testSidebarFolderStateManyProjectIds() throws { - let ids = (0..<100).map { "proj-\($0)" } - let state = SidebarFolderState( - id: "f-large", - name: "Large Folder", - isCollapsed: false, - projectIds: ids - ) - - let data = try JSONEncoder().encode(state) - let decoded = try JSONDecoder().decode(SidebarFolderState.self, from: data) - - XCTAssertEqual(decoded.projectIds.count, 100) - XCTAssertEqual(decoded.projectIds.first, "proj-0") - XCTAssertEqual(decoded.projectIds.last, "proj-99") - } - - // MARK: - SidebarOrderItem array roundtrip - - func testSidebarOrderItemArrayRoundtrip() throws { - let items: [SidebarOrderItem] = [ - .folder("f1"), - .project("p1"), - .project("p2"), - .folder("f2"), - .project("p3"), - ] - - let data = try JSONEncoder().encode(items) - let decoded = try JSONDecoder().decode([SidebarOrderItem].self, from: data) - - XCTAssertEqual(decoded.count, 5) - - if case .folder(let id) = decoded[0] { XCTAssertEqual(id, "f1") } - else { XCTFail("Expected .folder at 0") } - - if case .project(let id) = decoded[1] { XCTAssertEqual(id, "p1") } - else { XCTFail("Expected .project at 1") } - - if case .project(let id) = decoded[2] { XCTAssertEqual(id, "p2") } - else { XCTFail("Expected .project at 2") } - - if case .folder(let id) = decoded[3] { XCTAssertEqual(id, "f2") } - else { XCTFail("Expected .folder at 3") } - - if case .project(let id) = decoded[4] { XCTAssertEqual(id, "p3") } - else { XCTFail("Expected .project at 4") } - } -} diff --git a/Tests/SidebarGroupTests.swift b/Tests/SidebarGroupTests.swift new file mode 100644 index 0000000..c7358a6 --- /dev/null +++ b/Tests/SidebarGroupTests.swift @@ -0,0 +1,533 @@ +import XCTest +@testable import Deckard + +final class SidebarGroupTests: XCTestCase { + + // MARK: - SidebarGroupState Codable roundtrips + + func testSidebarGroupStateRoundtrip() throws { + let state = SidebarGroupState( + id: "group-1", + name: "My Group", + isCollapsed: true, + workspaceIds: ["proj-a", "proj-b", "proj-c"] + ) + + let data = try JSONEncoder().encode(state) + let decoded = try JSONDecoder().decode(SidebarGroupState.self, from: data) + + XCTAssertEqual(decoded.id, "group-1") + XCTAssertEqual(decoded.name, "My Group") + XCTAssertTrue(decoded.isCollapsed) + XCTAssertEqual(decoded.workspaceIds, ["proj-a", "proj-b", "proj-c"]) + } + + func testSidebarGroupStateEmptyWorkspaceIds() throws { + let state = SidebarGroupState( + id: "group-empty", + name: "Empty Group", + isCollapsed: false, + workspaceIds: [] + ) + + let data = try JSONEncoder().encode(state) + let decoded = try JSONDecoder().decode(SidebarGroupState.self, from: data) + + XCTAssertEqual(decoded.id, "group-empty") + XCTAssertEqual(decoded.name, "Empty Group") + XCTAssertFalse(decoded.isCollapsed) + XCTAssertEqual(decoded.workspaceIds, []) + } + + // MARK: - SidebarOrderItem Codable roundtrips + + func testSidebarOrderItemGroupRoundtrip() throws { + let item = SidebarOrderItem.group("group-abc") + + let data = try JSONEncoder().encode(item) + let decoded = try JSONDecoder().decode(SidebarOrderItem.self, from: data) + + if case .group(let id) = decoded { + XCTAssertEqual(id, "group-abc") + } else { + XCTFail("Expected .group case, got \(decoded)") + } + } + + func testSidebarOrderItemWorkspaceRoundtrip() throws { + let item = SidebarOrderItem.workspace("proj-xyz") + + let data = try JSONEncoder().encode(item) + let decoded = try JSONDecoder().decode(SidebarOrderItem.self, from: data) + + if case .workspace(let id) = decoded { + XCTAssertEqual(id, "proj-xyz") + } else { + XCTFail("Expected .workspace case, got \(decoded)") + } + } + + func testSidebarOrderItemInvalidTypeThrows() throws { + let json = """ + {"type": "unknown", "id": "some-id"} + """.data(using: .utf8)! + + XCTAssertThrowsError(try JSONDecoder().decode(SidebarOrderItem.self, from: json)) { error in + guard case DecodingError.dataCorrupted(let context) = error else { + XCTFail("Expected DecodingError.dataCorrupted, got \(error)") + return + } + XCTAssertTrue(context.debugDescription.contains("Unknown sidebar order item type")) + } + } + + func testSidebarOrderItemEncodedShape() throws { + // Verify the JSON shape is {"type": "group", "id": "..."} + let item = SidebarOrderItem.group("f1") + let data = try JSONEncoder().encode(item) + let dict = try JSONSerialization.jsonObject(with: data) as? [String: String] + + XCTAssertEqual(dict?["type"], "group") + XCTAssertEqual(dict?["id"], "f1") + } + + func testSidebarOrderItemWorkspaceEncodedShape() throws { + let item = SidebarOrderItem.workspace("p1") + let data = try JSONEncoder().encode(item) + let dict = try JSONSerialization.jsonObject(with: data) as? [String: String] + + XCTAssertEqual(dict?["type"], "project") + XCTAssertEqual(dict?["id"], "p1") + } + + // MARK: - DeckardState with groups + + func testDeckardStateWithGroupsRoundtrip() throws { + var state = DeckardState() + state.sidebarGroups = [ + SidebarGroupState(id: "f1", name: "Work", isCollapsed: false, workspaceIds: ["p1", "p2"]), + SidebarGroupState(id: "f2", name: "Personal", isCollapsed: true, workspaceIds: ["p3"]), + ] + state.sidebarOrder = [ + .group("f1"), + .workspace("p4"), + .group("f2"), + ] + state.workspaces = [ + WorkspaceState(id: "p1", path: "/work/a", name: "a", selectedTabIndex: 0, tabs: []), + WorkspaceState(id: "p2", path: "/work/b", name: "b", selectedTabIndex: 0, tabs: []), + WorkspaceState(id: "p3", path: "/personal/c", name: "c", selectedTabIndex: 0, tabs: []), + WorkspaceState(id: "p4", path: "/other/d", name: "d", selectedTabIndex: 0, tabs: []), + ] + + let data = try JSONEncoder().encode(state) + let decoded = try JSONDecoder().decode(DeckardState.self, from: data) + + XCTAssertEqual(decoded.sidebarGroups?.count, 2) + XCTAssertEqual(decoded.sidebarGroups?[0].name, "Work") + XCTAssertEqual(decoded.sidebarGroups?[0].workspaceIds, ["p1", "p2"]) + XCTAssertEqual(decoded.sidebarGroups?[1].name, "Personal") + XCTAssertTrue(decoded.sidebarGroups?[1].isCollapsed == true) + XCTAssertEqual(decoded.sidebarOrder?.count, 3) + + // Verify order items + if case .group(let id) = decoded.sidebarOrder?[0] { + XCTAssertEqual(id, "f1") + } else { + XCTFail("Expected .group at index 0") + } + if case .workspace(let id) = decoded.sidebarOrder?[1] { + XCTAssertEqual(id, "p4") + } else { + XCTFail("Expected .workspace at index 1") + } + if case .group(let id) = decoded.sidebarOrder?[2] { + XCTAssertEqual(id, "f2") + } else { + XCTFail("Expected .group at index 2") + } + } + + func testDeckardStateNilGroupsBackwardCompat() throws { + // Simulate a v2 state without group fields + var state = DeckardState() + state.workspaces = [ + WorkspaceState(id: "p1", path: "/test", name: "test", selectedTabIndex: 0, tabs: []) + ] + // sidebarGroups and sidebarOrder deliberately left nil + + let data = try JSONEncoder().encode(state) + let decoded = try JSONDecoder().decode(DeckardState.self, from: data) + + XCTAssertNil(decoded.sidebarGroups) + XCTAssertNil(decoded.sidebarOrder) + XCTAssertEqual(decoded.workspaces?.count, 1) + } + + func testDeckardStateMixedSidebarOrder() throws { + var state = DeckardState() + state.sidebarGroups = [ + SidebarGroupState(id: "f1", name: "Group", isCollapsed: false, workspaceIds: []) + ] + state.sidebarOrder = [ + .workspace("p1"), + .group("f1"), + .workspace("p2"), + .workspace("p3"), + .group("f1"), // duplicate group reference (edge case) + .workspace("p4"), + ] + + let data = try JSONEncoder().encode(state) + let decoded = try JSONDecoder().decode(DeckardState.self, from: data) + + XCTAssertEqual(decoded.sidebarOrder?.count, 6) + + // Verify alternating types + if case .workspace = decoded.sidebarOrder?[0] {} else { XCTFail("Expected .workspace at 0") } + if case .group = decoded.sidebarOrder?[1] {} else { XCTFail("Expected .group at 1") } + if case .workspace = decoded.sidebarOrder?[2] {} else { XCTFail("Expected .workspace at 2") } + if case .workspace = decoded.sidebarOrder?[3] {} else { XCTFail("Expected .workspace at 3") } + if case .group = decoded.sidebarOrder?[4] {} else { XCTFail("Expected .group at 4") } + if case .workspace = decoded.sidebarOrder?[5] {} else { XCTFail("Expected .workspace at 5") } + } + + func testDeckardStateEmptyGroupsAndOrder() throws { + var state = DeckardState() + state.sidebarGroups = [] + state.sidebarOrder = [] + + let data = try JSONEncoder().encode(state) + let decoded = try JSONDecoder().decode(DeckardState.self, from: data) + + XCTAssertEqual(decoded.sidebarGroups?.count, 0) + XCTAssertEqual(decoded.sidebarOrder?.count, 0) + } + + // MARK: - SidebarGroup data model + + func testSidebarGroupInitDefaults() { + let group = SidebarGroup(name: "Test Group") + + XCTAssertEqual(group.name, "Test Group") + XCTAssertFalse(group.isCollapsed) + XCTAssertEqual(group.workspaceIds, []) + XCTAssertNotEqual(group.id, UUID()) // has a valid UUID + } + + func testSidebarGroupWorkspaceIdsAddRemove() { + let group = SidebarGroup(name: "Group") + let id1 = UUID() + let id2 = UUID() + let id3 = UUID() + + group.workspaceIds.append(id1) + group.workspaceIds.append(id2) + group.workspaceIds.append(id3) + XCTAssertEqual(group.workspaceIds.count, 3) + XCTAssertEqual(group.workspaceIds, [id1, id2, id3]) + + group.workspaceIds.removeAll { $0 == id2 } + XCTAssertEqual(group.workspaceIds.count, 2) + XCTAssertEqual(group.workspaceIds, [id1, id3]) + + group.workspaceIds.removeAll() + XCTAssertEqual(group.workspaceIds.count, 0) + } + + func testSidebarGroupIsCollapsedToggle() { + let group = SidebarGroup(name: "Group") + XCTAssertFalse(group.isCollapsed) + + group.isCollapsed.toggle() + XCTAssertTrue(group.isCollapsed) + + group.isCollapsed.toggle() + XCTAssertFalse(group.isCollapsed) + } + + func testSidebarGroupFullInit() { + let id = UUID() + let pid1 = UUID() + let pid2 = UUID() + let group = SidebarGroup(id: id, name: "Custom", isCollapsed: true, workspaceIds: [pid1, pid2]) + + XCTAssertEqual(group.id, id) + XCTAssertEqual(group.name, "Custom") + XCTAssertTrue(group.isCollapsed) + XCTAssertEqual(group.workspaceIds, [pid1, pid2]) + } + + // MARK: - SidebarItem enum + + func testSidebarItemGroupCase() { + let group = SidebarGroup(name: "Test") + let item = SidebarItem.group(group) + + if case .group(let f) = item { + XCTAssertTrue(f === group) // same reference + XCTAssertEqual(f.name, "Test") + } else { + XCTFail("Expected .group case") + } + } + + func testSidebarItemWorkspaceCase() { + let workspaceId = UUID() + let item = SidebarItem.workspace(workspaceId) + + if case .workspace(let id) = item { + XCTAssertEqual(id, workspaceId) + } else { + XCTFail("Expected .workspace case") + } + } + + func testSidebarItemGroupMutationThroughReference() { + let group = SidebarGroup(name: "Before") + let item = SidebarItem.group(group) + + // Mutating the group should be visible through the enum + group.name = "After" + + if case .group(let f) = item { + XCTAssertEqual(f.name, "After") + } else { + XCTFail("Expected .group case") + } + } + + // MARK: - WorkspaceTabState with tmuxSessionName + + func testWorkspaceTabStateWithTmuxSessionName() throws { + let tab = WorkspaceTabState( + id: "tab-1", + name: "Terminal", + isClaude: false, + sessionId: "sess-1", + tmuxSessionName: "deckard-main-1" + ) + + let data = try JSONEncoder().encode(tab) + let decoded = try JSONDecoder().decode(WorkspaceTabState.self, from: data) + + XCTAssertEqual(decoded.id, "tab-1") + XCTAssertEqual(decoded.name, "Terminal") + XCTAssertFalse(decoded.isClaude) + XCTAssertEqual(decoded.sessionId, "sess-1") + XCTAssertEqual(decoded.tmuxSessionName, "deckard-main-1") + } + + func testWorkspaceTabStateWithNilTmuxSessionName() throws { + let tab = WorkspaceTabState( + id: "tab-2", + name: "Claude", + isClaude: true, + sessionId: "sess-2", + tmuxSessionName: nil + ) + + let data = try JSONEncoder().encode(tab) + let decoded = try JSONDecoder().decode(WorkspaceTabState.self, from: data) + + XCTAssertEqual(decoded.id, "tab-2") + XCTAssertEqual(decoded.name, "Claude") + XCTAssertTrue(decoded.isClaude) + XCTAssertEqual(decoded.sessionId, "sess-2") + XCTAssertNil(decoded.tmuxSessionName) + } + + func testWorkspaceTabStateBackwardCompatNoTmuxField() throws { + // Simulate JSON without tmuxSessionName field (old format) + let json = """ + {"id": "tab-3", "name": "Terminal", "isClaude": false} + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(WorkspaceTabState.self, from: json) + + XCTAssertEqual(decoded.id, "tab-3") + XCTAssertEqual(decoded.name, "Terminal") + XCTAssertFalse(decoded.isClaude) + XCTAssertNil(decoded.sessionId) + XCTAssertNil(decoded.tmuxSessionName) + } + + // MARK: - SidebarGroupState edge cases + + func testSidebarGroupStateSpecialCharactersInName() throws { + let state = SidebarGroupState( + id: "f-special", + name: "Work / Personal (2024) & More \u{1F4C1}", + isCollapsed: false, + workspaceIds: ["p1"] + ) + + let data = try JSONEncoder().encode(state) + let decoded = try JSONDecoder().decode(SidebarGroupState.self, from: data) + + XCTAssertEqual(decoded.name, "Work / Personal (2024) & More \u{1F4C1}") + } + + func testSidebarGroupStateManyWorkspaceIds() throws { + let ids = (0..<100).map { "proj-\($0)" } + let state = SidebarGroupState( + id: "f-large", + name: "Large Group", + isCollapsed: false, + workspaceIds: ids + ) + + let data = try JSONEncoder().encode(state) + let decoded = try JSONDecoder().decode(SidebarGroupState.self, from: data) + + XCTAssertEqual(decoded.workspaceIds.count, 100) + XCTAssertEqual(decoded.workspaceIds.first, "proj-0") + XCTAssertEqual(decoded.workspaceIds.last, "proj-99") + } + + // MARK: - SidebarOrderItem array roundtrip + + func testSidebarOrderItemArrayRoundtrip() throws { + let items: [SidebarOrderItem] = [ + .group("f1"), + .workspace("p1"), + .workspace("p2"), + .group("f2"), + .workspace("p3"), + ] + + let data = try JSONEncoder().encode(items) + let decoded = try JSONDecoder().decode([SidebarOrderItem].self, from: data) + + XCTAssertEqual(decoded.count, 5) + + if case .group(let id) = decoded[0] { XCTAssertEqual(id, "f1") } + else { XCTFail("Expected .group at 0") } + + if case .workspace(let id) = decoded[1] { XCTAssertEqual(id, "p1") } + else { XCTFail("Expected .workspace at 1") } + + if case .workspace(let id) = decoded[2] { XCTAssertEqual(id, "p2") } + else { XCTFail("Expected .workspace at 2") } + + if case .group(let id) = decoded[3] { XCTAssertEqual(id, "f2") } + else { XCTFail("Expected .group at 3") } + + if case .workspace(let id) = decoded[4] { XCTAssertEqual(id, "p3") } + else { XCTFail("Expected .workspace at 4") } + } + + // MARK: - Legacy v2 state.json migration + + func testLegacySidebarFoldersKeyDecodesAsGroups() throws { + // v2 state.json wrote sidebar groups under the outer key "sidebarFolders" + // and members under the inner key "projectIds". Both legacy keys must + // decode cleanly into the new sidebarGroups/workspaceIds shape. + let json = """ + { + "version": 2, + "selectedTabIndex": 0, + "sidebarFolders": [ + {"id": "f1", "name": "Work", "isCollapsed": false, "projectIds": ["p1"]} + ] + } + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(DeckardState.self, from: json) + XCTAssertEqual(decoded.sidebarGroups?.count, 1) + XCTAssertEqual(decoded.sidebarGroups?.first?.name, "Work") + XCTAssertEqual(decoded.sidebarGroups?.first?.workspaceIds, ["p1"]) + } + + func testLegacyProjectsKeyDecodesAsWorkspaces() throws { + // v2 state.json wrote workspaces under the outer key "projects". + let json = """ + { + "version": 2, + "selectedTabIndex": 1, + "projects": [ + {"id": "p1", "path": "/work/a", "name": "a", "selectedTabIndex": 0, "tabs": []}, + {"id": "p2", "path": "/work/b", "name": "b", "selectedTabIndex": 0, "tabs": []} + ] + } + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(DeckardState.self, from: json) + XCTAssertEqual(decoded.workspaces?.count, 2) + XCTAssertEqual(decoded.workspaces?[0].name, "a") + XCTAssertEqual(decoded.workspaces?[1].path, "/work/b") + } + + func testFullV2StateRoundtripsToV3OnEncode() throws { + // A complete v2 state.json — every legacy key combined — decodes cleanly + // and re-encodes using only the new keys. This is the actual upgrade path. + let v2json = """ + { + "version": 2, + "selectedTabIndex": 0, + "projects": [ + {"id": "p1", "path": "/a", "name": "a", "selectedTabIndex": 0, "tabs": []} + ], + "sidebarFolders": [ + {"id": "f1", "name": "Work", "isCollapsed": false, "projectIds": ["p1"]} + ], + "sidebarOrder": [ + {"type": "folder", "id": "f1"} + ] + } + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(DeckardState.self, from: v2json) + let reencoded = try JSONEncoder().encode(decoded) + let reencodedString = String(data: reencoded, encoding: .utf8) ?? "" + + XCTAssertTrue(reencodedString.contains("\"workspaces\"")) + XCTAssertTrue(reencodedString.contains("\"sidebarGroups\"")) + XCTAssertTrue(reencodedString.contains("\"workspaceIds\"")) + // sidebarOrder discriminator was migrated from "folder" to "group" + XCTAssertTrue(reencodedString.contains("\"type\":\"group\"")) + XCTAssertFalse(reencodedString.contains("\"projects\"")) + XCTAssertFalse(reencodedString.contains("\"sidebarFolders\"")) + XCTAssertFalse(reencodedString.contains("\"projectIds\"")) + XCTAssertFalse(reencodedString.contains("\"type\":\"folder\"")) + } + + func testLegacyFolderDiscriminatorDecodesAsGroup() throws { + // v2 sidebarOrder items used {"type": "folder", "id": "..."}. + // The new decoder must accept that and surface as .group. + let json = """ + [ + {"type": "folder", "id": "f1"}, + {"type": "project", "id": "p1"}, + {"type": "folder", "id": "f2"} + ] + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode([SidebarOrderItem].self, from: json) + XCTAssertEqual(decoded.count, 3) + if case .group(let id) = decoded[0] { XCTAssertEqual(id, "f1") } + else { XCTFail("Expected .group at 0") } + if case .workspace(let id) = decoded[1] { XCTAssertEqual(id, "p1") } + else { XCTFail("Expected .workspace at 1") } + if case .group(let id) = decoded[2] { XCTAssertEqual(id, "f2") } + else { XCTFail("Expected .group at 2") } + } + + func testEncoderWritesNewKeysOnly() throws { + // After a roundtrip, the JSON must use the new keys (sidebarGroups, "group" + // discriminator), never the legacy ones — otherwise downgrade would silently + // work and obscure when the migration happened. + var state = DeckardState() + state.sidebarGroups = [SidebarGroupState(id: "f1", name: "g", isCollapsed: false, workspaceIds: [])] + state.sidebarOrder = [.group("f1")] + + let data = try JSONEncoder().encode(state) + let json = String(data: data, encoding: .utf8) ?? "" + XCTAssertTrue(json.contains("\"sidebarGroups\"")) + XCTAssertFalse(json.contains("\"sidebarFolders\"")) + XCTAssertTrue(json.contains("\"type\":\"group\"")) + // The legacy "folder" discriminator must not appear in encoded output. + XCTAssertFalse(json.contains("\"type\":\"folder\"")) + XCTAssertFalse(json.contains("\"type\" : \"folder\"")) + } +} diff --git a/Tests/SidebarFolderViewTests.swift b/Tests/SidebarGroupViewTests.swift similarity index 77% rename from Tests/SidebarFolderViewTests.swift rename to Tests/SidebarGroupViewTests.swift index c88fb45..4ca0256 100644 --- a/Tests/SidebarFolderViewTests.swift +++ b/Tests/SidebarGroupViewTests.swift @@ -2,19 +2,19 @@ import XCTest import AppKit @testable import Deckard -final class SidebarFolderViewTests: XCTestCase { +final class SidebarGroupViewTests: XCTestCase { // MARK: - Helpers - /// Create a SidebarFolderView with a known frame inside a parent view + /// Create a SidebarGroupView with a known frame inside a parent view /// so that hitTest receives meaningful superview-relative coordinates. - private func makeFolderView( + private func makeGroupView( collapsed: Bool = false, origin: NSPoint = NSPoint(x: 0, y: 50) - ) -> SidebarFolderView { - let folder = SidebarFolder(name: "Test Folder") - folder.isCollapsed = collapsed - let view = SidebarFolderView(folder: folder, projectCount: 2) + ) -> SidebarGroupView { + let group = SidebarGroup(name: "Test Group") + group.isCollapsed = collapsed + let view = SidebarGroupView(group: group, workspaceCount: 2) // Embed in a parent so hitTest gets superview-relative points. let parent = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 200)) @@ -27,7 +27,7 @@ final class SidebarFolderViewTests: XCTestCase { // MARK: - hitTest func testHitTestReturnsSelfWhenNotEditing() { - let view = makeFolderView() + let view = makeGroupView() // Point inside the view's frame (superview coordinates). let point = NSPoint(x: 10, y: view.frame.midY) let result = view.hitTest(point) @@ -35,7 +35,7 @@ final class SidebarFolderViewTests: XCTestCase { } func testHitTestReturnsNilOutsideFrame() { - let view = makeFolderView() + let view = makeGroupView() // Point outside the view's frame. let point = NSPoint(x: 10, y: view.frame.maxY + 50) let result = view.hitTest(point) @@ -44,7 +44,7 @@ final class SidebarFolderViewTests: XCTestCase { func testHitTestUsesFrameNotBounds() { // Place the view at a non-zero origin to verify frame (not bounds) is used. - let view = makeFolderView(origin: NSPoint(x: 0, y: 100)) + let view = makeGroupView(origin: NSPoint(x: 0, y: 100)) XCTAssertEqual(view.frame.origin.y, 100) // Point at y=110 is inside frame (100..128) but outside bounds (0..28). @@ -59,7 +59,7 @@ final class SidebarFolderViewTests: XCTestCase { } func testHitTestDelegatesToSuperWhenEditing() { - let view = makeFolderView() + let view = makeGroupView() // Start editing to flip isEditingName. view.startEditing() XCTAssertTrue(view.isEditingName) @@ -74,8 +74,8 @@ final class SidebarFolderViewTests: XCTestCase { // MARK: - Chevron image func testChevronImageReflectsCollapsedState() { - let expandedView = makeFolderView(collapsed: false) - let collapsedView = makeFolderView(collapsed: true) + let expandedView = makeGroupView(collapsed: false) + let collapsedView = makeGroupView(collapsed: true) // Access the image via the accessibilityDescription to verify it was set. // Both should have images (we can't easily compare SF Symbol names). @@ -84,16 +84,16 @@ final class SidebarFolderViewTests: XCTestCase { let collapsedDesc = collapsedView.subviews .compactMap { $0 as? NSImageView }.first?.image?.accessibilityDescription - XCTAssertEqual(expandedDesc, "Toggle folder") - XCTAssertEqual(collapsedDesc, "Toggle folder") + XCTAssertEqual(expandedDesc, "Toggle group") + XCTAssertEqual(collapsedDesc, "Toggle group") } func testUpdateChevronChangesImage() { - let view = makeFolderView(collapsed: false) + let view = makeGroupView(collapsed: false) let imageView = view.subviews.compactMap { $0 as? NSImageView }.first! let imageBefore = imageView.image - view.folder.isCollapsed = true + view.group.isCollapsed = true view.updateChevron() let imageAfter = imageView.image @@ -105,7 +105,7 @@ final class SidebarFolderViewTests: XCTestCase { // MARK: - mouseDown: chevron area fires onToggle immediately func testMouseDownOnChevronAreaCallsOnToggle() { - let view = makeFolderView() + let view = makeGroupView() var toggleCount = 0 view.onToggle = { _ in toggleCount += 1 } @@ -127,7 +127,7 @@ final class SidebarFolderViewTests: XCTestCase { } func testMouseDownOnChevronAreaDoesNotSetDragStartPoint() { - let view = makeFolderView() + let view = makeGroupView() view.onToggle = { _ in } let event = NSEvent.mouseEvent( @@ -165,7 +165,7 @@ final class SidebarFolderViewTests: XCTestCase { } func testRapidChevronClicksDoNotTriggerEditing() { - let view = makeFolderView() + let view = makeGroupView() var toggleCount = 0 view.onToggle = { _ in toggleCount += 1 } @@ -191,7 +191,7 @@ final class SidebarFolderViewTests: XCTestCase { // MARK: - mouseDown: label area uses mouseUp for toggle func testMouseDownOnLabelAreaDoesNotCallOnToggle() { - let view = makeFolderView() + let view = makeGroupView() var toggleCount = 0 view.onToggle = { _ in toggleCount += 1 } @@ -213,7 +213,7 @@ final class SidebarFolderViewTests: XCTestCase { } func testMouseUpOnLabelAreaCallsOnToggle() { - let view = makeFolderView() + let view = makeGroupView() var toggleCount = 0 view.onToggle = { _ in toggleCount += 1 } @@ -251,7 +251,7 @@ final class SidebarFolderViewTests: XCTestCase { // MARK: - Double-click on label starts editing func testDoubleClickOnLabelStartsEditing() { - let view = makeFolderView() + let view = makeGroupView() var toggleCount = 0 view.onToggle = { _ in toggleCount += 1 } @@ -272,41 +272,41 @@ final class SidebarFolderViewTests: XCTestCase { XCTAssertEqual(toggleCount, 0, "Double-click on label should not toggle") } - // MARK: - folderToggleClicked guard + // MARK: - groupToggleClicked guard - func testFolderToggleBlocksCollapseWhenContainingSelectedProject() { - let folder = SidebarFolder(name: "Active") - let projectId = UUID() - folder.projectIds = [projectId] - folder.isCollapsed = false + func testGroupToggleBlocksCollapseWhenContainingSelectedWorkspace() { + let group = SidebarGroup(name: "Active") + let workspaceId = UUID() + group.workspaceIds = [workspaceId] + group.isCollapsed = false - // Simulate the guard logic from folderToggleClicked. - folder.isCollapsed.toggle() - // Guard: if collapsing a folder that contains the selected project, force expand. - let selectedProjectId = projectId // selected project is inside this folder - if folder.isCollapsed, folder.projectIds.contains(selectedProjectId) { - folder.isCollapsed = false + // Simulate the guard logic from groupToggleClicked. + group.isCollapsed.toggle() + // Guard: if collapsing a group that contains the selected workspace, force expand. + let selectedWorkspaceId = workspaceId // selected workspace is inside this group + if group.isCollapsed, group.workspaceIds.contains(selectedWorkspaceId) { + group.isCollapsed = false } - XCTAssertFalse(folder.isCollapsed, - "Folder containing the selected project should not stay collapsed") + XCTAssertFalse(group.isCollapsed, + "Group containing the selected workspace should not stay collapsed") } - func testFolderToggleAllowsCollapseWhenNotContainingSelectedProject() { - let folder = SidebarFolder(name: "Other") - let projectId = UUID() - let otherProjectId = UUID() - folder.projectIds = [projectId] - folder.isCollapsed = false - - folder.isCollapsed.toggle() - // Guard: selected project is NOT in this folder. - let selectedProjectId = otherProjectId - if folder.isCollapsed, folder.projectIds.contains(selectedProjectId) { - folder.isCollapsed = false + func testGroupToggleAllowsCollapseWhenNotContainingSelectedWorkspace() { + let group = SidebarGroup(name: "Other") + let workspaceId = UUID() + let otherWorkspaceId = UUID() + group.workspaceIds = [workspaceId] + group.isCollapsed = false + + group.isCollapsed.toggle() + // Guard: selected workspace is NOT in this group. + let selectedWorkspaceId = otherWorkspaceId + if group.isCollapsed, group.workspaceIds.contains(selectedWorkspaceId) { + group.isCollapsed = false } - XCTAssertTrue(folder.isCollapsed, - "Folder NOT containing the selected project should collapse normally") + XCTAssertTrue(group.isCollapsed, + "Group NOT containing the selected workspace should collapse normally") } } diff --git a/Tests/WindowControllerLogicTests.swift b/Tests/WindowControllerLogicTests.swift index 26a9036..12877eb 100644 --- a/Tests/WindowControllerLogicTests.swift +++ b/Tests/WindowControllerLogicTests.swift @@ -66,64 +66,64 @@ final class WindowControllerLogicTests: XCTestCase { XCTAssertFalse(TabKind.terminal.isAgent) } - // MARK: - ProjectItem - - func testProjectItemInit() { - let project = ProjectItem(path: "/Users/test/my-project") - XCTAssertEqual(project.path, "/Users/test/my-project") - XCTAssertEqual(project.name, "my-project") - XCTAssertTrue(project.tabs.isEmpty) - XCTAssertEqual(project.selectedTabIndex, 0) + // MARK: - WorkspaceItem + + func testWorkspaceItemInit() { + let workspace = WorkspaceItem(path: "/Users/test/my-workspace") + XCTAssertEqual(workspace.path, "/Users/test/my-workspace") + XCTAssertEqual(workspace.name, "my-workspace") + XCTAssertTrue(workspace.tabs.isEmpty) + XCTAssertEqual(workspace.selectedTabIndex, 0) } - func testProjectItemNameIsBasename() { - let project = ProjectItem(path: "/a/b/c/deep-folder") - XCTAssertEqual(project.name, "deep-folder") + func testWorkspaceItemNameIsBasename() { + let workspace = WorkspaceItem(path: "/a/b/c/deep-group") + XCTAssertEqual(workspace.name, "deep-group") } - // MARK: - ProjectItem symlink resolution + // MARK: - WorkspaceItem symlink resolution - func testProjectItemResolvesSymlinks() throws { + func testWorkspaceItemResolvesSymlinks() throws { let tempDir = NSTemporaryDirectory() + "deckard-symlink-\(UUID().uuidString)" - let realDir = tempDir + "/real-project" - let linkDir = tempDir + "/linked-project" + let realDir = tempDir + "/real-workspace" + let linkDir = tempDir + "/linked-workspace" try FileManager.default.createDirectory(atPath: realDir, withIntermediateDirectories: true) addTeardownBlock { try? FileManager.default.removeItem(atPath: tempDir) } try FileManager.default.createSymbolicLink(atPath: linkDir, withDestinationPath: realDir) - let project = ProjectItem(path: linkDir) - XCTAssertEqual(project.path, realDir, "ProjectItem should resolve symlinks to canonical path") - XCTAssertEqual(project.name, "real-project") + let workspace = WorkspaceItem(path: linkDir) + XCTAssertEqual(workspace.path, realDir, "WorkspaceItem should resolve symlinks to canonical path") + XCTAssertEqual(workspace.name, "real-workspace") } - func testProjectItemCanonicalPathIsIdempotent() throws { + func testWorkspaceItemCanonicalPathIsIdempotent() throws { let tempDir = NSTemporaryDirectory() + "deckard-symlink-\(UUID().uuidString)" - let realDir = tempDir + "/real-project" + let realDir = tempDir + "/real-workspace" try FileManager.default.createDirectory(atPath: realDir, withIntermediateDirectories: true) addTeardownBlock { try? FileManager.default.removeItem(atPath: tempDir) } // A non-symlink path should be unchanged - let project = ProjectItem(path: realDir) - XCTAssertEqual(project.path, realDir) + let workspace = WorkspaceItem(path: realDir) + XCTAssertEqual(workspace.path, realDir) } - func testProjectItemViaSymlinkMatchesCanonical() throws { + func testWorkspaceItemViaSymlinkMatchesCanonical() throws { let tempDir = NSTemporaryDirectory() + "deckard-symlink-\(UUID().uuidString)" - let realDir = tempDir + "/real-project" - let linkDir = tempDir + "/linked-project" + let realDir = tempDir + "/real-workspace" + let linkDir = tempDir + "/linked-workspace" try FileManager.default.createDirectory(atPath: realDir, withIntermediateDirectories: true) addTeardownBlock { try? FileManager.default.removeItem(atPath: tempDir) } try FileManager.default.createSymbolicLink(atPath: linkDir, withDestinationPath: realDir) - let fromSymlink = ProjectItem(path: linkDir) - let fromCanonical = ProjectItem(path: realDir) + let fromSymlink = WorkspaceItem(path: linkDir) + let fromCanonical = WorkspaceItem(path: realDir) XCTAssertEqual(fromSymlink.path, fromCanonical.path, - "ProjectItems opened via symlink and canonical path should have the same path") + "WorkspaceItems opened via symlink and canonical path should have the same path") } - func testProjectItemChainedSymlinks() throws { + func testWorkspaceItemChainedSymlinks() throws { let tempDir = NSTemporaryDirectory() + "deckard-symlink-\(UUID().uuidString)" - let realDir = tempDir + "/real-project" + let realDir = tempDir + "/real-workspace" let link1 = tempDir + "/link1" let link2 = tempDir + "/link2" try FileManager.default.createDirectory(atPath: realDir, withIntermediateDirectories: true) @@ -131,8 +131,8 @@ final class WindowControllerLogicTests: XCTestCase { try FileManager.default.createSymbolicLink(atPath: link1, withDestinationPath: realDir) try FileManager.default.createSymbolicLink(atPath: link2, withDestinationPath: link1) - let project = ProjectItem(path: link2) - XCTAssertEqual(project.path, realDir, "Chained symlinks should fully resolve") + let workspace = WorkspaceItem(path: link2) + XCTAssertEqual(workspace.path, realDir, "Chained symlinks should fully resolve") } // MARK: - DefaultTabConfig