Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions GhosttyTabs.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -215,6 +217,8 @@
A5001223 /* UpdateLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateLogStore.swift; sourceTree = "<group>"; };
A5001241 /* WindowDecorationsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDecorationsController.swift; sourceTree = "<group>"; };
A5001611 /* SessionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistence.swift; sourceTree = "<group>"; };
A5009921 /* ProfileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStore.swift; sourceTree = "<group>"; };
A5009931 /* ProfileStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStoreTests.swift; sourceTree = "<group>"; };
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = "<group>"; };
B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = "<group>"; };
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -416,6 +420,7 @@
A5001241 /* WindowDecorationsController.swift */,
A5001222 /* WindowAccessor.swift */,
A5001611 /* SessionPersistence.swift */,
A5009921 /* ProfileStore.swift */,
);
path = Sources;
sourceTree = "<group>";
Expand Down Expand Up @@ -489,6 +494,7 @@
FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */,
A5008380 /* BrowserFindJavaScriptTests.swift */,
A5008382 /* CommandPaletteSearchEngineTests.swift */,
A5009931 /* ProfileStoreTests.swift */,
);
path = cmuxTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -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;
};
Expand Down
16 changes: 16 additions & 0 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2371,12 +2371,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {

@cubic-dev-ai cubic-dev-ai Bot Mar 17, 2026 •

Copy link
Copy Markdown

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
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/AppDelegate.swift, line 3231:

<comment>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.</comment>

<file context>
@@ -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 {
</file context>
Fix with Cubic

isTerminatingApp = true
_ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false)
autosaveActiveProfiles(includeScrollback: true)

@cubic-dev-ai cubic-dev-ai Bot Mar 17, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/AppDelegate.swift, line 2374:

<comment>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.</comment>

<file context>
@@ -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
     }
</file context>
Fix with Cubic

return .terminateNow
}

func applicationWillTerminate(_ notification: Notification) {
isTerminatingApp = true
_ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false)
autosaveActiveProfiles(includeScrollback: true)
stopSessionAutosaveTimer()
TerminalController.shared.stop()
VSCodeServeWebController.shared.stop()
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down
48 changes: 43 additions & 5 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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))
Expand Down
196 changes: 196 additions & 0 deletions Sources/ProfileStore.swift
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)

@cubic-dev-ai cubic-dev-ai Bot Mar 17, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/ProfileStore.swift, line 149:

<comment>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.</comment>

<file context>
@@ -0,0 +1,196 @@
+        }
+
+        // Delete old file first (the filename is derived from the name).
+        delete(name: oldName)
+
+        profile.name = trimmedNew
</file context>
Fix with Cubic


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(

@cubic-dev-ai cubic-dev-ai Bot Mar 17, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Profile filenames are derived from a lossy sanitized version of Profile.name, but overwrite checks compare the raw name. Distinct names that sanitize to the same filename can overwrite each other or be loaded/deleted incorrectly.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/ProfileStore.swift, line 184:

<comment>Profile filenames are derived from a lossy sanitized version of `Profile.name`, but overwrite checks compare the raw name. Distinct names that sanitize to the same filename can overwrite each other or be loaded/deleted incorrectly.</comment>

<file context>
@@ -0,0 +1,196 @@
+        let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else { return "" }
+        // Replace characters that are problematic in filenames.
+        let sanitized = trimmed.replacingOccurrences(
+            of: "[/\\\\:*?\"<>|]",
+            with: "_",
</file context>
Fix with Cubic

of: "[/\\\\:*?\"<>|]",
with: "_",
options: .regularExpression
)
// Limit length to avoid filesystem issues.
let maxLength = 128
if sanitized.count > maxLength {
return String(sanitized.prefix(maxLength))
}
return sanitized
}
}
8 changes: 8 additions & 0 deletions Sources/TabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,14 @@ class TabManager: ObservableObject {
@Published private(set) var pendingBackgroundWorkspaceLoadIds: Set<UUID> = []
@Published private(set) var debugPinnedWorkspaceLoadIds: Set<UUID> = []

/// 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
Expand Down
Loading