Repository navigation
feat: add Profiles — save and restore workspace layouts #1617
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Profile autosave now runs synchronous snapshot creation and JSON file writes on the main thread during each autosave tick, which can cause UI hitches for large scrollback or multiple windows. Prompt for AI agents |
||
| 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 | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: rename deletes the original profile before a successful save and ignores delete failure, which can cause data loss or duplicate profiles if save/delete fails. Prompt for AI agents |
||
|
|
||
| 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( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Profile filenames are derived from a lossy sanitized version of Prompt for AI agents |
||
| of: "[/\\\\:*?\"<>|]", | ||
| with: "_", | ||
| options: .regularExpression | ||
| ) | ||
| // Limit length to avoid filesystem issues. | ||
| let maxLength = 128 | ||
| if sanitized.count > maxLength { | ||
| return String(sanitized.prefix(maxLength)) | ||
| } | ||
| return sanitized | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Autosave for active profiles is added to normal termination and timer paths, but the existing fallback shutdown/update lifecycle paths (update relaunch, power-off, session resign while terminating) still only save the session snapshot. In those nonstandard termination paths, profile changes can be lost even though the session snapshot is preserved. Consider invoking autosaveActiveProfiles in those fallback handlers as well.
Prompt for AI agents