diff --git a/.gitignore b/.gitignore index 38bbdb95..454ad20e 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,4 @@ build/ # Release-only credentials (analytics, crash reporting) Config/Secrets.env +.build-benchmark/ diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index a55d7a12..92ac81f8 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -357,10 +357,12 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COMPILATION_CACHE_ENABLE_CACHING = YES; COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 9ZLSJ2GN2B; + EAGER_LINKING = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index a98c34a2..1b99106a 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -201,17 +201,7 @@ struct SupacodeApp: App { let cliServer = Self.makeCLISocketServer(appStore: appStore, terminalManager: terminalManager) _cliSocketServer = State(initialValue: cliServer) - let watchdog = MemoryWatchdog( - analyticsCapture: AnalyticsClient.liveValue.capture, - contextProvider: { [appStore, terminalManager] in - let state = appStore.state - return MemoryWatchdog.Context( - repositoryCount: state.repositories.repositories.count, - openedWorktreeCount: state.repositories.repositories.flatMap(\.worktrees).count, - terminalTabCount: terminalManager.activeWorktreeStates.flatMap(\.tabManager.tabs).count - ) - } - ) + let watchdog = Self.makeMemoryWatchdog(appStore: appStore, terminalManager: terminalManager) #if !DEBUG watchdog.start() #endif @@ -233,6 +223,28 @@ struct SupacodeApp: App { #endif } + private static func makeMemoryWatchdog( + appStore: StoreOf, + terminalManager: WorktreeTerminalManager + ) -> MemoryWatchdog { + MemoryWatchdog( + analyticsCapture: AnalyticsClient.liveValue.capture, + contextProvider: { [appStore, terminalManager] in + let repositoriesState = appStore.state.repositories + let repositories = repositoriesState.repositories + let repositoryCount = repositories.count + let openedWorktreeCount = repositories.reduce(0) { $0 + $1.worktrees.count } + let activeStates = terminalManager.activeWorktreeStates + let terminalTabCount = activeStates.reduce(0) { $0 + $1.tabManager.tabs.count } + return MemoryWatchdog.Context( + repositoryCount: repositoryCount, + openedWorktreeCount: openedWorktreeCount, + terminalTabCount: terminalTabCount + ) + } + ) + } + private static func makeTerminalClient(terminalManager: WorktreeTerminalManager) -> TerminalClient { TerminalClient( send: { command in diff --git a/supacode/CLIService/ListRuntimeSnapshotBuilder.swift b/supacode/CLIService/ListRuntimeSnapshotBuilder.swift index 3d0f6e1f..720a2fc1 100644 --- a/supacode/CLIService/ListRuntimeSnapshotBuilder.swift +++ b/supacode/CLIService/ListRuntimeSnapshotBuilder.swift @@ -14,10 +14,12 @@ enum ListRuntimeSnapshotBuilder { repositoriesState: RepositoriesFeature.State, terminalManager: WorktreeTerminalManager ) -> ListRuntimeSnapshot { - let activeSnapshots = Dictionary( - uniqueKeysWithValues: terminalManager.activeWorktreeStates.map { - ($0.worktreeID, $0.makeCLIListSnapshot()) - }) + let activeStates = terminalManager.activeWorktreeStates + var activeSnapshots: [String: CLIWorktreeTerminalSnapshot] = [:] + activeSnapshots.reserveCapacity(activeStates.count) + for state in activeStates { + activeSnapshots[state.worktreeID] = state.makeCLIListSnapshot() + } let orderedContexts = orderedWorktreeContexts(from: repositoriesState) let focusedWorktreeID = terminalManager.selectedWorktreeID ?? terminalManager.canvasFocusedWorktreeID diff --git a/supacode/CLIService/TargetResolver.swift b/supacode/CLIService/TargetResolver.swift index 12084940..9e5da629 100644 --- a/supacode/CLIService/TargetResolver.swift +++ b/supacode/CLIService/TargetResolver.swift @@ -243,11 +243,11 @@ enum TargetResolutionSnapshotBuilder { repositoriesState: RepositoriesFeature.State, terminalManager: WorktreeTerminalManager ) -> TargetResolutionSnapshot { - let activeSnapshots = Dictionary( - uniqueKeysWithValues: terminalManager.activeWorktreeStates.map { - ($0.worktreeID, $0) - } - ) + var activeSnapshots: [String: WorktreeTerminalState] = [:] + activeSnapshots.reserveCapacity(terminalManager.activeWorktreeStates.count) + for state in terminalManager.activeWorktreeStates { + activeSnapshots[state.worktreeID] = state + } let orderedContexts = ListRuntimeSnapshotBuilder.orderedWorktreeContexts(from: repositoriesState) let focusedWorktreeID = terminalManager.selectedWorktreeID ?? terminalManager.canvasFocusedWorktreeID diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index 559011fd..43f6887f 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -78,97 +78,7 @@ struct CanvasView: View { .onTapGesture { clearSelection(states: activeStates) } .gesture(canvasPanGesture) - // Cards layer: one card per open tab across all worktrees. - // Uses .offset() (not .position()) to avoid parent size proposals - // reaching the NSView, keeping terminal grid stable during zoom. - ForEach(activeStates, id: \.worktreeID) { state in - ForEach(state.tabManager.tabs) { tab in - if state.surfaceView(for: tab.id) != nil { - let tree = state.splitTree(for: tab.id) - let cardKey = tab.id.rawValue.uuidString - let baseLayout = layoutStore.cardLayouts[cardKey] ?? CanvasCardLayout(position: .zero) - let resized = resizedFrame(for: tab.id, baseLayout: baseLayout) - let screenCenter = screenPosition(for: resized.center) - let cardTotalHeight = resized.size.height + titleBarHeight - let unfocusedSplitOverlay = terminalManager.unfocusedSplitOverlay() - let splitDivider = terminalManager.splitDividerAppearance() - - let repositoryAppearance = appearance(for: state.repositoryRootURL) - let resolvedRepositoryName = repositoryDisplayName(for: state.repositoryRootURL) - CanvasCardView( - repositoryName: resolvedRepositoryName, - worktreeName: tab.displayTitle, - repositoryIcon: repositoryAppearance.icon, - repositoryColor: repositoryAppearance.color?.color, - repositoryRootURL: state.repositoryRootURL, - tree: tree, - activeSurfaceID: state.activeSurfaceID(for: tab.id), - unfocusedSplitOverlay: unfocusedSplitOverlay, - splitDivider: splitDivider, - isFocused: selectionState.primaryTabID == tab.id, - isSelected: selectionState.selectedTabIDs.contains(tab.id), - hasUnseenNotification: state.hasUnseenNotification(for: tab.id), - cardSize: resized.size, - canvasScale: canvasScale, - showsSelectionShield: showsSelectionShield(for: tab.id), - onTap: { - let cmdHeld = NSEvent.modifierFlags.contains(.command) - if cmdHeld { - handleSelectionShieldTap(tab.id, surfaceState: state, states: activeStates) - } else { - focusSingleCard(tab.id, surfaceState: state, states: activeStates) - } - }, - onSelectionTap: { - handleSelectionShieldTap(tab.id, surfaceState: state, states: activeStates) - }, - onDragCommit: { translation in commitDrag(for: cardKey, translation: translation) }, - onResize: { edge, translation in - activeResize[tab.id] = ActiveResize( - edge: edge, - translation: CGSize( - width: translation.width / canvasScale, - height: translation.height / canvasScale - ) - ) - }, - onResizeEnd: { commitResize(for: tab.id, cardKey: cardKey, surfaces: tree.leaves()) }, - onSplitOperation: { operation in - state.performSplitOperation(operation, in: tab.id) - if selectionState.isBroadcasting { - syncBroadcastCallbacks(states: activeStates) - } - }, - onTitleBarTap: { - let wasAlreadyFocused = - selectionState.primaryTabID == tab.id - && selectionState.selectedTabIDs.count <= 1 - focusSingleCard(tab.id, surfaceState: state, states: activeStates) - let now = Date() - if wasAlreadyFocused, - now.timeIntervalSince(lastTitleBarTapDate) <= NSEvent.doubleClickInterval - { - onExitToTab() - } - lastTitleBarTapDate = now - }, - onExpand: { - focusSingleCard(tab.id, surfaceState: state, states: activeStates) - onExitToTab() - }, - onClose: { - state.closeTab(tab.id) - } - ) - .scaleEffect(canvasScale, anchor: .center) - .offset( - x: screenCenter.x - resized.size.width / 2, - y: screenCenter.y - cardTotalHeight / 2 - ) - .zIndex(zIndex(for: tab.id)) - } - } - } + cardsLayer(activeStates: activeStates) } .contentShape(.rect) .simultaneousGesture(canvasZoomGesture) @@ -220,6 +130,112 @@ struct CanvasView: View { return false } + // MARK: - Cards Layer + + /// Cards layer: one card per open tab across all worktrees. + /// Uses .offset() (not .position()) to avoid parent size proposals + /// reaching the NSView, keeping terminal grid stable during zoom. + @ViewBuilder + private func cardsLayer(activeStates: [WorktreeTerminalState]) -> some View { + ForEach(activeStates, id: \.worktreeID) { state in + ForEach(state.tabManager.tabs) { tab in + if state.surfaceView(for: tab.id) != nil { + cardView(for: tab, in: state, activeStates: activeStates) + } + } + } + } + + @ViewBuilder + private func cardView( + for tab: TerminalTabItem, + in state: WorktreeTerminalState, + activeStates: [WorktreeTerminalState] + ) -> some View { + let tree = state.splitTree(for: tab.id) + let cardKey = tab.id.rawValue.uuidString + let baseLayout = layoutStore.cardLayouts[cardKey] ?? CanvasCardLayout(position: .zero) + let resized = resizedFrame(for: tab.id, baseLayout: baseLayout) + let screenCenter = screenPosition(for: resized.center) + let cardTotalHeight = resized.size.height + titleBarHeight + let unfocusedSplitOverlay = terminalManager.unfocusedSplitOverlay() + let splitDivider = terminalManager.splitDividerAppearance() + let repositoryAppearance = appearance(for: state.repositoryRootURL) + let resolvedRepositoryName = repositoryDisplayName(for: state.repositoryRootURL) + + CanvasCardView( + repositoryName: resolvedRepositoryName, + worktreeName: tab.displayTitle, + repositoryIcon: repositoryAppearance.icon, + repositoryColor: repositoryAppearance.color?.color, + repositoryRootURL: state.repositoryRootURL, + tree: tree, + activeSurfaceID: state.activeSurfaceID(for: tab.id), + unfocusedSplitOverlay: unfocusedSplitOverlay, + splitDivider: splitDivider, + isFocused: selectionState.primaryTabID == tab.id, + isSelected: selectionState.selectedTabIDs.contains(tab.id), + hasUnseenNotification: state.hasUnseenNotification(for: tab.id), + cardSize: resized.size, + canvasScale: canvasScale, + showsSelectionShield: showsSelectionShield(for: tab.id), + onTap: { + let cmdHeld = NSEvent.modifierFlags.contains(.command) + if cmdHeld { + handleSelectionShieldTap(tab.id, surfaceState: state, states: activeStates) + } else { + focusSingleCard(tab.id, surfaceState: state, states: activeStates) + } + }, + onSelectionTap: { + handleSelectionShieldTap(tab.id, surfaceState: state, states: activeStates) + }, + onDragCommit: { translation in commitDrag(for: cardKey, translation: translation) }, + onResize: { edge, translation in + activeResize[tab.id] = ActiveResize( + edge: edge, + translation: CGSize( + width: translation.width / canvasScale, + height: translation.height / canvasScale + ) + ) + }, + onResizeEnd: { commitResize(for: tab.id, cardKey: cardKey, surfaces: tree.leaves()) }, + onSplitOperation: { operation in + state.performSplitOperation(operation, in: tab.id) + if selectionState.isBroadcasting { + syncBroadcastCallbacks(states: activeStates) + } + }, + onTitleBarTap: { + let wasAlreadyFocused = + selectionState.primaryTabID == tab.id + && selectionState.selectedTabIDs.count <= 1 + focusSingleCard(tab.id, surfaceState: state, states: activeStates) + let now = Date() + if wasAlreadyFocused, + now.timeIntervalSince(lastTitleBarTapDate) <= NSEvent.doubleClickInterval + { + onExitToTab() + } + lastTitleBarTapDate = now + }, + onExpand: { + focusSingleCard(tab.id, surfaceState: state, states: activeStates) + onExitToTab() + }, + onClose: { + state.closeTab(tab.id) + } + ) + .scaleEffect(canvasScale, anchor: .center) + .offset( + x: screenCenter.x - resized.size.width / 2, + y: screenCenter.y - cardTotalHeight / 2 + ) + .zIndex(zIndex(for: tab.id)) + } + // MARK: - Canvas Gestures private var canvasPanGesture: some Gesture { diff --git a/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift b/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift index 7d503de0..d83b34a5 100644 --- a/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift +++ b/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift @@ -33,27 +33,19 @@ extension RepositoriesFeature.State { terminalManager: WorktreeTerminalManager, customTitles: [Repository.ID: String] = [:] ) -> [ToolbarNotificationRepositoryGroup] { - let repositoriesByID = Dictionary(uniqueKeysWithValues: repositories.map { ($0.id, $0) }) - var groups: [ToolbarNotificationRepositoryGroup] = [] + var repositoriesByID: [Repository.ID: Repository] = [:] + repositoriesByID.reserveCapacity(repositories.count) + for repository in repositories { + repositoriesByID[repository.id] = repository + } + var groups: [ToolbarNotificationRepositoryGroup] = [] for repositoryID in orderedRepositoryIDs() { - guard let repository = repositoriesByID[repositoryID] else { - continue - } - - let worktreeGroups: [ToolbarNotificationWorktreeGroup] = - orderedWorktrees(in: repository).compactMap { worktree -> ToolbarNotificationWorktreeGroup? in - guard let state = terminalManager.stateIfExists(for: worktree.id), !state.notifications.isEmpty else { - return nil - } - return ToolbarNotificationWorktreeGroup( - id: worktree.id, - name: worktree.name, - notifications: state.notifications, - hasUnseenNotifications: terminalManager.hasUnseenNotifications(for: worktree.id) - ) - } - + guard let repository = repositoriesByID[repositoryID] else { continue } + let worktreeGroups = worktreeNotificationGroups( + repository: repository, + terminalManager: terminalManager + ) if !worktreeGroups.isEmpty { groups.append( ToolbarNotificationRepositoryGroup( @@ -64,7 +56,27 @@ extension RepositoriesFeature.State { ) } } - return groups } + + private func worktreeNotificationGroups( + repository: Repository, + terminalManager: WorktreeTerminalManager + ) -> [ToolbarNotificationWorktreeGroup] { + var result: [ToolbarNotificationWorktreeGroup] = [] + for worktree in orderedWorktrees(in: repository) { + guard let state = terminalManager.stateIfExists(for: worktree.id), + !state.notifications.isEmpty + else { continue } + result.append( + ToolbarNotificationWorktreeGroup( + id: worktree.id, + name: worktree.name, + notifications: state.notifications, + hasUnseenNotifications: terminalManager.hasUnseenNotifications(for: worktree.id) + ) + ) + } + return result + } } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index ab3290d5..330f86ba 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -1729,13 +1729,18 @@ extension RepositoriesFeature.State { return worktrees.filter { !archivedSet.contains($0.id) } } - func archivedWorktreesByRepository() -> [(repository: Repository, worktrees: [Worktree])] { + struct ArchivedWorktreeGroup: Equatable { + var repository: Repository + var worktrees: [Worktree] + } + + func archivedWorktreesByRepository() -> [ArchivedWorktreeGroup] { let archivedSet = archivedWorktreeIDSet - var groups: [(repository: Repository, worktrees: [Worktree])] = [] + var groups: [ArchivedWorktreeGroup] = [] for repository in repositories { let worktrees = Array(repository.worktrees.filter { archivedSet.contains($0.id) }) if !worktrees.isEmpty { - groups.append((repository: repository, worktrees: worktrees)) + groups.append(ArchivedWorktreeGroup(repository: repository, worktrees: worktrees)) } } return groups diff --git a/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift b/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift index c6c4a846..61d32b9a 100644 --- a/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift +++ b/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift @@ -7,95 +7,158 @@ struct ArchivedWorktreesDetailView: View { @State private var selectedArchivedWorktreeIDs: Set = [] var body: some View { + let snapshot = makeSnapshot() + if snapshot.groups.isEmpty { + emptyView + } else { + listView(snapshot: snapshot) + } + } + + private var emptyView: some View { + ContentUnavailableView( + "Archived Worktrees", + systemImage: "archivebox", + description: Text("Archive worktrees to keep them out of the main list.") + ) + } + + private func listView(snapshot: ArchivedSnapshot) -> some View { + let groups = snapshot.groups + return List(selection: $selectedArchivedWorktreeIDs) { + ForEach(groups.indices, id: \.self) { index in + archivedSection(index: index, group: groups[index]) + } + } + .listStyle(.sidebar) + .onChange(of: snapshot.groupIDs) { _, newValue in + collapsedRepositoryIDs = collapsedRepositoryIDs.intersection(newValue) + } + .onChange(of: snapshot.archivedWorktreeIDs) { _, newValue in + selectedArchivedWorktreeIDs = selectedArchivedWorktreeIDs.intersection(newValue) + } + .animation(.easeOut(duration: 0.2), value: snapshot.archivedRowIDs) + .focusedValue(\.deleteWorktreeAction, snapshot.deleteWorktreeAction) + .focusedSceneValue(\.confirmWorktreeAction, snapshot.confirmWorktreeAction) + .toolbar { toolbarContent(deleteWorktreeAction: snapshot.deleteWorktreeAction) } + } + + @ViewBuilder + private func archivedSection( + index: Int, + group: RepositoriesFeature.State.ArchivedWorktreeGroup + ) -> some View { + let isCollapsed = collapsedRepositoryIDs.contains(group.repository.id) + Section { + if !isCollapsed { + ForEach(group.worktrees, id: \.id) { worktree in + archivedRow(worktree: worktree, repositoryID: group.repository.id) + } + } + } header: { + ArchivedWorktreeSectionHeader( + name: group.repository.name, + worktreeCount: group.worktrees.count, + isCollapsed: isCollapsed, + showsTopSeparator: index > 0, + onToggle: { toggleSection(group.repository.id) } + ) + } + } + + private func archivedRow(worktree: Worktree, repositoryID: Repository.ID) -> some View { + ArchivedWorktreeRowView( + worktree: worktree, + info: store.state.worktreeInfo(for: worktree.id), + onUnarchive: { + store.send(.worktreeLifecycle(.unarchiveWorktree(worktree.id))) + }, + onDelete: { + store.send(.worktreeLifecycle(.requestDeleteWorktree(worktree.id, repositoryID))) + } + ) + .tag(worktree.id) + .typeSelectEquivalent("") + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + } + + @ToolbarContentBuilder + private func toolbarContent(deleteWorktreeAction: (() -> Void)?) -> some ToolbarContent { + ToolbarItem { + let deleteShortcut = KeyboardShortcut(.delete, modifiers: [.command, .shift]).display + Button("Delete Selected", systemImage: "trash", role: .destructive) { + deleteWorktreeAction?() + } + .help("Delete Selected (\(deleteShortcut))") + .disabled(deleteWorktreeAction == nil) + } + } + + private struct ArchivedSnapshot { + var groups: [RepositoriesFeature.State.ArchivedWorktreeGroup] + var groupIDs: Set + var archivedRowIDs: [Worktree.ID] + var archivedWorktreeIDs: Set + var deleteWorktreeAction: (() -> Void)? + var confirmWorktreeAction: (() -> Void)? + } + + private func makeSnapshot() -> ArchivedSnapshot { let groups = store.state.archivedWorktreesByRepository() let groupIDs = Set(groups.map(\.repository.id)) let archivedRowIDs = groups.flatMap(\.worktrees).map(\.id) - let archivedWorktreeIDs = Set(groups.flatMap(\.worktrees).map(\.id)) - let repositoryByWorktreeID = Dictionary( - uniqueKeysWithValues: groups.flatMap { group in - group.worktrees.map { worktree in - (worktree.id, group.repository.id) - } + let archivedWorktreeIDs = Set(archivedRowIDs) + + var repositoryByWorktreeID: [Worktree.ID: Repository.ID] = [:] + repositoryByWorktreeID.reserveCapacity(archivedRowIDs.count) + for group in groups { + for worktree in group.worktrees { + repositoryByWorktreeID[worktree.id] = group.repository.id } - ) - let selectedTargets: [RepositoriesFeature.DeleteWorktreeTarget] = - selectedArchivedWorktreeIDs.compactMap { worktreeID in - guard let repositoryID = repositoryByWorktreeID[worktreeID] else { return nil } - return RepositoriesFeature.DeleteWorktreeTarget( - worktreeID: worktreeID, - repositoryID: repositoryID + } + + var selectedTargets: [RepositoriesFeature.DeleteWorktreeTarget] = [] + selectedTargets.reserveCapacity(selectedArchivedWorktreeIDs.count) + for worktreeID in selectedArchivedWorktreeIDs { + if let repositoryID = repositoryByWorktreeID[worktreeID] { + selectedTargets.append( + RepositoriesFeature.DeleteWorktreeTarget( + worktreeID: worktreeID, + repositoryID: repositoryID + ) ) } - let deleteWorktreeAction: (() -> Void)? = { - guard !selectedTargets.isEmpty else { return nil } - return { + } + + let deleteWorktreeAction: (() -> Void)? + if selectedTargets.isEmpty { + deleteWorktreeAction = nil + } else { + let store = self.store + deleteWorktreeAction = { store.send(.worktreeLifecycle(.requestDeleteWorktrees(selectedTargets))) } - }() - let confirmWorktreeAction: (() -> Void)? = { - guard let alert = store.state.confirmWorktreeAlert else { return nil } - return { + } + + let confirmWorktreeAction: (() -> Void)? + if let alert = store.state.confirmWorktreeAlert { + let store = self.store + confirmWorktreeAction = { store.send(.alert(.presented(alert))) } - }() - if groups.isEmpty { - ContentUnavailableView( - "Archived Worktrees", - systemImage: "archivebox", - description: Text("Archive worktrees to keep them out of the main list.") - ) } else { - List(selection: $selectedArchivedWorktreeIDs) { - ForEach(Array(groups.enumerated()), id: \.element.repository.id) { index, group in - Section { - if !collapsedRepositoryIDs.contains(group.repository.id) { - ForEach(group.worktrees, id: \.id) { worktree in - ArchivedWorktreeRowView( - worktree: worktree, - info: store.state.worktreeInfo(for: worktree.id), - onUnarchive: { - store.send(.worktreeLifecycle(.unarchiveWorktree(worktree.id))) - }, - onDelete: { - store.send(.worktreeLifecycle(.requestDeleteWorktree(worktree.id, group.repository.id))) - } - ) - .tag(worktree.id) - .typeSelectEquivalent("") - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) - } - } - } header: { - ArchivedWorktreeSectionHeader( - name: group.repository.name, - worktreeCount: group.worktrees.count, - isCollapsed: collapsedRepositoryIDs.contains(group.repository.id), - showsTopSeparator: index > 0, - onToggle: { toggleSection(group.repository.id) } - ) - } - } - } - .listStyle(.sidebar) - .onChange(of: groupIDs) { _, newValue in - collapsedRepositoryIDs = collapsedRepositoryIDs.intersection(newValue) - } - .onChange(of: archivedWorktreeIDs) { _, newValue in - selectedArchivedWorktreeIDs = selectedArchivedWorktreeIDs.intersection(newValue) - } - .animation(.easeOut(duration: 0.2), value: archivedRowIDs) - .focusedValue(\.deleteWorktreeAction, deleteWorktreeAction) - .focusedSceneValue(\.confirmWorktreeAction, confirmWorktreeAction) - .toolbar { - let deleteShortcut = KeyboardShortcut(.delete, modifiers: [.command, .shift]).display - Button("Delete Selected", systemImage: "trash", role: .destructive) { - deleteWorktreeAction?() - } - .help("Delete Selected (\(deleteShortcut))") - .disabled(deleteWorktreeAction == nil) - } + confirmWorktreeAction = nil } + + return ArchivedSnapshot( + groups: groups, + groupIDs: groupIDs, + archivedRowIDs: archivedRowIDs, + archivedWorktreeIDs: archivedWorktreeIDs, + deleteWorktreeAction: deleteWorktreeAction, + confirmWorktreeAction: confirmWorktreeAction + ) } private func toggleSection(_ repositoryID: Repository.ID) { diff --git a/supacode/Features/Shelf/Views/ShelfOpenBookView.swift b/supacode/Features/Shelf/Views/ShelfOpenBookView.swift index b24796f5..81eb7241 100644 --- a/supacode/Features/Shelf/Views/ShelfOpenBookView.swift +++ b/supacode/Features/Shelf/Views/ShelfOpenBookView.swift @@ -24,82 +24,89 @@ struct ShelfOpenBookView: View { var body: some View { let state = manager.state(for: worktree) { shouldRunSetupScript } let _ = configReloadCounter - let unfocusedSplitOverlay = manager.unfocusedSplitOverlay() - let splitDivider = manager.splitDividerAppearance() - Group { - if let selectedId = state.tabManager.selectedTabId { - TerminalTabContentStack(tabs: state.tabManager.tabs, selectedTabId: selectedId) { tabId in - TerminalSplitTreeAXContainer( - tree: state.splitTree(for: tabId), - activeSurfaceID: state.activeSurfaceID(for: tabId), - unfocusedSplitOverlay: unfocusedSplitOverlay, - splitDivider: splitDivider, - hasNotification: { surfaceID in - state.hasUnseenNotification(forSurfaceID: surfaceID) - }, - action: { operation in - state.performSplitOperation(operation, in: tabId) - } - ) - } - } else { - EmptyTerminalPaneView(message: "No terminals open") + contentGroup(state: state) + .sheet( + item: Binding( + get: { state.iconPickerTabId }, + set: { state.iconPickerTabId = $0 } + ) + ) { tabId in + iconPickerSheet(state: state, tabId: tabId) } - } - .sheet( - item: Binding( - get: { state.iconPickerTabId }, - set: { state.iconPickerTabId = $0 } - ) - ) { tabId in - let currentIcon = state.tabManager.tabs.first(where: { $0.id == tabId })?.icon - TabIconPickerView( - initialIcon: currentIcon, - defaultIcon: state.defaultIcon(for: tabId), - onApply: { newIcon in - state.applyIconChange(tabId, icon: newIcon) - state.dismissIconPicker() - }, - onCancel: { - state.dismissIconPicker() + .background( + WindowFocusObserverView { activity in + windowActivity = activity + state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) } ) - } - .background( - WindowFocusObserverView { activity in - windowActivity = activity - state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) - } - ) - .onAppear { - shelfLogger.interval("OpenBook.onAppear") { - state.ensureInitialTab(focusing: false) - if shouldAutoFocusTerminal { - state.focusSelectedTab() + .onAppear { + shelfLogger.interval("OpenBook.onAppear") { + state.ensureInitialTab(focusing: false) + if shouldAutoFocusTerminal { + state.focusSelectedTab() + } + let activity = resolvedWindowActivity + state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) } - let activity = resolvedWindowActivity - state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) } - } - .onDisappear { - // Long-term diagnostic — pairs with `OpenBook.onAppear` so that - // any future regression in the per-book-switch teardown/remount - // cadence shows up as a count delta on the Points of Interest - // timeline. - shelfLogger.event("OpenBook.onDisappear") - } - .onChange(of: state.tabManager.selectedTabId) { _, _ in - shelfLogger.interval("OpenBook.onChange.selectedTabId") { - if shouldAutoFocusTerminal { - state.focusSelectedTab() + .onDisappear { + // Long-term diagnostic — pairs with `OpenBook.onAppear` so that + // any future regression in the per-book-switch teardown/remount + // cadence shows up as a count delta on the Points of Interest + // timeline. + shelfLogger.event("OpenBook.onDisappear") + } + .onChange(of: state.tabManager.selectedTabId) { _, _ in + shelfLogger.interval("OpenBook.onChange.selectedTabId") { + if shouldAutoFocusTerminal { + state.focusSelectedTab() + } + let activity = resolvedWindowActivity + state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) } - let activity = resolvedWindowActivity - state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) } + .onReceive(NotificationCenter.default.publisher(for: .ghosttyRuntimeConfigDidChange)) { _ in + configReloadCounter &+= 1 + } + } + + @ViewBuilder + private func contentGroup(state: WorktreeTerminalState) -> some View { + let unfocusedSplitOverlay = manager.unfocusedSplitOverlay() + let splitDivider = manager.splitDividerAppearance() + if let selectedId = state.tabManager.selectedTabId { + TerminalTabContentStack(tabs: state.tabManager.tabs, selectedTabId: selectedId) { tabId in + TerminalSplitTreeAXContainer( + tree: state.splitTree(for: tabId), + activeSurfaceID: state.activeSurfaceID(for: tabId), + unfocusedSplitOverlay: unfocusedSplitOverlay, + splitDivider: splitDivider, + hasNotification: { surfaceID in + state.hasUnseenNotification(forSurfaceID: surfaceID) + }, + action: { operation in + state.performSplitOperation(operation, in: tabId) + } + ) + } + } else { + EmptyTerminalPaneView(message: "No terminals open") } - .onReceive(NotificationCenter.default.publisher(for: .ghosttyRuntimeConfigDidChange)) { _ in - configReloadCounter &+= 1 - } + } + + private func iconPickerSheet(state: WorktreeTerminalState, tabId: TerminalTabID) -> some View { + let currentIcon = state.tabManager.tabs.first(where: { $0.id == tabId })?.icon + return TabIconPickerView( + initialIcon: currentIcon, + defaultIcon: state.defaultIcon(for: tabId), + onApply: { newIcon in + state.applyIconChange(tabId, icon: newIcon) + state.dismissIconPicker() + }, + onCancel: { + state.dismissIconPicker() + } + ) } private var shouldAutoFocusTerminal: Bool { diff --git a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift index fa2ba54e..6e89e568 100644 --- a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift +++ b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift @@ -13,59 +13,9 @@ struct WorktreeTerminalTabsView: View { var body: some View { let state = manager.state(for: worktree) { shouldRunSetupScript } let _ = configReloadCounter - let unfocusedSplitOverlay = manager.unfocusedSplitOverlay() - let splitDivider = manager.splitDividerAppearance() VStack(spacing: 0) { - TerminalTabBarView( - manager: state.tabManager, - createTab: createTab, - splitHorizontally: { - _ = state.performBindingActionOnFocusedSurface("new_split:down") - }, - splitVertically: { - _ = state.performBindingActionOnFocusedSurface("new_split:right") - }, - canSplit: state.tabManager.selectedTabId != nil, - renameTab: { tabId in - state.tabManager.beginTabRename(tabId) - }, - changeIcon: { tabId in - state.presentIconPicker(for: tabId) - }, - closeTab: { tabId in - state.closeTab(tabId) - }, - closeOthers: { tabId in - state.closeOtherTabs(keeping: tabId) - }, - closeToRight: { tabId in - state.closeTabsToRight(of: tabId) - }, - closeAll: { - state.closeAllTabs() - }, - hasNotification: { tabId in - state.hasUnseenNotification(for: tabId) - } - ) - if let selectedId = state.tabManager.selectedTabId { - TerminalTabContentStack(tabs: state.tabManager.tabs, selectedTabId: selectedId) { tabId in - TerminalSplitTreeAXContainer( - tree: state.splitTree(for: tabId), - activeSurfaceID: state.activeSurfaceID(for: tabId), - unfocusedSplitOverlay: unfocusedSplitOverlay, - splitDivider: splitDivider, - hasNotification: { surfaceID in - state.hasUnseenNotification(forSurfaceID: surfaceID) - }, - action: { operation in - state.performSplitOperation(operation, in: tabId) - } - ) - } - } else { - EmptyTerminalPaneView(message: "No terminals open") - } + tabBar(state: state) + tabContent(state: state) } .sheet( item: Binding( @@ -73,18 +23,7 @@ struct WorktreeTerminalTabsView: View { set: { state.iconPickerTabId = $0 } ) ) { tabId in - let currentIcon = state.tabManager.tabs.first(where: { $0.id == tabId })?.icon - TabIconPickerView( - initialIcon: currentIcon, - defaultIcon: state.defaultIcon(for: tabId), - onApply: { newIcon in - state.applyIconChange(tabId, icon: newIcon) - state.dismissIconPicker() - }, - onCancel: { - state.dismissIconPicker() - } - ) + iconPickerSheet(state: state, tabId: tabId) } .background( WindowFocusObserverView { activity in @@ -112,6 +51,80 @@ struct WorktreeTerminalTabsView: View { } } + private func tabBar(state: WorktreeTerminalState) -> some View { + TerminalTabBarView( + manager: state.tabManager, + createTab: createTab, + splitHorizontally: { + _ = state.performBindingActionOnFocusedSurface("new_split:down") + }, + splitVertically: { + _ = state.performBindingActionOnFocusedSurface("new_split:right") + }, + canSplit: state.tabManager.selectedTabId != nil, + renameTab: { tabId in + state.tabManager.beginTabRename(tabId) + }, + changeIcon: { tabId in + state.presentIconPicker(for: tabId) + }, + closeTab: { tabId in + state.closeTab(tabId) + }, + closeOthers: { tabId in + state.closeOtherTabs(keeping: tabId) + }, + closeToRight: { tabId in + state.closeTabsToRight(of: tabId) + }, + closeAll: { + state.closeAllTabs() + }, + hasNotification: { tabId in + state.hasUnseenNotification(for: tabId) + } + ) + } + + @ViewBuilder + private func tabContent(state: WorktreeTerminalState) -> some View { + let unfocusedSplitOverlay = manager.unfocusedSplitOverlay() + let splitDivider = manager.splitDividerAppearance() + if let selectedId = state.tabManager.selectedTabId { + TerminalTabContentStack(tabs: state.tabManager.tabs, selectedTabId: selectedId) { tabId in + TerminalSplitTreeAXContainer( + tree: state.splitTree(for: tabId), + activeSurfaceID: state.activeSurfaceID(for: tabId), + unfocusedSplitOverlay: unfocusedSplitOverlay, + splitDivider: splitDivider, + hasNotification: { surfaceID in + state.hasUnseenNotification(forSurfaceID: surfaceID) + }, + action: { operation in + state.performSplitOperation(operation, in: tabId) + } + ) + } + } else { + EmptyTerminalPaneView(message: "No terminals open") + } + } + + private func iconPickerSheet(state: WorktreeTerminalState, tabId: TerminalTabID) -> some View { + let currentIcon = state.tabManager.tabs.first(where: { $0.id == tabId })?.icon + return TabIconPickerView( + initialIcon: currentIcon, + defaultIcon: state.defaultIcon(for: tabId), + onApply: { newIcon in + state.applyIconChange(tabId, icon: newIcon) + state.dismissIconPicker() + }, + onCancel: { + state.dismissIconPicker() + } + ) + } + private var shouldAutoFocusTerminal: Bool { if forceAutoFocus { return true