From 712011b0e57d5aaca1ef17d0de3600294cdea4f8 Mon Sep 17 00:00:00 2001 From: Gilles Dubuc Date: Sat, 2 May 2026 22:13:21 +0200 Subject: [PATCH 1/5] refactor: rename folder UX to workspace and group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The word "Folder" was overloaded across the UI for two unrelated concepts: a filesystem folder you open as a project, and a sidebar grouping that contains projects. Both meanings appeared in the same right-click menu, where four "Folder" labels referred to two different things. Adopt two distinct nouns: "Workspace" for the filesystem folder + its tabs + its sessions, and "Group" for the sidebar bucket. This commit covers user-facing strings only — File menu items, sidebar context menus, tooltips, accessibility descriptions, picker placeholder, and shortcut display labels in the Settings pane. Internal type names and shortcut identifiers are unchanged in this commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/App/AppDelegate.swift | 8 ++--- Sources/App/ShortcutNames.swift | 32 ++++++++++---------- Sources/Window/DeckardWindowController.swift | 2 +- Sources/Window/ProjectPicker.swift | 2 +- Sources/Window/SidebarController.swift | 16 +++++----- Sources/Window/SidebarViews.swift | 6 ++-- Tests/SidebarFolderViewTests.swift | 4 +-- 7 files changed, 35 insertions(+), 35 deletions(-) diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index c5f8923..68dbafe 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -192,7 +192,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { let fileMenuItem = NSMenuItem() let fileMenu = NSMenu(title: "File") - let openItem = NSMenuItem(title: "Open Folder...", action: #selector(openProject), keyEquivalent: "") + let openItem = NSMenuItem(title: "Open Workspace...", action: #selector(openProject), keyEquivalent: "") openItem.setShortcut(for: .openFolder) fileMenu.addItem(openItem) fileMenu.addItem(.separator()) @@ -215,12 +215,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { closeItem.setShortcut(for: .closeTab) fileMenu.addItem(closeItem) - let newFolderItem = NSMenuItem(title: "New Sidebar Folder", action: #selector(createNewSidebarFolder), keyEquivalent: "") + let newFolderItem = NSMenuItem(title: "New Group", action: #selector(createNewSidebarFolder), keyEquivalent: "") newFolderItem.setShortcut(for: .newSidebarFolder) newFolderItem.target = self fileMenu.addItem(newFolderItem) - let moveOutItem = NSMenuItem(title: "Move Out of Folder", action: #selector(moveCurrentProjectOutOfFolder), keyEquivalent: "") + let moveOutItem = NSMenuItem(title: "Move Out of Group", action: #selector(moveCurrentProjectOutOfFolder), keyEquivalent: "") moveOutItem.setShortcut(for: .moveOutOfFolder) moveOutItem.target = self fileMenu.addItem(moveOutItem) @@ -229,7 +229,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { exploreSessionsItem.setShortcut(for: .exploreSessions) fileMenu.addItem(exploreSessionsItem) - let closeProjectItem = NSMenuItem(title: "Close Folder", action: #selector(closeCurrentProject), keyEquivalent: "") + let closeProjectItem = NSMenuItem(title: "Close Workspace", action: #selector(closeCurrentProject), keyEquivalent: "") closeProjectItem.setShortcut(for: .closeFolder) fileMenu.addItem(closeProjectItem) fileMenu.addItem(.separator()) diff --git a/Sources/App/ShortcutNames.swift b/Sources/App/ShortcutNames.swift index 02d607a..7c540ce 100644 --- a/Sources/App/ShortcutNames.swift +++ b/Sources/App/ShortcutNames.swift @@ -36,31 +36,31 @@ struct ShortcutEntry { } let configurableShortcuts: [ShortcutEntry] = [ - ShortcutEntry(name: .openFolder, label: "Open Folder"), + ShortcutEntry(name: .openFolder, 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: .closeFolder, 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: .nextProject, label: "Next Workspace"), + ShortcutEntry(name: .previousProject, 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: .newSidebarFolder, label: "New Group"), + ShortcutEntry(name: .moveOutOfFolder, 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] = [ diff --git a/Sources/Window/DeckardWindowController.swift b/Sources/Window/DeckardWindowController.swift index 54641d5..57ec2ef 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) { diff --git a/Sources/Window/ProjectPicker.swift b/Sources/Window/ProjectPicker.swift index ee6da1a..5cdbd7e 100644 --- a/Sources/Window/ProjectPicker.swift +++ b/Sources/Window/ProjectPicker.swift @@ -36,7 +36,7 @@ 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 diff --git a/Sources/Window/SidebarController.swift b/Sources/Window/SidebarController.swift index e7864a8..6e35e1a 100644 --- a/Sources/Window/SidebarController.swift +++ b/Sources/Window/SidebarController.swift @@ -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?.sidebarEmptyContextNewFolder), keyEquivalent: "") item.target = self menu.addItem(item) return menu @@ -414,7 +414,7 @@ extension DeckardWindowController { createSidebarFolder() } - func createSidebarFolder(name: String = "New Folder") { + func createSidebarFolder(name: String = "New Group") { let folder = SidebarFolder(name: name) sidebarFolders.append(folder) sidebarOrder.append(.folder(folder)) @@ -542,14 +542,14 @@ extension DeckardWindowController { func buildFolderContextMenu(for folder: SidebarFolder) -> NSMenu { let menu = NSMenu() - let renameItem = NSMenuItem(title: "Rename Folder", action: #selector(renameFolderMenuAction(_:)), keyEquivalent: "") + let renameItem = NSMenuItem(title: "Rename Group", action: #selector(renameFolderMenuAction(_:)), keyEquivalent: "") renameItem.target = self renameItem.representedObject = folder menu.addItem(renameItem) menu.addItem(.separator()) - let deleteItem = NSMenuItem(title: "Delete Folder", action: #selector(deleteFolderMenuAction(_:)), keyEquivalent: "") + let deleteItem = NSMenuItem(title: "Delete Group", action: #selector(deleteFolderMenuAction(_:)), keyEquivalent: "") deleteItem.target = self deleteItem.representedObject = folder menu.addItem(deleteItem) @@ -600,13 +600,13 @@ extension DeckardWindowController { let isInFolder = sidebarFolders.contains { $0.projectIds.contains(project.id) } if isInFolder { - let moveOutItem = NSMenuItem(title: "Move Out of Folder", action: #selector(moveProjectOutOfFolderAction(_:)), keyEquivalent: "") + let moveOutItem = NSMenuItem(title: "Move Out of Group", action: #selector(moveProjectOutOfFolderAction(_:)), keyEquivalent: "") moveOutItem.setShortcut(for: .moveOutOfFolder) moveOutItem.target = self moveOutItem.representedObject = project menu.addItem(moveOutItem) } else if !sidebarFolders.isEmpty { - let moveToItem = NSMenuItem(title: "Move to Folder", action: nil, keyEquivalent: "") + 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: "") @@ -620,14 +620,14 @@ extension DeckardWindowController { menu.addItem(.separator()) - let newFolderItem = NSMenuItem(title: "New Folder", action: #selector(newFolderMenuAction), keyEquivalent: "") + let newFolderItem = NSMenuItem(title: "New Group", action: #selector(newFolderMenuAction), keyEquivalent: "") newFolderItem.setShortcut(for: .newSidebarFolder) newFolderItem.target = self menu.addItem(newFolderItem) menu.addItem(.separator()) - let closeItem = NSMenuItem(title: "Close Folder", action: #selector(closeProjectMenuAction(_:)), keyEquivalent: "") + let closeItem = NSMenuItem(title: "Close Workspace", action: #selector(closeProjectMenuAction(_:)), keyEquivalent: "") closeItem.setShortcut(for: .closeFolder) closeItem.target = self closeItem.representedObject = project diff --git a/Sources/Window/SidebarViews.swift b/Sources/Window/SidebarViews.swift index 39075a4..9737774 100644 --- a/Sources/Window/SidebarViews.swift +++ b/Sources/Window/SidebarViews.swift @@ -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: .closeFolder) badgeContainer.translatesAutoresizingMaskIntoConstraints = false shortcutOverlay.translatesAutoresizingMaskIntoConstraints = false addSubview(label) @@ -322,7 +322,7 @@ class SidebarFolderView: NSView, NSTextFieldDelegate, NSDraggingSource { disclosureImageView = NSImageView() disclosureImageView.image = NSImage(systemSymbolName: folder.isCollapsed ? "chevron.right" : "chevron.down", - accessibilityDescription: "Toggle folder") + accessibilityDescription: "Toggle group") disclosureImageView.contentTintColor = ThemeManager.shared.currentColors.secondaryText disclosureImageView.imageAlignment = .alignCenter @@ -386,7 +386,7 @@ class SidebarFolderView: NSView, NSTextFieldDelegate, NSDraggingSource { func updateChevron() { disclosureImageView.image = NSImage(systemSymbolName: folder.isCollapsed ? "chevron.right" : "chevron.down", - accessibilityDescription: "Toggle folder") + accessibilityDescription: "Toggle group") } override func mouseDown(with event: NSEvent) { diff --git a/Tests/SidebarFolderViewTests.swift b/Tests/SidebarFolderViewTests.swift index c88fb45..afcdda7 100644 --- a/Tests/SidebarFolderViewTests.swift +++ b/Tests/SidebarFolderViewTests.swift @@ -84,8 +84,8 @@ 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() { From 940b7b41ddc82e25dec05f0241f56dab9cba0230 Mon Sep 17 00:00:00 2001 From: Gilles Dubuc Date: Sat, 2 May 2026 22:24:04 +0200 Subject: [PATCH 2/5] refactor: rename SidebarFolder internals to SidebarGroup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Internal counterpart to the prior commit. SidebarFolder/SidebarFolderView/ SidebarFolderState become SidebarGroup/SidebarGroupView/SidebarGroupState; the SidebarItem and SidebarOrderItem .folder cases become .group; every folder-prefixed method/var/constant referring to the sidebar bucket gets renamed. Persistence migration is included so existing users don't lose state: - DeckardState.CodingKeys reads the legacy "sidebarFolders" key as well as the new "sidebarGroups", and only ever writes "sidebarGroups". - SidebarOrderItem decodes both "folder" and "group" discriminator strings, and only ever encodes "group". Bumps state.json version 2→3. - One-shot DeckardShortcutMigration copies user shortcut overrides from the old KeyboardShortcuts identifiers (newSidebarFolder, moveOutOfFolder) to the new ones (newGroup, moveOutOfGroup) on first launch, then removes the old keys. Guarded by a UserDefaults flag. Tests cover both decoder paths plus all migration edge cases (no-op when new key already set, no-op on subsequent runs, multi-identifier migration). Co-Authored-By: Claude Opus 4.7 (1M context) --- Deckard.xcodeproj/project.pbxproj | 20 +- Sources/App/AppDelegate.swift | 20 +- Sources/App/ShortcutNames.swift | 39 +++- Sources/Session/SessionState.swift | 60 +++++- Sources/Window/DeckardWindowController.swift | 52 ++--- Sources/Window/SidebarController.swift | 156 +++++++------- Sources/Window/SidebarViews.swift | 54 ++--- Tests/SessionStateTests.swift | 6 +- Tests/ShortcutMigrationTests.swift | 87 ++++++++ ...derTests.swift => SidebarGroupTests.swift} | 190 ++++++++++++------ ...ests.swift => SidebarGroupViewTests.swift} | 50 ++--- 11 files changed, 481 insertions(+), 253 deletions(-) create mode 100644 Tests/ShortcutMigrationTests.swift rename Tests/{SidebarFolderTests.swift => SidebarGroupTests.swift} (63%) rename Tests/{SidebarFolderViewTests.swift => SidebarGroupViewTests.swift} (89%) diff --git a/Deckard.xcodeproj/project.pbxproj b/Deckard.xcodeproj/project.pbxproj index 0b89a08..3c61879 100644 --- a/Deckard.xcodeproj/project.pbxproj +++ b/Deckard.xcodeproj/project.pbxproj @@ -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,6 +66,7 @@ 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 */ @@ -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 */ @@ -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 = ""; @@ -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 68dbafe..8e044f8 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() @@ -215,13 +219,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { closeItem.setShortcut(for: .closeTab) fileMenu.addItem(closeItem) - let newFolderItem = NSMenuItem(title: "New Group", action: #selector(createNewSidebarFolder), keyEquivalent: "") - newFolderItem.setShortcut(for: .newSidebarFolder) + let newFolderItem = NSMenuItem(title: "New Group", action: #selector(createNewSidebarGroup), keyEquivalent: "") + newFolderItem.setShortcut(for: .newGroup) newFolderItem.target = self fileMenu.addItem(newFolderItem) - let moveOutItem = NSMenuItem(title: "Move Out of Group", action: #selector(moveCurrentProjectOutOfFolder), keyEquivalent: "") - moveOutItem.setShortcut(for: .moveOutOfFolder) + let moveOutItem = NSMenuItem(title: "Move Out of Group", action: #selector(moveCurrentProjectOutOfGroup), keyEquivalent: "") + moveOutItem.setShortcut(for: .moveOutOfGroup) moveOutItem.target = self fileMenu.addItem(moveOutItem) @@ -334,8 +338,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { windowController?.exploreCurrentProjectSessions() } - @objc private func moveCurrentProjectOutOfFolder() { - windowController?.moveCurrentProjectOutOfFolder() + @objc private func moveCurrentProjectOutOfGroup() { + windowController?.moveCurrentProjectOutOfGroup() } @objc private func closeCurrentProject() { @@ -367,8 +371,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { windowController?.selectProject(byNumber: sender.tag) } - @objc private func createNewSidebarFolder() { - windowController?.createSidebarFolder() + @objc private func createNewSidebarGroup() { + windowController?.createSidebarGroup() } @objc private func toggleSidebar() { diff --git a/Sources/App/ShortcutNames.swift b/Sources/App/ShortcutNames.swift index 7c540ce..747274f 100644 --- a/Sources/App/ShortcutNames.swift +++ b/Sources/App/ShortcutNames.swift @@ -14,8 +14,8 @@ extension KeyboardShortcuts.Name { static let previousProject = Self("previousProject", 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)) @@ -48,8 +48,8 @@ let configurableShortcuts: [ShortcutEntry] = [ ShortcutEntry(name: .previousProject, label: "Previous Workspace"), ShortcutEntry(name: .toggleSidebar, label: "Toggle Sidebar"), ShortcutEntry(name: .exploreSessions, label: "Explore Sessions"), - ShortcutEntry(name: .newSidebarFolder, label: "New Group"), - ShortcutEntry(name: .moveOutOfFolder, label: "Move Out of Group"), + ShortcutEntry(name: .newGroup, label: "New Group"), + ShortcutEntry(name: .moveOutOfGroup, label: "Move Out of Group"), ShortcutEntry(name: .settings, label: "Settings"), ShortcutEntry(name: .tab1, label: "Workspace 1"), ShortcutEntry(name: .tab2, label: "Workspace 2"), @@ -67,6 +67,37 @@ 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 = "shortcutsMigratedToGroupNames" + + /// Old identifier → new identifier renames for the folder→group commit. + static let renames: [(oldName: String, newName: String)] = [ + ("newSidebarFolder", "newGroup"), + ("moveOutOfFolder", "moveOutOfGroup"), + ] + + 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/Session/SessionState.swift b/Sources/Session/SessionState.swift index e2370b9..a99c449 100644 --- a/Sources/Session/SessionState.swift +++ b/Sources/Session/SessionState.swift @@ -20,7 +20,7 @@ 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 version: Int = 3 var selectedTabIndex: Int = 0 // selected project index var defaultWorkingDirectory: String? @@ -33,9 +33,49 @@ struct DeckardState: Codable { // v2: project-based var projects: [ProjectState]? - // 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 projects + case sidebarGroups, sidebarOrder + // Legacy key — read on decode, never written. + case sidebarFolders + } + + 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) + projects = try c.decodeIfPresent([ProjectState].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(projects, forKey: .projects) + try c.encodeIfPresent(sidebarGroups, forKey: .sidebarGroups) + try c.encodeIfPresent(sidebarOrder, forKey: .sidebarOrder) + } } struct TabState: Codable { @@ -111,16 +151,16 @@ struct ProjectTabState: Codable { } } -struct SidebarFolderState: Codable { +struct SidebarGroupState: Codable { var id: String var name: String var isCollapsed: Bool var projectIds: [String] } -/// 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 project. enum SidebarOrderItem: Codable { - case folder(String) // folder id + case group(String) // group id case project(String) // project id private enum CodingKeys: String, CodingKey { @@ -130,8 +170,8 @@ 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): try container.encode("project", forKey: .type) @@ -144,8 +184,8 @@ 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) default: diff --git a/Sources/Window/DeckardWindowController.swift b/Sources/Window/DeckardWindowController.swift index 57ec2ef..18f0c59 100644 --- a/Sources/Window/DeckardWindowController.swift +++ b/Sources/Window/DeckardWindowController.swift @@ -100,7 +100,7 @@ class ProjectItem { // MARK: - Sidebar Folder Model /// A folder in the sidebar that groups projects. -class SidebarFolder { +class SidebarGroup { let id: UUID var name: String var isCollapsed: Bool @@ -121,9 +121,9 @@ class SidebarFolder { } } -/// Ordered sidebar items: either a folder or an ungrouped project reference. +/// Ordered sidebar items: either a group or an ungrouped project reference. enum SidebarItem { - case folder(SidebarFolder) + case group(SidebarGroup) case project(UUID) // ProjectItem.id } @@ -151,7 +151,7 @@ struct DefaultTabConfig { let deckardProjectDragType = NSPasteboard.PasteboardType("com.deckard.project-reorder") let deckardSidebarDragType = NSPasteboard.PasteboardType("com.deckard.sidebar-drag") -let deckardFolderDragType = NSPasteboard.PasteboardType("com.deckard.folder-reorder") +let deckardGroupDragType = NSPasteboard.PasteboardType("com.deckard.folder-reorder") private class CollapsibleSplitView: NSSplitView { @@ -169,7 +169,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { var selectedProjectIndex: Int = -1 // Sidebar folders - var sidebarFolders: [SidebarFolder] = [] + var sidebarGroups: [SidebarGroup] = [] var sidebarOrder: [SidebarItem] = [] // Theme @@ -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([deckardProjectDragType, deckardGroupDragType]) sidebarView.addSubview(sidebarDropZone) sidebarStackView.orientation = .vertical @@ -613,10 +613,10 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { exploreSessionsMenuAction(fakeMenuItem) } - func moveCurrentProjectOutOfFolder() { + func moveCurrentProjectOutOfGroup() { guard selectedProjectIndex >= 0, selectedProjectIndex < projects.count else { return } let project = projects[selectedProjectIndex] - moveProjectOutOfFolder(projectId: project.id) + moveProjectOutOfGroup(projectId: project.id) } func closeProject(at index: Int) { @@ -685,7 +685,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { /// 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 collapsedProjectIds = Set(sidebarGroups.filter(\.isCollapsed).flatMap(\.projectIds)) let clamped = min(index, projects.count - 1) // Search outward from `clamped`: check clamped, clamped-1, clamped+1, ... var lo = clamped, hi = clamped + 1 @@ -705,7 +705,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { // 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) { + for folder in sidebarGroups where folder.isCollapsed && folder.projectIds.contains(project.id) { folder.isCollapsed = false rebuildSidebar() } @@ -1593,8 +1593,8 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } // Persist sidebar folders - state.sidebarFolders = sidebarFolders.map { folder in - SidebarFolderState( + state.sidebarGroups = sidebarGroups.map { folder in + SidebarGroupState( id: folder.id.uuidString, name: folder.name, isCollapsed: folder.isCollapsed, @@ -1605,8 +1605,8 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { // Persist sidebar order state.sidebarOrder = sidebarOrder.compactMap { item in switch item { - case .folder(let folder): - return .folder(folder.id.uuidString) + case .group(let folder): + return .group(folder.id.uuidString) case .project(let pid): return .project(pid.uuidString) } @@ -1704,7 +1704,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { // won't clamp selectedTabIndex before all tabs are inserted. // Restore sidebar folders - restoreSidebarFolders(from: state) + restoreSidebarGroups(from: state) rebuildSidebar() if selectedIdx >= 0 && selectedIdx < projects.count { @@ -1715,7 +1715,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { createTabsProgressively(pending) } - private func restoreSidebarFolders(from state: DeckardState) { + private func restoreSidebarGroups(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). @@ -1727,17 +1727,17 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } // Restore folders - if let folderStates = state.sidebarFolders { - for fs in folderStates { - guard let folderId = UUID(uuidString: fs.id) else { continue } + if let groupStates = state.sidebarGroups { + for fs in groupStates { + guard let groupId = UUID(uuidString: fs.id) else { continue } let resolvedIds = fs.projectIds.compactMap { savedIdToProject[$0]?.id } - let folder = SidebarFolder( - id: folderId, + let folder = SidebarGroup( + id: groupId, name: fs.name, isCollapsed: fs.isCollapsed, projectIds: resolvedIds ) - sidebarFolders.append(folder) + sidebarGroups.append(folder) } } @@ -1745,9 +1745,9 @@ 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 folder = sidebarGroups.first(where: { $0.id.uuidString == idStr }) { + return .group(folder) } return nil case .project(let idStr): @@ -1874,7 +1874,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { switch item { case .project(let id): if let i = projects.firstIndex(where: { $0.id == id }) { indices.append(i) } - case .folder(let folder): + case .group(let folder): guard !folder.isCollapsed else { continue } for id in folder.projectIds { if let i = projects.firstIndex(where: { $0.id == id }) { indices.append(i) } diff --git a/Sources/Window/SidebarController.swift b/Sources/Window/SidebarController.swift index 6e35e1a..6c22eb1 100644 --- a/Sources/Window/SidebarController.swift +++ b/Sources/Window/SidebarController.swift @@ -19,7 +19,7 @@ extension DeckardWindowController { if case .project(let id) = item, id == projectId { return true } return false } - for folder in sidebarFolders { + for folder in sidebarGroups { folder.projectIds.removeAll { $0 == projectId } } } @@ -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 } @@ -109,20 +109,20 @@ extension DeckardWindowController { sidebarRowToProjectIndex[rowIndex] = pi rowIndex += 1 - case .folder(let folder): + case .group(let folder): // Folder header - let folderView = SidebarFolderView( + let folderView = SidebarGroupView( folder: folder, projectCount: folder.projectIds.count ) folderView.onToggle = { [weak self] fv in - self?.folderToggleClicked(fv) + self?.groupToggleClicked(fv) } folderView.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) + self.moveProjectIntoGroup(projectId: project.id, folder: fv.folder) } // Aggregate badge infos from all projects in the folder @@ -143,7 +143,7 @@ extension DeckardWindowController { } folderView.onContextMenu = { [weak self] event in guard let self = self else { return nil } - return self.buildFolderContextMenu(for: folder) + return self.buildGroupContextMenu(for: folder) } folderView.rowIndex = rowIndex sidebarStackView.addArrangedSubview(folderView) @@ -188,22 +188,22 @@ extension DeckardWindowController { } } - sidebarStackView.registerForDraggedTypes([deckardProjectDragType, deckardSidebarDragType, deckardFolderDragType]) + sidebarStackView.registerForDraggedTypes([deckardProjectDragType, deckardSidebarDragType, deckardGroupDragType]) sidebarStackView.onReorder = { [weak self] from, to, forceTopLevel in self?.handleSidebarDragReorder(fromProjectIndex: from, toRow: to, forceTopLevel: forceTopLevel) } - sidebarStackView.onDropOntoFolder = { [weak self] folderView, fromIndex in + sidebarStackView.onDropOntoGroup = { [weak self] folderView, fromIndex in folderView.onDrop?(folderView, 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) + if self.sidebarGroups.contains(where: { $0.projectIds.contains(project.id) }) { + self.moveProjectOutOfGroup(projectId: project.id) } // Move the sidebarOrder item to the end self.sidebarOrder.removeAll { item in @@ -213,14 +213,14 @@ extension DeckardWindowController { self.sidebarOrder.append(.project(project.id)) self.reorderProject(from: fromIndex, to: self.projects.count) } - sidebarDropZone.onFolderDrop = { [weak self] fromRow in + sidebarDropZone.onGroupDrop = { [weak self] fromRow in guard let self else { return } // Move folder to end of sidebarOrder let infos = self.sidebarRowInfos() guard fromRow >= 0, fromRow < infos.count, infos[fromRow].isFolder, - let folderId = infos[fromRow].folderId else { return } + 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 Group", action: #selector(self?.sidebarEmptyContextNewFolder), keyEquivalent: "") + let item = NSMenuItem(title: "New Group", action: #selector(self?.sidebarEmptyContextNewGroup), keyEquivalent: "") item.target = self menu.addItem(item) return menu @@ -269,10 +269,10 @@ extension DeckardWindowController { struct SidebarRowInfo { var sidebarOrderIndex: Int var isFolder: Bool - var parentFolder: SidebarFolder? + var parentFolder: SidebarGroup? var childIndexInFolder: Int? var projectId: UUID? - var folderId: UUID? + var groupId: UUID? } func sidebarRowInfos() -> [SidebarRowInfo] { @@ -283,18 +283,18 @@ extension DeckardWindowController { infos.append(SidebarRowInfo( sidebarOrderIndex: orderIdx, isFolder: false, parentFolder: nil, childIndexInFolder: nil, - projectId: pid, folderId: nil)) - case .folder(let folder): + projectId: pid, groupId: nil)) + case .group(let folder): infos.append(SidebarRowInfo( sidebarOrderIndex: orderIdx, isFolder: true, parentFolder: nil, childIndexInFolder: nil, - projectId: nil, folderId: folder.id)) + projectId: nil, groupId: folder.id)) if !folder.isCollapsed { for (ci, pid) in folder.projectIds.enumerated() { infos.append(SidebarRowInfo( sidebarOrderIndex: orderIdx, isFolder: false, parentFolder: folder, childIndexInFolder: ci, - projectId: pid, folderId: nil)) + projectId: pid, groupId: nil)) } } } @@ -313,8 +313,8 @@ extension DeckardWindowController { 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) } + let wasInFolder = sidebarGroups.contains { $0.projectIds.contains(draggedProject.id) } + if wasInFolder { moveProjectOutOfGroup(projectId: draggedProject.id) } sidebarOrder.removeAll { if case .project(let id) = $0, id == draggedProject.id { return true }; return false } sidebarOrder.append(.project(draggedProject.id)) rebuildSidebar() @@ -325,12 +325,12 @@ extension DeckardWindowController { let toInfo = infos[toRow] // Note: dropping directly *onto* a folder header (with highlight) is - // handled separately via onDropOntoFolder in performDragOperation. + // 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? + let effectiveFolder: SidebarGroup? let effectiveChildIndex: Int? if let pf = toInfo.parentFolder { effectiveFolder = pf @@ -345,7 +345,7 @@ extension DeckardWindowController { } // Dropping between items inside the same folder → reorder within folder - let sourceFolder = sidebarFolders.first { $0.projectIds.contains(draggedProject.id) } + let sourceFolder = sidebarGroups.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), @@ -410,26 +410,26 @@ extension DeckardWindowController { // MARK: - Folder Management - @objc func sidebarEmptyContextNewFolder() { - createSidebarFolder() + @objc func sidebarEmptyContextNewGroup() { + createSidebarGroup() } - func createSidebarFolder(name: String = "New Group") { - let folder = SidebarFolder(name: name) - sidebarFolders.append(folder) - sidebarOrder.append(.folder(folder)) + func createSidebarGroup(name: String = "New Group") { + let folder = SidebarGroup(name: name) + sidebarGroups.append(folder) + sidebarOrder.append(.group(folder)) rebuildSidebar() saveState() // Start editing the name immediately - if let folderView = sidebarStackView.arrangedSubviews.compactMap({ $0 as? SidebarFolderView }).last { + if let folderView = sidebarStackView.arrangedSubviews.compactMap({ $0 as? SidebarGroupView }).last { folderView.startEditing() } } - func deleteSidebarFolder(_ folder: SidebarFolder) { + func deleteSidebarGroup(_ folder: SidebarGroup) { // Move all projects inside the folder 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 == folder.id { return true } return false }) @@ -443,18 +443,18 @@ extension DeckardWindowController { } } - sidebarFolders.removeAll { $0.id == folder.id } + sidebarGroups.removeAll { $0.id == folder.id } rebuildSidebar() saveState() } - func moveProjectIntoFolder(projectId: UUID, folder: SidebarFolder) { + func moveProjectIntoGroup(projectId: UUID, folder: SidebarGroup) { // Remove project from current location (top-level or another folder) sidebarOrder.removeAll { item in if case .project(let id) = item, id == projectId { return true } return false } - for f in sidebarFolders where f.id != folder.id { + for f in sidebarGroups where f.id != folder.id { f.projectIds.removeAll { $0 == projectId } } @@ -470,14 +470,14 @@ extension DeckardWindowController { saveState() } - func moveProjectOutOfFolder(projectId: UUID) { + func moveProjectOutOfGroup(projectId: UUID) { // Find which folder contains this project - guard let folder = sidebarFolders.first(where: { $0.projectIds.contains(projectId) }) else { return } + guard let folder = sidebarGroups.first(where: { $0.projectIds.contains(projectId) }) else { return } folder.projectIds.removeAll { $0 == projectId } // 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 } + if case .group(let f) = $0, f.id == folder.id { return true } return false }) { sidebarOrder.insert(.project(projectId), at: folderIdx + 1) @@ -489,7 +489,7 @@ extension DeckardWindowController { saveState() } - func folderToggleClicked(_ sender: SidebarFolderView) { + func groupToggleClicked(_ sender: SidebarGroupView) { let wasCollapsed = sender.folder.isCollapsed sender.folder.isCollapsed.toggle() @@ -500,7 +500,7 @@ extension DeckardWindowController { } DiagnosticLog.shared.log("sidebar", - "folderToggle: \(sender.folder.name) was=\(wasCollapsed) now=\(sender.folder.isCollapsed) projects=\(sender.folder.projectIds.count)") + "groupToggle: \(sender.folder.name) was=\(wasCollapsed) now=\(sender.folder.isCollapsed) projects=\(sender.folder.projectIds.count)") rebuildSidebar() saveState() @@ -508,14 +508,14 @@ extension DeckardWindowController { /// 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) { + 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 } + let groupId = infos[fromRow].groupId else { return } // Find the folder'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 } @@ -539,17 +539,17 @@ extension DeckardWindowController { // MARK: - Folder Context Menu - func buildFolderContextMenu(for folder: SidebarFolder) -> NSMenu { + func buildGroupContextMenu(for folder: SidebarGroup) -> NSMenu { let menu = NSMenu() - let renameItem = NSMenuItem(title: "Rename Group", action: #selector(renameFolderMenuAction(_:)), keyEquivalent: "") + let renameItem = NSMenuItem(title: "Rename Group", action: #selector(renameGroupMenuAction(_:)), keyEquivalent: "") renameItem.target = self renameItem.representedObject = folder menu.addItem(renameItem) menu.addItem(.separator()) - let deleteItem = NSMenuItem(title: "Delete Group", action: #selector(deleteFolderMenuAction(_:)), keyEquivalent: "") + let deleteItem = NSMenuItem(title: "Delete Group", action: #selector(deleteGroupMenuAction(_:)), keyEquivalent: "") deleteItem.target = self deleteItem.representedObject = folder menu.addItem(deleteItem) @@ -557,20 +557,20 @@ extension DeckardWindowController { 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 folder = sender.representedObject as? SidebarGroup else { return } + // Find the SidebarGroupView for this folder 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.folder.id == folder.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 folder = sender.representedObject as? SidebarGroup else { return } + deleteSidebarGroup(folder) } // MARK: - Project Context Menu @@ -597,21 +597,21 @@ extension DeckardWindowController { menu.addItem(.separator()) // Folder options - let isInFolder = sidebarFolders.contains { $0.projectIds.contains(project.id) } + let isInFolder = sidebarGroups.contains { $0.projectIds.contains(project.id) } if isInFolder { - let moveOutItem = NSMenuItem(title: "Move Out of Group", action: #selector(moveProjectOutOfFolderAction(_:)), keyEquivalent: "") - moveOutItem.setShortcut(for: .moveOutOfFolder) + let moveOutItem = NSMenuItem(title: "Move Out of Group", action: #selector(moveProjectOutOfGroupAction(_:)), keyEquivalent: "") + moveOutItem.setShortcut(for: .moveOutOfGroup) moveOutItem.target = self moveOutItem.representedObject = project menu.addItem(moveOutItem) - } else if !sidebarFolders.isEmpty { + } 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 folder in sidebarGroups { + let item = NSMenuItem(title: folder.name, action: #selector(moveProjectToGroupAction(_:)), keyEquivalent: "") item.target = self - item.representedObject = MoveToFolderInfo(project: project, folder: folder) + item.representedObject = MoveToGroupInfo(project: project, folder: folder) moveSubmenu.addItem(item) } moveToItem.submenu = moveSubmenu @@ -620,8 +620,8 @@ extension DeckardWindowController { menu.addItem(.separator()) - let newFolderItem = NSMenuItem(title: "New Group", action: #selector(newFolderMenuAction), keyEquivalent: "") - newFolderItem.setShortcut(for: .newSidebarFolder) + let newFolderItem = NSMenuItem(title: "New Group", action: #selector(newGroupMenuAction), keyEquivalent: "") + newFolderItem.setShortcut(for: .newGroup) newFolderItem.target = self menu.addItem(newFolderItem) @@ -636,27 +636,27 @@ extension DeckardWindowController { return menu } - class MoveToFolderInfo { + class MoveToGroupInfo { let project: ProjectItem - let folder: SidebarFolder - init(project: ProjectItem, folder: SidebarFolder) { + let folder: SidebarGroup + init(project: ProjectItem, folder: SidebarGroup) { self.project = project self.folder = folder } } - @objc func moveProjectToFolderAction(_ sender: NSMenuItem) { - guard let info = sender.representedObject as? MoveToFolderInfo else { return } - moveProjectIntoFolder(projectId: info.project.id, folder: info.folder) + @objc func moveProjectToGroupAction(_ sender: NSMenuItem) { + guard let info = sender.representedObject as? MoveToGroupInfo else { return } + moveProjectIntoGroup(projectId: info.project.id, folder: info.folder) } - @objc func moveProjectOutOfFolderAction(_ sender: NSMenuItem) { + @objc func moveProjectOutOfGroupAction(_ sender: NSMenuItem) { guard let project = sender.representedObject as? ProjectItem else { return } - moveProjectOutOfFolder(projectId: project.id) + moveProjectOutOfGroup(projectId: project.id) } - @objc func newFolderMenuAction() { - createSidebarFolder() + @objc func newGroupMenuAction() { + createSidebarGroup() } @objc func closeProjectMenuAction(_ sender: NSMenuItem) { @@ -764,7 +764,7 @@ 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 { + } else if let fv = view as? SidebarGroupView { // Highlight folder if it contains the selected project fv.isContainingSelected = fv.folder.projectIds.contains(currentProjectId) && fv.folder.isCollapsed } diff --git a/Sources/Window/SidebarViews.swift b/Sources/Window/SidebarViews.swift index 9737774..9e27af7 100644 --- a/Sources/Window/SidebarViews.swift +++ b/Sources/Window/SidebarViews.swift @@ -283,19 +283,19 @@ 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 +class SidebarGroupView: NSView, NSTextFieldDelegate, NSDraggingSource { + let folder: 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)? // folder, project index /// Row index in the sidebar stack view (set during rebuildSidebar). var rowIndex: Int = 0 @@ -317,7 +317,7 @@ class SidebarFolderView: NSView, NSTextFieldDelegate, NSDraggingSource { didSet { needsDisplay = true } } - init(folder: SidebarFolder, projectCount: Int) { + init(folder: SidebarGroup, projectCount: Int) { self.folder = folder disclosureImageView = NSImageView() @@ -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) @@ -518,7 +518,7 @@ class SidebarFolderView: NSView, NSTextFieldDelegate, NSDraggingSource { /// Covers the empty area below the project 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)? // folder 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(deckardProjectDragType) || types.contains(deckardGroupDragType) } override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { @@ -560,9 +560,9 @@ class SidebarDropZone: NSView { 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 @@ -575,8 +575,8 @@ class SidebarDropZone: NSView { /// Supports project drag (reorder/drop onto folder) and folder drag (reorder folders). 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 + /// Returns the SidebarGroupView at the drag location, if the cursor is /// within the center region of a folder 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 folderView(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 { @@ -617,9 +617,9 @@ class ReorderableStackView: NSStackView { } private func clearFolderHighlight() { - if let prev = highlightedFolder { + if let prev = highlightedGroup { prev.isDropTarget = false - highlightedFolder = nil + highlightedGroup = nil } } @@ -676,7 +676,7 @@ class ReorderableStackView: NSStackView { } private func acceptsFolderDrag(_ sender: NSDraggingInfo) -> Bool { - sender.draggingPasteboard.types?.contains(deckardFolderDragType) == true + sender.draggingPasteboard.types?.contains(deckardGroupDragType) == true } override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { @@ -725,7 +725,7 @@ class ReorderableStackView: NSStackView { // (after the folder + all its children) best = i // Find end of this folder's children - if view is SidebarFolderView { + if view is SidebarGroupView { var end = i + 1 while end < arrangedSubviews.count, let r = arrangedSubviews[end] as? VerticalTabRowView, r.indent > 0 { @@ -745,10 +745,10 @@ class ReorderableStackView: NSStackView { // Hovering over a folder row — highlight it, hide the line indicator dropIndicator.isHidden = true currentDropIndex = -1 - if highlightedFolder !== fv { + if highlightedGroup !== fv { clearFolderHighlight() fv.isDropTarget = true - highlightedFolder = fv + highlightedGroup = fv } } else { // Not over a folder — show the line indicator @@ -781,7 +781,7 @@ class ReorderableStackView: NSStackView { } override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { - let wasOnFolder = highlightedFolder + let wasOnFolder = highlightedGroup let wasForceFullWidth = currentDropForceFullWidth hideIndicator() @@ -790,7 +790,7 @@ class ReorderableStackView: NSStackView { let fromIndex = Int(fromStr) { // If dropped on a highlighted folder, route to folder drop handler if let fv = wasOnFolder { - onDropOntoFolder?(fv, fromIndex) + onDropOntoGroup?(fv, fromIndex) return true } let toIndex = dropIndex(for: sender) @@ -799,11 +799,11 @@ class ReorderableStackView: NSStackView { } // Handle folder drag - if let fromStr = sender.draggingPasteboard.string(forType: deckardFolderDragType), + 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/Tests/SessionStateTests.swift b/Tests/SessionStateTests.swift index c5e86f5..f6d99df 100644 --- a/Tests/SessionStateTests.swift +++ b/Tests/SessionStateTests.swift @@ -51,7 +51,7 @@ 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) @@ -219,7 +219,7 @@ 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) @@ -306,7 +306,7 @@ final class SessionStateTests: XCTestCase { // New ProjectItem (opened via symlink, but path is resolved) let project = ProjectItem(path: linkDir) - // restoreSidebarFolders resolves ps.path before comparison + // restoreSidebarGroups 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") diff --git a/Tests/ShortcutMigrationTests.swift b/Tests/ShortcutMigrationTests.swift new file mode 100644 index 0000000..a0f3a1d --- /dev/null +++ b/Tests/ShortcutMigrationTests.swift @@ -0,0 +1,87 @@ +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("v1", forKey: "KeyboardShortcuts_newSidebarFolder") + defaults.set("v2", forKey: "KeyboardShortcuts_moveOutOfFolder") + + DeckardShortcutMigration.migrate(defaults: defaults) + + XCTAssertEqual(defaults.string(forKey: "KeyboardShortcuts_newGroup"), "v1") + XCTAssertEqual(defaults.string(forKey: "KeyboardShortcuts_moveOutOfGroup"), "v2") + XCTAssertNil(defaults.object(forKey: "KeyboardShortcuts_newSidebarFolder")) + XCTAssertNil(defaults.object(forKey: "KeyboardShortcuts_moveOutOfFolder")) + } + + 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/SidebarGroupTests.swift similarity index 63% rename from Tests/SidebarFolderTests.swift rename to Tests/SidebarGroupTests.swift index 6c8112f..d75713d 100644 --- a/Tests/SidebarFolderTests.swift +++ b/Tests/SidebarGroupTests.swift @@ -1,12 +1,12 @@ import XCTest @testable import Deckard -final class SidebarFolderTests: XCTestCase { +final class SidebarGroupTests: XCTestCase { - // MARK: - SidebarFolderState Codable roundtrips + // MARK: - SidebarGroupState Codable roundtrips - func testSidebarFolderStateRoundtrip() throws { - let state = SidebarFolderState( + func testSidebarGroupStateRoundtrip() throws { + let state = SidebarGroupState( id: "folder-1", name: "My Folder", isCollapsed: true, @@ -14,7 +14,7 @@ final class SidebarFolderTests: XCTestCase { ) let data = try JSONEncoder().encode(state) - let decoded = try JSONDecoder().decode(SidebarFolderState.self, from: data) + let decoded = try JSONDecoder().decode(SidebarGroupState.self, from: data) XCTAssertEqual(decoded.id, "folder-1") XCTAssertEqual(decoded.name, "My Folder") @@ -22,8 +22,8 @@ final class SidebarFolderTests: XCTestCase { XCTAssertEqual(decoded.projectIds, ["proj-a", "proj-b", "proj-c"]) } - func testSidebarFolderStateEmptyProjectIds() throws { - let state = SidebarFolderState( + func testSidebarGroupStateEmptyProjectIds() throws { + let state = SidebarGroupState( id: "folder-empty", name: "Empty Folder", isCollapsed: false, @@ -31,7 +31,7 @@ final class SidebarFolderTests: XCTestCase { ) let data = try JSONEncoder().encode(state) - let decoded = try JSONDecoder().decode(SidebarFolderState.self, from: data) + let decoded = try JSONDecoder().decode(SidebarGroupState.self, from: data) XCTAssertEqual(decoded.id, "folder-empty") XCTAssertEqual(decoded.name, "Empty Folder") @@ -41,13 +41,13 @@ final class SidebarFolderTests: XCTestCase { // MARK: - SidebarOrderItem Codable roundtrips - func testSidebarOrderItemFolderRoundtrip() throws { - let item = SidebarOrderItem.folder("folder-abc") + func testSidebarOrderItemGroupRoundtrip() throws { + let item = SidebarOrderItem.group("folder-abc") let data = try JSONEncoder().encode(item) let decoded = try JSONDecoder().decode(SidebarOrderItem.self, from: data) - if case .folder(let id) = decoded { + if case .group(let id) = decoded { XCTAssertEqual(id, "folder-abc") } else { XCTFail("Expected .folder case, got \(decoded)") @@ -82,12 +82,12 @@ final class SidebarFolderTests: XCTestCase { } func testSidebarOrderItemEncodedShape() throws { - // Verify the JSON shape is {"type": "folder", "id": "..."} - let item = SidebarOrderItem.folder("f1") + // 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"], "folder") + XCTAssertEqual(dict?["type"], "group") XCTAssertEqual(dict?["id"], "f1") } @@ -102,16 +102,16 @@ final class SidebarFolderTests: XCTestCase { // MARK: - DeckardState with folders - func testDeckardStateWithFoldersRoundtrip() throws { + func testDeckardStateWithGroupsRoundtrip() 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.sidebarGroups = [ + SidebarGroupState(id: "f1", name: "Work", isCollapsed: false, projectIds: ["p1", "p2"]), + SidebarGroupState(id: "f2", name: "Personal", isCollapsed: true, projectIds: ["p3"]), ] state.sidebarOrder = [ - .folder("f1"), + .group("f1"), .project("p4"), - .folder("f2"), + .group("f2"), ] state.projects = [ ProjectState(id: "p1", path: "/work/a", name: "a", selectedTabIndex: 0, tabs: []), @@ -123,15 +123,15 @@ final class SidebarFolderTests: XCTestCase { 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.sidebarGroups?.count, 2) + XCTAssertEqual(decoded.sidebarGroups?[0].name, "Work") + XCTAssertEqual(decoded.sidebarGroups?[0].projectIds, ["p1", "p2"]) + XCTAssertEqual(decoded.sidebarGroups?[1].name, "Personal") + XCTAssertTrue(decoded.sidebarGroups?[1].isCollapsed == true) XCTAssertEqual(decoded.sidebarOrder?.count, 3) // Verify order items - if case .folder(let id) = decoded.sidebarOrder?[0] { + if case .group(let id) = decoded.sidebarOrder?[0] { XCTAssertEqual(id, "f1") } else { XCTFail("Expected .folder at index 0") @@ -141,40 +141,40 @@ final class SidebarFolderTests: XCTestCase { } else { XCTFail("Expected .project at index 1") } - if case .folder(let id) = decoded.sidebarOrder?[2] { + if case .group(let id) = decoded.sidebarOrder?[2] { XCTAssertEqual(id, "f2") } else { XCTFail("Expected .folder at index 2") } } - func testDeckardStateNilFoldersBackwardCompat() throws { + func testDeckardStateNilGroupsBackwardCompat() 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 + // sidebarGroups 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.sidebarGroups) 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.sidebarGroups = [ + SidebarGroupState(id: "f1", name: "Folder", isCollapsed: false, projectIds: []) ] state.sidebarOrder = [ .project("p1"), - .folder("f1"), + .group("f1"), .project("p2"), .project("p3"), - .folder("f1"), // duplicate folder reference (edge case) + .group("f1"), // duplicate folder reference (edge case) .project("p4"), ] @@ -185,29 +185,29 @@ final class SidebarFolderTests: XCTestCase { // 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 .group = 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 .group = 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.sidebarGroups = [] state.sidebarOrder = [] let data = try JSONEncoder().encode(state) let decoded = try JSONDecoder().decode(DeckardState.self, from: data) - XCTAssertEqual(decoded.sidebarFolders?.count, 0) + XCTAssertEqual(decoded.sidebarGroups?.count, 0) XCTAssertEqual(decoded.sidebarOrder?.count, 0) } - // MARK: - SidebarFolder data model + // MARK: - SidebarGroup data model - func testSidebarFolderInitDefaults() { - let folder = SidebarFolder(name: "Test Folder") + func testSidebarGroupInitDefaults() { + let folder = SidebarGroup(name: "Test Folder") XCTAssertEqual(folder.name, "Test Folder") XCTAssertFalse(folder.isCollapsed) @@ -215,8 +215,8 @@ final class SidebarFolderTests: XCTestCase { XCTAssertNotEqual(folder.id, UUID()) // has a valid UUID } - func testSidebarFolderProjectIdsAddRemove() { - let folder = SidebarFolder(name: "Folder") + func testSidebarGroupProjectIdsAddRemove() { + let folder = SidebarGroup(name: "Folder") let id1 = UUID() let id2 = UUID() let id3 = UUID() @@ -235,8 +235,8 @@ final class SidebarFolderTests: XCTestCase { XCTAssertEqual(folder.projectIds.count, 0) } - func testSidebarFolderIsCollapsedToggle() { - let folder = SidebarFolder(name: "Folder") + func testSidebarGroupIsCollapsedToggle() { + let folder = SidebarGroup(name: "Folder") XCTAssertFalse(folder.isCollapsed) folder.isCollapsed.toggle() @@ -246,11 +246,11 @@ final class SidebarFolderTests: XCTestCase { XCTAssertFalse(folder.isCollapsed) } - func testSidebarFolderFullInit() { + func testSidebarGroupFullInit() { let id = UUID() let pid1 = UUID() let pid2 = UUID() - let folder = SidebarFolder(id: id, name: "Custom", isCollapsed: true, projectIds: [pid1, pid2]) + let folder = SidebarGroup(id: id, name: "Custom", isCollapsed: true, projectIds: [pid1, pid2]) XCTAssertEqual(folder.id, id) XCTAssertEqual(folder.name, "Custom") @@ -261,10 +261,10 @@ final class SidebarFolderTests: XCTestCase { // MARK: - SidebarItem enum func testSidebarItemFolderCase() { - let folder = SidebarFolder(name: "Test") - let item = SidebarItem.folder(folder) + let folder = SidebarGroup(name: "Test") + let item = SidebarItem.group(folder) - if case .folder(let f) = item { + if case .group(let f) = item { XCTAssertTrue(f === folder) // same reference XCTAssertEqual(f.name, "Test") } else { @@ -284,13 +284,13 @@ final class SidebarFolderTests: XCTestCase { } func testSidebarItemFolderMutationThroughReference() { - let folder = SidebarFolder(name: "Before") - let item = SidebarItem.folder(folder) + let folder = SidebarGroup(name: "Before") + let item = SidebarItem.group(folder) // Mutating the folder should be visible through the enum folder.name = "After" - if case .folder(let f) = item { + if case .group(let f) = item { XCTAssertEqual(f.name, "After") } else { XCTFail("Expected .folder case") @@ -352,10 +352,10 @@ final class SidebarFolderTests: XCTestCase { XCTAssertNil(decoded.tmuxSessionName) } - // MARK: - SidebarFolderState edge cases + // MARK: - SidebarGroupState edge cases - func testSidebarFolderStateSpecialCharactersInName() throws { - let state = SidebarFolderState( + func testSidebarGroupStateSpecialCharactersInName() throws { + let state = SidebarGroupState( id: "f-special", name: "Work / Personal (2024) & More \u{1F4C1}", isCollapsed: false, @@ -363,14 +363,14 @@ final class SidebarFolderTests: XCTestCase { ) let data = try JSONEncoder().encode(state) - let decoded = try JSONDecoder().decode(SidebarFolderState.self, from: data) + let decoded = try JSONDecoder().decode(SidebarGroupState.self, from: data) XCTAssertEqual(decoded.name, "Work / Personal (2024) & More \u{1F4C1}") } - func testSidebarFolderStateManyProjectIds() throws { + func testSidebarGroupStateManyProjectIds() throws { let ids = (0..<100).map { "proj-\($0)" } - let state = SidebarFolderState( + let state = SidebarGroupState( id: "f-large", name: "Large Folder", isCollapsed: false, @@ -378,7 +378,7 @@ final class SidebarFolderTests: XCTestCase { ) let data = try JSONEncoder().encode(state) - let decoded = try JSONDecoder().decode(SidebarFolderState.self, from: data) + let decoded = try JSONDecoder().decode(SidebarGroupState.self, from: data) XCTAssertEqual(decoded.projectIds.count, 100) XCTAssertEqual(decoded.projectIds.first, "proj-0") @@ -389,10 +389,10 @@ final class SidebarFolderTests: XCTestCase { func testSidebarOrderItemArrayRoundtrip() throws { let items: [SidebarOrderItem] = [ - .folder("f1"), + .group("f1"), .project("p1"), .project("p2"), - .folder("f2"), + .group("f2"), .project("p3"), ] @@ -401,7 +401,7 @@ final class SidebarFolderTests: XCTestCase { XCTAssertEqual(decoded.count, 5) - if case .folder(let id) = decoded[0] { XCTAssertEqual(id, "f1") } + if case .group(let id) = decoded[0] { XCTAssertEqual(id, "f1") } else { XCTFail("Expected .folder at 0") } if case .project(let id) = decoded[1] { XCTAssertEqual(id, "p1") } @@ -410,10 +410,72 @@ final class SidebarFolderTests: XCTestCase { 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") } + if case .group(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") } } + + // MARK: - Legacy v2 state.json migration + + func testLegacySidebarFoldersKeyDecodesAsGroups() throws { + // v2 state.json wrote sidebar groups under the key "sidebarFolders". + // The new decoder must accept that key and surface them as sidebarGroups. + 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?.projectIds, ["p1"]) + } + + 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 .project(let id) = decoded[1] { XCTAssertEqual(id, "p1") } + else { XCTFail("Expected .project 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"), + // 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, projectIds: [])] + 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("\"group\"")) + // Sanity: the discriminator value "folder" should not appear in encoded output. + // (We can't assert it's totally absent because group names could include the word, + // so check the specific key/value pair shape.) + XCTAssertFalse(json.contains("\"type\":\"folder\"")) + XCTAssertFalse(json.contains("\"type\" : \"folder\"")) + } } diff --git a/Tests/SidebarFolderViewTests.swift b/Tests/SidebarGroupViewTests.swift similarity index 89% rename from Tests/SidebarFolderViewTests.swift rename to Tests/SidebarGroupViewTests.swift index afcdda7..7380d53 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") + ) -> SidebarGroupView { + let folder = SidebarGroup(name: "Test Folder") folder.isCollapsed = collapsed - let view = SidebarFolderView(folder: folder, projectCount: 2) + let view = SidebarGroupView(folder: folder, projectCount: 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). @@ -89,7 +89,7 @@ final class SidebarFolderViewTests: XCTestCase { } 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 @@ -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,15 +272,15 @@ 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") + func testGroupToggleBlocksCollapseWhenContainingSelectedProject() { + let folder = SidebarGroup(name: "Active") let projectId = UUID() folder.projectIds = [projectId] folder.isCollapsed = false - // Simulate the guard logic from folderToggleClicked. + // Simulate the guard logic from groupToggleClicked. folder.isCollapsed.toggle() // Guard: if collapsing a folder that contains the selected project, force expand. let selectedProjectId = projectId // selected project is inside this folder @@ -292,8 +292,8 @@ final class SidebarFolderViewTests: XCTestCase { "Folder containing the selected project should not stay collapsed") } - func testFolderToggleAllowsCollapseWhenNotContainingSelectedProject() { - let folder = SidebarFolder(name: "Other") + func testGroupToggleAllowsCollapseWhenNotContainingSelectedProject() { + let folder = SidebarGroup(name: "Other") let projectId = UUID() let otherProjectId = UUID() folder.projectIds = [projectId] From c3f1380b9b5af0f78f7d2e6a17bcda66c829c532 Mon Sep 17 00:00:00 2001 From: Gilles Dubuc Date: Sat, 2 May 2026 22:31:33 +0200 Subject: [PATCH 3/5] refactor: rename ProjectItem internals to WorkspaceItem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Internal counterpart to the prior commits. ProjectItem/ProjectState/ ProjectTabState/ProjectPicker become WorkspaceItem/WorkspaceState/ WorkspaceTabState/WorkspacePicker. The matching properties and methods across DeckardWindowController, SidebarController, TabBarController, HookHandler, and SidebarViews follow suit (currentProject -> currentWorkspace, openProject -> openWorkspace, projectIds -> workspaceIds, etc.). The pasteboard drag-type *constant* is renamed to deckardWorkspaceDragType while its underlying string value ("com.deckard.project-reorder") stays unchanged so the runtime drag identifier remains stable. Persistence migration: - DeckardState.CodingKeys reads the legacy "projects" key as well as the new "workspaces", and only ever writes "workspaces". - SidebarGroupState gets explicit Codable so it reads "projectIds" and "workspaceIds", writing only "workspaceIds". - DeckardShortcutMigration is extended to cover four more identifier renames (openFolder, closeFolder, nextProject, previousProject -> openWorkspace, closeWorkspace, nextWorkspace, previousWorkspace) and a new flag key so it re-runs once on the upgrade beyond commit 2. Tests cover the projects -> workspaces decode path, the projectIds -> workspaceIds decode path, a full v2 -> v3 round-trip that exercises every legacy key at once, and the expanded set of shortcut renames. The literal "~/.claude/projects/" paths used by Claude Code's session storage layout are intentionally untouched — those refer to the upstream directory, not Deckard's concept of a workspace. Co-Authored-By: Claude Opus 4.7 (1M context) --- Deckard.xcodeproj/project.pbxproj | 8 +- Sources/App/AppDelegate.swift | 32 +-- Sources/App/ShortcutNames.swift | 25 +- Sources/Detection/HookHandler.swift | 2 +- Sources/Session/SessionState.swift | 54 +++- Sources/Window/DeckardWindowController.swift | 242 +++++++++--------- Sources/Window/SidebarController.swift | 206 +++++++-------- Sources/Window/SidebarViews.swift | 16 +- Sources/Window/TabBarController.swift | 10 +- ...jectPicker.swift => WorkspacePicker.swift} | 6 +- Tests/ContextMonitorTests.swift | 6 +- Tests/SessionStateTests.swift | 108 ++++---- Tests/ShortcutMigrationTests.swift | 23 +- Tests/SidebarGroupTests.swift | 141 ++++++---- Tests/SidebarGroupViewTests.swift | 8 +- Tests/WindowControllerLogicTests.swift | 20 +- 16 files changed, 505 insertions(+), 402 deletions(-) rename Sources/Window/{ProjectPicker.swift => WorkspacePicker.swift} (98%) diff --git a/Deckard.xcodeproj/project.pbxproj b/Deckard.xcodeproj/project.pbxproj index 3c61879..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 */; }; @@ -73,7 +73,7 @@ 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 = ""; }; @@ -216,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 */, @@ -429,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 */, diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index 8e044f8..03db3ec 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -76,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) @@ -196,8 +196,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { let fileMenuItem = NSMenuItem() let fileMenu = NSMenu(title: "File") - let openItem = NSMenuItem(title: "Open Workspace...", 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()) @@ -224,7 +224,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { newFolderItem.target = self fileMenu.addItem(newFolderItem) - let moveOutItem = NSMenuItem(title: "Move Out of Group", action: #selector(moveCurrentProjectOutOfGroup), keyEquivalent: "") + let moveOutItem = NSMenuItem(title: "Move Out of Group", action: #selector(moveCurrentWorkspaceOutOfGroup), keyEquivalent: "") moveOutItem.setShortcut(for: .moveOutOfGroup) moveOutItem.target = self fileMenu.addItem(moveOutItem) @@ -233,8 +233,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { exploreSessionsItem.setShortcut(for: .exploreSessions) fileMenu.addItem(exploreSessionsItem) - let closeProjectItem = NSMenuItem(title: "Close Workspace", action: #selector(closeCurrentProject), keyEquivalent: "") - closeProjectItem.setShortcut(for: .closeFolder) + let closeProjectItem = NSMenuItem(title: "Close Workspace", action: #selector(closeCurrentWorkspace), keyEquivalent: "") + closeProjectItem.setShortcut(for: .closeWorkspace) fileMenu.addItem(closeProjectItem) fileMenu.addItem(.separator()) @@ -247,11 +247,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { fileMenu.addItem(prevTabItem) let nextProjectItem = NSMenuItem(title: "Next Project", action: #selector(selectNextProject), keyEquivalent: "") - nextProjectItem.setShortcut(for: .nextProject) + nextProjectItem.setShortcut(for: .nextWorkspace) fileMenu.addItem(nextProjectItem) let prevProjectItem = NSMenuItem(title: "Previous Project", action: #selector(selectPrevProject), keyEquivalent: "") - prevProjectItem.setShortcut(for: .previousProject) + prevProjectItem.setShortcut(for: .previousWorkspace) fileMenu.addItem(prevProjectItem) fileMenu.addItem(.separator()) @@ -300,16 +300,16 @@ class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - Actions - private let projectPicker = ProjectPicker() + private let projectPicker = WorkspacePicker() func openProjectPicker() { - openProject() + openWorkspace() } - @objc private func openProject() { + @objc private func openWorkspace() { projectPicker.show(relativeTo: windowController?.window) { [weak self] path in guard let path = path else { return } - self?.windowController?.openProject(path: path) + self?.windowController?.openWorkspace(path: path) } } @@ -338,17 +338,17 @@ class AppDelegate: NSObject, NSApplicationDelegate { windowController?.exploreCurrentProjectSessions() } - @objc private func moveCurrentProjectOutOfGroup() { - windowController?.moveCurrentProjectOutOfGroup() + @objc private func moveCurrentWorkspaceOutOfGroup() { + windowController?.moveCurrentWorkspaceOutOfGroup() } - @objc private func closeCurrentProject() { + @objc private func closeCurrentWorkspace() { if let keyWindow = NSApp.keyWindow, keyWindow != windowController?.window { keyWindow.performClose(nil) return } - windowController?.closeCurrentProject() + windowController?.closeCurrentWorkspace() } @objc private func selectNextTab() { diff --git a/Sources/App/ShortcutNames.swift b/Sources/App/ShortcutNames.swift index 747274f..fb995ea 100644 --- a/Sources/App/ShortcutNames.swift +++ b/Sources/App/ShortcutNames.swift @@ -2,16 +2,16 @@ 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 newGroup = Self("newGroup", default: .init(.n, modifiers: [.command, .option])) @@ -36,16 +36,16 @@ struct ShortcutEntry { } let configurableShortcuts: [ShortcutEntry] = [ - ShortcutEntry(name: .openFolder, label: "Open Workspace"), + 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 Workspace"), + ShortcutEntry(name: .closeWorkspace, label: "Close Workspace"), ShortcutEntry(name: .nextTab, label: "Next Tab"), ShortcutEntry(name: .previousTab, label: "Previous Tab"), - ShortcutEntry(name: .nextProject, label: "Next Workspace"), - ShortcutEntry(name: .previousProject, label: "Previous Workspace"), + 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: .newGroup, label: "New Group"), @@ -72,12 +72,17 @@ let tabShortcutNames: [KeyboardShortcuts.Name] = [ /// flag so it only runs once. KeyboardShortcuts persists each override under /// `KeyboardShortcuts_` (see KeyboardShortcuts.swift in the upstream). enum DeckardShortcutMigration { - static let migrationFlagKey = "shortcutsMigratedToGroupNames" + static let migrationFlagKey = "shortcutsMigratedToWorkspaceAndGroupNames" - /// Old identifier → new identifier renames for the folder→group commit. + /// 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) { 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/Session/SessionState.swift b/Sources/Session/SessionState.swift index a99c449..ab98cef 100644 --- a/Sources/Session/SessionState.swift +++ b/Sources/Session/SessionState.swift @@ -31,7 +31,7 @@ struct DeckardState: Codable { var masterSessionId: String? // v2: project-based - var projects: [ProjectState]? + var workspaces: [WorkspaceState]? // v3: sidebar groups (was "sidebarFolders" in v2-era state.json) var sidebarGroups: [SidebarGroupState]? @@ -42,10 +42,11 @@ struct DeckardState: Codable { private enum CodingKeys: String, CodingKey { case version, selectedTabIndex, defaultWorkingDirectory case tabs, claudeTabCounter, terminalTabCounter, masterSessionId - case projects + case workspaces case sidebarGroups, sidebarOrder - // Legacy key — read on decode, never written. - case sidebarFolders + // Legacy keys — read on decode, never written. + case projects // v2 name for workspaces + case sidebarFolders // v2 name for sidebarGroups } init(from decoder: Decoder) throws { @@ -57,7 +58,8 @@ struct DeckardState: Codable { claudeTabCounter = try c.decodeIfPresent(Int.self, forKey: .claudeTabCounter) terminalTabCounter = try c.decodeIfPresent(Int.self, forKey: .terminalTabCounter) masterSessionId = try c.decodeIfPresent(String.self, forKey: .masterSessionId) - projects = try c.decodeIfPresent([ProjectState].self, forKey: .projects) + 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) @@ -72,7 +74,7 @@ struct DeckardState: Codable { try c.encodeIfPresent(claudeTabCounter, forKey: .claudeTabCounter) try c.encodeIfPresent(terminalTabCounter, forKey: .terminalTabCounter) try c.encodeIfPresent(masterSessionId, forKey: .masterSessionId) - try c.encodeIfPresent(projects, forKey: .projects) + try c.encodeIfPresent(workspaces, forKey: .workspaces) try c.encodeIfPresent(sidebarGroups, forKey: .sidebarGroups) try c.encodeIfPresent(sidebarOrder, forKey: .sidebarOrder) } @@ -88,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 @@ -155,7 +157,39 @@ 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 group or an ungrouped project. diff --git a/Sources/Window/DeckardWindowController.swift b/Sources/Window/DeckardWindowController.swift index 18f0c59..1c20708 100644 --- a/Sources/Window/DeckardWindowController.swift +++ b/Sources/Window/DeckardWindowController.swift @@ -81,7 +81,7 @@ class TabItem { } /// A project in the vertical sidebar — contains horizontal tabs. -class ProjectItem { +class WorkspaceItem { let id: UUID var path: String var name: String // basename of path @@ -99,32 +99,32 @@ class ProjectItem { // MARK: - Sidebar Folder Model -/// A folder in the sidebar that groups projects. +/// A folder 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 group or an ungrouped project reference. enum SidebarItem { case group(SidebarGroup) - case project(UUID) // ProjectItem.id + case project(UUID) // WorkspaceItem.id } // MARK: - Default Tab Configuration @@ -149,7 +149,7 @@ struct DefaultTabConfig { // MARK: - Window Controller -let deckardProjectDragType = NSPasteboard.PasteboardType("com.deckard.project-reorder") +let deckardWorkspaceDragType = NSPasteboard.PasteboardType("com.deckard.project-reorder") let deckardSidebarDragType = NSPasteboard.PasteboardType("com.deckard.sidebar-drag") let deckardGroupDragType = NSPasteboard.PasteboardType("com.deckard.folder-reorder") @@ -165,8 +165,8 @@ 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 sidebarGroups: [SidebarGroup] = [] @@ -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 recentlyClosedProjects: [WorkspaceState] = [] var isRestoring = false /// Tabs in the order they were created (for ProcessMonitor PID matching). var tabCreationOrder: [UUID] = [] @@ -272,8 +272,8 @@ 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 project picker + if workspaces.isEmpty { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { AppDelegate.shared?.openProjectPicker() } @@ -368,7 +368,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } private func restoreFirstResponderAfterWake() { - guard let project = currentProject else { return } + guard let project = currentWorkspace else { return } let idx = project.selectedTabIndex guard idx >= 0, idx < project.tabs.count else { return } let tab = project.tabs[idx] @@ -410,7 +410,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { // Drop zone covers the entire sidebar area below the stack sidebarDropZone.translatesAutoresizingMaskIntoConstraints = false - sidebarDropZone.registerForDraggedTypes([deckardProjectDragType, deckardGroupDragType]) + sidebarDropZone.registerForDraggedTypes([deckardWorkspaceDragType, deckardGroupDragType]) sidebarView.addSubview(sidebarDropZone) sidebarStackView.orientation = .vertical @@ -563,12 +563,12 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { // MARK: - Project 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 project = 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. @@ -593,44 +593,44 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } } - projects.append(project) + workspaces.append(project) sidebarOrder.append(.project(project.id)) rebuildSidebar() - selectProject(at: projects.count - 1) + selectProject(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 } + closeProject(at: selectedWorkspaceIndex) } func exploreCurrentProjectSessions() { - guard selectedProjectIndex >= 0, selectedProjectIndex < projects.count else { return } - let project = projects[selectedProjectIndex] + guard selectedWorkspaceIndex >= 0, selectedWorkspaceIndex < workspaces.count else { return } + let project = workspaces[selectedWorkspaceIndex] let fakeMenuItem = NSMenuItem() fakeMenuItem.representedObject = project exploreSessionsMenuAction(fakeMenuItem) } - func moveCurrentProjectOutOfGroup() { - guard selectedProjectIndex >= 0, selectedProjectIndex < projects.count else { return } - let project = projects[selectedProjectIndex] - moveProjectOutOfGroup(projectId: project.id) + func moveCurrentWorkspaceOutOfGroup() { + guard selectedWorkspaceIndex >= 0, selectedWorkspaceIndex < workspaces.count else { return } + let project = workspaces[selectedWorkspaceIndex] + moveWorkspaceOutOfGroup(projectId: project.id) } func closeProject(at index: Int) { - guard index >= 0, index < projects.count else { return } - let project = projects[index] + guard index >= 0, index < workspaces.count else { return } + let project = workspaces[index] // Save project state for potential restoration - let snapshot = ProjectState( + let snapshot = WorkspaceState( 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, + WorkspaceTabState(id: tab.id.uuidString, name: tab.name, kind: tab.kind, sessionId: tab.sessionId, tmuxSessionName: tab.surface.tmuxSessionName) }, @@ -659,20 +659,20 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } } - projects.remove(at: index) + workspaces.remove(at: index) removeSidebarReference(projectId: project.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 { - // All remaining projects are inside collapsed folders — show empty state. - selectedProjectIndex = -1 + // All remaining workspaces are inside collapsed folders — show empty state. + selectedWorkspaceIndex = -1 currentTerminalView?.removeFromSuperview() currentTerminalView = nil rebuildTabBar() @@ -685,27 +685,27 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { /// 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(sidebarGroups.filter(\.isCollapsed).flatMap(\.projectIds)) - let clamped = min(index, projects.count - 1) + let collapsedProjectIds = 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, !collapsedProjectIds.contains(workspaces[lo].id) { return lo } + if hi < workspaces.count, !collapsedProjectIds.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 + guard index >= 0, index < workspaces.count else { return } + selectedWorkspaceIndex = index - let project = projects[index] + let project = workspaces[index] // Auto-expand folder if the selected project is inside a collapsed one if autoExpandFolder { - for folder in sidebarGroups where folder.isCollapsed && folder.projectIds.contains(project.id) { + for folder in sidebarGroups where folder.isCollapsed && folder.workspaceIds.contains(project.id) { folder.isCollapsed = false rebuildSidebar() } @@ -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) { + func createTabInProject(_ project: WorkspaceItem, 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 createTabInProject(_ project: ProjectItem, kind: TabKind, name: String? = nil, sessionIdToResume: String? = nil, forkSession: Bool = false, tmuxSessionToResume: String? = nil, extraArgs: String? = nil) { + func createTabInProject(_ project: 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 { @@ -876,11 +876,11 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { 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 project = workspaces[selectedWorkspaceIndex] if kind == .claude && UserDefaults.standard.bool(forKey: "promptForSessionArgs") { promptForClaudeArgs(for: project) { [weak self] args in @@ -890,7 +890,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { self.isCreatingTab = false return } - guard self.projects.contains(where: { $0 === project }) else { + guard self.workspaces.contains(where: { $0 === project }) else { self.isCreatingTab = false return } @@ -904,7 +904,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { self.isCreatingTab = false return } - guard self.projects.contains(where: { $0 === project }) else { + guard self.workspaces.contains(where: { $0 === project }) else { self.isCreatingTab = false return } @@ -917,7 +917,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } } - private func finalizeTabCreation(in project: ProjectItem) { + private func finalizeTabCreation(in project: WorkspaceItem) { project.selectedTabIndex = project.tabs.count - 1 rebuildTabBar() rebuildSidebar() @@ -929,7 +929,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } } - private func promptForClaudeArgs(for project: ProjectItem, completion: @escaping (String?) -> Void) { + private func promptForClaudeArgs(for project: WorkspaceItem, completion: @escaping (String?) -> Void) { let alert = NSAlert() alert.messageText = "Claude Code Arguments" alert.informativeText = "Arguments passed to this session:" @@ -954,7 +954,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } } - private func promptForCodexArgs(for project: ProjectItem, completion: @escaping (String?) -> Void) { + private func promptForCodexArgs(for project: WorkspaceItem, completion: @escaping (String?) -> Void) { let alert = NSAlert() alert.messageText = "Codex Arguments" alert.informativeText = "Arguments passed to this session:" @@ -983,7 +983,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } func closeCurrentTab() { - guard let project = currentProject else { return } + guard let project = currentWorkspace else { return } let idx = project.selectedTabIndex guard idx >= 0, idx < project.tabs.count else { return } @@ -1030,7 +1030,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } func selectTabInProject(at tabIndex: Int) { - guard let project = currentProject else { return } + guard let project = currentWorkspace else { return } guard tabIndex >= 0, tabIndex < project.tabs.count else { return } project.selectedTabIndex = tabIndex clearUnseenIfNeeded(project.tabs[tabIndex]) @@ -1042,7 +1042,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { /// 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 let project = currentWorkspace else { return } guard tabIndex >= 0, tabIndex < project.tabs.count else { return } guard tabIndex != project.selectedTabIndex else { return } project.selectedTabIndex = tabIndex @@ -1051,18 +1051,18 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } func selectNextTab() { - guard let project = currentProject, !project.tabs.isEmpty else { return } + guard let project = currentWorkspace, !project.tabs.isEmpty else { return } selectTabInProject(at: (project.selectedTabIndex + 1) % project.tabs.count) } func selectPrevTab() { - guard let project = currentProject, !project.tabs.isEmpty else { return } + guard let project = currentWorkspace, !project.tabs.isEmpty else { return } selectTabInProject(at: (project.selectedTabIndex - 1 + project.tabs.count) % project.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) { @@ -1138,9 +1138,9 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { private func updateContextUsage(for tab: TabItem) { guard let sessionId = tab.sessionId, - let project = currentProject else { + let project = currentWorkspace else { DiagnosticLog.shared.log("context", - "updateContextUsage: skipped — sessionId=\(tab.sessionId ?? "nil") project=\(currentProject != nil)") + "updateContextUsage: skipped — sessionId=\(tab.sessionId ?? "nil") project=\(currentWorkspace != nil)") quotaView.updateContext(usage: nil, tabName: nil) return } @@ -1148,14 +1148,14 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { let tabName = tab.name let tabId = tab.id let projectPath = project.path - let allPaths = projects.map { $0.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) 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, + guard let project = self.currentWorkspace, let activeTab = project.tabs[safe: project.selectedTabIndex], activeTab.id == tabId else { DiagnosticLog.shared.log("context", @@ -1173,7 +1173,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } private func updateCodexUsage(for tab: TabItem) { - guard let project = currentProject else { + guard let project = currentWorkspace else { quotaView.clear() return } @@ -1194,7 +1194,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { let usage = ContextMonitor.shared.getCodexUsage(sessionId: sessionId) DispatchQueue.main.async { [weak self] in guard let self = self else { return } - guard let project = self.currentProject, + guard let project = self.currentWorkspace, let activeTab = project.tabs[safe: project.selectedTabIndex], activeTab.id == tabId else { DiagnosticLog.shared.log("context", @@ -1242,7 +1242,7 @@ 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 project in self.workspaces { for tab in project.tabs { tabInfos.append(ProcessMonitor.TabInfo( surfaceId: tab.id, kind: tab.kind, @@ -1302,7 +1302,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { private func applyCodexBadgeStates(_ states: [UUID: ContextMonitor.CodexActivityInfo]) { var changed = false - for project in projects { + for project in workspaces { for tab in project.tabs where tab.kind == .codex { guard let state = states[tab.id] else { continue } @@ -1336,7 +1336,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { private func applyTerminalBadgeStates(_ states: [UUID: ProcessMonitor.ActivityInfo]) { var changed = false - for project in projects { + for project in workspaces { for tab in project.tabs where tab.isTerminal { let activity = states[tab.id] ?? ProcessMonitor.ActivityInfo() @@ -1379,7 +1379,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } func setTitle(_ title: String, forSurfaceId surfaceId: UUID) { - for project in projects { + for project in workspaces { for tab in project.tabs where tab.surface.surfaceId == surfaceId { guard tab.surface.title != title else { return } tab.surface.title = title @@ -1389,7 +1389,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } func handleSurfaceClosedById(_ surfaceId: UUID) { - for (pi, project) in projects.enumerated() { + for (pi, project) in workspaces.enumerated() { if let ti = project.tabs.firstIndex(where: { $0.id == surfaceId }) { let tab = project.tabs[ti] @@ -1408,14 +1408,14 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { project.tabs.remove(at: ti) - if project.tabs.isEmpty && pi == selectedProjectIndex { + if project.tabs.isEmpty && pi == selectedWorkspaceIndex { currentTerminalView?.removeFromSuperview() currentTerminalView = nil rebuildTabBar() rebuildSidebar() } else if project.tabs.isEmpty { rebuildSidebar() - } else if pi == selectedProjectIndex { + } else if pi == selectedWorkspaceIndex { project.selectedTabIndex = min(project.selectedTabIndex, project.tabs.count - 1) rebuildTabBar() rebuildSidebar() @@ -1433,7 +1433,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { func tabForSurfaceId(_ surfaceIdStr: String) -> TabItem? { guard let surfaceId = UUID(uuidString: surfaceIdStr) else { return nil } - for project in projects { + for project in workspaces { if let tab = project.tabs.first(where: { $0.id == surfaceId }) { return tab } @@ -1448,7 +1448,7 @@ 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 } + guard let project = currentWorkspace 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) @@ -1458,14 +1458,14 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { /// 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 } + guard let project = currentWorkspace else { return false } let idx = project.selectedTabIndex guard idx >= 0, idx < project.tabs.count else { return false } return project.tabs[idx].id == surfaceId } func focusTabById(_ tabId: UUID) { - for (pi, project) in projects.enumerated() { + for (pi, project) in workspaces.enumerated() { if let ti = project.tabs.firstIndex(where: { $0.id == tabId }) { selectProject(at: pi) selectTabInProject(at: ti) @@ -1484,7 +1484,7 @@ 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, + if let project = currentWorkspace, let idx = project.tabs.firstIndex(where: { $0.id == tab.id }), idx == project.selectedTabIndex { refreshContextBar(for: tab) @@ -1520,7 +1520,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { func listTabInfo() -> [TabInfo] { var result: [TabInfo] = [] - for project in projects { + for project in workspaces { for tab in project.tabs { result.append(TabInfo( id: tab.id.uuidString, @@ -1558,8 +1558,8 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { func captureState() -> DeckardState { var state = DeckardState() - state.selectedTabIndex = selectedProjectIndex - state.tabs = projects.map { project in + state.selectedTabIndex = selectedWorkspaceIndex + state.tabs = workspaces.map { project in // Store project-level info; individual tabs stored in a new field TabState( id: project.id.uuidString, @@ -1571,15 +1571,15 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { workingDirectory: project.path ) } - // Store full project data in the new projects field - state.projects = projects.map { project in - ProjectState( + // Store full project data in the new workspaces field + state.workspaces = workspaces.map { project in + WorkspaceState( id: project.id.uuidString, path: project.path, name: project.name, selectedTabIndex: project.selectedTabIndex, tabs: project.tabs.map { tab in - ProjectTabState( + WorkspaceTabState( id: tab.id.uuidString, name: tab.name, kind: tab.kind, @@ -1598,7 +1598,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { id: folder.id.uuidString, name: folder.name, isCollapsed: folder.isCollapsed, - projectIds: folder.projectIds.map { $0.uuidString } + workspaceIds: folder.workspaceIds.map { $0.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 projectStates = state.workspaces, !projectStates.isEmpty else { // Nothing to restore — start autosave immediately SessionManager.shared.startAutosave { [weak self] in self?.captureState() ?? DeckardState() @@ -1670,10 +1670,10 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { // Phase 1: Create the active project'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: [(project: WorkspaceItem, tab: WorkspaceTabState, originalIndex: Int)] = [] for (i, ps) in projectStates.enumerated() { - let project = ProjectItem(path: ps.path) + let project = WorkspaceItem(path: ps.path) project.name = ps.name project.defaultArgs = ps.defaultArgs project.defaultCodexArgs = ps.defaultCodexArgs @@ -1697,7 +1697,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } project.selectedTabIndex = selTab - projects.append(project) + workspaces.append(project) } // Keep isRestoring = true until Phase 2 finishes, so selectProject @@ -1707,7 +1707,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { restoreSidebarGroups(from: state) rebuildSidebar() - if selectedIdx >= 0 && selectedIdx < projects.count { + if selectedIdx >= 0 && selectedIdx < workspaces.count { selectProject(at: selectedIdx) } @@ -1716,26 +1716,26 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } private func restoreSidebarGroups(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] = [:] + // 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 projectStates) rather than + // by path, because multiple workspaces can share the same path (e.g. ~/Downloads). + guard let projectStates = state.workspaces else { return } + var savedIdToProject: [String: WorkspaceItem] = [:] for (i, ps) in projectStates.enumerated() { - guard i < projects.count else { continue } - savedIdToProject[ps.id] = projects[i] + guard i < workspaces.count else { continue } + savedIdToProject[ps.id] = workspaces[i] } // Restore folders if let groupStates = state.sidebarGroups { for fs in groupStates { guard let groupId = UUID(uuidString: fs.id) else { continue } - let resolvedIds = fs.projectIds.compactMap { savedIdToProject[$0]?.id } + let resolvedIds = fs.workspaceIds.compactMap { savedIdToProject[$0]?.id } let folder = SidebarGroup( id: groupId, name: fs.name, isCollapsed: fs.isCollapsed, - projectIds: resolvedIds + workspaceIds: resolvedIds ) sidebarGroups.append(folder) } @@ -1759,10 +1759,10 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } } - // 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: [(project: WorkspaceItem, tab: WorkspaceTabState, originalIndex: Int)]) { guard let first = remaining.first else { // All tabs created — rebuild UI to reflect the full state isRestoring = false @@ -1779,7 +1779,7 @@ 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 { + for project in workspaces { if let tab = project.tabs.first(where: { $0.id == id }) { label = "\(tab.kind.rawValue.prefix(1).uppercased()):\(tab.name)@\(project.name)" break @@ -1832,7 +1832,7 @@ 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, + guard let project = self.currentWorkspace, let activeTab = project.tabs[safe: project.selectedTabIndex], activeTab.kind == .claude else { return } self.quotaView.update( @@ -1858,7 +1858,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { rebuildTabBar() // Apply color scheme to all terminal surfaces - for project in projects { + for project in workspaces { for tab in project.tabs { tab.surface.applyColorScheme(scheme) } @@ -1868,16 +1868,16 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { // MARK: - Navigation /// Project indices matching visible sidebar rows (skips collapsed folders). - func projectIndicesInSidebarOrder() -> [Int] { + 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) } + if let i = workspaces.firstIndex(where: { $0.id == id }) { indices.append(i) } case .group(let folder): guard !folder.isCollapsed else { continue } - for id in folder.projectIds { - if let i = projects.firstIndex(where: { $0.id == id }) { indices.append(i) } + for id in folder.workspaceIds { + if let i = workspaces.firstIndex(where: { $0.id == id }) { indices.append(i) } } } } @@ -1885,27 +1885,27 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } func selectNextProject() { - let ordered = projectIndicesInSidebarOrder() + let ordered = workspaceIndicesInSidebarOrder() guard !ordered.isEmpty else { return } - let cur = ordered.firstIndex(of: selectedProjectIndex) ?? -1 + let cur = ordered.firstIndex(of: selectedWorkspaceIndex) ?? -1 selectProject(at: ordered[(cur + 1) % ordered.count]) } func selectPrevProject() { - let ordered = projectIndicesInSidebarOrder() + let ordered = workspaceIndicesInSidebarOrder() guard !ordered.isEmpty else { return } - let cur = ordered.firstIndex(of: selectedProjectIndex) ?? ordered.count + let cur = ordered.firstIndex(of: selectedWorkspaceIndex) ?? ordered.count selectProject(at: ordered[(cur - 1 + ordered.count) % ordered.count]) } func selectProject(byNumber n: Int) { - let ordered = projectIndicesInSidebarOrder() + let ordered = workspaceIndicesInSidebarOrder() guard n >= 0, n < ordered.count else { return } selectProject(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/SidebarController.swift b/Sources/Window/SidebarController.swift index 6c22eb1..248da90 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 { .project($0.id) } } - /// Remove a project from sidebarOrder and all folders' projectIds. + /// Remove a project from sidebarOrder and all folders' workspaceIds. func removeSidebarReference(projectId: UUID) { sidebarOrder.removeAll { item in if case .project(let id) = item, id == projectId { return true } return false } for folder in sidebarGroups { - folder.projectIds.removeAll { $0 == projectId } + folder.workspaceIds.removeAll { $0 == projectId } } } - /// 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 project id, or -1. + func workspaceIndex(forId id: UUID) -> Int { + workspaces.firstIndex { $0.id == id } ?? -1 } // MARK: - Sidebar Rebuild @@ -67,7 +67,7 @@ extension DeckardWindowController { let cmdHeld = !revealMods.isEmpty && NSEvent.modifierFlags.contains(revealMods) var shortcutForProjectIndex: [Int: String] = [:] if cmdHeld { - for (pos, pi) in projectIndicesInSidebarOrder().prefix(10).enumerated() { + for (pos, pi) in workspaceIndicesInSidebarOrder().prefix(10).enumerated() { shortcutForProjectIndex[pi] = "\((pos + 1) % 10)" } } @@ -80,10 +80,10 @@ extension DeckardWindowController { for sidebarItem in sidebarOrder { switch sidebarItem { case .project(let projectId): - guard let project = projectById(projectId) else { continue } - let pi = projectIndex(forId: projectId) + guard let project = workspaceById(projectId) else { continue } + let pi = workspaceIndex(forId: projectId) let row = VerticalTabRowView(title: project.name, bold: false, index: pi, - target: self, action: #selector(projectRowClicked(_:))) + target: self, action: #selector(workspaceRowClicked(_:))) row.shortcutBadge = shortcutForProjectIndex[pi] row.badgeInfos = project.tabs.filter { $0.badgeState != .none }.map { tab in (state: tab.badgeState, name: tab.name, activity: self.terminalActivity[tab.id]) @@ -101,7 +101,7 @@ extension DeckardWindowController { } row.onContextMenu = { [weak self] event in guard let self = self else { return nil } - return self.buildProjectContextMenu(for: project) + return self.buildWorkspaceContextMenu(for: project) } sidebarStackView.addArrangedSubview(row) row.leadingAnchor.constraint(equalTo: sidebarStackView.leadingAnchor).isActive = true @@ -113,22 +113,22 @@ extension DeckardWindowController { // Folder header let folderView = SidebarGroupView( folder: folder, - projectCount: folder.projectIds.count + projectCount: folder.workspaceIds.count ) folderView.onToggle = { [weak self] fv in self?.groupToggleClicked(fv) } folderView.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.moveProjectIntoGroup(projectId: project.id, folder: fv.folder) + guard fromIndex >= 0, fromIndex < self.workspaces.count else { return } + let project = self.workspaces[fromIndex] + self.moveWorkspaceIntoGroup(projectId: project.id, folder: fv.folder) } - // Aggregate badge infos from all projects in the folder + // Aggregate badge infos from all workspaces in the folder var aggregatedBadges: [(state: TabItem.BadgeState, name: String, activity: ProcessMonitor.ActivityInfo?)] = [] - for pid in folder.projectIds { - if let project = projectById(pid) { + for pid in folder.workspaceIds { + if let project = workspaceById(pid) { for tab in project.tabs where tab.badgeState != .none { aggregatedBadges.append((state: tab.badgeState, name: tab.name, activity: self.terminalActivity[tab.id])) } @@ -151,13 +151,13 @@ extension DeckardWindowController { folderView.trailingAnchor.constraint(equalTo: sidebarStackView.trailingAnchor).isActive = true rowIndex += 1 - // Render projects inside the folder (if not collapsed) + // Render workspaces 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) + for projectId in folder.workspaceIds { + guard let project = workspaceById(projectId) else { continue } + let pi = workspaceIndex(forId: projectId) let row = VerticalTabRowView(title: project.name, bold: false, index: pi, - target: self, action: #selector(projectRowClicked(_:))) + target: self, action: #selector(workspaceRowClicked(_:))) row.indent = 16 row.shortcutBadge = shortcutForProjectIndex[pi] row.badgeInfos = project.tabs.filter { $0.badgeState != .none }.map { tab in @@ -176,7 +176,7 @@ extension DeckardWindowController { } row.onContextMenu = { [weak self] event in guard let self = self else { return nil } - return self.buildProjectContextMenu(for: project) + return self.buildWorkspaceContextMenu(for: project) } sidebarStackView.addArrangedSubview(row) row.leadingAnchor.constraint(equalTo: sidebarStackView.leadingAnchor).isActive = true @@ -188,7 +188,7 @@ extension DeckardWindowController { } } - sidebarStackView.registerForDraggedTypes([deckardProjectDragType, deckardSidebarDragType, deckardGroupDragType]) + sidebarStackView.registerForDraggedTypes([deckardWorkspaceDragType, deckardSidebarDragType, deckardGroupDragType]) sidebarStackView.onReorder = { [weak self] from, to, forceTopLevel in self?.handleSidebarDragReorder(fromProjectIndex: from, toRow: to, forceTopLevel: forceTopLevel) } @@ -199,11 +199,11 @@ extension DeckardWindowController { 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] + guard let self = self, fromIndex >= 0, fromIndex < self.workspaces.count else { return } + let project = self.workspaces[fromIndex] // If the project was inside a folder, move it out first - if self.sidebarGroups.contains(where: { $0.projectIds.contains(project.id) }) { - self.moveProjectOutOfGroup(projectId: project.id) + if self.sidebarGroups.contains(where: { $0.workspaceIds.contains(project.id) }) { + self.moveWorkspaceOutOfGroup(projectId: project.id) } // Move the sidebarOrder item to the end self.sidebarOrder.removeAll { item in @@ -211,7 +211,7 @@ extension DeckardWindowController { return false } self.sidebarOrder.append(.project(project.id)) - self.reorderProject(from: fromIndex, to: self.projects.count) + self.reorderWorkspace(from: fromIndex, to: self.workspaces.count) } sidebarDropZone.onGroupDrop = { [weak self] fromRow in guard let self else { return } @@ -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 project = workspaces.remove(at: fromIndex) let insertAt = toIndex > fromIndex ? toIndex - 1 : toIndex - projects.insert(project, at: min(insertAt, projects.count)) + workspaces.insert(project, 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() @@ -290,7 +290,7 @@ extension DeckardWindowController { parentFolder: nil, childIndexInFolder: nil, projectId: nil, groupId: folder.id)) if !folder.isCollapsed { - for (ci, pid) in folder.projectIds.enumerated() { + for (ci, pid) in folder.workspaceIds.enumerated() { infos.append(SidebarRowInfo( sidebarOrderIndex: orderIdx, isFolder: false, parentFolder: folder, childIndexInFolder: ci, @@ -305,16 +305,16 @@ extension DeckardWindowController { // MARK: - Sidebar Drag Handling /// Handle drag reorder in the sidebar. - /// `fromProjectIndex` is the flat projects array index (from the pasteboard). + /// `fromProjectIndex` 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] + guard fromProjectIndex >= 0, fromProjectIndex < workspaces.count else { return } + let draggedProject = workspaces[fromProjectIndex] let infos = sidebarRowInfos() guard toRow >= 0, toRow < infos.count else { // Drop past the end — move to top level at the end - let wasInFolder = sidebarGroups.contains { $0.projectIds.contains(draggedProject.id) } - if wasInFolder { moveProjectOutOfGroup(projectId: draggedProject.id) } + let wasInFolder = sidebarGroups.contains { $0.workspaceIds.contains(draggedProject.id) } + if wasInFolder { moveWorkspaceOutOfGroup(projectId: draggedProject.id) } sidebarOrder.removeAll { if case .project(let id) = $0, id == draggedProject.id { return true }; return false } sidebarOrder.append(.project(draggedProject.id)) rebuildSidebar() @@ -338,21 +338,21 @@ extension DeckardWindowController { } 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 + effectiveChildIndex = prevFolder.workspaceIds.count } else { effectiveFolder = nil effectiveChildIndex = nil } // Dropping between items inside the same folder → reorder within folder - let sourceFolder = sidebarGroups.first { $0.projectIds.contains(draggedProject.id) } + let sourceFolder = sidebarGroups.first { $0.workspaceIds.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), + guard let fromIdx = sf.workspaceIds.firstIndex(of: draggedProject.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(draggedProject.id, at: insertAt) rebuildSidebar() saveState() return @@ -362,15 +362,15 @@ extension DeckardWindowController { if let targetFolder = effectiveFolder { // Remove from source folder if needed if let sf = sourceFolder { - sf.projectIds.removeAll { $0 == draggedProject.id } + sf.workspaceIds.removeAll { $0 == draggedProject.id } } else { // Remove from top-level sidebarOrder sidebarOrder.removeAll { if case .project(let id) = $0, id == draggedProject.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)) + let insertAt = toInfo.childIndexInFolder ?? targetFolder.workspaceIds.count + if !targetFolder.workspaceIds.contains(draggedProject.id) { + targetFolder.workspaceIds.insert(draggedProject.id, at: min(insertAt, targetFolder.workspaceIds.count)) } rebuildSidebar() saveState() @@ -379,7 +379,7 @@ extension DeckardWindowController { // Dropping at top level — reorder in sidebarOrder if let sf = sourceFolder { - sf.projectIds.removeAll { $0 == draggedProject.id } + sf.workspaceIds.removeAll { $0 == draggedProject.id } // Add as top-level project in sidebarOrder at the target position let targetOrderIdx = toInfo.sidebarOrderIndex // Remove existing top-level entry if any @@ -398,10 +398,10 @@ extension DeckardWindowController { } } - // Also reorder in the flat projects array + // Also reorder in the flat workspaces array let fromPi = fromProjectIndex - if let pid = toInfo.projectId, let toPi = projects.firstIndex(where: { $0.id == pid }), fromPi != toPi { - reorderProject(from: fromPi, to: toPi) + if let pid = toInfo.projectId, let toPi = workspaces.firstIndex(where: { $0.id == pid }), fromPi != toPi { + reorderWorkspace(from: fromPi, to: toPi) } else { rebuildSidebar() saveState() @@ -427,7 +427,7 @@ extension DeckardWindowController { } func deleteSidebarGroup(_ folder: SidebarGroup) { - // Move all projects inside the folder back to top level (ungrouped) + // Move all workspaces inside the folder back to top level (ungrouped) let orderIndex = sidebarOrder.firstIndex(where: { if case .group(let f) = $0, f.id == folder.id { return true } return false @@ -437,7 +437,7 @@ extension DeckardWindowController { if let idx = orderIndex { sidebarOrder.remove(at: idx) var insertIdx = idx - for pid in folder.projectIds { + for pid in folder.workspaceIds { sidebarOrder.insert(.project(pid), at: insertIdx) insertIdx += 1 } @@ -448,32 +448,32 @@ extension DeckardWindowController { saveState() } - func moveProjectIntoGroup(projectId: UUID, folder: SidebarGroup) { + func moveWorkspaceIntoGroup(projectId: UUID, folder: SidebarGroup) { // Remove project from current location (top-level or another folder) sidebarOrder.removeAll { item in if case .project(let id) = item, id == projectId { return true } return false } for f in sidebarGroups where f.id != folder.id { - f.projectIds.removeAll { $0 == projectId } + f.workspaceIds.removeAll { $0 == projectId } } // Add to target folder - if !folder.projectIds.contains(projectId) { - folder.projectIds.append(projectId) + if !folder.workspaceIds.contains(projectId) { + folder.workspaceIds.append(projectId) } - // Auto-expand folder when adding projects + // Auto-expand folder when adding workspaces folder.isCollapsed = false rebuildSidebar() saveState() } - func moveProjectOutOfGroup(projectId: UUID) { + func moveWorkspaceOutOfGroup(projectId: UUID) { // Find which folder contains this project - guard let folder = sidebarGroups.first(where: { $0.projectIds.contains(projectId) }) else { return } - folder.projectIds.removeAll { $0 == projectId } + guard let folder = sidebarGroups.first(where: { $0.workspaceIds.contains(projectId) }) else { return } + folder.workspaceIds.removeAll { $0 == projectId } // Insert as ungrouped project right after the folder in sidebarOrder if let folderIdx = sidebarOrder.firstIndex(where: { @@ -494,13 +494,13 @@ extension DeckardWindowController { sender.folder.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) { + if sender.folder.isCollapsed, let current = currentWorkspace, + sender.folder.workspaceIds.contains(current.id) { sender.folder.isCollapsed = false } DiagnosticLog.shared.log("sidebar", - "groupToggle: \(sender.folder.name) was=\(wasCollapsed) now=\(sender.folder.isCollapsed) projects=\(sender.folder.projectIds.count)") + "groupToggle: \(sender.folder.name) was=\(wasCollapsed) now=\(sender.folder.isCollapsed) workspaces=\(sender.folder.workspaceIds.count)") rebuildSidebar() saveState() @@ -575,7 +575,7 @@ extension DeckardWindowController { // MARK: - Project Context Menu - func buildProjectContextMenu(for project: ProjectItem) -> NSMenu { + func buildWorkspaceContextMenu(for project: WorkspaceItem) -> NSMenu { let menu = NSMenu() let exploreItem = NSMenuItem(title: "Explore Sessions", action: #selector(exploreSessionsMenuAction(_:)), keyEquivalent: "") @@ -597,10 +597,10 @@ extension DeckardWindowController { menu.addItem(.separator()) // Folder options - let isInFolder = sidebarGroups.contains { $0.projectIds.contains(project.id) } + let isInFolder = sidebarGroups.contains { $0.workspaceIds.contains(project.id) } if isInFolder { - let moveOutItem = NSMenuItem(title: "Move Out of Group", action: #selector(moveProjectOutOfGroupAction(_:)), keyEquivalent: "") + let moveOutItem = NSMenuItem(title: "Move Out of Group", action: #selector(moveWorkspaceOutOfGroupAction(_:)), keyEquivalent: "") moveOutItem.setShortcut(for: .moveOutOfGroup) moveOutItem.target = self moveOutItem.representedObject = project @@ -609,7 +609,7 @@ extension DeckardWindowController { let moveToItem = NSMenuItem(title: "Move to Group", action: nil, keyEquivalent: "") let moveSubmenu = NSMenu() for folder in sidebarGroups { - let item = NSMenuItem(title: folder.name, action: #selector(moveProjectToGroupAction(_:)), keyEquivalent: "") + let item = NSMenuItem(title: folder.name, action: #selector(moveWorkspaceToGroupAction(_:)), keyEquivalent: "") item.target = self item.representedObject = MoveToGroupInfo(project: project, folder: folder) moveSubmenu.addItem(item) @@ -627,8 +627,8 @@ extension DeckardWindowController { menu.addItem(.separator()) - let closeItem = NSMenuItem(title: "Close Workspace", 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 menu.addItem(closeItem) @@ -637,36 +637,36 @@ extension DeckardWindowController { } class MoveToGroupInfo { - let project: ProjectItem + let project: WorkspaceItem let folder: SidebarGroup - init(project: ProjectItem, folder: SidebarGroup) { + init(project: WorkspaceItem, folder: SidebarGroup) { self.project = project self.folder = folder } } - @objc func moveProjectToGroupAction(_ sender: NSMenuItem) { + @objc func moveWorkspaceToGroupAction(_ sender: NSMenuItem) { guard let info = sender.representedObject as? MoveToGroupInfo else { return } - moveProjectIntoGroup(projectId: info.project.id, folder: info.folder) + moveWorkspaceIntoGroup(projectId: info.project.id, folder: info.folder) } - @objc func moveProjectOutOfGroupAction(_ sender: NSMenuItem) { - guard let project = sender.representedObject as? ProjectItem else { return } - moveProjectOutOfGroup(projectId: project.id) + @objc func moveWorkspaceOutOfGroupAction(_ sender: NSMenuItem) { + guard let project = sender.representedObject as? WorkspaceItem else { return } + moveWorkspaceOutOfGroup(projectId: project.id) } @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 } + @objc func closeWorkspaceMenuAction(_ sender: NSMenuItem) { + guard let project = sender.representedObject as? WorkspaceItem, + let pi = workspaces.firstIndex(where: { $0.id == project.id }) else { return } closeProject(at: pi) } @objc func exploreSessionsMenuAction(_ sender: NSMenuItem) { - guard let project = sender.representedObject as? ProjectItem else { return } + guard let project = sender.representedObject as? WorkspaceItem else { return } // If an explorer window already exists for this project, bring it to front let expectedTitle = "Sessions — \(project.name)" @@ -688,7 +688,7 @@ extension DeckardWindowController { 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 }) { + if let idx = self.workspaces.firstIndex(where: { $0 === project }) { self.selectProject(at: idx) } self.rebuildTabBar() @@ -704,7 +704,7 @@ extension DeckardWindowController { } @objc func defaultArgsMenuAction(_ sender: NSMenuItem) { - guard let project = sender.representedObject as? ProjectItem, + guard let project = sender.representedObject as? WorkspaceItem, let window else { return } let alert = NSAlert() @@ -726,7 +726,7 @@ extension DeckardWindowController { } @objc func defaultCodexArgsMenuAction(_ sender: NSMenuItem) { - guard let project = sender.representedObject as? ProjectItem, + guard let project = sender.representedObject as? WorkspaceItem, let window else { return } let alert = NSAlert() @@ -753,7 +753,7 @@ extension DeckardWindowController { // MARK: - Sidebar Selection func updateSidebarSelection() { - guard let currentProjectId = currentProject?.id else { + guard let currentProjectId = currentWorkspace?.id else { for view in sidebarStackView.arrangedSubviews { if let row = view as? VerticalTabRowView { row.isSelected = false @@ -763,10 +763,10 @@ extension DeckardWindowController { } for view in sidebarStackView.arrangedSubviews { if let row = view as? VerticalTabRowView { - row.isSelected = (row.index == selectedProjectIndex) + row.isSelected = (row.index == selectedWorkspaceIndex) } else if let fv = view as? SidebarGroupView { // Highlight folder if it contains the selected project - fv.isContainingSelected = fv.folder.projectIds.contains(currentProjectId) && fv.folder.isCollapsed + fv.isContainingSelected = fv.folder.workspaceIds.contains(currentProjectId) && fv.folder.isCollapsed } } } @@ -775,7 +775,7 @@ extension DeckardWindowController { AppDelegate.shared?.openProjectPicker() } - @objc func projectRowClicked(_ sender: VerticalTabRowView) { + @objc func workspaceRowClicked(_ sender: VerticalTabRowView) { selectProject(at: sender.index) } } diff --git a/Sources/Window/SidebarViews.swift b/Sources/Window/SidebarViews.swift index 9e27af7..6238a9e 100644 --- a/Sources/Window/SidebarViews.swift +++ b/Sources/Window/SidebarViews.swift @@ -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 folders). 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 Workspace", 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) @@ -307,7 +307,7 @@ class SidebarGroupView: NSView, NSTextFieldDelegate, NSDraggingSource { didSet { needsDisplay = true } } - /// Badge info aggregated from all projects in the folder. + /// Badge info aggregated from all workspaces in the folder. var badgeInfos: [(state: TabItem.BadgeState, name: String, activity: ProcessMonitor.ActivityInfo?)] = [] { didSet { updateBadgeDots() } } @@ -530,7 +530,7 @@ class SidebarDropZone: NSView { private func acceptsDrag(_ sender: NSDraggingInfo) -> Bool { let types = sender.draggingPasteboard.types ?? [] - return types.contains(deckardProjectDragType) || types.contains(deckardGroupDragType) + return types.contains(deckardWorkspaceDragType) || types.contains(deckardGroupDragType) } override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { @@ -555,7 +555,7 @@ 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 @@ -672,7 +672,7 @@ class ReorderableStackView: NSStackView { } private func acceptsProjectDrag(_ sender: NSDraggingInfo) -> Bool { - sender.draggingPasteboard.types?.contains(deckardProjectDragType) == true + sender.draggingPasteboard.types?.contains(deckardWorkspaceDragType) == true } private func acceptsFolderDrag(_ sender: NSDraggingInfo) -> Bool { @@ -786,7 +786,7 @@ class ReorderableStackView: NSStackView { hideIndicator() // Handle project drag - if let fromStr = sender.draggingPasteboard.string(forType: deckardProjectDragType), + 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 { diff --git a/Sources/Window/TabBarController.swift b/Sources/Window/TabBarController.swift index bd32727..dd56f1d 100644 --- a/Sources/Window/TabBarController.swift +++ b/Sources/Window/TabBarController.swift @@ -31,7 +31,7 @@ extension DeckardWindowController { savedFirstResponder = window?.firstResponder tabBar.arrangedSubviews.forEach { $0.removeFromSuperview() } - guard let project = currentProject else { return } + guard let project = currentWorkspace else { return } for (i, tab) in project.tabs.enumerated() { let isSelected = (i == project.selectedTabIndex) @@ -49,7 +49,7 @@ extension DeckardWindowController { clickAction: #selector(tabBarClicked(_:)) ) tabView.onRename = { [weak self] newName in - guard let self = self, let project = self.currentProject, + guard let self = self, let project = self.currentWorkspace, i < project.tabs.count else { return } let tab = project.tabs[i] tab.name = newName @@ -60,7 +60,7 @@ extension DeckardWindowController { self.saveState() } tabView.onClearName = { [weak self] in - guard let self = self, let project = self.currentProject, + guard let self = self, let project = self.currentWorkspace, i < project.tabs.count else { return } let tab = project.tabs[i] let base = tab.kind.displayName @@ -115,7 +115,7 @@ extension DeckardWindowController { } func reorderTab(from fromIndex: Int, to toIndex: Int) { - guard let project = currentProject else { return } + guard let project = currentWorkspace else { return } guard fromIndex != toIndex, fromIndex >= 0, fromIndex < project.tabs.count, toIndex >= 0, toIndex <= project.tabs.count else { return } @@ -142,7 +142,7 @@ extension DeckardWindowController { } @objc func tabBarCloseClicked(_ sender: NSButton) { - guard let project = currentProject else { return } + guard let project = currentWorkspace else { return } let idx = sender.tag guard idx >= 0, idx < project.tabs.count else { return } diff --git a/Sources/Window/ProjectPicker.swift b/Sources/Window/WorkspacePicker.swift similarity index 98% rename from Sources/Window/ProjectPicker.swift rename to Sources/Window/WorkspacePicker.swift index 5cdbd7e..c3297fd 100644 --- a/Sources/Window/ProjectPicker.swift +++ b/Sources/Window/WorkspacePicker.swift @@ -2,8 +2,8 @@ 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 { +/// 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 @@ -92,7 +92,7 @@ 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 diff --git a/Tests/ContextMonitorTests.swift b/Tests/ContextMonitorTests.swift index 168a251..bbeb5cf 100644 --- a/Tests/ContextMonitorTests.swift +++ b/Tests/ContextMonitorTests.swift @@ -509,10 +509,10 @@ final class ContextMonitorTests: XCTestCase { 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) + // WorkspaceItem resolves symlinks; claudeProjectDirName should agree + let project = WorkspaceItem(path: linkDir) let encoded = project.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/SessionStateTests.swift b/Tests/SessionStateTests.swift index f6d99df..f5ac2d4 100644 --- a/Tests/SessionStateTests.swift +++ b/Tests/SessionStateTests.swift @@ -10,16 +10,16 @@ final class SessionStateTests: XCTestCase { state.version = 2 state.selectedTabIndex = 3 state.defaultWorkingDirectory = "/Users/test/project" - state.projects = [ - ProjectState( + state.workspaces = [ + WorkspaceState( id: "proj-1", path: "/Users/test/project", name: "project", 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" @@ -33,17 +33,17 @@ 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.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 { @@ -54,25 +54,25 @@ final class SessionStateTests: XCTestCase { XCTAssertEqual(decoded.version, 3) XCTAssertEqual(decoded.selectedTabIndex, 0) XCTAssertNil(decoded.defaultWorkingDirectory) - XCTAssertNil(decoded.projects) + XCTAssertNil(decoded.workspaces) } func testMultipleProjectsRoundtrip() 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") + 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") @@ -115,7 +115,7 @@ final class SessionStateTests: XCTestCase { } func testProjectTabStateCodexRoundtrip() throws { - let tab = ProjectTabState( + 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) @@ -142,7 +142,7 @@ final class SessionStateTests: XCTestCase { {"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) @@ -154,7 +154,7 @@ final class SessionStateTests: XCTestCase { {"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 @@ -223,13 +223,13 @@ final class SessionStateTests: XCTestCase { 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 + // 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" @@ -239,10 +239,10 @@ final class SessionStateTests: XCTestCase { // 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-project", selectedTabIndex: 0, tabs: [ - ProjectTabState(id: "t1", name: "Claude", isClaude: true, sessionId: "sess-1") + WorkspaceTabState(id: "t1", name: "Claude", isClaude: true, sessionId: "sess-1") ]) ] @@ -250,23 +250,23 @@ 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 project = WorkspaceItem(path: ps.path) // The resolved path should match the canonical path XCTAssertEqual(project.path, realDir, - "ProjectItem should resolve symlink from old state.json") + "WorkspaceItem should resolve symlink from old state.json") // Sidebar folder 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") + "Resolved ps.path should match WorkspaceItem.path for sidebar folder mapping") } func testProjectStateSavedWithCanonicalPath() throws { // When captureState() saves a project that was opened via symlink, - // the path should be canonical (because ProjectItem.init resolves) + // 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" @@ -274,9 +274,9 @@ final class SessionStateTests: XCTestCase { addTeardownBlock { try? FileManager.default.removeItem(atPath: tempDir) } try FileManager.default.createSymbolicLink(atPath: linkDir, withDestinationPath: realDir) - let project = ProjectItem(path: linkDir) + let project = WorkspaceItem(path: linkDir) // Simulate what captureState() does - let saved = ProjectState( + let saved = WorkspaceState( id: project.id.uuidString, path: project.path, name: project.name, @@ -285,7 +285,7 @@ final class SessionStateTests: XCTestCase { ) XCTAssertEqual(saved.path, realDir, - "Saved ProjectState should contain canonical path, not symlink") + "Saved WorkspaceState should contain canonical path, not symlink") } func testOldAndNewStatePathsMatchAfterResolution() throws { @@ -298,21 +298,21 @@ final class SessionStateTests: XCTestCase { try FileManager.default.createSymbolicLink(atPath: linkDir, withDestinationPath: realDir) // Old state (saved with symlink path) - let oldProjectState = ProjectState( + let oldProjectState = WorkspaceState( id: "p1", path: linkDir, name: "linked-project", 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 project = WorkspaceItem(path: linkDir) // restoreSidebarGroups 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") + "Migration: resolved old state path must match new WorkspaceItem.path") // New state (saved after fix) already has canonical path - let newProjectState = ProjectState( + let newProjectState = WorkspaceState( id: "p2", path: project.path, name: project.name, selectedTabIndex: 0, tabs: [] ) diff --git a/Tests/ShortcutMigrationTests.swift b/Tests/ShortcutMigrationTests.swift index a0f3a1d..5a80590 100644 --- a/Tests/ShortcutMigrationTests.swift +++ b/Tests/ShortcutMigrationTests.swift @@ -36,15 +36,26 @@ final class ShortcutMigrationTests: XCTestCase { } func testMigratesAllRenamedIdentifiers() { - defaults.set("v1", forKey: "KeyboardShortcuts_newSidebarFolder") - defaults.set("v2", forKey: "KeyboardShortcuts_moveOutOfFolder") + 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"), "v1") - XCTAssertEqual(defaults.string(forKey: "KeyboardShortcuts_moveOutOfGroup"), "v2") - XCTAssertNil(defaults.object(forKey: "KeyboardShortcuts_newSidebarFolder")) - XCTAssertNil(defaults.object(forKey: "KeyboardShortcuts_moveOutOfFolder")) + 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() { diff --git a/Tests/SidebarGroupTests.swift b/Tests/SidebarGroupTests.swift index d75713d..ca5192f 100644 --- a/Tests/SidebarGroupTests.swift +++ b/Tests/SidebarGroupTests.swift @@ -10,7 +10,7 @@ final class SidebarGroupTests: XCTestCase { id: "folder-1", name: "My Folder", isCollapsed: true, - projectIds: ["proj-a", "proj-b", "proj-c"] + workspaceIds: ["proj-a", "proj-b", "proj-c"] ) let data = try JSONEncoder().encode(state) @@ -19,7 +19,7 @@ final class SidebarGroupTests: XCTestCase { XCTAssertEqual(decoded.id, "folder-1") XCTAssertEqual(decoded.name, "My Folder") XCTAssertTrue(decoded.isCollapsed) - XCTAssertEqual(decoded.projectIds, ["proj-a", "proj-b", "proj-c"]) + XCTAssertEqual(decoded.workspaceIds, ["proj-a", "proj-b", "proj-c"]) } func testSidebarGroupStateEmptyProjectIds() throws { @@ -27,7 +27,7 @@ final class SidebarGroupTests: XCTestCase { id: "folder-empty", name: "Empty Folder", isCollapsed: false, - projectIds: [] + workspaceIds: [] ) let data = try JSONEncoder().encode(state) @@ -36,7 +36,7 @@ final class SidebarGroupTests: XCTestCase { XCTAssertEqual(decoded.id, "folder-empty") XCTAssertEqual(decoded.name, "Empty Folder") XCTAssertFalse(decoded.isCollapsed) - XCTAssertEqual(decoded.projectIds, []) + XCTAssertEqual(decoded.workspaceIds, []) } // MARK: - SidebarOrderItem Codable roundtrips @@ -105,19 +105,19 @@ final class SidebarGroupTests: XCTestCase { func testDeckardStateWithGroupsRoundtrip() throws { var state = DeckardState() state.sidebarGroups = [ - SidebarGroupState(id: "f1", name: "Work", isCollapsed: false, projectIds: ["p1", "p2"]), - SidebarGroupState(id: "f2", name: "Personal", isCollapsed: true, projectIds: ["p3"]), + SidebarGroupState(id: "f1", name: "Work", isCollapsed: false, workspaceIds: ["p1", "p2"]), + SidebarGroupState(id: "f2", name: "Personal", isCollapsed: true, workspaceIds: ["p3"]), ] state.sidebarOrder = [ .group("f1"), .project("p4"), .group("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: []), + 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) @@ -125,7 +125,7 @@ final class SidebarGroupTests: XCTestCase { XCTAssertEqual(decoded.sidebarGroups?.count, 2) XCTAssertEqual(decoded.sidebarGroups?[0].name, "Work") - XCTAssertEqual(decoded.sidebarGroups?[0].projectIds, ["p1", "p2"]) + XCTAssertEqual(decoded.sidebarGroups?[0].workspaceIds, ["p1", "p2"]) XCTAssertEqual(decoded.sidebarGroups?[1].name, "Personal") XCTAssertTrue(decoded.sidebarGroups?[1].isCollapsed == true) XCTAssertEqual(decoded.sidebarOrder?.count, 3) @@ -151,8 +151,8 @@ final class SidebarGroupTests: XCTestCase { func testDeckardStateNilGroupsBackwardCompat() throws { // Simulate a v2 state without folder fields var state = DeckardState() - state.projects = [ - ProjectState(id: "p1", path: "/test", name: "test", selectedTabIndex: 0, tabs: []) + state.workspaces = [ + WorkspaceState(id: "p1", path: "/test", name: "test", selectedTabIndex: 0, tabs: []) ] // sidebarGroups and sidebarOrder deliberately left nil @@ -161,13 +161,13 @@ final class SidebarGroupTests: XCTestCase { XCTAssertNil(decoded.sidebarGroups) XCTAssertNil(decoded.sidebarOrder) - XCTAssertEqual(decoded.projects?.count, 1) + XCTAssertEqual(decoded.workspaces?.count, 1) } func testDeckardStateMixedSidebarOrder() throws { var state = DeckardState() state.sidebarGroups = [ - SidebarGroupState(id: "f1", name: "Folder", isCollapsed: false, projectIds: []) + SidebarGroupState(id: "f1", name: "Folder", isCollapsed: false, workspaceIds: []) ] state.sidebarOrder = [ .project("p1"), @@ -211,7 +211,7 @@ final class SidebarGroupTests: XCTestCase { XCTAssertEqual(folder.name, "Test Folder") XCTAssertFalse(folder.isCollapsed) - XCTAssertEqual(folder.projectIds, []) + XCTAssertEqual(folder.workspaceIds, []) XCTAssertNotEqual(folder.id, UUID()) // has a valid UUID } @@ -221,18 +221,18 @@ final class SidebarGroupTests: XCTestCase { 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.workspaceIds.append(id1) + folder.workspaceIds.append(id2) + folder.workspaceIds.append(id3) + XCTAssertEqual(folder.workspaceIds.count, 3) + XCTAssertEqual(folder.workspaceIds, [id1, id2, id3]) - folder.projectIds.removeAll { $0 == id2 } - XCTAssertEqual(folder.projectIds.count, 2) - XCTAssertEqual(folder.projectIds, [id1, id3]) + folder.workspaceIds.removeAll { $0 == id2 } + XCTAssertEqual(folder.workspaceIds.count, 2) + XCTAssertEqual(folder.workspaceIds, [id1, id3]) - folder.projectIds.removeAll() - XCTAssertEqual(folder.projectIds.count, 0) + folder.workspaceIds.removeAll() + XCTAssertEqual(folder.workspaceIds.count, 0) } func testSidebarGroupIsCollapsedToggle() { @@ -250,12 +250,12 @@ final class SidebarGroupTests: XCTestCase { let id = UUID() let pid1 = UUID() let pid2 = UUID() - let folder = SidebarGroup(id: id, name: "Custom", isCollapsed: true, projectIds: [pid1, pid2]) + let folder = SidebarGroup(id: id, name: "Custom", isCollapsed: true, workspaceIds: [pid1, pid2]) XCTAssertEqual(folder.id, id) XCTAssertEqual(folder.name, "Custom") XCTAssertTrue(folder.isCollapsed) - XCTAssertEqual(folder.projectIds, [pid1, pid2]) + XCTAssertEqual(folder.workspaceIds, [pid1, pid2]) } // MARK: - SidebarItem enum @@ -297,10 +297,10 @@ final class SidebarGroupTests: XCTestCase { } } - // MARK: - ProjectTabState with tmuxSessionName + // MARK: - WorkspaceTabState with tmuxSessionName func testProjectTabStateWithTmuxSessionName() throws { - let tab = ProjectTabState( + let tab = WorkspaceTabState( id: "tab-1", name: "Terminal", isClaude: false, @@ -309,7 +309,7 @@ final class SidebarGroupTests: XCTestCase { ) 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, "tab-1") XCTAssertEqual(decoded.name, "Terminal") @@ -319,7 +319,7 @@ final class SidebarGroupTests: XCTestCase { } func testProjectTabStateWithNilTmuxSessionName() throws { - let tab = ProjectTabState( + let tab = WorkspaceTabState( id: "tab-2", name: "Claude", isClaude: true, @@ -328,7 +328,7 @@ final class SidebarGroupTests: XCTestCase { ) 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, "tab-2") XCTAssertEqual(decoded.name, "Claude") @@ -343,7 +343,7 @@ final class SidebarGroupTests: XCTestCase { {"id": "tab-3", "name": "Terminal", "isClaude": false} """.data(using: .utf8)! - let decoded = try JSONDecoder().decode(ProjectTabState.self, from: json) + let decoded = try JSONDecoder().decode(WorkspaceTabState.self, from: json) XCTAssertEqual(decoded.id, "tab-3") XCTAssertEqual(decoded.name, "Terminal") @@ -359,7 +359,7 @@ final class SidebarGroupTests: XCTestCase { id: "f-special", name: "Work / Personal (2024) & More \u{1F4C1}", isCollapsed: false, - projectIds: ["p1"] + workspaceIds: ["p1"] ) let data = try JSONEncoder().encode(state) @@ -374,15 +374,15 @@ final class SidebarGroupTests: XCTestCase { id: "f-large", name: "Large Folder", isCollapsed: false, - projectIds: ids + workspaceIds: ids ) let data = try JSONEncoder().encode(state) let decoded = try JSONDecoder().decode(SidebarGroupState.self, from: data) - XCTAssertEqual(decoded.projectIds.count, 100) - XCTAssertEqual(decoded.projectIds.first, "proj-0") - XCTAssertEqual(decoded.projectIds.last, "proj-99") + XCTAssertEqual(decoded.workspaceIds.count, 100) + XCTAssertEqual(decoded.workspaceIds.first, "proj-0") + XCTAssertEqual(decoded.workspaceIds.last, "proj-99") } // MARK: - SidebarOrderItem array roundtrip @@ -420,8 +420,9 @@ final class SidebarGroupTests: XCTestCase { // MARK: - Legacy v2 state.json migration func testLegacySidebarFoldersKeyDecodesAsGroups() throws { - // v2 state.json wrote sidebar groups under the key "sidebarFolders". - // The new decoder must accept that key and surface them as sidebarGroups. + // 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, @@ -435,7 +436,59 @@ final class SidebarGroupTests: XCTestCase { let decoded = try JSONDecoder().decode(DeckardState.self, from: json) XCTAssertEqual(decoded.sidebarGroups?.count, 1) XCTAssertEqual(decoded.sidebarGroups?.first?.name, "Work") - XCTAssertEqual(decoded.sidebarGroups?.first?.projectIds, ["p1"]) + 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\"")) + XCTAssertTrue(reencodedString.contains("\"group\"")) + XCTAssertFalse(reencodedString.contains("\"projects\"")) + XCTAssertFalse(reencodedString.contains("\"sidebarFolders\"")) + XCTAssertFalse(reencodedString.contains("\"projectIds\"")) + XCTAssertFalse(reencodedString.contains("\"type\":\"folder\"")) } func testLegacyFolderDiscriminatorDecodesAsGroup() throws { @@ -464,7 +517,7 @@ final class SidebarGroupTests: XCTestCase { // 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, projectIds: [])] + state.sidebarGroups = [SidebarGroupState(id: "f1", name: "g", isCollapsed: false, workspaceIds: [])] state.sidebarOrder = [.group("f1")] let data = try JSONEncoder().encode(state) diff --git a/Tests/SidebarGroupViewTests.swift b/Tests/SidebarGroupViewTests.swift index 7380d53..8250ec3 100644 --- a/Tests/SidebarGroupViewTests.swift +++ b/Tests/SidebarGroupViewTests.swift @@ -277,14 +277,14 @@ final class SidebarGroupViewTests: XCTestCase { func testGroupToggleBlocksCollapseWhenContainingSelectedProject() { let folder = SidebarGroup(name: "Active") let projectId = UUID() - folder.projectIds = [projectId] + folder.workspaceIds = [projectId] folder.isCollapsed = false // Simulate the guard logic from groupToggleClicked. 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) { + if folder.isCollapsed, folder.workspaceIds.contains(selectedProjectId) { folder.isCollapsed = false } @@ -296,13 +296,13 @@ final class SidebarGroupViewTests: XCTestCase { let folder = SidebarGroup(name: "Other") let projectId = UUID() let otherProjectId = UUID() - folder.projectIds = [projectId] + folder.workspaceIds = [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) { + if folder.isCollapsed, folder.workspaceIds.contains(selectedProjectId) { folder.isCollapsed = false } diff --git a/Tests/WindowControllerLogicTests.swift b/Tests/WindowControllerLogicTests.swift index 26a9036..4fe867f 100644 --- a/Tests/WindowControllerLogicTests.swift +++ b/Tests/WindowControllerLogicTests.swift @@ -66,10 +66,10 @@ final class WindowControllerLogicTests: XCTestCase { XCTAssertFalse(TabKind.terminal.isAgent) } - // MARK: - ProjectItem + // MARK: - WorkspaceItem func testProjectItemInit() { - let project = ProjectItem(path: "/Users/test/my-project") + let project = WorkspaceItem(path: "/Users/test/my-project") XCTAssertEqual(project.path, "/Users/test/my-project") XCTAssertEqual(project.name, "my-project") XCTAssertTrue(project.tabs.isEmpty) @@ -77,11 +77,11 @@ final class WindowControllerLogicTests: XCTestCase { } func testProjectItemNameIsBasename() { - let project = ProjectItem(path: "/a/b/c/deep-folder") + let project = WorkspaceItem(path: "/a/b/c/deep-folder") XCTAssertEqual(project.name, "deep-folder") } - // MARK: - ProjectItem symlink resolution + // MARK: - WorkspaceItem symlink resolution func testProjectItemResolvesSymlinks() throws { let tempDir = NSTemporaryDirectory() + "deckard-symlink-\(UUID().uuidString)" @@ -91,8 +91,8 @@ final class WindowControllerLogicTests: XCTestCase { 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") + let project = WorkspaceItem(path: linkDir) + XCTAssertEqual(project.path, realDir, "WorkspaceItem should resolve symlinks to canonical path") XCTAssertEqual(project.name, "real-project") } @@ -103,7 +103,7 @@ final class WindowControllerLogicTests: XCTestCase { addTeardownBlock { try? FileManager.default.removeItem(atPath: tempDir) } // A non-symlink path should be unchanged - let project = ProjectItem(path: realDir) + let project = WorkspaceItem(path: realDir) XCTAssertEqual(project.path, realDir) } @@ -115,8 +115,8 @@ final class WindowControllerLogicTests: XCTestCase { 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") } @@ -131,7 +131,7 @@ 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) + let project = WorkspaceItem(path: link2) XCTAssertEqual(project.path, realDir, "Chained symlinks should fully resolve") } From 8884065f2ad20246e27a17be050f1719f94c187e Mon Sep 17 00:00:00 2001 From: Gilles Dubuc Date: Sat, 2 May 2026 23:36:43 +0200 Subject: [PATCH 4/5] refactor: finish folder/project rename across internal identifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review follow-up: the prior commits left a long tail of identifiers, local bindings, comments, and a few user-facing strings that still used the legacy vocabulary even though their referenced types had moved to WorkspaceItem / SidebarGroup. User-visible: - File menu items "Next Project", "Previous Project", and "Project 1..10" become "Next Workspace", "Previous Workspace", and "Workspace 1..10", matching what the Settings shortcut pane already shows. - Settings help text for the default Claude/Codex args now says "overridden per workspace" instead of "per project". Internal renames: - SidebarOrderItem.project enum case -> .workspace (the on-disk discriminator stays "project" so existing state.json round-trips unchanged; this rename is Swift-side only). - SidebarItem.project -> .workspace (runtime enum, no persistence). - SidebarGroupView.folder property and init label -> .group. - MoveToGroupInfo.project / .folder fields -> .workspace / .group. - All remaining method names that meant "the workspace as a unit": closeProject -> closeWorkspace, selectProject -> selectWorkspace, selectNextProject / selectPrevProject -> selectNextWorkspace / selectPrevWorkspace, addTabToCurrentProject -> addTabToCurrentWorkspace, createTabInProject -> createTabInWorkspace, exploreCurrentProjectSessions -> exploreCurrentWorkspaceSessions, openProjectPicker -> openWorkspacePicker, openProjectClicked -> openWorkspaceClicked, sidebarRowToProjectIndex -> sidebarRowToWorkspaceIndex, recentlyClosedProjects -> recentlyClosedWorkspaces, nextVisibleProjectIndex -> nextVisibleWorkspaceIndex, collapsedProjectIds -> collapsedWorkspaceIds. - Argument labels: projectId: -> workspaceId:, folder: -> group: on group-membership APIs, autoExpandFolder: -> autoExpandGroup:. - Local var bindings: let folder -> let group, let project -> let workspace, projectId -> workspaceId where the binding was a WorkspaceItem id. - WorkspacePicker internals: allProjects/filteredProjects -> allWorkspaces/filteredWorkspaces, loadRecentProjects -> loadRecentWorkspaces, NSUserInterfaceItemIdentifier("Project") -> ("Workspace"). - All // MARK: section headers and adjacent comments in the renamed files. - Test class methods and XCTFail messages updated to the new vocabulary. Pasteboard type strings (com.deckard.workspace-reorder / com.deckard.group-reorder) are now consistent with their constants — the prior commit's "preserve the old string for stability" comment was mistaken since pasteboards never survive an app restart. Untouched (intentional): the on-disk SidebarOrderItem discriminator "project", every "~/.claude/projects/" reference (Claude Code's upstream layout), and the ShortcutMigration history comment that names the old "folder/project" identifiers. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/App/AppDelegate.swift | 40 +- Sources/Session/SessionState.swift | 17 +- Sources/Window/DeckardWindowController.swift | 514 +++++++++---------- Sources/Window/SettingsWindow.swift | 6 +- Sources/Window/SidebarController.swift | 348 ++++++------- Sources/Window/SidebarViews.swift | 76 +-- Sources/Window/TabBarController.swift | 76 +-- Sources/Window/WorkspacePicker.swift | 78 +-- Tests/SessionStateTests.swift | 8 +- Tests/SidebarGroupTests.swift | 82 +-- Tests/SidebarGroupViewTests.swift | 60 +-- Tests/WindowControllerLogicTests.swift | 12 +- 12 files changed, 660 insertions(+), 657 deletions(-) diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index 03db3ec..27b2bcb 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -156,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() { @@ -246,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: .nextWorkspace) - 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: .previousWorkspace) - 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.. 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,7 +80,7 @@ class TabItem { } } -/// A project in the vertical sidebar — contains horizontal tabs. +/// A workspace in the vertical sidebar — contains horizontal tabs. class WorkspaceItem { let id: UUID var path: String @@ -97,9 +97,9 @@ class WorkspaceItem { } } -// MARK: - Sidebar Folder Model +// MARK: - Sidebar Group Model -/// A folder in the sidebar that groups workspaces. +/// A group in the sidebar that groups workspaces. class SidebarGroup { let id: UUID var name: String @@ -121,10 +121,10 @@ class SidebarGroup { } } -/// Ordered sidebar items: either a group or an ungrouped project reference. +/// Ordered sidebar items: either a group or an ungrouped workspace reference. enum SidebarItem { case group(SidebarGroup) - case project(UUID) // WorkspaceItem.id + case workspace(UUID) // WorkspaceItem.id } // MARK: - Default Tab Configuration @@ -149,9 +149,9 @@ struct DefaultTabConfig { // MARK: - Window Controller -let deckardWorkspaceDragType = NSPasteboard.PasteboardType("com.deckard.project-reorder") +let deckardWorkspaceDragType = NSPasteboard.PasteboardType("com.deckard.workspace-reorder") let deckardSidebarDragType = NSPasteboard.PasteboardType("com.deckard.sidebar-drag") -let deckardGroupDragType = NSPasteboard.PasteboardType("com.deckard.folder-reorder") +let deckardGroupDragType = NSPasteboard.PasteboardType("com.deckard.group-reorder") private class CollapsibleSplitView: NSSplitView { @@ -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() @@ -199,7 +199,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { private var sidebarInitialized = false private var sidebarWidthBeforeCollapse: CGFloat = 210 /// Recently closed workspaces — stored so reopening the same path restores tabs. - private var recentlyClosedProjects: [WorkspaceState] = [] + 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 workspaces after restore, auto-show the project picker + // 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 = currentWorkspace 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)") @@ -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 openWorkspacePaths() -> [String] { return workspaces.map { $0.path } } func openWorkspace(path: String) { - let project = WorkspaceItem(path: path) + 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) } } - workspaces.append(project) - sidebarOrder.append(.project(project.id)) + workspaces.append(workspace) + sidebarOrder.append(.workspace(workspace.id)) rebuildSidebar() - selectProject(at: workspaces.count - 1) + selectWorkspace(at: workspaces.count - 1) if !isRestoring { saveState() } } func closeCurrentWorkspace() { guard selectedWorkspaceIndex >= 0, selectedWorkspaceIndex < workspaces.count else { return } - closeProject(at: selectedWorkspaceIndex) + closeWorkspace(at: selectedWorkspaceIndex) } - func exploreCurrentProjectSessions() { + func exploreCurrentWorkspaceSessions() { guard selectedWorkspaceIndex >= 0, selectedWorkspaceIndex < workspaces.count else { return } - let project = workspaces[selectedWorkspaceIndex] + let workspace = workspaces[selectedWorkspaceIndex] let fakeMenuItem = NSMenuItem() - fakeMenuItem.representedObject = project + fakeMenuItem.representedObject = workspace exploreSessionsMenuAction(fakeMenuItem) } func moveCurrentWorkspaceOutOfGroup() { guard selectedWorkspaceIndex >= 0, selectedWorkspaceIndex < workspaces.count else { return } - let project = workspaces[selectedWorkspaceIndex] - moveWorkspaceOutOfGroup(projectId: project.id) + let workspace = workspaces[selectedWorkspaceIndex] + moveWorkspaceOutOfGroup(workspaceId: workspace.id) } - func closeProject(at index: Int) { + func closeWorkspace(at index: Int) { guard index >= 0, index < workspaces.count else { return } - let project = workspaces[index] + let workspace = workspaces[index] - // Save project state for potential restoration + // Save workspace state for potential restoration let snapshot = WorkspaceState( - id: project.id.uuidString, - path: project.path, - name: project.name, - selectedTabIndex: project.selectedTabIndex, - tabs: project.tabs.map { tab in + 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 { @@ -660,7 +660,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } workspaces.remove(at: index) - removeSidebarReference(projectId: project.id) + removeSidebarReference(workspaceId: workspace.id) rebuildSidebar() if workspaces.isEmpty { @@ -668,8 +668,8 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { 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 workspaces are inside collapsed folders — show empty state. selectedWorkspaceIndex = -1 @@ -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(sidebarGroups.filter(\.isCollapsed).flatMap(\.workspaceIds)) + /// 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 < workspaces.count { - if lo >= 0, !collapsedProjectIds.contains(workspaces[lo].id) { return lo } - if hi < workspaces.count, !collapsedProjectIds.contains(workspaces[hi].id) { return hi } + 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) { + func selectWorkspace(at index: Int, autoExpandGroup: Bool = true) { guard index >= 0, index < workspaces.count else { return } selectedWorkspaceIndex = index - let project = workspaces[index] + let workspace = workspaces[index] - // Auto-expand folder if the selected project is inside a collapsed one - if autoExpandFolder { - for folder in sidebarGroups where folder.isCollapsed && folder.workspaceIds.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: WorkspaceItem, 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: WorkspaceItem, 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,11 +838,11 @@ 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, projectPath: workspace.path) } } @@ -868,11 +868,11 @@ 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 @@ -880,48 +880,48 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { isCreatingTab = false return } - let project = workspaces[selectedWorkspaceIndex] + 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.workspaces.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.workspaces.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: WorkspaceItem) { - 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: WorkspaceItem, 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: WorkspaceItem, 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 = currentWorkspace 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() } @@ -1030,34 +1030,34 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } func selectTabInProject(at tabIndex: Int) { - guard let project = currentWorkspace else { return } - guard tabIndex >= 0, tabIndex < project.tabs.count else { return } - project.selectedTabIndex = tabIndex - clearUnseenIfNeeded(project.tabs[tabIndex]) + 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 = currentWorkspace 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 = currentWorkspace, !project.tabs.isEmpty else { return } - selectTabInProject(at: (project.selectedTabIndex + 1) % project.tabs.count) + guard let workspace = currentWorkspace, !workspace.tabs.isEmpty else { return } + selectTabInProject(at: (workspace.selectedTabIndex + 1) % workspace.tabs.count) } func selectPrevTab() { - guard let project = currentWorkspace, !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 } + selectTabInProject(at: (workspace.selectedTabIndex - 1 + workspace.tabs.count) % workspace.tabs.count) } var currentWorkspace: WorkspaceItem? { @@ -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,16 +1138,16 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { private func updateContextUsage(for tab: TabItem) { guard let sessionId = tab.sessionId, - let project = currentWorkspace else { + let workspace = currentWorkspace else { DiagnosticLog.shared.log("context", - "updateContextUsage: skipped — sessionId=\(tab.sessionId ?? "nil") project=\(currentWorkspace != 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 projectPath = workspace.path let allPaths = workspaces.map { $0.path } DispatchQueue.global(qos: .utility).async { let usage = ContextMonitor.shared.getUsage(sessionId: sessionId, projectPath: projectPath) @@ -1155,8 +1155,8 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { 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.currentWorkspace, - 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 = currentWorkspace else { + guard let workspace = currentWorkspace else { quotaView.clear() return } @@ -1181,7 +1181,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { let tabName = tab.name let tabId = tab.id let initialSessionId = tab.sessionId - let projectPath = project.path + let projectPath = workspace.path DispatchQueue.global(qos: .utility).async { var sessionId = initialSessionId if sessionId == nil, @@ -1194,8 +1194,8 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { let usage = ContextMonitor.shared.getCodexUsage(sessionId: sessionId) DispatchQueue.main.async { [weak self] in guard let self = self else { return } - guard let project = self.currentWorkspace, - 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") @@ -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.workspaces { - 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, projectPath: workspace.path)) if tab.kind == .codex { codexTargets.append(CodexBadgePollTarget( surfaceId: tab.id, - projectPath: project.path, + projectPath: workspace.path, sessionId: tab.sessionId, processId: ProcessMonitor.shared.shellPid(forSurface: tab.id))) } @@ -1302,8 +1302,8 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { private func applyCodexBadgeStates(_ states: [UUID: ContextMonitor.CodexActivityInfo]) { var changed = false - for project in workspaces { - 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 workspaces { - 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 workspaces { - 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 workspaces.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 == selectedWorkspaceIndex { + 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 == selectedWorkspaceIndex { - project.selectedTabIndex = min(project.selectedTabIndex, project.tabs.count - 1) + 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 workspaces { - 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,26 +1448,26 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { func isTabFocused(_ surfaceIdStr: String) -> Bool { guard let surfaceId = UUID(uuidString: surfaceIdStr) else { return false } - guard let project = currentWorkspace 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 = currentWorkspace 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 workspaces.enumerated() { - if let ti = project.tabs.firstIndex(where: { $0.id == tabId }) { - selectProject(at: pi) + for (pi, workspace) in workspaces.enumerated() { + if let ti = workspace.tabs.firstIndex(where: { $0.id == tabId }) { + selectWorkspace(at: pi) selectTabInProject(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 = currentWorkspace, - 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 workspaces { - 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 )) } } @@ -1559,26 +1559,26 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { func captureState() -> DeckardState { var state = DeckardState() state.selectedTabIndex = selectedWorkspaceIndex - state.tabs = workspaces.map { project in - // Store project-level info; individual tabs stored in a new field + 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 workspaces field - state.workspaces = workspaces.map { project in + // Store full workspace data in the new workspaces field + state.workspaces = workspaces.map { workspace in WorkspaceState( - id: project.id.uuidString, - path: project.path, - name: project.name, - selectedTabIndex: project.selectedTabIndex, - tabs: project.tabs.map { tab in + 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, @@ -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.sidebarGroups = sidebarGroups.map { folder in + state.sidebarGroups = sidebarGroups.map { group in SidebarGroupState( - id: folder.id.uuidString, - name: folder.name, - isCollapsed: folder.isCollapsed, - workspaceIds: folder.workspaceIds.map { $0.uuidString } + 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 .group(let folder): - return .group(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) } } @@ -1631,8 +1631,8 @@ 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)) @@ -1642,8 +1642,8 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { let selectedIdx = min(max(state.selectedTabIndex, 0), projectStates.count - 1) var codexRestoreCandidatesByPath: [String: [String]] = [:] - var usedCodexSessionIds = Set(projectStates.flatMap { project in - project.tabs.compactMap { tab in + var usedCodexSessionIds = Set(projectStates.flatMap { workspace in + workspace.tabs.compactMap { tab in tab.kind == .codex ? tab.sessionId : nil } }) @@ -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: WorkspaceItem, tab: WorkspaceTabState, originalIndex: Int)] = [] + var pending: [(workspace: WorkspaceItem, tab: WorkspaceTabState, originalIndex: Int)] = [] for (i, ps) in projectStates.enumerated() { - let project = WorkspaceItem(path: ps.path) - project.name = ps.name - project.defaultArgs = ps.defaultArgs - project.defaultCodexArgs = ps.defaultCodexArgs + 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,19 +1688,19 @@ 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 - workspaces.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 @@ -1708,7 +1708,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { rebuildSidebar() if selectedIdx >= 0 && selectedIdx < workspaces.count { - selectProject(at: selectedIdx) + selectWorkspace(at: selectedIdx) } // Phase 2: Create remaining surfaces progressively with small delays for UX. @@ -1731,13 +1731,13 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { for fs in groupStates { guard let groupId = UUID(uuidString: fs.id) else { continue } let resolvedIds = fs.workspaceIds.compactMap { savedIdToProject[$0]?.id } - let folder = SidebarGroup( + let group = SidebarGroup( id: groupId, name: fs.name, isCollapsed: fs.isCollapsed, workspaceIds: resolvedIds ) - sidebarGroups.append(folder) + sidebarGroups.append(group) } } @@ -1746,13 +1746,13 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { sidebarOrder = orderItems.compactMap { item in switch item { case .group(let idStr): - if let folder = sidebarGroups.first(where: { $0.id.uuidString == idStr }) { - return .group(folder) + 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 = savedIdToProject[idStr] { + return .workspace(workspace.id) } return nil } @@ -1762,7 +1762,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { // If no saved order, ensureSidebarOrder() will build one from workspaces } - private func createTabsProgressively(_ remaining: [(project: WorkspaceItem, tab: WorkspaceTabState, 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 workspaces { - 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.currentWorkspace, - 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 workspaces { - for tab in project.tabs { + for workspace in workspaces { + for tab in workspace.tabs { tab.surface.applyColorScheme(scheme) } } @@ -1867,16 +1867,16 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { // MARK: - Navigation - /// Project indices matching visible sidebar rows (skips collapsed folders). + /// 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): + case .workspace(let id): if let i = workspaces.firstIndex(where: { $0.id == id }) { indices.append(i) } - case .group(let folder): - guard !folder.isCollapsed else { continue } - for id in folder.workspaceIds { + 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) } } } @@ -1884,24 +1884,24 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { return indices } - func selectNextProject() { + func selectNextWorkspace() { let ordered = workspaceIndicesInSidebarOrder() guard !ordered.isEmpty else { return } let cur = ordered.firstIndex(of: selectedWorkspaceIndex) ?? -1 - selectProject(at: ordered[(cur + 1) % ordered.count]) + selectWorkspace(at: ordered[(cur + 1) % ordered.count]) } - func selectPrevProject() { + func selectPrevWorkspace() { let ordered = workspaceIndicesInSidebarOrder() guard !ordered.isEmpty else { return } let cur = ordered.firstIndex(of: selectedWorkspaceIndex) ?? ordered.count - selectProject(at: ordered[(cur - 1 + ordered.count) % ordered.count]) + selectWorkspace(at: ordered[(cur - 1 + ordered.count) % ordered.count]) } - func selectProject(byNumber n: Int) { + 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) { 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 248da90..e6e4eef 100644 --- a/Sources/Window/SidebarController.swift +++ b/Sources/Window/SidebarController.swift @@ -10,17 +10,17 @@ extension DeckardWindowController { /// Build `sidebarOrder` from the flat workspaces array when no order exists yet (migration). func ensureSidebarOrder() { guard sidebarOrder.isEmpty, !workspaces.isEmpty else { return } - sidebarOrder = workspaces.map { .project($0.id) } + sidebarOrder = workspaces.map { .workspace($0.id) } } - /// Remove a project from sidebarOrder and all folders' workspaceIds. - func removeSidebarReference(projectId: UUID) { + /// Remove a workspace from sidebarOrder and all folders' 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 sidebarGroups { - folder.workspaceIds.removeAll { $0 == projectId } + for group in sidebarGroups { + group.workspaceIds.removeAll { $0 == workspaceId } } } @@ -29,7 +29,7 @@ extension DeckardWindowController { workspaces.first { $0.id == id } } - /// Returns the flat index into `workspaces` for a given project id, or -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 } @@ -72,48 +72,48 @@ extension DeckardWindowController { } } - // 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 = workspaceById(projectId) else { continue } - let pi = workspaceIndex(forId: projectId) - let row = VerticalTabRowView(title: project.name, bold: false, index: pi, + 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 = shortcutForProjectIndex[pi] - row.badgeInfos = project.tabs.filter { $0.badgeState != .none }.map { tab in + 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.buildWorkspaceContextMenu(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 .group(let folder): + case .group(let group): // Folder header let folderView = SidebarGroupView( - folder: folder, - projectCount: folder.workspaceIds.count + group: group, + projectCount: group.workspaceIds.count ) folderView.onToggle = { [weak self] fv in self?.groupToggleClicked(fv) @@ -121,15 +121,15 @@ extension DeckardWindowController { folderView.onDrop = { [weak self] fv, fromIndex in guard let self else { return } guard fromIndex >= 0, fromIndex < self.workspaces.count else { return } - let project = self.workspaces[fromIndex] - self.moveWorkspaceIntoGroup(projectId: project.id, folder: fv.folder) + let workspace = self.workspaces[fromIndex] + self.moveWorkspaceIntoGroup(workspaceId: workspace.id, group: fv.group) } - // Aggregate badge infos from all workspaces 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.workspaceIds { - if let project = workspaceById(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])) } } @@ -138,12 +138,12 @@ extension DeckardWindowController { folderView.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 guard let self = self else { return nil } - return self.buildGroupContextMenu(for: folder) + return self.buildGroupContextMenu(for: group) } folderView.rowIndex = rowIndex sidebarStackView.addArrangedSubview(folderView) @@ -151,37 +151,37 @@ extension DeckardWindowController { folderView.trailingAnchor.constraint(equalTo: sidebarStackView.trailingAnchor).isActive = true rowIndex += 1 - // Render workspaces inside the folder (if not collapsed) - if !folder.isCollapsed { - for projectId in folder.workspaceIds { - guard let project = workspaceById(projectId) else { continue } - let pi = workspaceIndex(forId: projectId) - let row = VerticalTabRowView(title: project.name, bold: false, index: pi, + // 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.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.buildWorkspaceContextMenu(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 } } @@ -200,22 +200,22 @@ extension DeckardWindowController { } sidebarDropZone.onDrop = { [weak self] fromIndex in guard let self = self, fromIndex >= 0, fromIndex < self.workspaces.count else { return } - let project = self.workspaces[fromIndex] - // If the project was inside a folder, move it out first - if self.sidebarGroups.contains(where: { $0.workspaceIds.contains(project.id) }) { - self.moveWorkspaceOutOfGroup(projectId: project.id) + 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.sidebarOrder.append(.workspace(workspace.id)) self.reorderWorkspace(from: fromIndex, to: self.workspaces.count) } 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 groupId = infos[fromRow].groupId else { return } @@ -245,9 +245,9 @@ extension DeckardWindowController { fromIndex >= 0, fromIndex < workspaces.count, toIndex >= 0, toIndex <= workspaces.count else { return } - let project = workspaces.remove(at: fromIndex) + let workspace = workspaces.remove(at: fromIndex) let insertAt = toIndex > fromIndex ? toIndex - 1 : toIndex - workspaces.insert(project, at: min(insertAt, workspaces.count)) + workspaces.insert(workspace, at: min(insertAt, workspaces.count)) // Update selected index if selectedWorkspaceIndex == fromIndex { @@ -271,7 +271,7 @@ extension DeckardWindowController { var isFolder: Bool var parentFolder: SidebarGroup? var childIndexInFolder: Int? - var projectId: UUID? + var workspaceId: UUID? var groupId: UUID? } @@ -279,22 +279,22 @@ extension DeckardWindowController { 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, groupId: nil)) - case .group(let folder): + workspaceId: pid, groupId: nil)) + case .group(let group): infos.append(SidebarRowInfo( sidebarOrderIndex: orderIdx, isFolder: true, parentFolder: nil, childIndexInFolder: nil, - projectId: nil, groupId: folder.id)) - if !folder.isCollapsed { - for (ci, pid) in folder.workspaceIds.enumerated() { + 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, groupId: nil)) + parentFolder: group, childIndexInFolder: ci, + workspaceId: pid, groupId: nil)) } } } @@ -314,9 +314,9 @@ extension DeckardWindowController { guard toRow >= 0, toRow < infos.count else { // Drop past the end — move to top level at the end let wasInFolder = sidebarGroups.contains { $0.workspaceIds.contains(draggedProject.id) } - if wasInFolder { moveWorkspaceOutOfGroup(projectId: draggedProject.id) } - sidebarOrder.removeAll { if case .project(let id) = $0, id == draggedProject.id { return true }; return false } - sidebarOrder.append(.project(draggedProject.id)) + if wasInFolder { moveWorkspaceOutOfGroup(workspaceId: draggedProject.id) } + sidebarOrder.removeAll { if case .workspace(let id) = $0, id == draggedProject.id { return true }; return false } + sidebarOrder.append(.workspace(draggedProject.id)) rebuildSidebar() saveState() return @@ -324,19 +324,19 @@ extension DeckardWindowController { let toInfo = infos[toRow] - // Note: dropping directly *onto* a folder header (with highlight) is + // 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). + // 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 effectiveFolder: 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 + // The previous row is a group child — we're inserting at the end of that group effectiveFolder = prevFolder effectiveChildIndex = prevFolder.workspaceIds.count } else { @@ -344,10 +344,10 @@ extension DeckardWindowController { effectiveChildIndex = nil } - // Dropping between items inside the same folder → reorder within folder + // Dropping between items inside the same group → reorder within group let sourceFolder = sidebarGroups.first { $0.workspaceIds.contains(draggedProject.id) } if let targetFolder = effectiveFolder, let sf = sourceFolder, sf.id == targetFolder.id { - // Reorder within the same folder + // Reorder within the same group guard let fromIdx = sf.workspaceIds.firstIndex(of: draggedProject.id), let toIdx = effectiveChildIndex else { return } sf.workspaceIds.remove(at: fromIdx) @@ -358,16 +358,16 @@ extension DeckardWindowController { return } - // Dropping between items inside a different folder → move into that folder at position + // Dropping between items inside a different group → move into that group at position if let targetFolder = effectiveFolder { - // Remove from source folder if needed + // Remove from source group if needed if let sf = sourceFolder { sf.workspaceIds.removeAll { $0 == draggedProject.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 == draggedProject.id { return true }; return false } } - // Insert at position in target folder + // Insert at position in target group let insertAt = toInfo.childIndexInFolder ?? targetFolder.workspaceIds.count if !targetFolder.workspaceIds.contains(draggedProject.id) { targetFolder.workspaceIds.insert(draggedProject.id, at: min(insertAt, targetFolder.workspaceIds.count)) @@ -380,17 +380,17 @@ extension DeckardWindowController { // Dropping at top level — reorder in sidebarOrder if let sf = sourceFolder { sf.workspaceIds.removeAll { $0 == draggedProject.id } - // Add as top-level project in sidebarOrder at the target position + // 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 == draggedProject.id { return true }; return false } + sidebarOrder.insert(.workspace(draggedProject.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 == draggedProject.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 @@ -400,7 +400,7 @@ extension DeckardWindowController { // Also reorder in the flat workspaces array let fromPi = fromProjectIndex - if let pid = toInfo.projectId, let toPi = workspaces.firstIndex(where: { $0.id == pid }), fromPi != toPi { + if let pid = toInfo.workspaceId, let toPi = workspaces.firstIndex(where: { $0.id == pid }), fromPi != toPi { reorderWorkspace(from: fromPi, to: toPi) } else { rebuildSidebar() @@ -408,16 +408,16 @@ extension DeckardWindowController { } } - // MARK: - Folder Management + // MARK: - Group Management @objc func sidebarEmptyContextNewGroup() { createSidebarGroup() } func createSidebarGroup(name: String = "New Group") { - let folder = SidebarGroup(name: name) - sidebarGroups.append(folder) - sidebarOrder.append(.group(folder)) + let group = SidebarGroup(name: name) + sidebarGroups.append(group) + sidebarOrder.append(.group(group)) rebuildSidebar() saveState() // Start editing the name immediately @@ -426,63 +426,63 @@ extension DeckardWindowController { } } - func deleteSidebarGroup(_ folder: SidebarGroup) { - // Move all workspaces 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 .group(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.workspaceIds { - sidebarOrder.insert(.project(pid), at: insertIdx) + for pid in group.workspaceIds { + sidebarOrder.insert(.workspace(pid), at: insertIdx) insertIdx += 1 } } - sidebarGroups.removeAll { $0.id == folder.id } + sidebarGroups.removeAll { $0.id == group.id } rebuildSidebar() saveState() } - func moveWorkspaceIntoGroup(projectId: UUID, folder: SidebarGroup) { - // 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 sidebarGroups where f.id != folder.id { - f.workspaceIds.removeAll { $0 == projectId } + for f in sidebarGroups where f.id != group.id { + f.workspaceIds.removeAll { $0 == workspaceId } } - // Add to target folder - if !folder.workspaceIds.contains(projectId) { - folder.workspaceIds.append(projectId) + // Add to target group + if !group.workspaceIds.contains(workspaceId) { + group.workspaceIds.append(workspaceId) } - // Auto-expand folder when adding workspaces - folder.isCollapsed = false + // Auto-expand group when adding workspaces + group.isCollapsed = false rebuildSidebar() saveState() } - func moveWorkspaceOutOfGroup(projectId: UUID) { - // Find which folder contains this project - guard let folder = sidebarGroups.first(where: { $0.workspaceIds.contains(projectId) }) else { return } - folder.workspaceIds.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 + // Insert as ungrouped workspace right after the group in sidebarOrder if let folderIdx = sidebarOrder.firstIndex(where: { - if case .group(let f) = $0, f.id == folder.id { return true } + 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: folderIdx + 1) } else { - sidebarOrder.append(.project(projectId)) + sidebarOrder.append(.workspace(workspaceId)) } rebuildSidebar() @@ -490,30 +490,30 @@ extension DeckardWindowController { } func groupToggleClicked(_ sender: SidebarGroupView) { - let wasCollapsed = sender.folder.isCollapsed - sender.folder.isCollapsed.toggle() + 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 = currentWorkspace, - sender.folder.workspaceIds.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", - "groupToggle: \(sender.folder.name) was=\(wasCollapsed) now=\(sender.folder.isCollapsed) workspaces=\(sender.folder.workspaceIds.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. + /// 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 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 .group(let f) = $0, f.id == groupId { return true } return false @@ -537,31 +537,31 @@ extension DeckardWindowController { saveState() } - // MARK: - Folder Context Menu + // MARK: - Group Context Menu - func buildGroupContextMenu(for folder: SidebarGroup) -> NSMenu { + func buildGroupContextMenu(for group: SidebarGroup) -> NSMenu { let menu = NSMenu() 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 Group", action: #selector(deleteGroupMenuAction(_:)), keyEquivalent: "") deleteItem.target = self - deleteItem.representedObject = folder + deleteItem.representedObject = group menu.addItem(deleteItem) return menu } @objc func renameGroupMenuAction(_ sender: NSMenuItem) { - guard let folder = sender.representedObject as? SidebarGroup else { return } - // Find the SidebarGroupView for this folder and start editing + 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? SidebarGroupView, fv.folder.id == folder.id { + if let fv = view as? SidebarGroupView, fv.group.id == group.id { fv.startEditing() break } @@ -569,49 +569,49 @@ extension DeckardWindowController { } @objc func deleteGroupMenuAction(_ sender: NSMenuItem) { - guard let folder = sender.representedObject as? SidebarGroup else { return } - deleteSidebarGroup(folder) + guard let group = sender.representedObject as? SidebarGroup else { return } + deleteSidebarGroup(group) } - // MARK: - Project Context Menu + // MARK: - Workspace Context Menu - func buildWorkspaceContextMenu(for project: WorkspaceItem) -> 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 = sidebarGroups.contains { $0.workspaceIds.contains(project.id) } + // Group options + let isInFolder = sidebarGroups.contains { $0.workspaceIds.contains(workspace.id) } if isInFolder { 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 !sidebarGroups.isEmpty { let moveToItem = NSMenuItem(title: "Move to Group", action: nil, keyEquivalent: "") let moveSubmenu = NSMenu() - for folder in sidebarGroups { - let item = NSMenuItem(title: folder.name, action: #selector(moveWorkspaceToGroupAction(_:)), keyEquivalent: "") + for group in sidebarGroups { + let item = NSMenuItem(title: group.name, action: #selector(moveWorkspaceToGroupAction(_:)), keyEquivalent: "") item.target = self - item.representedObject = MoveToGroupInfo(project: project, folder: folder) + item.representedObject = MoveToGroupInfo(workspace: workspace, group: group) moveSubmenu.addItem(item) } moveToItem.submenu = moveSubmenu @@ -630,29 +630,29 @@ extension DeckardWindowController { 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 MoveToGroupInfo { - let project: WorkspaceItem - let folder: SidebarGroup - init(project: WorkspaceItem, folder: SidebarGroup) { - self.project = project - self.folder = folder + let workspace: WorkspaceItem + let group: SidebarGroup + init(workspace: WorkspaceItem, group: SidebarGroup) { + self.workspace = workspace + self.group = group } } @objc func moveWorkspaceToGroupAction(_ sender: NSMenuItem) { guard let info = sender.representedObject as? MoveToGroupInfo else { return } - moveWorkspaceIntoGroup(projectId: info.project.id, folder: info.folder) + moveWorkspaceIntoGroup(workspaceId: info.workspace.id, group: info.group) } @objc func moveWorkspaceOutOfGroupAction(_ sender: NSMenuItem) { - guard let project = sender.representedObject as? WorkspaceItem else { return } - moveWorkspaceOutOfGroup(projectId: project.id) + guard let workspace = sender.representedObject as? WorkspaceItem else { return } + moveWorkspaceOutOfGroup(workspaceId: workspace.id) } @objc func newGroupMenuAction() { @@ -660,16 +660,16 @@ extension DeckardWindowController { } @objc func closeWorkspaceMenuAction(_ sender: NSMenuItem) { - guard let project = sender.representedObject as? WorkspaceItem, - let pi = workspaces.firstIndex(where: { $0.id == project.id }) else { return } - closeProject(at: pi) + 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? WorkspaceItem 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 + projectPath: workspace.path, + projectName: 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.workspaces.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? WorkspaceItem, + 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? WorkspaceItem, + 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() } } @@ -765,17 +765,17 @@ extension DeckardWindowController { if let row = view as? VerticalTabRowView { row.isSelected = (row.index == selectedWorkspaceIndex) } else if let fv = view as? SidebarGroupView { - // Highlight folder if it contains the selected project - fv.isContainingSelected = fv.folder.workspaceIds.contains(currentProjectId) && fv.folder.isCollapsed + // Highlight group if it contains the selected workspace + fv.isContainingSelected = fv.group.workspaceIds.contains(currentProjectId) && fv.group.isCollapsed } } } - @objc func openProjectClicked() { - AppDelegate.shared?.openProjectPicker() + @objc func openWorkspaceClicked() { + AppDelegate.shared?.openWorkspacePicker() } @objc func workspaceRowClicked(_ sender: VerticalTabRowView) { - selectProject(at: sender.index) + selectWorkspace(at: sender.index) } } diff --git a/Sources/Window/SidebarViews.swift b/Sources/Window/SidebarViews.swift index 6238a9e..be2f108 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() } } @@ -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() { @@ -285,9 +285,9 @@ class VerticalTabRowView: NSView, NSTextFieldDelegate, NSDraggingSource { // MARK: - SidebarGroupView -/// A folder header row in the sidebar with disclosure triangle and name. +/// A group header row in the sidebar with disclosure triangle and name. class SidebarGroupView: NSView, NSTextFieldDelegate, NSDraggingSource { - let folder: SidebarGroup + let group: SidebarGroup private let disclosureImageView: NSImageView private let label: NSTextField private let badgeContainer: NSStackView @@ -295,38 +295,38 @@ class SidebarGroupView: NSView, NSTextFieldDelegate, NSDraggingSource { var onToggle: ((SidebarGroupView) -> Void)? var onRename: ((String) -> Void)? var onContextMenu: ((NSEvent) -> NSMenu?)? - var onDrop: ((SidebarGroupView, 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 workspaces 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: SidebarGroup, projectCount: Int) { - self.folder = folder + init(group: SidebarGroup, projectCount: Int) { + self.group = group disclosureImageView = NSImageView() - disclosureImageView.image = NSImage(systemSymbolName: folder.isCollapsed ? "chevron.right" : "chevron.down", + 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 SidebarGroupView: 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,7 +385,7 @@ class SidebarGroupView: NSView, NSTextFieldDelegate, NSDraggingSource { } func updateChevron() { - disclosureImageView.image = NSImage(systemSymbolName: folder.isCollapsed ? "chevron.right" : "chevron.down", + disclosureImageView.image = NSImage(systemSymbolName: group.isCollapsed ? "chevron.right" : "chevron.down", accessibilityDescription: "Toggle group") } @@ -448,7 +448,7 @@ class SidebarGroupView: 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 SidebarGroupView: 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 SidebarGroupView: 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 SidebarGroupView: 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 SidebarGroupView: 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 onGroupDrop: ((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? @@ -572,7 +572,7 @@ 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 folders). class ReorderableStackView: NSStackView { var onReorder: ((Int, Int, Bool) -> Void)? var onDropOntoGroup: ((SidebarGroupView, Int) -> Void)? @@ -600,7 +600,7 @@ class ReorderableStackView: NSStackView { } /// Returns the SidebarGroupView at the drag location, if the cursor is - /// within the center region of a folder row. The top and bottom edges + /// 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) -> SidebarGroupView? { let location = convert(sender.draggingLocation, from: 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 @@ -697,7 +697,7 @@ class ReorderableStackView: NSStackView { return [] } - /// Folder drag: only show indicator between top-level items (not inside folders). + /// Group drag: only show indicator between top-level items (not inside groups). private func updateFolderDrag(_ sender: NSDraggingInfo) -> NSDragOperation { let snapped = snapToTopLevel(for: sender) showIndicator(at: snapped, forceFullWidth: true) @@ -706,7 +706,7 @@ class ReorderableStackView: NSStackView { /// 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. + /// 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,9 +722,9 @@ 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 + // Find end of this group's children if view is SidebarGroupView { var end = i + 1 while end < arrangedSubviews.count, @@ -739,10 +739,10 @@ class ReorderableStackView: NSStackView { return best } - /// Common logic for project drag: highlight folder or show line indicator. + /// Common logic for workspace drag: highlight group 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 + // Hovering over a group row — highlight it, hide the line indicator dropIndicator.isHidden = true currentDropIndex = -1 if highlightedGroup !== fv { @@ -751,12 +751,12 @@ class ReorderableStackView: NSStackView { highlightedGroup = fv } } else { - // Not over a folder — show the line indicator + // Not over a group — show the line indicator clearFolderHighlight() 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 { @@ -785,10 +785,10 @@ class ReorderableStackView: NSStackView { let wasForceFullWidth = currentDropForceFullWidth hideIndicator() - // Handle project drag + // 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 dropped on a highlighted group, route to group drop handler if let fv = wasOnFolder { onDropOntoGroup?(fv, fromIndex) return true @@ -798,7 +798,7 @@ class ReorderableStackView: NSStackView { return true } - // Handle folder drag + // Handle group drag if let fromStr = sender.draggingPasteboard.string(forType: deckardGroupDragType), let fromRow = Int(fromStr) { let toRow = dropIndex(for: sender) diff --git a/Sources/Window/TabBarController.swift b/Sources/Window/TabBarController.swift index dd56f1d..e1aeb42 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 = currentWorkspace 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.currentWorkspace, - 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.currentWorkspace, - 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 = currentWorkspace 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() @@ -142,27 +142,27 @@ extension DeckardWindowController { } @objc func tabBarCloseClicked(_ sender: NSButton) { - guard let project = currentWorkspace 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/WorkspacePicker.swift b/Sources/Window/WorkspacePicker.swift index c3297fd..fedff5f 100644 --- a/Sources/Window/WorkspacePicker.swift +++ b/Sources/Window/WorkspacePicker.swift @@ -1,7 +1,7 @@ import AppKit import Fuse -/// A Spotlight-style project picker that appears when creating a new Claude tab. +/// 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 { @@ -13,8 +13,8 @@ class WorkspacePicker: NSObject, NSTableViewDataSource, NSTableViewDelegate, NST 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? @@ -42,8 +42,8 @@ class WorkspacePicker: NSObject, NSTableViewDataSource, NSTableViewDelegate, NST 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() @@ -96,12 +96,12 @@ class WorkspacePicker: NSObject, NSTableViewDataSource, NSTableViewDelegate, NST 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 WorkspacePicker: NSObject, NSTableViewDataSource, NSTableViewDelegate, NST 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 WorkspacePicker: NSObject, NSTableViewDataSource, NSTableViewDelegate, NST } 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 WorkspacePicker: NSObject, NSTableViewDataSource, NSTableViewDelegate, NST 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 WorkspacePicker: NSObject, NSTableViewDataSource, NSTableViewDelegate, NST // 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 WorkspacePicker: NSObject, NSTableViewDataSource, NSTableViewDelegate, NST // 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 WorkspacePicker: NSObject, NSTableViewDataSource, NSTableViewDelegate, NST 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 WorkspacePicker: NSObject, NSTableViewDataSource, NSTableViewDelegate, NST // 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 workspace = filteredWorkspaces[row] let cell: NSTableCellView if let recycled = tableView.makeView(withIdentifier: id, owner: nil) as? NSTableCellView { @@ -409,11 +409,11 @@ class WorkspacePicker: NSObject, NSTableViewDataSource, NSTableViewDelegate, NST ]) } - // 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) @@ -429,7 +429,7 @@ class WorkspacePicker: NSObject, NSTableViewDataSource, NSTableViewDelegate, NST // MARK: - Load Projects - 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 WorkspacePicker: NSObject, NSTableViewDataSource, NSTableViewDelegate, NST 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/SessionStateTests.swift b/Tests/SessionStateTests.swift index f5ac2d4..89c081d 100644 --- a/Tests/SessionStateTests.swift +++ b/Tests/SessionStateTests.swift @@ -102,7 +102,7 @@ final class SessionStateTests: XCTestCase { // MARK: - WorkspaceTabState Codable - func testProjectTabStateRoundtrip() throws { + 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(WorkspaceTabState.self, from: data) @@ -114,7 +114,7 @@ final class SessionStateTests: XCTestCase { XCTAssertEqual(decoded.sessionId, "s1") } - func testProjectTabStateCodexRoundtrip() throws { + func testWorkspaceTabStateCodexRoundtrip() throws { let tab = WorkspaceTabState( id: "t-codex", name: "Codex", @@ -137,7 +137,7 @@ 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)! @@ -149,7 +149,7 @@ final class SessionStateTests: XCTestCase { 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)! diff --git a/Tests/SidebarGroupTests.swift b/Tests/SidebarGroupTests.swift index ca5192f..1a33da8 100644 --- a/Tests/SidebarGroupTests.swift +++ b/Tests/SidebarGroupTests.swift @@ -22,7 +22,7 @@ final class SidebarGroupTests: XCTestCase { XCTAssertEqual(decoded.workspaceIds, ["proj-a", "proj-b", "proj-c"]) } - func testSidebarGroupStateEmptyProjectIds() throws { + func testSidebarGroupStateEmptyWorkspaceIds() throws { let state = SidebarGroupState( id: "folder-empty", name: "Empty Folder", @@ -50,17 +50,17 @@ final class SidebarGroupTests: XCTestCase { if case .group(let id) = decoded { XCTAssertEqual(id, "folder-abc") } else { - XCTFail("Expected .folder case, got \(decoded)") + XCTFail("Expected .group case, got \(decoded)") } } - func testSidebarOrderItemProjectRoundtrip() throws { - let item = SidebarOrderItem.project("proj-xyz") + 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 .project(let id) = decoded { + if case .workspace(let id) = decoded { XCTAssertEqual(id, "proj-xyz") } else { XCTFail("Expected .project case, got \(decoded)") @@ -91,8 +91,8 @@ final class SidebarGroupTests: XCTestCase { XCTAssertEqual(dict?["id"], "f1") } - func testSidebarOrderItemProjectEncodedShape() throws { - let item = SidebarOrderItem.project("p1") + func testSidebarOrderItemWorkspaceEncodedShape() throws { + let item = SidebarOrderItem.workspace("p1") let data = try JSONEncoder().encode(item) let dict = try JSONSerialization.jsonObject(with: data) as? [String: String] @@ -110,7 +110,7 @@ final class SidebarGroupTests: XCTestCase { ] state.sidebarOrder = [ .group("f1"), - .project("p4"), + .workspace("p4"), .group("f2"), ] state.workspaces = [ @@ -134,9 +134,9 @@ final class SidebarGroupTests: XCTestCase { if case .group(let id) = decoded.sidebarOrder?[0] { XCTAssertEqual(id, "f1") } else { - XCTFail("Expected .folder at index 0") + XCTFail("Expected .group at index 0") } - if case .project(let id) = decoded.sidebarOrder?[1] { + if case .workspace(let id) = decoded.sidebarOrder?[1] { XCTAssertEqual(id, "p4") } else { XCTFail("Expected .project at index 1") @@ -144,12 +144,12 @@ final class SidebarGroupTests: XCTestCase { if case .group(let id) = decoded.sidebarOrder?[2] { XCTAssertEqual(id, "f2") } else { - XCTFail("Expected .folder at index 2") + XCTFail("Expected .group at index 2") } } func testDeckardStateNilGroupsBackwardCompat() throws { - // Simulate a v2 state without folder fields + // Simulate a v2 state without group fields var state = DeckardState() state.workspaces = [ WorkspaceState(id: "p1", path: "/test", name: "test", selectedTabIndex: 0, tabs: []) @@ -170,12 +170,12 @@ final class SidebarGroupTests: XCTestCase { SidebarGroupState(id: "f1", name: "Folder", isCollapsed: false, workspaceIds: []) ] state.sidebarOrder = [ - .project("p1"), + .workspace("p1"), .group("f1"), - .project("p2"), - .project("p3"), + .workspace("p2"), + .workspace("p3"), .group("f1"), // duplicate folder reference (edge case) - .project("p4"), + .workspace("p4"), ] let data = try JSONEncoder().encode(state) @@ -184,12 +184,12 @@ final class SidebarGroupTests: XCTestCase { XCTAssertEqual(decoded.sidebarOrder?.count, 6) // Verify alternating types - if case .project = decoded.sidebarOrder?[0] {} else { XCTFail("Expected .project at 0") } - if case .group = 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 .group = decoded.sidebarOrder?[4] {} else { XCTFail("Expected .folder at 4") } - if case .project = decoded.sidebarOrder?[5] {} else { XCTFail("Expected .project at 5") } + if case .workspace = decoded.sidebarOrder?[0] {} else { XCTFail("Expected .project at 0") } + if case .group = decoded.sidebarOrder?[1] {} else { XCTFail("Expected .group at 1") } + if case .workspace = decoded.sidebarOrder?[2] {} else { XCTFail("Expected .project at 2") } + if case .workspace = decoded.sidebarOrder?[3] {} else { XCTFail("Expected .project at 3") } + if case .group = decoded.sidebarOrder?[4] {} else { XCTFail("Expected .group at 4") } + if case .workspace = decoded.sidebarOrder?[5] {} else { XCTFail("Expected .project at 5") } } func testDeckardStateEmptyFoldersAndOrder() throws { @@ -215,7 +215,7 @@ final class SidebarGroupTests: XCTestCase { XCTAssertNotEqual(folder.id, UUID()) // has a valid UUID } - func testSidebarGroupProjectIdsAddRemove() { + func testSidebarGroupWorkspaceIdsAddRemove() { let folder = SidebarGroup(name: "Folder") let id1 = UUID() let id2 = UUID() @@ -268,16 +268,16 @@ final class SidebarGroupTests: XCTestCase { XCTAssertTrue(f === folder) // same reference XCTAssertEqual(f.name, "Test") } else { - XCTFail("Expected .folder case") + XCTFail("Expected .group case") } } func testSidebarItemProjectCase() { - let projectId = UUID() - let item = SidebarItem.project(projectId) + let workspaceId = UUID() + let item = SidebarItem.workspace(workspaceId) - if case .project(let id) = item { - XCTAssertEqual(id, projectId) + if case .workspace(let id) = item { + XCTAssertEqual(id, workspaceId) } else { XCTFail("Expected .project case") } @@ -293,13 +293,13 @@ final class SidebarGroupTests: XCTestCase { if case .group(let f) = item { XCTAssertEqual(f.name, "After") } else { - XCTFail("Expected .folder case") + XCTFail("Expected .group case") } } // MARK: - WorkspaceTabState with tmuxSessionName - func testProjectTabStateWithTmuxSessionName() throws { + func testWorkspaceTabStateWithTmuxSessionName() throws { let tab = WorkspaceTabState( id: "tab-1", name: "Terminal", @@ -318,7 +318,7 @@ final class SidebarGroupTests: XCTestCase { XCTAssertEqual(decoded.tmuxSessionName, "deckard-main-1") } - func testProjectTabStateWithNilTmuxSessionName() throws { + func testWorkspaceTabStateWithNilTmuxSessionName() throws { let tab = WorkspaceTabState( id: "tab-2", name: "Claude", @@ -337,7 +337,7 @@ final class SidebarGroupTests: XCTestCase { XCTAssertNil(decoded.tmuxSessionName) } - func testProjectTabStateBackwardCompatNoTmuxField() throws { + func testWorkspaceTabStateBackwardCompatNoTmuxField() throws { // Simulate JSON without tmuxSessionName field (old format) let json = """ {"id": "tab-3", "name": "Terminal", "isClaude": false} @@ -390,10 +390,10 @@ final class SidebarGroupTests: XCTestCase { func testSidebarOrderItemArrayRoundtrip() throws { let items: [SidebarOrderItem] = [ .group("f1"), - .project("p1"), - .project("p2"), + .workspace("p1"), + .workspace("p2"), .group("f2"), - .project("p3"), + .workspace("p3"), ] let data = try JSONEncoder().encode(items) @@ -402,18 +402,18 @@ final class SidebarGroupTests: XCTestCase { XCTAssertEqual(decoded.count, 5) if case .group(let id) = decoded[0] { XCTAssertEqual(id, "f1") } - else { XCTFail("Expected .folder at 0") } + else { XCTFail("Expected .group at 0") } - if case .project(let id) = decoded[1] { XCTAssertEqual(id, "p1") } + if case .workspace(let id) = decoded[1] { XCTAssertEqual(id, "p1") } else { XCTFail("Expected .project at 1") } - if case .project(let id) = decoded[2] { XCTAssertEqual(id, "p2") } + if case .workspace(let id) = decoded[2] { XCTAssertEqual(id, "p2") } else { XCTFail("Expected .project at 2") } if case .group(let id) = decoded[3] { XCTAssertEqual(id, "f2") } - else { XCTFail("Expected .folder at 3") } + else { XCTFail("Expected .group at 3") } - if case .project(let id) = decoded[4] { XCTAssertEqual(id, "p3") } + if case .workspace(let id) = decoded[4] { XCTAssertEqual(id, "p3") } else { XCTFail("Expected .project at 4") } } @@ -506,7 +506,7 @@ final class SidebarGroupTests: XCTestCase { XCTAssertEqual(decoded.count, 3) if case .group(let id) = decoded[0] { XCTAssertEqual(id, "f1") } else { XCTFail("Expected .group at 0") } - if case .project(let id) = decoded[1] { XCTAssertEqual(id, "p1") } + if case .workspace(let id) = decoded[1] { XCTAssertEqual(id, "p1") } else { XCTFail("Expected .project at 1") } if case .group(let id) = decoded[2] { XCTAssertEqual(id, "f2") } else { XCTFail("Expected .group at 2") } diff --git a/Tests/SidebarGroupViewTests.swift b/Tests/SidebarGroupViewTests.swift index 8250ec3..598480d 100644 --- a/Tests/SidebarGroupViewTests.swift +++ b/Tests/SidebarGroupViewTests.swift @@ -12,9 +12,9 @@ final class SidebarGroupViewTests: XCTestCase { collapsed: Bool = false, origin: NSPoint = NSPoint(x: 0, y: 50) ) -> SidebarGroupView { - let folder = SidebarGroup(name: "Test Folder") - folder.isCollapsed = collapsed - let view = SidebarGroupView(folder: folder, projectCount: 2) + let group = SidebarGroup(name: "Test Folder") + group.isCollapsed = collapsed + let view = SidebarGroupView(group: group, projectCount: 2) // Embed in a parent so hitTest gets superview-relative points. let parent = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 200)) @@ -93,7 +93,7 @@ final class SidebarGroupViewTests: XCTestCase { 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 @@ -274,39 +274,39 @@ final class SidebarGroupViewTests: XCTestCase { // MARK: - groupToggleClicked guard - func testGroupToggleBlocksCollapseWhenContainingSelectedProject() { - let folder = SidebarGroup(name: "Active") - let projectId = UUID() - folder.workspaceIds = [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 groupToggleClicked. - 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.workspaceIds.contains(selectedProjectId) { - folder.isCollapsed = false + group.isCollapsed.toggle() + // Guard: if collapsing a group that contains the selected project, 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 testGroupToggleAllowsCollapseWhenNotContainingSelectedProject() { - let folder = SidebarGroup(name: "Other") - let projectId = UUID() - let otherProjectId = UUID() - folder.workspaceIds = [projectId] - folder.isCollapsed = false - - folder.isCollapsed.toggle() - // Guard: selected project is NOT in this folder. - let selectedProjectId = otherProjectId - if folder.isCollapsed, folder.workspaceIds.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 project 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 4fe867f..a93b3b2 100644 --- a/Tests/WindowControllerLogicTests.swift +++ b/Tests/WindowControllerLogicTests.swift @@ -68,7 +68,7 @@ final class WindowControllerLogicTests: XCTestCase { // MARK: - WorkspaceItem - func testProjectItemInit() { + func testWorkspaceItemInit() { let project = WorkspaceItem(path: "/Users/test/my-project") XCTAssertEqual(project.path, "/Users/test/my-project") XCTAssertEqual(project.name, "my-project") @@ -76,14 +76,14 @@ final class WindowControllerLogicTests: XCTestCase { XCTAssertEqual(project.selectedTabIndex, 0) } - func testProjectItemNameIsBasename() { + func testWorkspaceItemNameIsBasename() { let project = WorkspaceItem(path: "/a/b/c/deep-folder") XCTAssertEqual(project.name, "deep-folder") } // 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" @@ -96,7 +96,7 @@ final class WindowControllerLogicTests: XCTestCase { XCTAssertEqual(project.name, "real-project") } - func testProjectItemCanonicalPathIsIdempotent() throws { + func testWorkspaceItemCanonicalPathIsIdempotent() throws { let tempDir = NSTemporaryDirectory() + "deckard-symlink-\(UUID().uuidString)" let realDir = tempDir + "/real-project" try FileManager.default.createDirectory(atPath: realDir, withIntermediateDirectories: true) @@ -107,7 +107,7 @@ final class WindowControllerLogicTests: XCTestCase { XCTAssertEqual(project.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" @@ -121,7 +121,7 @@ final class WindowControllerLogicTests: XCTestCase { "ProjectItems 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 link1 = tempDir + "/link1" From 47930d61e26524bab210b461ef5b37b027829278 Mon Sep 17 00:00:00 2001 From: Gilles Dubuc Date: Sun, 3 May 2026 10:22:08 +0200 Subject: [PATCH 5/5] refactor: exhaustive folder/project sweep across remaining identifiers Comprehensive cleanup that catches every greppable folder/project identifier and comment that the prior commits missed. Previously the rename was only partial inside the Detection and Session modules and inside several test files; this commit unifies vocabulary across the whole codebase. Notable widening: - `projectPath:` parameter label across ContextMonitor, ProcessMonitor, QuotaMonitor, BookmarkManager, SessionExplorerWindowController, DeckardWindowController, SidebarController, TabBarController and the matching `forProjectPath:` variants -> `workspacePath:` / `forWorkspacePath:`. Same for `projectName:`, `projectKey`, `projectPaths:`. - All remaining `*Project*` method/local names in tab management: selectTabInWorkspace (formerly InProject), addTabToCurrentWorkspace call sites, recoverCodexSessionId(forWorkspacePath:), etc. - SidebarController internals: shortcutForWorkspaceIndex (was shortcutForProjectIndex), local `groupView` (was `folderView`), `groupIdx`, `isInGroup`, `newGroupItem`, `currentWorkspaceId`. - SidebarRowInfo struct fields parentGroup/childIndexInGroup/isGroup (were parentFolder/childIndexInFolder/isFolder). - SidebarViews helpers acceptsGroupDrag / updateGroupDrag / groupView(at:) / clearGroupHighlight (were the *Folder* variants). - AppDelegate's `projectPicker` field -> `workspacePicker`, `closeWorkspaceItem` local var. - Init labels: `SidebarGroupView(group: workspaceCount:)` (was `folder: projectCount:`). - Drag-handler local bindings draggedWorkspace / sourceGroup / targetGroup / etc. - Comments throughout (// Sidebar groups, // Group header, // Restore groups, "workspaces inside groups", BookmarkManager docstrings, ContextMonitor docstrings, ClaudeCLIFlags Codex notes). - ProcessMonitor diagnostic log strings "ACTIVE: workspace=" (was "project="). - Test method names testWorkspace*, testWorkspaceItem*, test fixture data ("Test Group" / "My Group" / "Empty Group" / "Large Group" / /Users/test/workspace / /real-workspace / /linked-workspace / /my-workspace / "Workspace/Codex"), and bindings (oldWorkspaceState, newWorkspaceState, let workspace = WorkspaceItem(...), let group = SidebarGroup(...)). - XCTFail messages corrected to reference the actual case name (.workspace / .group) instead of the now-renamed .project / .folder. - Pasteboard type strings com.deckard.workspace-reorder / com.deckard.group-reorder (were ".project-reorder" / ".folder-reorder"). Untouched (intentional): on-disk SidebarOrderItem discriminator "project", legacy CodingKey aliases (sidebarFolders / projects / projectIds), the ShortcutMigration history comment that names the old identifiers, every "~/.claude/projects/" path (Claude Code's upstream storage layout), the `claudeProjectDirName` extension and the tests that exercise it, and the `revealProjectNumbersModifiers` UserDefaults key (renaming would lose users' current preference for no benefit). Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/App/AppDelegate.swift | 18 +- Sources/App/ClaudeCLIFlags.swift | 4 +- Sources/Detection/ContextMonitor.swift | 58 +++---- Sources/Detection/ProcessMonitor.swift | 16 +- Sources/Detection/QuotaMonitor.swift | 8 +- Sources/Session/BookmarkManager.swift | 34 ++-- .../SessionExplorerWindowController.swift | 28 ++-- Sources/Window/DeckardWindowController.swift | 72 ++++---- Sources/Window/SidebarController.swift | 158 +++++++++--------- Sources/Window/SidebarViews.swift | 48 +++--- Sources/Window/TabBarController.swift | 2 +- Sources/Window/WorkspacePicker.swift | 4 +- Tests/ContextMonitorTests.swift | 38 ++--- Tests/ControlMessageTests.swift | 8 +- Tests/HookHandlerTests.swift | 2 +- Tests/ProcessMonitorTests.swift | 8 +- Tests/QuotaMonitorTests.swift | 2 +- Tests/SessionStateTests.swift | 64 +++---- Tests/SidebarGroupTests.swift | 143 ++++++++-------- Tests/SidebarGroupViewTests.swift | 8 +- Tests/WindowControllerLogicTests.swift | 42 ++--- 21 files changed, 382 insertions(+), 383 deletions(-) diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index 27b2bcb..e32d9ab 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -219,10 +219,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { closeItem.setShortcut(for: .closeTab) fileMenu.addItem(closeItem) - let newFolderItem = NSMenuItem(title: "New Group", action: #selector(createNewSidebarGroup), keyEquivalent: "") - newFolderItem.setShortcut(for: .newGroup) - 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 Group", action: #selector(moveCurrentWorkspaceOutOfGroup), keyEquivalent: "") moveOutItem.setShortcut(for: .moveOutOfGroup) @@ -233,9 +233,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { exploreSessionsItem.setShortcut(for: .exploreSessions) fileMenu.addItem(exploreSessionsItem) - let closeProjectItem = NSMenuItem(title: "Close Workspace", action: #selector(closeCurrentWorkspace), keyEquivalent: "") - closeProjectItem.setShortcut(for: .closeWorkspace) - 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: "") @@ -300,14 +300,14 @@ class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - Actions - private let projectPicker = WorkspacePicker() + private let workspacePicker = WorkspacePicker() func openWorkspacePicker() { openWorkspace() } @objc private func openWorkspace() { - projectPicker.show(relativeTo: windowController?.window) { [weak self] path in + workspacePicker.show(relativeTo: windowController?.window) { [weak self] path in guard let path = path else { return } self?.windowController?.openWorkspace(path: path) } diff --git a/Sources/App/ClaudeCLIFlags.swift b/Sources/App/ClaudeCLIFlags.swift index ae1b6cd..bbfbcac 100644 --- a/Sources/App/ClaudeCLIFlags.swift +++ b/Sources/App/ClaudeCLIFlags.swift @@ -264,8 +264,8 @@ final class CodexCLIFlags { /// Flags Deckard manages internally — excluded from suggestions. static let blocklist: Set = [ "--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/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/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/Window/DeckardWindowController.swift b/Sources/Window/DeckardWindowController.swift index 7a0da40..56bb814 100644 --- a/Sources/Window/DeckardWindowController.swift +++ b/Sources/Window/DeckardWindowController.swift @@ -168,7 +168,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { var workspaces: [WorkspaceItem] = [] var selectedWorkspaceIndex: Int = -1 - // Sidebar folders + // Sidebar groups var sidebarGroups: [SidebarGroup] = [] var sidebarOrder: [SidebarItem] = [] @@ -671,7 +671,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } else if let next = nextVisibleWorkspaceIndex(near: index) { selectWorkspace(at: next, autoExpandGroup: false) } else { - // All remaining workspaces are inside collapsed folders — show empty state. + // All remaining workspaces are inside collapsed groups — show empty state. selectedWorkspaceIndex = -1 currentTerminalView?.removeFromSuperview() currentTerminalView = nil @@ -842,11 +842,11 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { tabCreationOrder.append(tab.id) if kind == .codex && (tab.sessionId == nil || forkSession) { - scheduleCodexSessionDiscovery(forSurfaceId: tab.id, projectPath: workspace.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) @@ -1029,7 +1029,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } } - func selectTabInProject(at tabIndex: Int) { + func selectTabInWorkspace(at tabIndex: Int) { guard let workspace = currentWorkspace else { return } guard tabIndex >= 0, tabIndex < workspace.tabs.count else { return } workspace.selectedTabIndex = tabIndex @@ -1052,12 +1052,12 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { func selectNextTab() { guard let workspace = currentWorkspace, !workspace.tabs.isEmpty else { return } - selectTabInProject(at: (workspace.selectedTabIndex + 1) % workspace.tabs.count) + selectTabInWorkspace(at: (workspace.selectedTabIndex + 1) % workspace.tabs.count) } func selectPrevTab() { guard let workspace = currentWorkspace, !workspace.tabs.isEmpty else { return } - selectTabInProject(at: (workspace.selectedTabIndex - 1 + workspace.tabs.count) % workspace.tabs.count) + selectTabInWorkspace(at: (workspace.selectedTabIndex - 1 + workspace.tabs.count) % workspace.tabs.count) } var currentWorkspace: WorkspaceItem? { @@ -1147,11 +1147,11 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { let tabName = tab.name let tabId = tab.id - let projectPath = workspace.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 @@ -1181,14 +1181,14 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { let tabName = tab.name let tabId = tab.id let initialSessionId = tab.sessionId - let projectPath = workspace.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) @@ -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? } @@ -1246,11 +1246,11 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { for tab in workspace.tabs { tabInfos.append(ProcessMonitor.TabInfo( surfaceId: tab.id, kind: tab.kind, - name: tab.name, projectPath: workspace.path)) + name: tab.name, workspacePath: workspace.path)) if tab.kind == .codex { codexTargets.append(CodexBadgePollTarget( surfaceId: tab.id, - projectPath: workspace.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 @@ -1468,7 +1468,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { for (pi, workspace) in workspaces.enumerated() { if let ti = workspace.tabs.firstIndex(where: { $0.id == tabId }) { selectWorkspace(at: pi) - selectTabInProject(at: ti) + selectTabInWorkspace(at: ti) window?.makeKeyAndOrderFront(nil) return } @@ -1592,7 +1592,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { ) } - // Persist sidebar folders + // Persist sidebar groups state.sidebarGroups = sidebarGroups.map { group in SidebarGroupState( id: group.id.uuidString, @@ -1621,7 +1621,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { private func restoreOrCreateInitial() { guard let state = SessionManager.shared.load(), - let projectStates = state.workspaces, !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() @@ -1635,24 +1635,24 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { // 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 { workspace 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) } @@ -1672,7 +1672,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { // sees a working terminal right away. Collect remaining tabs for Phase 2. var pending: [(workspace: WorkspaceItem, tab: WorkspaceTabState, originalIndex: Int)] = [] - for (i, ps) in projectStates.enumerated() { + for (i, ps) in workspaceStates.enumerated() { let workspace = WorkspaceItem(path: ps.path) workspace.name = ps.name workspace.defaultArgs = ps.defaultArgs @@ -1703,7 +1703,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { // Keep isRestoring = true until Phase 2 finishes, so selectWorkspace // won't clamp selectedTabIndex before all tabs are inserted. - // Restore sidebar folders + // Restore sidebar groups restoreSidebarGroups(from: state) rebuildSidebar() @@ -1717,20 +1717,20 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { 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 projectStates) rather than + // 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 projectStates = state.workspaces else { return } - var savedIdToProject: [String: WorkspaceItem] = [:] - for (i, ps) in projectStates.enumerated() { + guard let workspaceStates = state.workspaces else { return } + var savedIdToWorkspace: [String: WorkspaceItem] = [:] + for (i, ps) in workspaceStates.enumerated() { guard i < workspaces.count else { continue } - savedIdToProject[ps.id] = workspaces[i] + savedIdToWorkspace[ps.id] = workspaces[i] } - // Restore folders + // 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 { savedIdToProject[$0]?.id } + let resolvedIds = fs.workspaceIds.compactMap { savedIdToWorkspace[$0]?.id } let group = SidebarGroup( id: groupId, name: fs.name, @@ -1751,7 +1751,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } return nil case .workspace(let idStr): - if let workspace = savedIdToProject[idStr] { + if let workspace = savedIdToWorkspace[idStr] { return .workspace(workspace.id) } return nil diff --git a/Sources/Window/SidebarController.swift b/Sources/Window/SidebarController.swift index e6e4eef..f7498c4 100644 --- a/Sources/Window/SidebarController.swift +++ b/Sources/Window/SidebarController.swift @@ -13,7 +13,7 @@ extension DeckardWindowController { sidebarOrder = workspaces.map { .workspace($0.id) } } - /// Remove a workspace from sidebarOrder and all folders' workspaceIds. + /// Remove a workspace from sidebarOrder and all groups' workspaceIds. func removeSidebarReference(workspaceId: UUID) { sidebarOrder.removeAll { item in if case .workspace(let id) = item, id == workspaceId { return true } @@ -65,10 +65,10 @@ 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 workspaceIndicesInSidebarOrder().prefix(10).enumerated() { - shortcutForProjectIndex[pi] = "\((pos + 1) % 10)" + shortcutForWorkspaceIndex[pi] = "\((pos + 1) % 10)" } } @@ -84,7 +84,7 @@ extension DeckardWindowController { let pi = workspaceIndex(forId: workspaceId) let row = VerticalTabRowView(title: workspace.name, bold: false, index: pi, target: self, action: #selector(workspaceRowClicked(_:))) - row.shortcutBadge = shortcutForProjectIndex[pi] + 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]) } @@ -110,15 +110,15 @@ extension DeckardWindowController { rowIndex += 1 case .group(let group): - // Folder header - let folderView = SidebarGroupView( + // Group header + let groupView = SidebarGroupView( group: group, - projectCount: group.workspaceIds.count + workspaceCount: group.workspaceIds.count ) - folderView.onToggle = { [weak self] fv in + 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.workspaces.count else { return } let workspace = self.workspaces[fromIndex] @@ -134,21 +134,21 @@ extension DeckardWindowController { } } } - folderView.badgeInfos = aggregatedBadges + groupView.badgeInfos = aggregatedBadges - folderView.onRename = { [weak self] newName in + groupView.onRename = { [weak self] newName in guard let self = self else { return } 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.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 workspaces inside the group (if not collapsed) @@ -159,7 +159,7 @@ extension DeckardWindowController { let row = VerticalTabRowView(title: workspace.name, bold: false, index: pi, target: self, action: #selector(workspaceRowClicked(_:))) row.indent = 16 - row.shortcutBadge = shortcutForProjectIndex[pi] + 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]) } @@ -190,10 +190,10 @@ extension DeckardWindowController { 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.onDropOntoGroup = { [weak self] folderView, fromIndex in - folderView.onDrop?(folderView, fromIndex) + sidebarStackView.onDropOntoGroup = { [weak self] groupView, fromIndex in + groupView.onDrop?(groupView, fromIndex) } sidebarStackView.onGroupReorder = { [weak self] fromRow, toRow in self?.handleGroupDragReorder(fromRow: fromRow, toRow: toRow) @@ -217,7 +217,7 @@ extension DeckardWindowController { guard let self else { return } // Move group to end of sidebarOrder let infos = self.sidebarRowInfos() - guard fromRow >= 0, fromRow < infos.count, infos[fromRow].isFolder, + 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 .group(let f) = $0, f.id == groupId { return true } @@ -265,12 +265,12 @@ 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: SidebarGroup? - var childIndexInFolder: Int? + var isGroup: Bool + var parentGroup: SidebarGroup? + var childIndexInGroup: Int? var workspaceId: UUID? var groupId: UUID? } @@ -281,19 +281,19 @@ extension DeckardWindowController { switch item { case .workspace(let pid): infos.append(SidebarRowInfo( - sidebarOrderIndex: orderIdx, isFolder: false, - parentFolder: nil, childIndexInFolder: nil, + 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, + 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: group, childIndexInFolder: ci, + 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 workspaces 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 < workspaces.count else { return } - let draggedProject = workspaces[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 = sidebarGroups.contains { $0.workspaceIds.contains(draggedProject.id) } - if wasInFolder { moveWorkspaceOutOfGroup(workspaceId: draggedProject.id) } - sidebarOrder.removeAll { if case .workspace(let id) = $0, id == draggedProject.id { return true }; return false } - sidebarOrder.append(.workspace(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 @@ -330,47 +330,47 @@ extension DeckardWindowController { // 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 effectiveFolder: SidebarGroup? + 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 { + 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 - effectiveFolder = prevFolder - effectiveChildIndex = prevFolder.workspaceIds.count + effectiveGroup = prevGroup + effectiveChildIndex = prevGroup.workspaceIds.count } else { - effectiveFolder = nil + effectiveGroup = nil effectiveChildIndex = nil } // Dropping between items inside the same group → reorder within group - let sourceFolder = sidebarGroups.first { $0.workspaceIds.contains(draggedProject.id) } - if let targetFolder = effectiveFolder, let sf = sourceFolder, sf.id == targetFolder.id { + 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: draggedProject.id), + guard let fromIdx = sf.workspaceIds.firstIndex(of: draggedWorkspace.id), let toIdx = effectiveChildIndex else { return } sf.workspaceIds.remove(at: fromIdx) let insertAt = toIdx > fromIdx ? min(toIdx - 1, sf.workspaceIds.count) : toIdx - sf.workspaceIds.insert(draggedProject.id, at: insertAt) + sf.workspaceIds.insert(draggedWorkspace.id, at: insertAt) rebuildSidebar() saveState() return } // Dropping between items inside a different group → move into that group at position - if let targetFolder = effectiveFolder { + if let targetGroup = effectiveGroup { // Remove from source group if needed - if let sf = sourceFolder { - sf.workspaceIds.removeAll { $0 == draggedProject.id } + if let sf = sourceGroup { + sf.workspaceIds.removeAll { $0 == draggedWorkspace.id } } else { // Remove from top-level sidebarOrder - sidebarOrder.removeAll { if case .workspace(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 group - let insertAt = toInfo.childIndexInFolder ?? targetFolder.workspaceIds.count - if !targetFolder.workspaceIds.contains(draggedProject.id) { - targetFolder.workspaceIds.insert(draggedProject.id, at: min(insertAt, targetFolder.workspaceIds.count)) + 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,17 +378,17 @@ extension DeckardWindowController { } // Dropping at top level — reorder in sidebarOrder - if let sf = sourceFolder { - sf.workspaceIds.removeAll { $0 == draggedProject.id } + 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 .workspace(let id) = $0, id == draggedProject.id { return true }; return false } - sidebarOrder.insert(.workspace(draggedProject.id), at: min(targetOrderIdx, sidebarOrder.count)) + 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 .workspace(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 .workspace(let id) = $0, id == targetPid { return true }; return false }) { @@ -399,7 +399,7 @@ extension DeckardWindowController { } // Also reorder in the flat workspaces array - let fromPi = fromProjectIndex + let fromPi = fromWorkspaceIndex if let pid = toInfo.workspaceId, let toPi = workspaces.firstIndex(where: { $0.id == pid }), fromPi != toPi { reorderWorkspace(from: fromPi, to: toPi) } else { @@ -421,8 +421,8 @@ extension DeckardWindowController { rebuildSidebar() saveState() // Start editing the name immediately - if let folderView = sidebarStackView.arrangedSubviews.compactMap({ $0 as? SidebarGroupView }).last { - folderView.startEditing() + if let groupView = sidebarStackView.arrangedSubviews.compactMap({ $0 as? SidebarGroupView }).last { + groupView.startEditing() } } @@ -476,11 +476,11 @@ extension DeckardWindowController { group.workspaceIds.removeAll { $0 == workspaceId } // Insert as ungrouped workspace right after the group in sidebarOrder - if let folderIdx = sidebarOrder.firstIndex(where: { + if let groupIdx = sidebarOrder.firstIndex(where: { if case .group(let f) = $0, f.id == group.id { return true } return false }) { - sidebarOrder.insert(.workspace(workspaceId), at: folderIdx + 1) + sidebarOrder.insert(.workspace(workspaceId), at: groupIdx + 1) } else { sidebarOrder.append(.workspace(workspaceId)) } @@ -510,7 +510,7 @@ extension DeckardWindowController { /// `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, + guard fromRow >= 0, fromRow < infos.count, infos[fromRow].isGroup, let groupId = infos[fromRow].groupId else { return } // Find the group's index in sidebarOrder @@ -597,9 +597,9 @@ extension DeckardWindowController { menu.addItem(.separator()) // Group options - let isInFolder = sidebarGroups.contains { $0.workspaceIds.contains(workspace.id) } + let isInGroup = sidebarGroups.contains { $0.workspaceIds.contains(workspace.id) } - if isInFolder { + if isInGroup { let moveOutItem = NSMenuItem(title: "Move Out of Group", action: #selector(moveWorkspaceOutOfGroupAction(_:)), keyEquivalent: "") moveOutItem.setShortcut(for: .moveOutOfGroup) moveOutItem.target = self @@ -620,10 +620,10 @@ extension DeckardWindowController { menu.addItem(.separator()) - let newFolderItem = NSMenuItem(title: "New Group", action: #selector(newGroupMenuAction), keyEquivalent: "") - newFolderItem.setShortcut(for: .newGroup) - 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()) @@ -680,8 +680,8 @@ extension DeckardWindowController { } let explorer = SessionExplorerWindowController( - projectPath: workspace.path, - projectName: workspace.name + workspacePath: workspace.path, + workspaceName: workspace.name ) explorer.openSessionIds = Set(workspace.tabs.compactMap { $0.sessionCacheKey }) explorer.onSessionAction = { [weak self] kind, sessionId, fork, tabName in @@ -753,7 +753,7 @@ extension DeckardWindowController { // MARK: - Sidebar Selection func updateSidebarSelection() { - guard let currentProjectId = currentWorkspace?.id else { + guard let currentWorkspaceId = currentWorkspace?.id else { for view in sidebarStackView.arrangedSubviews { if let row = view as? VerticalTabRowView { row.isSelected = false @@ -766,7 +766,7 @@ extension DeckardWindowController { 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(currentProjectId) && fv.group.isCollapsed + fv.isContainingSelected = fv.group.workspaceIds.contains(currentWorkspaceId) && fv.group.isCollapsed } } } diff --git a/Sources/Window/SidebarViews.swift b/Sources/Window/SidebarViews.swift index be2f108..586387b 100644 --- a/Sources/Window/SidebarViews.swift +++ b/Sources/Window/SidebarViews.swift @@ -26,7 +26,7 @@ class VerticalTabRowView: NSView, NSTextFieldDelegate, NSDraggingSource { private var dragStartPoint: NSPoint? private var leadingConstraint: NSLayoutConstraint? - /// Leading indent (used for workspaces inside folders). + /// Leading indent (used for workspaces inside groups). var indent: CGFloat = 0 { didSet { leadingConstraint?.constant = 8 + indent } } @@ -317,7 +317,7 @@ class SidebarGroupView: NSView, NSTextFieldDelegate, NSDraggingSource { didSet { needsDisplay = true } } - init(group: SidebarGroup, projectCount: Int) { + init(group: SidebarGroup, workspaceCount: Int) { self.group = group disclosureImageView = NSImageView() @@ -572,7 +572,7 @@ class SidebarDropZone: NSView { // MARK: - ReorderableStackView /// NSStackView subclass that accepts drops for reordering. -/// Supports workspace drag (reorder/drop onto group) and group 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 onDropOntoGroup: ((SidebarGroupView, Int) -> Void)? @@ -602,7 +602,7 @@ class ReorderableStackView: NSStackView { /// 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) -> SidebarGroupView? { + private func groupView(at sender: NSDraggingInfo) -> SidebarGroupView? { let location = convert(sender.draggingLocation, from: nil) let edgeInset: CGFloat = 6 for view in arrangedSubviews { @@ -616,7 +616,7 @@ class ReorderableStackView: NSStackView { return nil } - private func clearFolderHighlight() { + private func clearGroupHighlight() { if let prev = highlightedGroup { prev.isDropTarget = false highlightedGroup = nil @@ -668,44 +668,44 @@ class ReorderableStackView: NSStackView { dropIndicator.isHidden = true currentDropIndex = -1 currentDropForceFullWidth = false - clearFolderHighlight() + clearGroupHighlight() } - private func acceptsProjectDrag(_ sender: NSDraggingInfo) -> Bool { + private func acceptsWorkspaceDrag(_ sender: NSDraggingInfo) -> Bool { sender.draggingPasteboard.types?.contains(deckardWorkspaceDragType) == true } - private func acceptsFolderDrag(_ sender: NSDraggingInfo) -> Bool { + 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 [] } /// Group drag: only show indicator between top-level items (not inside groups). - private func updateFolderDrag(_ sender: NSDraggingInfo) -> NSDragOperation { + 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 + /// 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) @@ -740,19 +740,19 @@ class ReorderableStackView: NSStackView { } /// Common logic for workspace drag: highlight group or show line indicator. - private func updateProjectDrag(_ sender: NSDraggingInfo) -> NSDragOperation { - if let fv = folderView(at: sender) { + 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 highlightedGroup !== fv { - clearFolderHighlight() + clearGroupHighlight() fv.isDropTarget = true highlightedGroup = fv } } else { // Not over a group — show the line indicator - clearFolderHighlight() + clearGroupHighlight() let idx = dropIndex(for: sender) // At the boundary between the last child of an expanded group // and the next non-indented row, use cursor Y to disambiguate: @@ -781,7 +781,7 @@ class ReorderableStackView: NSStackView { } override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { - let wasOnFolder = highlightedGroup + let wasOnGroup = highlightedGroup let wasForceFullWidth = currentDropForceFullWidth hideIndicator() @@ -789,7 +789,7 @@ class ReorderableStackView: NSStackView { if let fromStr = sender.draggingPasteboard.string(forType: deckardWorkspaceDragType), let fromIndex = Int(fromStr) { // If dropped on a highlighted group, route to group drop handler - if let fv = wasOnFolder { + if let fv = wasOnGroup { onDropOntoGroup?(fv, fromIndex) return true } diff --git a/Sources/Window/TabBarController.swift b/Sources/Window/TabBarController.swift index e1aeb42..41bd2c4 100644 --- a/Sources/Window/TabBarController.swift +++ b/Sources/Window/TabBarController.swift @@ -138,7 +138,7 @@ extension DeckardWindowController { } @objc func tabBarClicked(_ sender: HorizontalTabView) { - selectTabInProject(at: sender.index) + selectTabInWorkspace(at: sender.index) } @objc func tabBarCloseClicked(_ sender: NSButton) { diff --git a/Sources/Window/WorkspacePicker.swift b/Sources/Window/WorkspacePicker.swift index fedff5f..c9e183f 100644 --- a/Sources/Window/WorkspacePicker.swift +++ b/Sources/Window/WorkspacePicker.swift @@ -388,7 +388,7 @@ class WorkspacePicker: NSObject, NSTableViewDataSource, NSTableViewDelegate, NST // MARK: - NSTableViewDelegate func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { - let id = NSUserInterfaceItemIdentifier("ProjectCell") + let id = NSUserInterfaceItemIdentifier("WorkspaceCell") let workspace = filteredWorkspaces[row] let cell: NSTableCellView @@ -427,7 +427,7 @@ class WorkspacePicker: NSObject, NSTableViewDataSource, NSTableViewDelegate, NST confirm() } - // MARK: - Load Projects + // MARK: - Load Workspaces static func loadRecentWorkspaces() -> [(path: String, lastUsed: Date)] { let projectsDir = NSHomeDirectory() + "/.claude/projects" diff --git a/Tests/ContextMonitorTests.swift b/Tests/ContextMonitorTests.swift index bbeb5cf..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,17 +501,17 @@ 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) // WorkspaceItem resolves symlinks; claudeProjectDirName should agree - let project = WorkspaceItem(path: linkDir) - let encoded = project.path.claudeProjectDirName + let workspace = WorkspaceItem(path: linkDir) + let encoded = workspace.path.claudeProjectDirName XCTAssertEqual(encoded, realDir.claudeProjectDirName, "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 89c081d..c25cb49 100644 --- a/Tests/SessionStateTests.swift +++ b/Tests/SessionStateTests.swift @@ -9,12 +9,12 @@ final class SessionStateTests: XCTestCase { var state = DeckardState() state.version = 2 state.selectedTabIndex = 3 - state.defaultWorkingDirectory = "/Users/test/project" + 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: [ WorkspaceTabState(id: "tab-1", name: "Claude", isClaude: true, sessionId: "sess-1"), @@ -32,7 +32,7 @@ final class SessionStateTests: XCTestCase { XCTAssertEqual(decoded.version, 2) XCTAssertEqual(decoded.selectedTabIndex, 3) - XCTAssertEqual(decoded.defaultWorkingDirectory, "/Users/test/project") + 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) @@ -57,7 +57,7 @@ final class SessionStateTests: XCTestCase { XCTAssertNil(decoded.workspaces) } - func testMultipleProjectsRoundtrip() throws { + func testMultipleWorkspacesRoundtrip() throws { var state = DeckardState() state.workspaces = [ WorkspaceState(id: "p1", path: "/path/a", name: "a", selectedTabIndex: 0, tabs: []), @@ -228,11 +228,11 @@ final class SessionStateTests: XCTestCase { // MARK: - Symlink path restoration - func testProjectStatePathSurvivesRoundtripViaProjectItem() throws { + 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) @@ -240,7 +240,7 @@ final class SessionStateTests: XCTestCase { // Save state using symlink path (as old Deckard would) var state = DeckardState() state.workspaces = [ - WorkspaceState(id: "p1", path: linkDir, name: "linked-project", + WorkspaceState(id: "p1", path: linkDir, name: "linked-workspace", selectedTabIndex: 0, tabs: [ WorkspaceTabState(id: "t1", name: "Claude", isClaude: true, sessionId: "sess-1") ]) @@ -252,34 +252,34 @@ final class SessionStateTests: XCTestCase { // Simulate restoreOrCreateInitial: WorkspaceItem resolves the path let ps = restored.workspaces![0] - let project = WorkspaceItem(path: ps.path) + let workspace = WorkspaceItem(path: ps.path) // The resolved path should match the canonical path - XCTAssertEqual(project.path, realDir, + 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 WorkspaceItem.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, + 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 = WorkspaceItem(path: linkDir) + let workspace = WorkspaceItem(path: linkDir) // Simulate what captureState() does let saved = WorkspaceState( - id: project.id.uuidString, - path: project.path, - name: project.name, + id: workspace.id.uuidString, + path: workspace.path, + name: workspace.name, selectedTabIndex: 0, tabs: [] ) @@ -291,32 +291,32 @@ final class SessionStateTests: XCTestCase { 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 = WorkspaceState( - id: "p1", path: linkDir, name: "linked-project", + let oldWorkspaceState = WorkspaceState( + id: "p1", path: linkDir, name: "linked-workspace", selectedTabIndex: 0, tabs: [] ) // New WorkspaceItem (opened via symlink, but path is resolved) - let project = WorkspaceItem(path: linkDir) + let workspace = WorkspaceItem(path: linkDir) // restoreSidebarGroups resolves ps.path before comparison - let resolvedOldPath = (oldProjectState.path as NSString).resolvingSymlinksInPath - XCTAssertEqual(project.path, resolvedOldPath, + 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 = WorkspaceState( - 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/SidebarGroupTests.swift b/Tests/SidebarGroupTests.swift index 1a33da8..c7358a6 100644 --- a/Tests/SidebarGroupTests.swift +++ b/Tests/SidebarGroupTests.swift @@ -7,8 +7,8 @@ final class SidebarGroupTests: XCTestCase { func testSidebarGroupStateRoundtrip() throws { let state = SidebarGroupState( - id: "folder-1", - name: "My Folder", + id: "group-1", + name: "My Group", isCollapsed: true, workspaceIds: ["proj-a", "proj-b", "proj-c"] ) @@ -16,16 +16,16 @@ final class SidebarGroupTests: XCTestCase { let data = try JSONEncoder().encode(state) let decoded = try JSONDecoder().decode(SidebarGroupState.self, from: data) - XCTAssertEqual(decoded.id, "folder-1") - XCTAssertEqual(decoded.name, "My Folder") + 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: "folder-empty", - name: "Empty Folder", + id: "group-empty", + name: "Empty Group", isCollapsed: false, workspaceIds: [] ) @@ -33,8 +33,8 @@ final class SidebarGroupTests: XCTestCase { let data = try JSONEncoder().encode(state) let decoded = try JSONDecoder().decode(SidebarGroupState.self, from: data) - XCTAssertEqual(decoded.id, "folder-empty") - XCTAssertEqual(decoded.name, "Empty Folder") + XCTAssertEqual(decoded.id, "group-empty") + XCTAssertEqual(decoded.name, "Empty Group") XCTAssertFalse(decoded.isCollapsed) XCTAssertEqual(decoded.workspaceIds, []) } @@ -42,13 +42,13 @@ final class SidebarGroupTests: XCTestCase { // MARK: - SidebarOrderItem Codable roundtrips func testSidebarOrderItemGroupRoundtrip() throws { - let item = SidebarOrderItem.group("folder-abc") + 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, "folder-abc") + XCTAssertEqual(id, "group-abc") } else { XCTFail("Expected .group case, got \(decoded)") } @@ -63,7 +63,7 @@ final class SidebarGroupTests: XCTestCase { if case .workspace(let id) = decoded { XCTAssertEqual(id, "proj-xyz") } else { - XCTFail("Expected .project case, got \(decoded)") + XCTFail("Expected .workspace case, got \(decoded)") } } @@ -100,7 +100,7 @@ final class SidebarGroupTests: XCTestCase { XCTAssertEqual(dict?["id"], "p1") } - // MARK: - DeckardState with folders + // MARK: - DeckardState with groups func testDeckardStateWithGroupsRoundtrip() throws { var state = DeckardState() @@ -139,7 +139,7 @@ final class SidebarGroupTests: XCTestCase { if case .workspace(let id) = decoded.sidebarOrder?[1] { XCTAssertEqual(id, "p4") } else { - XCTFail("Expected .project at index 1") + XCTFail("Expected .workspace at index 1") } if case .group(let id) = decoded.sidebarOrder?[2] { XCTAssertEqual(id, "f2") @@ -167,14 +167,14 @@ final class SidebarGroupTests: XCTestCase { func testDeckardStateMixedSidebarOrder() throws { var state = DeckardState() state.sidebarGroups = [ - SidebarGroupState(id: "f1", name: "Folder", isCollapsed: false, workspaceIds: []) + SidebarGroupState(id: "f1", name: "Group", isCollapsed: false, workspaceIds: []) ] state.sidebarOrder = [ .workspace("p1"), .group("f1"), .workspace("p2"), .workspace("p3"), - .group("f1"), // duplicate folder reference (edge case) + .group("f1"), // duplicate group reference (edge case) .workspace("p4"), ] @@ -184,15 +184,15 @@ final class SidebarGroupTests: XCTestCase { XCTAssertEqual(decoded.sidebarOrder?.count, 6) // Verify alternating types - if case .workspace = decoded.sidebarOrder?[0] {} else { XCTFail("Expected .project at 0") } + 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 .project at 2") } - if case .workspace = decoded.sidebarOrder?[3] {} else { XCTFail("Expected .project at 3") } + 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 .project at 5") } + if case .workspace = decoded.sidebarOrder?[5] {} else { XCTFail("Expected .workspace at 5") } } - func testDeckardStateEmptyFoldersAndOrder() throws { + func testDeckardStateEmptyGroupsAndOrder() throws { var state = DeckardState() state.sidebarGroups = [] state.sidebarOrder = [] @@ -207,88 +207,88 @@ final class SidebarGroupTests: XCTestCase { // MARK: - SidebarGroup data model func testSidebarGroupInitDefaults() { - let folder = SidebarGroup(name: "Test Folder") + let group = SidebarGroup(name: "Test Group") - XCTAssertEqual(folder.name, "Test Folder") - XCTAssertFalse(folder.isCollapsed) - XCTAssertEqual(folder.workspaceIds, []) - XCTAssertNotEqual(folder.id, UUID()) // has a valid UUID + XCTAssertEqual(group.name, "Test Group") + XCTAssertFalse(group.isCollapsed) + XCTAssertEqual(group.workspaceIds, []) + XCTAssertNotEqual(group.id, UUID()) // has a valid UUID } func testSidebarGroupWorkspaceIdsAddRemove() { - let folder = SidebarGroup(name: "Folder") + let group = SidebarGroup(name: "Group") let id1 = UUID() let id2 = UUID() let id3 = UUID() - folder.workspaceIds.append(id1) - folder.workspaceIds.append(id2) - folder.workspaceIds.append(id3) - XCTAssertEqual(folder.workspaceIds.count, 3) - XCTAssertEqual(folder.workspaceIds, [id1, id2, id3]) + group.workspaceIds.append(id1) + group.workspaceIds.append(id2) + group.workspaceIds.append(id3) + XCTAssertEqual(group.workspaceIds.count, 3) + XCTAssertEqual(group.workspaceIds, [id1, id2, id3]) - folder.workspaceIds.removeAll { $0 == id2 } - XCTAssertEqual(folder.workspaceIds.count, 2) - XCTAssertEqual(folder.workspaceIds, [id1, id3]) + group.workspaceIds.removeAll { $0 == id2 } + XCTAssertEqual(group.workspaceIds.count, 2) + XCTAssertEqual(group.workspaceIds, [id1, id3]) - folder.workspaceIds.removeAll() - XCTAssertEqual(folder.workspaceIds.count, 0) + group.workspaceIds.removeAll() + XCTAssertEqual(group.workspaceIds.count, 0) } func testSidebarGroupIsCollapsedToggle() { - let folder = SidebarGroup(name: "Folder") - XCTAssertFalse(folder.isCollapsed) + let group = SidebarGroup(name: "Group") + XCTAssertFalse(group.isCollapsed) - folder.isCollapsed.toggle() - XCTAssertTrue(folder.isCollapsed) + group.isCollapsed.toggle() + XCTAssertTrue(group.isCollapsed) - folder.isCollapsed.toggle() - XCTAssertFalse(folder.isCollapsed) + group.isCollapsed.toggle() + XCTAssertFalse(group.isCollapsed) } func testSidebarGroupFullInit() { let id = UUID() let pid1 = UUID() let pid2 = UUID() - let folder = SidebarGroup(id: id, name: "Custom", isCollapsed: true, workspaceIds: [pid1, pid2]) + let group = SidebarGroup(id: id, name: "Custom", isCollapsed: true, workspaceIds: [pid1, pid2]) - XCTAssertEqual(folder.id, id) - XCTAssertEqual(folder.name, "Custom") - XCTAssertTrue(folder.isCollapsed) - XCTAssertEqual(folder.workspaceIds, [pid1, pid2]) + XCTAssertEqual(group.id, id) + XCTAssertEqual(group.name, "Custom") + XCTAssertTrue(group.isCollapsed) + XCTAssertEqual(group.workspaceIds, [pid1, pid2]) } // MARK: - SidebarItem enum - func testSidebarItemFolderCase() { - let folder = SidebarGroup(name: "Test") - let item = SidebarItem.group(folder) + func testSidebarItemGroupCase() { + let group = SidebarGroup(name: "Test") + let item = SidebarItem.group(group) if case .group(let f) = item { - XCTAssertTrue(f === folder) // same reference + XCTAssertTrue(f === group) // same reference XCTAssertEqual(f.name, "Test") } else { XCTFail("Expected .group case") } } - func testSidebarItemProjectCase() { + func testSidebarItemWorkspaceCase() { let workspaceId = UUID() let item = SidebarItem.workspace(workspaceId) if case .workspace(let id) = item { XCTAssertEqual(id, workspaceId) } else { - XCTFail("Expected .project case") + XCTFail("Expected .workspace case") } } - func testSidebarItemFolderMutationThroughReference() { - let folder = SidebarGroup(name: "Before") - let item = SidebarItem.group(folder) + func testSidebarItemGroupMutationThroughReference() { + let group = SidebarGroup(name: "Before") + let item = SidebarItem.group(group) - // Mutating the folder should be visible through the enum - folder.name = "After" + // Mutating the group should be visible through the enum + group.name = "After" if case .group(let f) = item { XCTAssertEqual(f.name, "After") @@ -368,11 +368,11 @@ final class SidebarGroupTests: XCTestCase { XCTAssertEqual(decoded.name, "Work / Personal (2024) & More \u{1F4C1}") } - func testSidebarGroupStateManyProjectIds() throws { + func testSidebarGroupStateManyWorkspaceIds() throws { let ids = (0..<100).map { "proj-\($0)" } let state = SidebarGroupState( id: "f-large", - name: "Large Folder", + name: "Large Group", isCollapsed: false, workspaceIds: ids ) @@ -405,16 +405,16 @@ final class SidebarGroupTests: XCTestCase { else { XCTFail("Expected .group at 0") } if case .workspace(let id) = decoded[1] { XCTAssertEqual(id, "p1") } - else { XCTFail("Expected .project at 1") } + else { XCTFail("Expected .workspace at 1") } if case .workspace(let id) = decoded[2] { XCTAssertEqual(id, "p2") } - else { XCTFail("Expected .project at 2") } + 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 .project at 4") } + else { XCTFail("Expected .workspace at 4") } } // MARK: - Legacy v2 state.json migration @@ -484,7 +484,8 @@ final class SidebarGroupTests: XCTestCase { XCTAssertTrue(reencodedString.contains("\"workspaces\"")) XCTAssertTrue(reencodedString.contains("\"sidebarGroups\"")) XCTAssertTrue(reencodedString.contains("\"workspaceIds\"")) - XCTAssertTrue(reencodedString.contains("\"group\"")) + // 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\"")) @@ -507,15 +508,15 @@ final class SidebarGroupTests: XCTestCase { 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 .project at 1") } + 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"), - // never the legacy ones — otherwise downgrade would silently work and obscure - // when the migration happened. + // 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")] @@ -524,10 +525,8 @@ final class SidebarGroupTests: XCTestCase { let json = String(data: data, encoding: .utf8) ?? "" XCTAssertTrue(json.contains("\"sidebarGroups\"")) XCTAssertFalse(json.contains("\"sidebarFolders\"")) - XCTAssertTrue(json.contains("\"group\"")) - // Sanity: the discriminator value "folder" should not appear in encoded output. - // (We can't assert it's totally absent because group names could include the word, - // so check the specific key/value pair shape.) + 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/SidebarGroupViewTests.swift b/Tests/SidebarGroupViewTests.swift index 598480d..4ca0256 100644 --- a/Tests/SidebarGroupViewTests.swift +++ b/Tests/SidebarGroupViewTests.swift @@ -12,9 +12,9 @@ final class SidebarGroupViewTests: XCTestCase { collapsed: Bool = false, origin: NSPoint = NSPoint(x: 0, y: 50) ) -> SidebarGroupView { - let group = SidebarGroup(name: "Test Folder") + let group = SidebarGroup(name: "Test Group") group.isCollapsed = collapsed - let view = SidebarGroupView(group: group, projectCount: 2) + 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)) @@ -282,7 +282,7 @@ final class SidebarGroupViewTests: XCTestCase { // Simulate the guard logic from groupToggleClicked. group.isCollapsed.toggle() - // Guard: if collapsing a group that contains the selected project, force expand. + // 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 @@ -300,7 +300,7 @@ final class SidebarGroupViewTests: XCTestCase { group.isCollapsed = false group.isCollapsed.toggle() - // Guard: selected project is NOT in this group. + // Guard: selected workspace is NOT in this group. let selectedWorkspaceId = otherWorkspaceId if group.isCollapsed, group.workspaceIds.contains(selectedWorkspaceId) { group.isCollapsed = false diff --git a/Tests/WindowControllerLogicTests.swift b/Tests/WindowControllerLogicTests.swift index a93b3b2..12877eb 100644 --- a/Tests/WindowControllerLogicTests.swift +++ b/Tests/WindowControllerLogicTests.swift @@ -69,48 +69,48 @@ final class WindowControllerLogicTests: XCTestCase { // MARK: - WorkspaceItem func testWorkspaceItemInit() { - let project = WorkspaceItem(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) + 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 testWorkspaceItemNameIsBasename() { - let project = WorkspaceItem(path: "/a/b/c/deep-folder") - XCTAssertEqual(project.name, "deep-folder") + let workspace = WorkspaceItem(path: "/a/b/c/deep-group") + XCTAssertEqual(workspace.name, "deep-group") } // MARK: - WorkspaceItem symlink resolution 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 = WorkspaceItem(path: linkDir) - XCTAssertEqual(project.path, realDir, "WorkspaceItem 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 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 = WorkspaceItem(path: realDir) - XCTAssertEqual(project.path, realDir) + let workspace = WorkspaceItem(path: realDir) + XCTAssertEqual(workspace.path, realDir) } 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) @@ -118,12 +118,12 @@ final class WindowControllerLogicTests: XCTestCase { 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 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 = WorkspaceItem(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