From ad1dfd90fd0ee1cf2f4fee5c8e1e6f8e77234054 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 8 May 2026 22:34:12 +0900 Subject: [PATCH] Polish persistent custom tab titles - Drop dead overrideTitle/clearTitleOverride wrappers; setCustomTitle is the only writer. - Bump snapshot schema to v2 and migrate v1 title -> customTitle on decode so previously frozen titles survive the upgrade as user overrides instead of getting overwritten by the next OSC-2 update. - Auto select-all when entering inline rename so users can immediately overwrite. - Frame the rename TextField with a bordered field to make edit mode visually obvious. - Remove redundant onDisappear rename-save path; tabs.didSet already drives the editing teardown. --- .../TerminalLayoutSnapshotPayload.swift | 43 ++++++++++++- .../Terminal/Models/TerminalTabManager.swift | 8 --- .../TabBar/TerminalTabBarMetrics.swift | 2 + .../TabBar/Views/TerminalTabView.swift | 32 +++++++--- .../TerminalLayoutSnapshotPayloadTests.swift | 62 +++++++++++++++++++ 5 files changed, 126 insertions(+), 21 deletions(-) diff --git a/supacode/Features/Terminal/Models/TerminalLayoutSnapshotPayload.swift b/supacode/Features/Terminal/Models/TerminalLayoutSnapshotPayload.swift index 0d0050d7..b66e7acc 100644 --- a/supacode/Features/Terminal/Models/TerminalLayoutSnapshotPayload.swift +++ b/supacode/Features/Terminal/Models/TerminalLayoutSnapshotPayload.swift @@ -1,7 +1,8 @@ import Foundation nonisolated struct TerminalLayoutSnapshotPayload: Codable, Equatable, Sendable { - nonisolated static let currentVersion = 1 + nonisolated static let currentVersion = 2 + nonisolated static let minSupportedVersion = 1 nonisolated static let maxSnapshotFileBytes = 2 * 1024 * 1024 nonisolated static let maxWorktrees = 128 nonisolated static let maxTabsPerWorktree = 128 @@ -32,11 +33,47 @@ nonisolated struct TerminalLayoutSnapshotPayload: Codable, Equatable, Sendable { guard let payload = try? decoder.decode(Self.self, from: data) else { return nil } - return payload.isValid ? payload : nil + let migrated = payload.migratedToCurrentVersion() + return migrated.isValid ? migrated : nil + } + + /// Upgrade v1 payloads to the current schema. + /// + /// In v1 the `title` field on `SnapshotTab` doubled as both the live shell + /// title (which was frozen on restore) and the user's overridden title — they + /// were indistinguishable on disk. After v2 the user override moves to + /// `customTitle`, so promoting a v1 `title` to `customTitle` preserves what + /// the user actually saw across the upgrade. The downside (a previously + /// non-overridden tab now looks "pinned") is something the user can clear via + /// the new inline rename. + func migratedToCurrentVersion() -> TerminalLayoutSnapshotPayload { + guard version < Self.currentVersion else { return self } + let migratedWorktrees = worktrees.map { worktree -> SnapshotWorktree in + let migratedTabs = worktree.tabs.map { tab -> SnapshotTab in + guard tab.customTitle == nil, let title = tab.title else { return tab } + return SnapshotTab( + tabID: tab.tabID, + title: nil, + customTitle: title, + icon: tab.icon, + splitRoot: tab.splitRoot + ) + } + return SnapshotWorktree( + worktreeID: worktree.worktreeID, + selectedTabID: worktree.selectedTabID, + tabs: migratedTabs + ) + } + return TerminalLayoutSnapshotPayload( + version: Self.currentVersion, + selectedWorktreeID: selectedWorktreeID, + worktrees: migratedWorktrees + ) } var isValid: Bool { - guard version == Self.currentVersion else { + guard (Self.minSupportedVersion...Self.currentVersion).contains(version) else { return false } guard !worktrees.isEmpty, worktrees.count <= Self.maxWorktrees else { diff --git a/supacode/Features/Terminal/Models/TerminalTabManager.swift b/supacode/Features/Terminal/Models/TerminalTabManager.swift index c6bb8759..436b15f4 100644 --- a/supacode/Features/Terminal/Models/TerminalTabManager.swift +++ b/supacode/Features/Terminal/Models/TerminalTabManager.swift @@ -37,14 +37,6 @@ final class TerminalTabManager { tabs[index].title = title } - func overrideTitle(_ id: TerminalTabID, title: String) { - setCustomTitle(id, title: title) - } - - func clearTitleOverride(_ id: TerminalTabID) { - setCustomTitle(id, title: "") - } - func setCustomTitle(_ id: TerminalTabID, title: String) { guard let index = tabs.firstIndex(where: { $0.id == id }) else { return } guard !tabs[index].isTitleLocked else { return } diff --git a/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift b/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift index 69841735..78e30e74 100644 --- a/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift +++ b/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift @@ -25,4 +25,6 @@ enum TerminalTabBarMetrics { static let selectionAnimationDuration: Double = 0.15 static let reorderAnimationDuration: Double = 0.3 static let reorderAnimationBounce: Double = 0.15 + static let renameFieldCornerRadius: CGFloat = 4 + static let renameFieldInset: CGFloat = 4 } diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift index 3fd75b79..e155b353 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift @@ -79,11 +79,24 @@ struct TerminalTabView: View { .focused($isFieldFocused) .foregroundStyle(TerminalTabBarColors.activeText) .accessibilityLabel("Rename tab") - .padding(.horizontal, TerminalTabBarMetrics.tabHorizontalPadding) - .padding( - .trailing, - TerminalTabBarMetrics.closeButtonSize + TerminalTabBarMetrics.contentSpacing + .padding(.horizontal, TerminalTabBarMetrics.contentSpacing) + .background( + RoundedRectangle( + cornerRadius: TerminalTabBarMetrics.renameFieldCornerRadius, + style: .continuous + ) + .fill(Color(nsColor: .textBackgroundColor)) + .overlay( + RoundedRectangle( + cornerRadius: TerminalTabBarMetrics.renameFieldCornerRadius, + style: .continuous + ) + .strokeBorder(Color.accentColor, lineWidth: 1.5) + ) ) + .padding(.leading, TerminalTabBarMetrics.tabHorizontalPadding - TerminalTabBarMetrics.contentSpacing) + .padding(.trailing, TerminalTabBarMetrics.closeButtonSize + TerminalTabBarMetrics.contentSpacing) + .padding(.vertical, TerminalTabBarMetrics.renameFieldInset) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) .onSubmit { onEndRename() } .onExitCommand { @@ -124,18 +137,17 @@ struct TerminalTabView: View { initialEditingTitle = tab.displayTitle cancelOnExit = false isFieldFocused = true + // The field editor only attaches after SwiftUI promotes the TextField + // to first responder, so defer selectAll one hop to land on it. + DispatchQueue.main.async { + NSApp.sendAction(#selector(NSText.selectAll(_:)), to: nil, from: nil) + } } else if cancelOnExit { cancelOnExit = false } else if editingTitle != initialEditingTitle { onRename(editingTitle) } } - .onDisappear { - guard isEditing else { return } - defer { onEndRename() } - guard !cancelOnExit, editingTitle != initialEditingTitle else { return } - onRename(editingTitle) - } .zIndex(isActive ? 2 : (isDragging ? 3 : 0)) .overlay { MiddleClickView(action: onClose) diff --git a/supacodeTests/TerminalLayoutSnapshotPayloadTests.swift b/supacodeTests/TerminalLayoutSnapshotPayloadTests.swift index 0b109cd8..7b52a7fe 100644 --- a/supacodeTests/TerminalLayoutSnapshotPayloadTests.swift +++ b/supacodeTests/TerminalLayoutSnapshotPayloadTests.swift @@ -273,6 +273,68 @@ struct TerminalLayoutSnapshotPayloadTests { #expect(decodedTab?.icon == nil) } + @Test func decodeValidatedMigratesV1TitleIntoCustomTitle() { + let json = #""" + { + "version": 1, + "worktrees": [ + { + "worktreeID": "wt-1", + "selectedTabID": "tab-1", + "tabs": [ + { + "tabID": "tab-1", + "title": "Frozen Title", + "splitRoot": { + "kind": "leaf", + "surfaceID": "surface-1" + } + } + ] + } + ] + } + """# + let data = Data(json.utf8) + + let decoded = TerminalLayoutSnapshotPayload.decodeValidated(from: data) + #expect(decoded?.version == TerminalLayoutSnapshotPayload.currentVersion) + let decodedTab = decoded?.worktrees.first?.tabs.first + #expect(decodedTab?.title == nil) + #expect(decodedTab?.customTitle == "Frozen Title") + } + + @Test func decodeValidatedV1MigrationLeavesExistingCustomTitleAlone() { + let json = #""" + { + "version": 1, + "worktrees": [ + { + "worktreeID": "wt-1", + "selectedTabID": "tab-1", + "tabs": [ + { + "tabID": "tab-1", + "title": "Live", + "customTitle": "Pinned", + "splitRoot": { + "kind": "leaf", + "surfaceID": "surface-1" + } + } + ] + } + ] + } + """# + let data = Data(json.utf8) + + let decoded = TerminalLayoutSnapshotPayload.decodeValidated(from: data) + let decodedTab = decoded?.worktrees.first?.tabs.first + #expect(decodedTab?.title == "Live") + #expect(decodedTab?.customTitle == "Pinned") + } + @Test func decodeValidatedBackwardCompatibleWithoutTitleAndIcon() { let json = #""" { -- 2.51.2