From d7b2d1f53f966c22e82f46269560a984d8f7d12b Mon Sep 17 00:00:00 2001 From: andrekat Date: Tue, 17 Mar 2026 18:59:54 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20add=20Profiles=20=E2=80=94=20save=20and?= =?UTF-8?q?=20restore=20workspace=20layouts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Profiles feature that lets users save their current workspace layout as a named profile (e.g. "Work", "Personal") and restore it later in the current window or a new one. - ProfileStore: save/load/delete/rename/list profiles as JSON in ~/Library/Application Support/cmux/profiles/ - Menu: File > Workspace > Profiles submenu with save, load, delete - Sidebar badge: shows active profile name inline next to help button - Auto-save: active profiles auto-save on the existing 8s timer and at app termination (with scrollback) - Socket commands: profile.list/save/load/delete (v1 and v2) - 18 unit tests covering model, persistence, and TabManager integration --- GhosttyTabs.xcodeproj/project.pbxproj | 8 + Sources/AppDelegate.swift | 16 ++ Sources/ContentView.swift | 48 +++- Sources/ProfileStore.swift | 196 +++++++++++++++ Sources/TabManager.swift | 8 + Sources/TerminalController.swift | 233 ++++++++++++++++++ Sources/cmuxApp.swift | 178 ++++++++++++++ cmuxTests/ProfileStoreTests.swift | 333 ++++++++++++++++++++++++++ 8 files changed, 1015 insertions(+), 5 deletions(-) create mode 100644 Sources/ProfileStore.swift create mode 100644 cmuxTests/ProfileStoreTests.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 1571043e30c0..8c05890a23ea 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -65,6 +65,8 @@ A5001209 /* WindowToolbarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001219 /* WindowToolbarController.swift */; }; A5001240 /* WindowDecorationsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001241 /* WindowDecorationsController.swift */; }; A5001610 /* SessionPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001611 /* SessionPersistence.swift */; }; + A5009920 /* ProfileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5009921 /* ProfileStore.swift */; }; + A5009930 /* ProfileStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5009931 /* ProfileStoreTests.swift */; }; A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; }; A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; }; B9000002A1B2C3D4E5F60719 /* cmux.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* cmux.swift */; }; @@ -215,6 +217,8 @@ A5001223 /* UpdateLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateLogStore.swift; sourceTree = ""; }; A5001241 /* WindowDecorationsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDecorationsController.swift; sourceTree = ""; }; A5001611 /* SessionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistence.swift; sourceTree = ""; }; + A5009921 /* ProfileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStore.swift; sourceTree = ""; }; + A5009931 /* ProfileStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStoreTests.swift; sourceTree = ""; }; 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = ""; }; B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = ""; }; C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = ""; }; @@ -416,6 +420,7 @@ A5001241 /* WindowDecorationsController.swift */, A5001222 /* WindowAccessor.swift */, A5001611 /* SessionPersistence.swift */, + A5009921 /* ProfileStore.swift */, ); path = Sources; sourceTree = ""; @@ -489,6 +494,7 @@ FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */, A5008380 /* BrowserFindJavaScriptTests.swift */, A5008382 /* CommandPaletteSearchEngineTests.swift */, + A5009931 /* ProfileStoreTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -692,6 +698,7 @@ A5001240 /* WindowDecorationsController.swift in Sources */, A500120C /* WindowAccessor.swift in Sources */, A5001610 /* SessionPersistence.swift in Sources */, + A5009920 /* ProfileStore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -733,6 +740,7 @@ FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */, A5008381 /* BrowserFindJavaScriptTests.swift in Sources */, A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */, + A5009930 /* ProfileStoreTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 26b1d4d4c6ac..6d8e6730801f 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2371,12 +2371,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { isTerminatingApp = true _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + autosaveActiveProfiles(includeScrollback: true) return .terminateNow } func applicationWillTerminate(_ notification: Notification) { isTerminatingApp = true _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + autosaveActiveProfiles(includeScrollback: true) stopSessionAutosaveTimer() TerminalController.shared.stop() VSCodeServeWebController.shared.stop() @@ -3214,6 +3216,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let saveStart = ProcessInfo.processInfo.systemUptime #endif _ = saveSessionSnapshot(includeScrollback: false) + autosaveActiveProfiles(includeScrollback: true) #if DEBUG saveMs = (ProcessInfo.processInfo.systemUptime - saveStart) * 1000.0 #endif @@ -3224,6 +3227,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } + /// Auto-saves any active profiles so changes are persisted without explicit user action. + private func autosaveActiveProfiles(includeScrollback: Bool = false) { + let contexts = Array(mainWindowContexts.values) + for context in contexts { + guard let profileName = context.tabManager.activeProfileName else { continue } + _ = ProfileStore.saveCurrentSession( + name: profileName, + tabManager: context.tabManager, + includeScrollback: includeScrollback + ) + } + } + fileprivate func recordTypingActivity() { lastTypingActivityAt = ProcessInfo.processInfo.systemUptime } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 38e908f508c6..ede16157ac1d 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -8922,27 +8922,60 @@ private final class SidebarShortcutHintModifierMonitor: ObservableObject { private struct SidebarFooter: View { @ObservedObject var updateViewModel: UpdateViewModel let onSendFeedback: () -> Void + @EnvironmentObject var tabManager: TabManager var body: some View { #if DEBUG SidebarDevFooter(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) #else - SidebarFooterButtons(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) - .padding(.leading, 6) - .padding(.trailing, 10) - .padding(.bottom, 6) + SidebarFooterButtons( + updateViewModel: updateViewModel, + onSendFeedback: onSendFeedback, + activeProfileName: tabManager.activeProfileName + ) + .padding(.leading, 6) + .padding(.trailing, 10) + .padding(.bottom, 6) #endif } } +private struct SidebarActiveProfileBadge: View { + let name: String + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + HStack(spacing: 3) { + Image(systemName: "person.crop.rectangle.stack") + .font(.system(size: 9, weight: .medium)) + Text(name) + .font(.system(size: 10, weight: .medium)) + .lineLimit(1) + .truncationMode(.tail) + } + .foregroundStyle(.secondary) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(Color.primary.opacity(colorScheme == .dark ? 0.08 : 0.06)) + ) + } +} + private struct SidebarFooterButtons: View { @ObservedObject var updateViewModel: UpdateViewModel let onSendFeedback: () -> Void + var activeProfileName: String? = nil var body: some View { HStack(spacing: 4) { SidebarHelpMenuButton(onSendFeedback: onSendFeedback) UpdatePill(model: updateViewModel) + Spacer(minLength: 0) + if let name = activeProfileName { + SidebarActiveProfileBadge(name: name) + } } .frame(maxWidth: .infinity, alignment: .leading) } @@ -10057,12 +10090,17 @@ private struct SidebarFooterIconButtonStyleBody: View { private struct SidebarDevFooter: View { @ObservedObject var updateViewModel: UpdateViewModel let onSendFeedback: () -> Void + @EnvironmentObject var tabManager: TabManager @AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey) private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner var body: some View { VStack(alignment: .leading, spacing: 6) { - SidebarFooterButtons(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) + SidebarFooterButtons( + updateViewModel: updateViewModel, + onSendFeedback: onSendFeedback, + activeProfileName: tabManager.activeProfileName + ) if showSidebarDevBuildBanner { Text(String(localized: "debug.devBuildBanner.title", defaultValue: "THIS IS A DEV BUILD")) .font(.system(size: 11, weight: .semibold)) diff --git a/Sources/ProfileStore.swift b/Sources/ProfileStore.swift new file mode 100644 index 000000000000..c15667379717 --- /dev/null +++ b/Sources/ProfileStore.swift @@ -0,0 +1,196 @@ +import Foundation + +// MARK: - Profile Model + +struct Profile: Codable, Identifiable, Sendable { + var id: UUID + var name: String + var createdAt: Date + var updatedAt: Date + var snapshot: SessionTabManagerSnapshot + + init(name: String, snapshot: SessionTabManagerSnapshot) { + self.id = UUID() + self.name = name + self.createdAt = Date() + self.updatedAt = Date() + self.snapshot = snapshot + } +} + +// MARK: - Profile Store + +enum ProfileStore { + static let maxProfiles = 24 + + /// Lists all saved profiles sorted by name. + static func list() -> [Profile] { + guard let directory = profilesDirectory() else { return [] } + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: directory.path) else { return [] } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + + var profiles: [Profile] = [] + guard let enumerator = fileManager.enumerator( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants] + ) else { return [] } + + for case let fileURL as URL in enumerator { + guard fileURL.pathExtension == "json" else { continue } + guard let data = try? Data(contentsOf: fileURL), + let profile = try? decoder.decode(Profile.self, from: data) else { + continue + } + profiles.append(profile) + } + + return profiles.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + /// Loads a profile by name. + static func load(name: String) -> Profile? { + guard let fileURL = profileFileURL(for: name) else { return nil } + guard let data = try? Data(contentsOf: fileURL) else { return nil } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + return try? decoder.decode(Profile.self, from: data) + } + + /// Loads a profile by ID. + static func load(id: UUID) -> Profile? { + list().first { $0.id == id } + } + + /// Saves a profile. Overwrites any existing profile with the same name. + /// Returns true on success. + @discardableResult + static func save(_ profile: Profile) -> Bool { + guard let directory = profilesDirectory() else { return false } + let fileManager = FileManager.default + + do { + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + } catch { + return false + } + + // Enforce max profile limit (don't count existing profile being overwritten). + let existing = list() + let isOverwrite = existing.contains { $0.name == profile.name } + if !isOverwrite && existing.count >= maxProfiles { + return false + } + + guard let fileURL = profileFileURL(for: profile.name) else { return false } + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + encoder.outputFormatting = [.sortedKeys] + + do { + let data = try encoder.encode(profile) + try data.write(to: fileURL, options: .atomic) + return true + } catch { + return false + } + } + + /// Saves the current TabManager state as a named profile. + @MainActor + static func saveCurrentSession( + name: String, + tabManager: TabManager, + includeScrollback: Bool = true + ) -> Profile? { + let snapshot = tabManager.sessionSnapshot(includeScrollback: includeScrollback) + guard !snapshot.workspaces.isEmpty else { return nil } + var profile = Profile(name: name, snapshot: snapshot) + + // If overwriting an existing profile, preserve its ID and creation date. + if let existing = load(name: name) { + profile.id = existing.id + profile.createdAt = existing.createdAt + } + profile.updatedAt = Date() + + guard save(profile) else { return nil } + return profile + } + + /// Deletes a profile by name. Returns true if the file was removed. + @discardableResult + static func delete(name: String) -> Bool { + guard let fileURL = profileFileURL(for: name) else { return false } + do { + try FileManager.default.removeItem(at: fileURL) + return true + } catch { + return false + } + } + + /// Renames a profile. Returns the updated profile on success. + static func rename(oldName: String, newName: String) -> Profile? { + let trimmedNew = newName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedNew.isEmpty else { return nil } + guard var profile = load(name: oldName) else { return nil } + + // If the new name already exists (and it's not the same file), bail. + if trimmedNew != oldName, load(name: trimmedNew) != nil { + return nil + } + + // Delete old file first (the filename is derived from the name). + delete(name: oldName) + + profile.name = trimmedNew + profile.updatedAt = Date() + guard save(profile) else { return nil } + return profile + } + + // MARK: - Private + + private static func profilesDirectory() -> URL? { + guard let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first else { return nil } + return appSupport + .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("profiles", isDirectory: true) + } + + /// Derives a filesystem-safe filename from a profile name. + private static func profileFileURL(for name: String) -> URL? { + guard let directory = profilesDirectory() else { return nil } + let sanitized = sanitizedFileName(name) + guard !sanitized.isEmpty else { return nil } + return directory + .appendingPathComponent(sanitized, isDirectory: false) + .appendingPathExtension("json") + } + + /// Creates a filesystem-safe version of a profile name. + private static func sanitizedFileName(_ name: String) -> String { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + // Replace characters that are problematic in filenames. + let sanitized = trimmed.replacingOccurrences( + of: "[/\\\\:*?\"<>|]", + with: "_", + options: .regularExpression + ) + // Limit length to avoid filesystem issues. + let maxLength = 128 + if sanitized.count > maxLength { + return String(sanitized.prefix(maxLength)) + } + return sanitized + } +} diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 97271038f0c7..e0b9d0d32903 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -656,6 +656,14 @@ class TabManager: ObservableObject { @Published private(set) var pendingBackgroundWorkspaceLoadIds: Set = [] @Published private(set) var debugPinnedWorkspaceLoadIds: Set = [] + /// The name of the currently active profile, if workspaces were loaded from one. + /// Cleared when workspaces are manually added or removed. + @Published private(set) var activeProfileName: String? + + func setActiveProfileName(_ name: String?) { + activeProfileName = name + } + /// Global monotonically increasing counter for CMUX_PORT ordinal assignment. /// Static so port ranges don't overlap across multiple windows (each window has its own TabManager). private static var nextPortOrdinal: Int = 0 diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index de1e96f3c1b0..218fa29ff3a3 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1786,6 +1786,17 @@ class TerminalController { case "read_screen": return readScreenText(args) + case "profile_list": + return profileList() + + case "profile_save": + return profileSave(args) + + case "profile_load": + return profileLoad(args) + + case "profile_delete": + return profileDelete(args) #if DEBUG case "send_workspace": @@ -2313,6 +2324,16 @@ class TerminalController { case "markdown.open": return v2Result(id: id, self.v2MarkdownOpen(params: params)) + // Profiles + case "profile.list": + return v2Result(id: id, self.v2ProfileList(params: params)) + case "profile.save": + return v2Result(id: id, self.v2ProfileSave(params: params)) + case "profile.load": + return v2Result(id: id, self.v2ProfileLoad(params: params)) + case "profile.delete": + return v2Result(id: id, self.v2ProfileDelete(params: params)) + case "surface.read_text": return v2Result(id: id, self.v2SurfaceReadText(params: params)) @@ -7122,6 +7143,125 @@ class TerminalController { return result } + // MARK: - Profiles + + private func v2ProfileList(params: [String: Any]) -> V2CallResult { + let profiles = ProfileStore.list() + let entries: [[String: Any]] = profiles.map { profile in + [ + "id": profile.id.uuidString, + "name": profile.name, + "workspace_count": profile.snapshot.workspaces.count, + "created_at": profile.createdAt.timeIntervalSince1970, + "updated_at": profile.updatedAt.timeIntervalSince1970, + ] + } + return .ok(["profiles": entries, "count": entries.count]) + } + + private func v2ProfileSave(params: [String: Any]) -> V2CallResult { + guard let name = v2String(params, "name"), + !name.isEmpty else { + return .err(code: "invalid_params", message: "Missing or empty 'name' parameter", data: nil) + } + let includeScrollback = v2Bool(params, "include_scrollback") ?? true + + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to save profile", data: nil) + v2MainSync { + guard let profile = ProfileStore.saveCurrentSession( + name: name, + tabManager: tabManager, + includeScrollback: includeScrollback + ) else { + result = .err(code: "save_failed", message: "Failed to save profile '\(name)'", data: nil) + return + } + result = .ok([ + "id": profile.id.uuidString, + "name": profile.name, + "workspace_count": profile.snapshot.workspaces.count, + ]) + } + return result + } + + private func v2ProfileLoad(params: [String: Any]) -> V2CallResult { + guard let name = v2String(params, "name"), + !name.isEmpty else { + return .err(code: "invalid_params", message: "Missing or empty 'name' parameter", data: nil) + } + + guard let profile = ProfileStore.load(name: name) else { + return .err(code: "not_found", message: "Profile '\(name)' not found", data: nil) + } + + let inNewWindow = v2Bool(params, "new_window") ?? false + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to load profile", data: nil) + v2MainSync { + if inNewWindow { + guard let appDelegate = AppDelegate.shared else { + result = .err(code: "unavailable", message: "AppDelegate not available", data: nil) + return + } + let snapshot = SessionWindowSnapshot( + frame: nil, + display: nil, + tabManager: profile.snapshot, + sidebar: SessionSidebarSnapshot( + isVisible: true, + selection: .tabs, + width: nil + ) + ) + let windowId = appDelegate.createMainWindow(sessionWindowSnapshot: snapshot) + appDelegate.tabManagerFor(windowId: windowId)?.setActiveProfileName(profile.name) + result = .ok([ + "name": profile.name, + "window_id": windowId.uuidString, + "workspace_count": profile.snapshot.workspaces.count, + "mode": "new_window", + ]) + } else { + guard let tabManager = v2ResolveTabManager(params: params) else { + result = .err(code: "unavailable", message: "TabManager not available", data: nil) + return + } + tabManager.restoreSessionSnapshot(profile.snapshot) + tabManager.setActiveProfileName(profile.name) + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "name": profile.name, + "window_id": v2OrNull(windowId?.uuidString), + "workspace_count": profile.snapshot.workspaces.count, + "mode": "replace", + ]) + } + } + return result + } + + private func v2ProfileDelete(params: [String: Any]) -> V2CallResult { + guard let name = v2String(params, "name"), + !name.isEmpty else { + return .err(code: "invalid_params", message: "Missing or empty 'name' parameter", data: nil) + } + + guard ProfileStore.load(name: name) != nil else { + return .err(code: "not_found", message: "Profile '\(name)' not found", data: nil) + } + + guard ProfileStore.delete(name: name) else { + return .err(code: "delete_failed", message: "Failed to delete profile '\(name)'", data: nil) + } + + return .ok(["name": name, "deleted": true]) + } + // MARK: - Browser private func v2BrowserOpenSplit(params: [String: Any]) -> V2CallResult { @@ -10758,6 +10898,93 @@ class TerminalController { return result } + // MARK: - Profile Commands (v1) + + private func profileList() -> String { + let profiles = ProfileStore.list() + if profiles.isEmpty { + return "OK: No profiles saved" + } + let lines = profiles.map { profile in + "\(profile.name) (workspaces: \(profile.snapshot.workspaces.count))" + } + return "OK:\n" + lines.joined(separator: "\n") + } + + private func profileSave(_ args: String) -> String { + let name = args.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { + return "ERROR: Usage: profile_save " + } + guard let tabManager else { + return "ERROR: TabManager not available" + } + guard let profile = ProfileStore.saveCurrentSession(name: name, tabManager: tabManager) else { + return "ERROR: Failed to save profile '\(name)'" + } + return "OK: Profile '\(profile.name)' saved with \(profile.snapshot.workspaces.count) workspace(s)" + } + + private func profileLoad(_ args: String) -> String { + let parts = args.trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: " ") + .filter { !$0.isEmpty } + guard !parts.isEmpty else { + return "ERROR: Usage: profile_load [--new-window]" + } + + let inNewWindow = parts.contains("--new-window") + let nameParts = parts.filter { $0 != "--new-window" } + let name = nameParts.joined(separator: " ") + guard !name.isEmpty else { + return "ERROR: Usage: profile_load [--new-window]" + } + + guard let profile = ProfileStore.load(name: name) else { + return "ERROR: Profile '\(name)' not found" + } + + if inNewWindow { + let snapshot = SessionWindowSnapshot( + frame: nil, + display: nil, + tabManager: profile.snapshot, + sidebar: SessionSidebarSnapshot( + isVisible: true, + selection: .tabs, + width: nil + ) + ) + guard let appDelegate = AppDelegate.shared else { + return "ERROR: AppDelegate not available" + } + let windowId = appDelegate.createMainWindow(sessionWindowSnapshot: snapshot) + appDelegate.tabManagerFor(windowId: windowId)?.setActiveProfileName(name) + return "OK: Profile '\(name)' loaded in new window (\(profile.snapshot.workspaces.count) workspace(s))" + } else { + guard let tabManager else { + return "ERROR: TabManager not available" + } + tabManager.restoreSessionSnapshot(profile.snapshot) + tabManager.setActiveProfileName(name) + return "OK: Profile '\(name)' loaded (\(profile.snapshot.workspaces.count) workspace(s))" + } + } + + private func profileDelete(_ args: String) -> String { + let name = args.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { + return "ERROR: Usage: profile_delete " + } + guard ProfileStore.load(name: name) != nil else { + return "ERROR: Profile '\(name)' not found" + } + guard ProfileStore.delete(name: name) else { + return "ERROR: Failed to delete profile '\(name)'" + } + return "OK: Profile '\(name)' deleted" + } + private func readScreenText(_ args: String) -> String { let options: ReadScreenOptions switch parseReadScreenArgs(args) { @@ -10856,6 +11083,12 @@ class TerminalController { sidebar_state [--tab=X] - Dump sidebar metadata reset_sidebar [--tab=X] - Clear sidebar metadata + Profile commands: + profile_list - List all saved profiles + profile_save - Save current workspaces as a named profile + profile_load [--new-window] - Load a profile (replace current or open in new window) + profile_delete - Delete a saved profile + Browser commands: open_browser [url] - Create browser panel with optional URL navigate - Navigate browser to URL diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 9e76784d71f4..a3ca9f03f9c4 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -476,6 +476,8 @@ struct cmuxApp: App { workspaceCommandMenuContent(manager: activeTabManager) } + profilesMenuContent(manager: activeTabManager) + Button(String(localized: "menu.file.reopenClosedBrowserPanel", defaultValue: "Reopen Closed Browser Panel")) { _ = activeTabManager.reopenMostRecentlyClosedBrowserPanel() } @@ -928,6 +930,182 @@ struct cmuxApp: App { notificationStore.markUnread(forTabId: workspaceId) } + // MARK: - Profiles Menu + + @ViewBuilder + private func profilesMenuContent(manager: TabManager) -> some View { + let profiles = ProfileStore.list() + + Menu(String(localized: "menu.file.profiles", defaultValue: "Profiles")) { + Button(String(localized: "menu.file.profiles.saveAs", defaultValue: "Save Current as Profile…")) { + showSaveProfileDialog(manager: manager) + } + .disabled(manager.tabs.isEmpty) + + if !profiles.isEmpty { + Divider() + + ForEach(profiles) { profile in + Menu(profile.name) { + Button(String(localized: "menu.file.profiles.openNewWindow", defaultValue: "Open in New Window")) { + loadProfile(profile, inNewWindow: true) + } + + Button(String(localized: "menu.file.profiles.loadHere", defaultValue: "Load in Current Window")) { + loadProfile(profile, inNewWindow: false, manager: manager) + } + + Divider() + + Button(String(localized: "menu.file.profiles.delete", defaultValue: "Delete Profile")) { + confirmDeleteProfile(profile) + } + } + } + } + + if !profiles.isEmpty { + Divider() + + Button(String(localized: "menu.file.profiles.deleteAll", defaultValue: "Delete All Profiles…")) { + confirmDeleteAllProfiles() + } + } + } + } + + private func showSaveProfileDialog(manager: TabManager) { + let alert = NSAlert() + alert.messageText = String(localized: "profile.save.title", defaultValue: "Save Profile") + alert.informativeText = String( + localized: "profile.save.message", + defaultValue: "Enter a name for this profile. The current workspace layout will be saved." + ) + alert.addButton(withTitle: String(localized: "profile.save.button", defaultValue: "Save")) + alert.addButton(withTitle: String(localized: "profile.cancel.button", defaultValue: "Cancel")) + + let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24)) + textField.placeholderString = String(localized: "profile.save.placeholder", defaultValue: "e.g. Work, Personal") + alert.accessoryView = textField + alert.window.initialFirstResponder = textField + + guard alert.runModal() == .alertFirstButtonReturn else { return } + + let name = textField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return } + + // Check if a profile with this name already exists. + if ProfileStore.load(name: name) != nil { + let confirm = NSAlert() + confirm.messageText = String( + localized: "profile.overwrite.title", + defaultValue: "Profile Already Exists" + ) + confirm.informativeText = String( + localized: "profile.overwrite.message", + defaultValue: "A profile named \"\(name)\" already exists. Do you want to replace it?" + ) + confirm.addButton(withTitle: String(localized: "profile.overwrite.replace", defaultValue: "Replace")) + confirm.addButton(withTitle: String(localized: "profile.cancel.button", defaultValue: "Cancel")) + confirm.alertStyle = .warning + guard confirm.runModal() == .alertFirstButtonReturn else { return } + } + + if ProfileStore.saveCurrentSession(name: name, tabManager: manager) != nil { +#if DEBUG + dlog("profile.save name=\(name) workspaces=\(manager.tabs.count)") +#endif + } + } + + private func loadProfile(_ profile: Profile, inNewWindow: Bool, manager: TabManager? = nil) { + if inNewWindow { + // Open a new window and restore the profile into it. + let snapshot = SessionWindowSnapshot( + frame: nil, + display: nil, + tabManager: profile.snapshot, + sidebar: SessionSidebarSnapshot( + isVisible: true, + selection: .tabs, + width: nil + ) + ) + if let appDelegate = AppDelegate.shared { + let windowId = appDelegate.createMainWindow(sessionWindowSnapshot: snapshot) + // Set the active profile on the new window's TabManager. + if let newManager = appDelegate.tabManagerFor(windowId: windowId) { + newManager.setActiveProfileName(profile.name) + } + } + } else if let manager { + // Confirm before replacing current workspaces. + let alert = NSAlert() + alert.messageText = String( + localized: "profile.load.title", + defaultValue: "Load Profile" + ) + alert.informativeText = String( + localized: "profile.load.message", + defaultValue: "Loading \"\(profile.name)\" will close all current workspaces in this window and replace them. Continue?" + ) + alert.addButton(withTitle: String(localized: "profile.load.button", defaultValue: "Load Profile")) + alert.addButton(withTitle: String(localized: "profile.cancel.button", defaultValue: "Cancel")) + alert.alertStyle = .warning + guard alert.runModal() == .alertFirstButtonReturn else { return } + + manager.restoreSessionSnapshot(profile.snapshot) + manager.setActiveProfileName(profile.name) +#if DEBUG + dlog("profile.load name=\(profile.name) inNewWindow=false workspaces=\(profile.snapshot.workspaces.count)") +#endif + } + } + + private func confirmDeleteProfile(_ profile: Profile) { + let alert = NSAlert() + alert.messageText = String( + localized: "profile.delete.title", + defaultValue: "Delete Profile" + ) + alert.informativeText = String( + localized: "profile.delete.message", + defaultValue: "Are you sure you want to delete the profile \"\(profile.name)\"? This cannot be undone." + ) + alert.addButton(withTitle: String(localized: "profile.delete.button", defaultValue: "Delete")) + alert.addButton(withTitle: String(localized: "profile.cancel.button", defaultValue: "Cancel")) + alert.alertStyle = .warning + guard alert.runModal() == .alertFirstButtonReturn else { return } + + ProfileStore.delete(name: profile.name) +#if DEBUG + dlog("profile.delete name=\(profile.name)") +#endif + } + + private func confirmDeleteAllProfiles() { + let alert = NSAlert() + alert.messageText = String( + localized: "profile.deleteAll.title", + defaultValue: "Delete All Profiles" + ) + alert.informativeText = String( + localized: "profile.deleteAll.message", + defaultValue: "Are you sure you want to delete all saved profiles? This cannot be undone." + ) + alert.addButton(withTitle: String(localized: "profile.deleteAll.button", defaultValue: "Delete All")) + alert.addButton(withTitle: String(localized: "profile.cancel.button", defaultValue: "Cancel")) + alert.alertStyle = .critical + guard alert.runModal() == .alertFirstButtonReturn else { return } + + for profile in ProfileStore.list() { + ProfileStore.delete(name: profile.name) + } +#if DEBUG + dlog("profile.deleteAll") +#endif + } + @ViewBuilder private func workspaceCommandMenuContent(manager: TabManager) -> some View { let workspace = manager.selectedWorkspace diff --git a/cmuxTests/ProfileStoreTests.swift b/cmuxTests/ProfileStoreTests.swift new file mode 100644 index 000000000000..b4473b5b4818 --- /dev/null +++ b/cmuxTests/ProfileStoreTests.swift @@ -0,0 +1,333 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class ProfileStoreTests: XCTestCase { + + // MARK: - Profile Model Codable Round-Trip + + func testProfileEncodesAndDecodesRoundTrip() throws { + let snapshot = makeTabManagerSnapshot(workspaceCount: 2) + let profile = Profile(name: "Work", snapshot: snapshot) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + let data = try encoder.encode(profile) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + let decoded = try decoder.decode(Profile.self, from: data) + + XCTAssertEqual(decoded.id, profile.id) + XCTAssertEqual(decoded.name, "Work") + XCTAssertEqual(decoded.snapshot.workspaces.count, 2) + XCTAssertEqual( + decoded.createdAt.timeIntervalSince1970, + profile.createdAt.timeIntervalSince1970, + accuracy: 1.0 + ) + } + + func testProfilePreservesAllWorkspaceSnapshotFields() throws { + let workspace = SessionWorkspaceSnapshot( + processTitle: "zsh", + customTitle: "My Terminal", + customColor: "#C0392B", + isPinned: true, + currentDirectory: "/Users/test/project", + focusedPanelId: UUID(), + layout: .pane(SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil)), + panels: [], + statusEntries: [], + logEntries: [], + progress: SessionProgressSnapshot(value: 0.5, label: "Building"), + gitBranch: SessionGitBranchSnapshot(branch: "main", isDirty: true) + ) + let snapshot = SessionTabManagerSnapshot( + selectedWorkspaceIndex: 0, + workspaces: [workspace] + ) + let profile = Profile(name: "Full", snapshot: snapshot) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + let data = try encoder.encode(profile) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + let decoded = try decoder.decode(Profile.self, from: data) + + let ws = decoded.snapshot.workspaces[0] + XCTAssertEqual(ws.processTitle, "zsh") + XCTAssertEqual(ws.customTitle, "My Terminal") + XCTAssertEqual(ws.customColor, "#C0392B") + XCTAssertTrue(ws.isPinned) + XCTAssertEqual(ws.currentDirectory, "/Users/test/project") + XCTAssertEqual(ws.progress?.value, 0.5) + XCTAssertEqual(ws.progress?.label, "Building") + XCTAssertEqual(ws.gitBranch?.branch, "main") + XCTAssertTrue(ws.gitBranch?.isDirty == true) + } + + func testProfileDecodesWithMissingOptionalFields() throws { + // Simulate a profile saved by an older version without optional fields. + let json = """ + { + "id": "11111111-1111-1111-1111-111111111111", + "name": "Legacy", + "createdAt": 1700000000, + "updatedAt": 1700000000, + "snapshot": { + "workspaces": [ + { + "processTitle": "zsh", + "isPinned": false, + "currentDirectory": "/tmp", + "layout": { + "type": "pane", + "pane": { "panelIds": [] } + }, + "panels": [], + "statusEntries": [], + "logEntries": [] + } + ] + } + } + """ + let data = try XCTUnwrap(json.data(using: .utf8)) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + let profile = try decoder.decode(Profile.self, from: data) + + XCTAssertEqual(profile.name, "Legacy") + XCTAssertEqual(profile.snapshot.workspaces.count, 1) + XCTAssertNil(profile.snapshot.workspaces[0].customTitle) + XCTAssertNil(profile.snapshot.workspaces[0].customColor) + XCTAssertNil(profile.snapshot.workspaces[0].gitBranch) + XCTAssertNil(profile.snapshot.workspaces[0].progress) + XCTAssertNil(profile.snapshot.selectedWorkspaceIndex) + } + + // MARK: - ProfileStore Save / Load / Delete + + func testSaveAndLoadByName() throws { + let snapshot = makeTabManagerSnapshot(workspaceCount: 3) + var profile = Profile(name: "SaveLoad", snapshot: snapshot) + profile.updatedAt = Date() + + XCTAssertTrue(ProfileStore.save(profile)) + + let loaded = ProfileStore.load(name: "SaveLoad") + XCTAssertNotNil(loaded) + XCTAssertEqual(loaded?.id, profile.id) + XCTAssertEqual(loaded?.name, "SaveLoad") + XCTAssertEqual(loaded?.snapshot.workspaces.count, 3) + + ProfileStore.delete(name: "SaveLoad") + } + + func testSaveOverwritePreservesContent() throws { + let snapshot1 = makeTabManagerSnapshot(workspaceCount: 1) + var profile1 = Profile(name: "Overwrite", snapshot: snapshot1) + XCTAssertTrue(ProfileStore.save(profile1)) + + let snapshot2 = makeTabManagerSnapshot(workspaceCount: 5) + profile1.snapshot = snapshot2 + profile1.updatedAt = Date() + XCTAssertTrue(ProfileStore.save(profile1)) + + let loaded = ProfileStore.load(name: "Overwrite") + XCTAssertEqual(loaded?.snapshot.workspaces.count, 5) + + ProfileStore.delete(name: "Overwrite") + } + + func testDeleteRemovesProfile() { + let profile = Profile(name: "ToDelete", snapshot: makeTabManagerSnapshot(workspaceCount: 1)) + ProfileStore.save(profile) + XCTAssertNotNil(ProfileStore.load(name: "ToDelete")) + + XCTAssertTrue(ProfileStore.delete(name: "ToDelete")) + XCTAssertNil(ProfileStore.load(name: "ToDelete")) + } + + func testDeleteNonExistentReturnsFalse() { + XCTAssertFalse(ProfileStore.delete(name: "NonExistent-\(UUID().uuidString)")) + } + + func testLoadNonExistentReturnsNil() { + XCTAssertNil(ProfileStore.load(name: "NonExistent-\(UUID().uuidString)")) + } + + // MARK: - List + + func testListReturnsSortedProfiles() { + let names = ["Zebra", "Alpha", "Middle"] + for name in names { + ProfileStore.save(Profile(name: name, snapshot: makeTabManagerSnapshot(workspaceCount: 1))) + } + defer { + for name in names { ProfileStore.delete(name: name) } + } + + let profiles = ProfileStore.list() + let listed = profiles.map(\.name) + + guard let alphaIdx = listed.firstIndex(of: "Alpha"), + let middleIdx = listed.firstIndex(of: "Middle"), + let zebraIdx = listed.firstIndex(of: "Zebra") else { + XCTFail("Expected all three profiles in list") + return + } + XCTAssertLessThan(alphaIdx, middleIdx) + XCTAssertLessThan(middleIdx, zebraIdx) + } + + func testLoadById() { + let profile = Profile(name: "ById", snapshot: makeTabManagerSnapshot(workspaceCount: 1)) + ProfileStore.save(profile) + defer { ProfileStore.delete(name: "ById") } + + let loaded = ProfileStore.load(id: profile.id) + XCTAssertNotNil(loaded) + XCTAssertEqual(loaded?.name, "ById") + } + + // MARK: - Rename + + func testRenameProfile() { + ProfileStore.save(Profile(name: "OldName", snapshot: makeTabManagerSnapshot(workspaceCount: 2))) + defer { ProfileStore.delete(name: "NewName") } + + let renamed = ProfileStore.rename(oldName: "OldName", newName: "NewName") + XCTAssertNotNil(renamed) + XCTAssertEqual(renamed?.name, "NewName") + XCTAssertEqual(renamed?.snapshot.workspaces.count, 2) + + XCTAssertNil(ProfileStore.load(name: "OldName")) + XCTAssertNotNil(ProfileStore.load(name: "NewName")) + } + + func testRenameToExistingNameFails() { + ProfileStore.save(Profile(name: "First", snapshot: makeTabManagerSnapshot(workspaceCount: 1))) + ProfileStore.save(Profile(name: "Second", snapshot: makeTabManagerSnapshot(workspaceCount: 1))) + defer { + ProfileStore.delete(name: "First") + ProfileStore.delete(name: "Second") + } + + let result = ProfileStore.rename(oldName: "First", newName: "Second") + XCTAssertNil(result) + + // Both should still exist. + XCTAssertNotNil(ProfileStore.load(name: "First")) + XCTAssertNotNil(ProfileStore.load(name: "Second")) + } + + func testRenameEmptyNameFails() { + XCTAssertNil(ProfileStore.rename(oldName: "Anything", newName: " ")) + } + + func testRenameNonExistentFails() { + XCTAssertNil(ProfileStore.rename(oldName: "Ghost-\(UUID().uuidString)", newName: "New")) + } + + // MARK: - Filename Sanitization + + func testProfileWithSpecialCharactersInName() { + let name = "Work/Project: Test<1>" + ProfileStore.save(Profile(name: name, snapshot: makeTabManagerSnapshot(workspaceCount: 1))) + defer { ProfileStore.delete(name: name) } + + let loaded = ProfileStore.load(name: name) + XCTAssertNotNil(loaded) + XCTAssertEqual(loaded?.name, name) + } + + // MARK: - Active Profile on TabManager + + @MainActor + func testActiveProfileNameIsNilByDefault() { + let manager = TabManager() + XCTAssertNil(manager.activeProfileName) + } + + @MainActor + func testSetAndClearActiveProfileName() { + let manager = TabManager() + manager.setActiveProfileName("Work") + XCTAssertEqual(manager.activeProfileName, "Work") + + manager.setActiveProfileName(nil) + XCTAssertNil(manager.activeProfileName) + } + + @MainActor + func testActiveProfilePersistsAfterAddingWorkspace() { + let manager = TabManager() + manager.setActiveProfileName("Work") + + _ = manager.addWorkspace(select: true, autoWelcomeIfNeeded: false) + XCTAssertEqual(manager.activeProfileName, "Work") + } + + @MainActor + func testLoadingDifferentProfileReplacesActiveProfileName() { + let manager = TabManager() + manager.setActiveProfileName("Work") + + // Simulate loading a different profile. + let snapshot = makeTabManagerSnapshot(workspaceCount: 2) + manager.restoreSessionSnapshot(snapshot) + manager.setActiveProfileName("Personal") + + XCTAssertEqual(manager.activeProfileName, "Personal") + } + + // MARK: - Snapshot Selection Preservation + + func testProfilePreservesSelectedWorkspaceIndex() { + let snapshot = SessionTabManagerSnapshot( + selectedWorkspaceIndex: 2, + workspaces: (0..<4).map { makeWorkspaceSnapshot(title: "WS \($0)") } + ) + let profile = Profile(name: "Selection", snapshot: snapshot) + ProfileStore.save(profile) + defer { ProfileStore.delete(name: "Selection") } + + let loaded = ProfileStore.load(name: "Selection") + XCTAssertEqual(loaded?.snapshot.selectedWorkspaceIndex, 2) + XCTAssertEqual(loaded?.snapshot.workspaces.count, 4) + } + + // MARK: - Helpers + + private func makeTabManagerSnapshot(workspaceCount: Int) -> SessionTabManagerSnapshot { + SessionTabManagerSnapshot( + selectedWorkspaceIndex: workspaceCount > 0 ? 0 : nil, + workspaces: (0.. SessionWorkspaceSnapshot { + SessionWorkspaceSnapshot( + processTitle: title, + customTitle: nil, + customColor: nil, + isPinned: false, + currentDirectory: "/tmp", + focusedPanelId: nil, + layout: .pane(SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil)), + panels: [], + statusEntries: [], + logEntries: [], + progress: nil, + gitBranch: nil + ) + } +}