native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #2 from onevcat/feature/dashboard-live-sessions

Add Canvas (Live Sessions) with multi-tab grid layout

authored by

Wei Wang and committed by
GitHub
1f4ef914 905ee0b5

+990 -20
+23
.claude/hooks/block-upstream-pr.sh
··· 1 + #!/bin/bash 2 + # PreToolUse hook: block gh pr create unless it explicitly targets the fork. 3 + # Exit 2 = block the command, exit 0 = allow. 4 + 5 + INPUT=$(cat) 6 + COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty') 7 + 8 + # Only inspect gh pr create commands 9 + if echo "$COMMAND" | grep -qE 'gh\s+pr\s+create'; then 10 + # Allow if explicitly targeting the fork 11 + if echo "$COMMAND" | grep -qE '(--repo|--repo=|-R)\s*onevcat/supacode'; then 12 + exit 0 13 + fi 14 + cat <<EOF >&2 15 + BLOCKED: gh pr create must explicitly target the fork. 16 + 17 + Use: gh pr create --repo onevcat/supacode ... 18 + Never target upstream (supabitapp/supacode). 19 + EOF 20 + exit 2 21 + fi 22 + 23 + exit 0
+15
.claude/settings.json
··· 1 + { 2 + "hooks": { 3 + "PreToolUse": [ 4 + { 5 + "matcher": "Bash", 6 + "hooks": [ 7 + { 8 + "type": "command", 9 + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-upstream-pr.sh" 10 + } 11 + ] 12 + } 13 + ] 14 + } 15 + }
+1
AGENTS.md
··· 114 114 - Automatically commit your changes and your changes only. Do not use `git add .` 115 115 - Before you go on your task, check the current git branch name, if it's something generic like an animal name, name it accordingly. Do not do this for main branch 116 116 - After implementing an execplan, always submit a PR if you're not in the main branch 117 + - PRs must target `onevcat/supacode` (this fork), never the upstream `supabitapp/supacode`, unless explicitly requested. 117 118 - Fork releases must be notarized. Never publish non-notarized releases (`ENABLE_NOTARIZATION=0` is forbidden). 118 119 119 120 ## Submodules
+2 -1
supacode/Clients/Git/GitClient.swift
··· 470 470 operation: .untrackedFilePaths, 471 471 arguments: ["-C", path, "ls-files", "--others", "--exclude-standard"] 472 472 ) 473 - return output 473 + return 474 + output 474 475 .split(whereSeparator: \.isNewline) 475 476 .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } 476 477 .filter { !$0.isEmpty }
+1 -1
supacode/Features/App/Reducer/AppFeature.swift
··· 146 146 await worktreeInfoWatcher.send(.setSelectedWorktreeID(nil)) 147 147 }, 148 148 ] 149 - if !state.repositories.isShowingArchivedWorktrees { 149 + if !state.repositories.isShowingArchivedWorktrees, !state.repositories.isShowingCanvas { 150 150 effects.insert( 151 151 .run { _ in 152 152 await repositoryPersistence.saveLastFocusedWorktreeID(lastFocusedWorktreeID)
+60
supacode/Features/Canvas/Models/CanvasCardLayout.swift
··· 1 + import CoreGraphics 2 + import Foundation 3 + 4 + struct CanvasCardLayout: Codable, Equatable, Hashable, Sendable { 5 + var positionX: CGFloat 6 + var positionY: CGFloat 7 + var width: CGFloat 8 + var height: CGFloat 9 + 10 + var position: CGPoint { 11 + get { CGPoint(x: positionX, y: positionY) } 12 + set { 13 + positionX = newValue.x 14 + positionY = newValue.y 15 + } 16 + } 17 + 18 + var size: CGSize { 19 + get { CGSize(width: width, height: height) } 20 + set { 21 + width = newValue.width 22 + height = newValue.height 23 + } 24 + } 25 + 26 + static let defaultSize = CGSize(width: 600, height: 400) 27 + 28 + init(position: CGPoint, size: CGSize = Self.defaultSize) { 29 + self.positionX = position.x 30 + self.positionY = position.y 31 + self.width = size.width 32 + self.height = size.height 33 + } 34 + } 35 + 36 + @MainActor 37 + @Observable 38 + final class CanvasLayoutStore { 39 + private static let storageKey = "canvasCardLayouts" 40 + 41 + var cardLayouts: [String: CanvasCardLayout] { 42 + didSet { save() } 43 + } 44 + 45 + init() { 46 + if let data = UserDefaults.standard.data(forKey: Self.storageKey), 47 + let layouts = try? JSONDecoder().decode([String: CanvasCardLayout].self, from: data) 48 + { 49 + self.cardLayouts = layouts 50 + } else { 51 + self.cardLayouts = [:] 52 + } 53 + } 54 + 55 + private func save() { 56 + if let data = try? JSONEncoder().encode(cardLayouts) { 57 + UserDefaults.standard.set(data, forKey: Self.storageKey) 58 + } 59 + } 60 + }
+252
supacode/Features/Canvas/Views/CanvasCardView.swift
··· 1 + import AppKit 2 + import SwiftUI 3 + 4 + struct CanvasCardView: View { 5 + let repositoryName: String 6 + let worktreeName: String 7 + let tree: SplitTree<GhosttySurfaceView> 8 + let isFocused: Bool 9 + let hasUnseenNotification: Bool 10 + let cardSize: CGSize 11 + let canvasScale: CGFloat 12 + let onTap: () -> Void 13 + let onDragCommit: (CGSize) -> Void 14 + let onResize: (CardResizeEdge, CGSize) -> Void 15 + let onResizeEnd: () -> Void 16 + let onSplitOperation: (TerminalSplitTreeView.Operation) -> Void 17 + 18 + enum CardResizeEdge { 19 + case leading, trailing, top, bottom 20 + case topLeading, topTrailing, bottomLeading, bottomTrailing 21 + 22 + /// Sign multipliers for width and height during resize. 23 + /// +1 = trailing/bottom grows, -1 = leading/top grows, 0 = no change. 24 + var resizeSigns: (width: Int, height: Int) { 25 + switch self { 26 + case .leading: (-1, 0) 27 + case .trailing: (1, 0) 28 + case .top: (0, -1) 29 + case .bottom: (0, 1) 30 + case .topLeading: (-1, -1) 31 + case .topTrailing: (1, -1) 32 + case .bottomLeading: (-1, 1) 33 + case .bottomTrailing: (1, 1) 34 + } 35 + } 36 + } 37 + 38 + private let titleBarHeight: CGFloat = 28 39 + private let cornerRadius: CGFloat = 8 40 + 41 + // Gesture-driven drag state: does NOT trigger body re-evaluation 42 + @GestureState private var dragTranslation: CGSize = .zero 43 + 44 + var body: some View { 45 + VStack(spacing: 0) { 46 + titleBar 47 + terminalContent 48 + } 49 + .frame(width: cardSize.width, height: cardSize.height + titleBarHeight) 50 + .clipShape(.rect(cornerRadius: cornerRadius)) 51 + .overlay { 52 + RoundedRectangle(cornerRadius: cornerRadius) 53 + .stroke(isFocused ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: isFocused ? 2 : 1) 54 + } 55 + .compositingGroup() 56 + .contentShape(.rect) 57 + .accessibilityAddTraits(.isButton) 58 + .onTapGesture { onTap() } 59 + .offset( 60 + x: dragTranslation.width / canvasScale, 61 + y: dragTranslation.height / canvasScale 62 + ) 63 + .overlay { resizeHandles } 64 + } 65 + 66 + private var titleBar: some View { 67 + HStack(spacing: 6) { 68 + if hasUnseenNotification { 69 + Circle() 70 + .fill(Color.orange) 71 + .frame(width: 6, height: 6) 72 + } 73 + Text(repositoryName) 74 + .font(.caption.bold()) 75 + .lineLimit(1) 76 + Text("/ \(worktreeName)") 77 + .font(.caption) 78 + .foregroundStyle(.secondary) 79 + .lineLimit(1) 80 + Spacer() 81 + } 82 + .padding(.horizontal, 8) 83 + .frame(height: titleBarHeight) 84 + .frame(maxWidth: .infinity) 85 + .background(.bar) 86 + .gesture( 87 + DragGesture(coordinateSpace: .global) 88 + .updating($dragTranslation) { value, state, _ in 89 + state = value.translation 90 + } 91 + .onEnded { value in 92 + onDragCommit( 93 + CGSize( 94 + width: value.translation.width / canvasScale, 95 + height: value.translation.height / canvasScale 96 + )) 97 + } 98 + ) 99 + } 100 + 101 + private var terminalContent: some View { 102 + TerminalSplitTreeView(tree: tree, pinnedSize: cardSize, action: onSplitOperation) 103 + .frame(width: cardSize.width, height: cardSize.height) 104 + .allowsHitTesting(isFocused) 105 + } 106 + 107 + // MARK: - Resize Handles 108 + 109 + private let edgeThickness: CGFloat = 10 110 + private let cornerSide: CGFloat = 18 111 + 112 + private var resizeHandles: some View { 113 + ZStack { 114 + edgeHandle( 115 + cursor: .frameResize(position: .left, directions: .all), 116 + isVertical: true, 117 + edgeOffset: CGSize(width: -edgeThickness / 2, height: 0) 118 + ) { translation in 119 + onResize(.leading, translation) 120 + } 121 + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) 122 + 123 + edgeHandle( 124 + cursor: .frameResize(position: .right, directions: .all), 125 + isVertical: true, 126 + edgeOffset: CGSize(width: edgeThickness / 2, height: 0) 127 + ) { translation in 128 + onResize(.trailing, translation) 129 + } 130 + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) 131 + 132 + edgeHandle( 133 + cursor: .frameResize(position: .top, directions: .all), 134 + isVertical: false, 135 + edgeOffset: CGSize(width: 0, height: -edgeThickness / 2) 136 + ) { translation in 137 + onResize(.top, translation) 138 + } 139 + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) 140 + 141 + edgeHandle( 142 + cursor: .frameResize(position: .bottom, directions: .all), 143 + isVertical: false, 144 + edgeOffset: CGSize(width: 0, height: edgeThickness / 2) 145 + ) { translation in 146 + onResize(.bottom, translation) 147 + } 148 + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) 149 + 150 + cornerHandle( 151 + cursor: .frameResize(position: .topLeft, directions: .all), 152 + alignment: .topLeading 153 + ) { translation in 154 + onResize(.topLeading, translation) 155 + } 156 + 157 + cornerHandle( 158 + cursor: .frameResize(position: .topRight, directions: .all), 159 + alignment: .topTrailing 160 + ) { translation in 161 + onResize(.topTrailing, translation) 162 + } 163 + 164 + cornerHandle( 165 + cursor: .frameResize(position: .bottomLeft, directions: .all), 166 + alignment: .bottomLeading 167 + ) { translation in 168 + onResize(.bottomLeading, translation) 169 + } 170 + 171 + cornerHandle( 172 + cursor: .frameResize(position: .bottomRight, directions: .all), 173 + alignment: .bottomTrailing 174 + ) { translation in 175 + onResize(.bottomTrailing, translation) 176 + } 177 + } 178 + } 179 + 180 + private func edgeHandle( 181 + cursor: NSCursor, 182 + isVertical: Bool, 183 + edgeOffset: CGSize, 184 + onChange: @escaping (CGSize) -> Void 185 + ) -> some View { 186 + ResizeCursorView(cursor: cursor) { 187 + Color.clear 188 + .frame( 189 + width: isVertical ? edgeThickness : nil, 190 + height: isVertical ? nil : edgeThickness 191 + ) 192 + .frame( 193 + maxWidth: isVertical ? nil : .infinity, 194 + maxHeight: isVertical ? .infinity : nil 195 + ) 196 + .contentShape(.rect) 197 + .gesture( 198 + DragGesture(coordinateSpace: .global) 199 + .onChanged { value in onChange(value.translation) } 200 + .onEnded { _ in onResizeEnd() } 201 + ) 202 + } 203 + .offset(edgeOffset) 204 + } 205 + 206 + private func cornerHandle( 207 + cursor: NSCursor, 208 + alignment: Alignment, 209 + onChange: @escaping (CGSize) -> Void 210 + ) -> some View { 211 + ResizeCursorView(cursor: cursor) { 212 + Color.clear 213 + .frame(width: cornerSide, height: cornerSide) 214 + .contentShape(.rect) 215 + .gesture( 216 + DragGesture(coordinateSpace: .global) 217 + .onChanged { value in onChange(value.translation) } 218 + .onEnded { _ in onResizeEnd() } 219 + ) 220 + } 221 + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment) 222 + .offset( 223 + x: (alignment == .bottomTrailing || alignment == .topTrailing) ? cornerSide / 3 : -cornerSide / 3, 224 + y: (alignment == .topLeading || alignment == .topTrailing) ? -cornerSide / 3 : cornerSide / 3 225 + ) 226 + } 227 + } 228 + 229 + private struct ResizeCursorView<Content: View>: View { 230 + let cursor: NSCursor 231 + @ViewBuilder let content: Content 232 + @State private var isHovered = false 233 + 234 + var body: some View { 235 + content 236 + .onHover { hovering in 237 + guard hovering != isHovered else { return } 238 + isHovered = hovering 239 + if hovering { 240 + cursor.push() 241 + } else { 242 + NSCursor.pop() 243 + } 244 + } 245 + .onDisappear { 246 + if isHovered { 247 + isHovered = false 248 + NSCursor.pop() 249 + } 250 + } 251 + } 252 + }
+22
supacode/Features/Canvas/Views/CanvasSidebarButton.swift
··· 1 + import ComposableArchitecture 2 + import SwiftUI 3 + 4 + struct CanvasSidebarButton: View { 5 + let store: StoreOf<RepositoriesFeature> 6 + let isSelected: Bool 7 + 8 + var body: some View { 9 + Button { 10 + store.send(.selectCanvas) 11 + } label: { 12 + Label("Canvas", systemImage: "square.grid.2x2") 13 + .font(.callout) 14 + .frame(maxWidth: .infinity, alignment: .leading) 15 + } 16 + .buttonStyle(.plain) 17 + .padding(.horizontal, 12) 18 + .padding(.vertical, 6) 19 + .background(isSelected ? Color.accentColor.opacity(0.15) : .clear, in: .rect(cornerRadius: 6)) 20 + .help("Canvas") 21 + } 22 + }
+487
supacode/Features/Canvas/Views/CanvasView.swift
··· 1 + import AppKit 2 + import SwiftUI 3 + 4 + struct CanvasView: View { 5 + let terminalManager: WorktreeTerminalManager 6 + @State private var layoutStore = CanvasLayoutStore() 7 + 8 + @State private var canvasOffset: CGSize = .zero 9 + @State private var lastCanvasOffset: CGSize = .zero 10 + @State private var canvasScale: CGFloat = 1.0 11 + @State private var lastCanvasScale: CGFloat = 1.0 12 + @State private var focusedTabID: TerminalTabID? 13 + @State private var activeResize: [TerminalTabID: ActiveResize] = [:] 14 + @State private var hasPerformedInitialFit = false 15 + @State private var viewportSize: CGSize = .zero 16 + 17 + private let minCardWidth: CGFloat = 300 18 + private let minCardHeight: CGFloat = 200 19 + private let maxCardWidth: CGFloat = 1200 20 + private let maxCardHeight: CGFloat = 900 21 + private let titleBarHeight: CGFloat = 28 22 + private let cardSpacing: CGFloat = 20 23 + 24 + var body: some View { 25 + CanvasScrollContainer(offset: $canvasOffset, lastOffset: $lastCanvasOffset) { 26 + GeometryReader { geometry in 27 + let activeStates = terminalManager.activeWorktreeStates 28 + let allCardKeys = collectCardKeys(from: activeStates) 29 + let _ = ensureLayouts(for: allCardKeys) 30 + 31 + // Background layer: handles canvas pan and tap-to-unfocus. 32 + Color.clear 33 + .contentShape(.rect) 34 + .accessibilityAddTraits(.isButton) 35 + .onTapGesture { unfocusAll() } 36 + .gesture(canvasPanGesture) 37 + 38 + // Cards layer: one card per open tab across all worktrees. 39 + // Uses .offset() (not .position()) to avoid parent size proposals 40 + // reaching the NSView, keeping terminal grid stable during zoom. 41 + ForEach(activeStates, id: \.worktreeID) { state in 42 + ForEach(state.tabManager.tabs) { tab in 43 + if state.surfaceView(for: tab.id) != nil { 44 + let tree = state.splitTree(for: tab.id) 45 + let cardKey = tab.id.rawValue.uuidString 46 + let baseLayout = layoutStore.cardLayouts[cardKey] ?? CanvasCardLayout(position: .zero) 47 + let resized = resizedFrame(for: tab.id, baseLayout: baseLayout) 48 + let screenCenter = screenPosition(for: resized.center) 49 + let cardTotalHeight = resized.size.height + titleBarHeight 50 + 51 + CanvasCardView( 52 + repositoryName: Repository.name(for: state.repositoryRootURL), 53 + worktreeName: tab.title, 54 + tree: tree, 55 + isFocused: focusedTabID == tab.id, 56 + hasUnseenNotification: state.hasUnseenNotification, 57 + cardSize: resized.size, 58 + canvasScale: canvasScale, 59 + onTap: { 60 + if let activeSurface = state.surfaceView(for: tab.id) { 61 + focusCard(tab.id, surfaceView: activeSurface, states: activeStates) 62 + } 63 + }, 64 + onDragCommit: { translation in commitDrag(for: cardKey, translation: translation) }, 65 + onResize: { edge, translation in 66 + activeResize[tab.id] = ActiveResize( 67 + edge: edge, 68 + translation: CGSize( 69 + width: translation.width / canvasScale, 70 + height: translation.height / canvasScale 71 + ) 72 + ) 73 + }, 74 + onResizeEnd: { commitResize(for: tab.id, cardKey: cardKey, surfaces: tree.leaves()) }, 75 + onSplitOperation: { operation in 76 + state.performSplitOperation(operation, in: tab.id) 77 + } 78 + ) 79 + .scaleEffect(canvasScale, anchor: .center) 80 + .offset( 81 + x: screenCenter.x - resized.size.width / 2, 82 + y: screenCenter.y - cardTotalHeight / 2 83 + ) 84 + .zIndex(focusedTabID == tab.id ? 1 : 0) 85 + } 86 + } 87 + } 88 + } 89 + .contentShape(.rect) 90 + .simultaneousGesture(canvasZoomGesture) 91 + .onGeometryChange(for: CGSize.self) { proxy in 92 + proxy.size 93 + } action: { newSize in 94 + viewportSize = newSize 95 + if !hasPerformedInitialFit { 96 + hasPerformedInitialFit = true 97 + fitToView(canvasSize: newSize) 98 + } 99 + } 100 + } 101 + .overlay(alignment: .bottomTrailing) { 102 + organizeButton 103 + } 104 + .task { activateCanvas() } 105 + .onDisappear { deactivateCanvas() } 106 + } 107 + 108 + // MARK: - Canvas Gestures 109 + 110 + private var canvasPanGesture: some Gesture { 111 + DragGesture() 112 + .onChanged { value in 113 + canvasOffset = CGSize( 114 + width: lastCanvasOffset.width + value.translation.width, 115 + height: lastCanvasOffset.height + value.translation.height 116 + ) 117 + } 118 + .onEnded { _ in 119 + lastCanvasOffset = canvasOffset 120 + } 121 + } 122 + 123 + private var canvasZoomGesture: some Gesture { 124 + MagnifyGesture() 125 + .onChanged { value in 126 + let newScale = max(0.25, min(2.0, lastCanvasScale * value.magnification)) 127 + let anchor = value.startLocation 128 + 129 + // Keep the canvas point under the pinch center fixed: 130 + // screenPos = canvasPoint * scale + offset 131 + // → canvasPoint = (anchor - lastOffset) / lastScale 132 + // → newOffset = anchor - canvasPoint * newScale 133 + let canvasX = (anchor.x - lastCanvasOffset.width) / lastCanvasScale 134 + let canvasY = (anchor.y - lastCanvasOffset.height) / lastCanvasScale 135 + 136 + canvasOffset = CGSize( 137 + width: anchor.x - canvasX * newScale, 138 + height: anchor.y - canvasY * newScale 139 + ) 140 + canvasScale = newScale 141 + } 142 + .onEnded { _ in 143 + lastCanvasScale = canvasScale 144 + lastCanvasOffset = canvasOffset 145 + } 146 + } 147 + 148 + // MARK: - Layout 149 + 150 + /// Batch-position all cards that don't have stored layouts yet. 151 + /// Uses a single, consistent column count to avoid overlap between 152 + /// cards positioned in different passes. 153 + private func ensureLayouts(for cardKeys: [String]) { 154 + let unpositioned = cardKeys.filter { layoutStore.cardLayouts[$0] == nil } 155 + guard !unpositioned.isEmpty else { return } 156 + 157 + // Count only VISIBLE cards that already have layouts (ignores stale entries). 158 + let positionedCount = cardKeys.count - unpositioned.count 159 + // For incremental adds, preserve the existing grid shape. 160 + // For initial layout, use total count for a balanced grid. 161 + let columns = positionedCount > 0 162 + ? gridColumns(for: positionedCount) 163 + : gridColumns(for: cardKeys.count) 164 + 165 + // Build locally, assign once to trigger a single save. 166 + var layouts = layoutStore.cardLayouts 167 + for (i, key) in unpositioned.enumerated() { 168 + layouts[key] = CanvasCardLayout( 169 + position: gridPosition(index: positionedCount + i, columns: columns) 170 + ) 171 + } 172 + layoutStore.cardLayouts = layouts 173 + } 174 + 175 + /// Balanced grid: columns ≈ sqrt(N). No viewport constraint — the canvas 176 + /// is infinite and fitToView handles zoom. 177 + private func gridColumns(for count: Int) -> Int { 178 + max(1, Int(ceil(sqrt(Double(count))))) 179 + } 180 + 181 + private func gridPosition(index: Int, columns: Int) -> CGPoint { 182 + let cardW = CanvasCardLayout.defaultSize.width 183 + let cardH = CanvasCardLayout.defaultSize.height + titleBarHeight 184 + let row = index / columns 185 + let col = index % columns 186 + return CGPoint( 187 + x: cardSpacing + (cardW + cardSpacing) * CGFloat(col) + cardW / 2, 188 + y: cardSpacing + (cardH + cardSpacing) * CGFloat(row) + cardH / 2 189 + ) 190 + } 191 + 192 + /// Compute effective center and size accounting for resize only (not drag). 193 + /// Drag is applied separately via `.offset()` to avoid layout passes. 194 + private func resizedFrame( 195 + for tabID: TerminalTabID, 196 + baseLayout: CanvasCardLayout 197 + ) -> (center: CGPoint, size: CGSize) { 198 + var centerX = baseLayout.position.x 199 + var centerY = baseLayout.position.y 200 + var width = baseLayout.size.width 201 + var height = baseLayout.size.height 202 + 203 + if let resize = activeResize[tabID] { 204 + let (wSign, hSign) = resize.edge.resizeSigns 205 + if wSign != 0 { 206 + let newW = clampWidth(width + CGFloat(wSign) * resize.translation.width) 207 + centerX += CGFloat(wSign) * (newW - width) / 2 208 + width = newW 209 + } 210 + if hSign != 0 { 211 + let newH = clampHeight(height + CGFloat(hSign) * resize.translation.height) 212 + centerY += CGFloat(hSign) * (newH - height) / 2 213 + height = newH 214 + } 215 + } 216 + 217 + return (CGPoint(x: centerX, y: centerY), CGSize(width: width, height: height)) 218 + } 219 + 220 + private func screenPosition(for canvasCenter: CGPoint) -> CGPoint { 221 + CGPoint( 222 + x: canvasCenter.x * canvasScale + canvasOffset.width, 223 + y: canvasCenter.y * canvasScale + canvasOffset.height 224 + ) 225 + } 226 + 227 + private func clampWidth(_ width: CGFloat) -> CGFloat { 228 + max(minCardWidth, min(maxCardWidth, width)) 229 + } 230 + 231 + private func clampHeight(_ height: CGFloat) -> CGFloat { 232 + max(minCardHeight, min(maxCardHeight, height)) 233 + } 234 + 235 + // MARK: - Organize & Fit 236 + 237 + private func collectCardKeys(from states: [WorktreeTerminalState]) -> [String] { 238 + states.flatMap { state in 239 + state.tabManager.tabs.compactMap { tab in 240 + state.surfaceView(for: tab.id) != nil ? tab.id.rawValue.uuidString : nil 241 + } 242 + } 243 + } 244 + 245 + /// Reset all card positions to a clean grid layout. 246 + private func organizeCards() { 247 + let keys = collectCardKeys(from: terminalManager.activeWorktreeStates) 248 + let columns = gridColumns(for: keys.count) 249 + var layouts = layoutStore.cardLayouts 250 + for (index, key) in keys.enumerated() { 251 + layouts[key] = CanvasCardLayout( 252 + position: gridPosition(index: index, columns: columns) 253 + ) 254 + } 255 + layoutStore.cardLayouts = layouts 256 + } 257 + 258 + /// Adjust scale and offset so all cards fit within the viewport. 259 + private func fitToView(canvasSize: CGSize) { 260 + guard canvasSize.width > 0, canvasSize.height > 0 else { return } 261 + 262 + let keys = collectCardKeys(from: terminalManager.activeWorktreeStates) 263 + guard !keys.isEmpty else { return } 264 + 265 + // Bounding box of all cards in canvas coordinates 266 + var minX = CGFloat.infinity, minY = CGFloat.infinity 267 + var maxX = -CGFloat.infinity, maxY = -CGFloat.infinity 268 + 269 + for key in keys { 270 + guard let layout = layoutStore.cardLayouts[key] else { continue } 271 + let halfW = layout.size.width / 2 272 + let halfH = (layout.size.height + titleBarHeight) / 2 273 + minX = min(minX, layout.position.x - halfW) 274 + minY = min(minY, layout.position.y - halfH) 275 + maxX = max(maxX, layout.position.x + halfW) 276 + maxY = max(maxY, layout.position.y + halfH) 277 + } 278 + 279 + guard minX.isFinite else { return } 280 + 281 + let padding: CGFloat = 40 282 + let bboxW = maxX - minX + padding * 2 283 + let bboxH = maxY - minY + padding * 2 284 + let bboxCenterX = (minX + maxX) / 2 285 + let bboxCenterY = (minY + maxY) / 2 286 + 287 + let newScale = max(0.25, min(1.0, min(canvasSize.width / bboxW, canvasSize.height / bboxH))) 288 + 289 + canvasOffset = CGSize( 290 + width: canvasSize.width / 2 - bboxCenterX * newScale, 291 + height: canvasSize.height / 2 - bboxCenterY * newScale 292 + ) 293 + canvasScale = newScale 294 + lastCanvasScale = newScale 295 + lastCanvasOffset = canvasOffset 296 + } 297 + 298 + /// Remove stored layouts for tabs that no longer exist. 299 + private func cleanStaleLayouts() { 300 + let visibleKeys = Set(collectCardKeys(from: terminalManager.activeWorktreeStates)) 301 + let staleKeys = layoutStore.cardLayouts.keys.filter { !visibleKeys.contains($0) } 302 + guard !staleKeys.isEmpty else { return } 303 + var layouts = layoutStore.cardLayouts 304 + for key in staleKeys { 305 + layouts.removeValue(forKey: key) 306 + } 307 + layoutStore.cardLayouts = layouts 308 + } 309 + 310 + private var organizeButton: some View { 311 + Button { 312 + organizeCards() 313 + fitToView(canvasSize: viewportSize) 314 + } label: { 315 + Image(systemName: "square.grid.2x2") 316 + .font(.body) 317 + } 318 + .buttonStyle(.bordered) 319 + .padding() 320 + .help("Organize cards in a grid") 321 + } 322 + 323 + // MARK: - Drag 324 + 325 + private func commitDrag(for cardKey: String, translation: CGSize) { 326 + if var layout = layoutStore.cardLayouts[cardKey] { 327 + layout.position.x += translation.width 328 + layout.position.y += translation.height 329 + layoutStore.cardLayouts[cardKey] = layout 330 + } 331 + } 332 + 333 + // MARK: - Resize 334 + 335 + private func commitResize(for tabID: TerminalTabID, cardKey: String, surfaces: [GhosttySurfaceView]) { 336 + guard activeResize[tabID] != nil else { return } 337 + if var layout = layoutStore.cardLayouts[cardKey] { 338 + let resized = resizedFrame(for: tabID, baseLayout: layout) 339 + layout.position = resized.center 340 + layout.size = resized.size 341 + layoutStore.cardLayouts[cardKey] = layout 342 + } 343 + activeResize[tabID] = nil 344 + for surface in surfaces { 345 + surface.needsLayout = true 346 + surface.needsDisplay = true 347 + } 348 + } 349 + 350 + // MARK: - Focus 351 + 352 + private func focusCard( 353 + _ tabID: TerminalTabID, 354 + surfaceView: GhosttySurfaceView, 355 + states: [WorktreeTerminalState] 356 + ) { 357 + let previousTabID = focusedTabID 358 + focusedTabID = tabID 359 + 360 + // Unfocus all surfaces in the previous card's split tree 361 + if let previousTabID, previousTabID != tabID { 362 + for state in states { 363 + if state.surfaceView(for: previousTabID) != nil { 364 + for surface in state.splitTree(for: previousTabID).leaves() { 365 + surface.focusDidChange(false) 366 + } 367 + break 368 + } 369 + } 370 + } 371 + 372 + surfaceView.focusDidChange(true) 373 + surfaceView.requestFocus() 374 + } 375 + 376 + private func unfocusAll() { 377 + guard let previousTabID = focusedTabID else { return } 378 + focusedTabID = nil 379 + for state in terminalManager.activeWorktreeStates { 380 + if state.surfaceView(for: previousTabID) != nil { 381 + for surface in state.splitTree(for: previousTabID).leaves() { 382 + surface.focusDidChange(false) 383 + } 384 + break 385 + } 386 + } 387 + } 388 + 389 + // MARK: - Occlusion 390 + 391 + private func activateCanvas() { 392 + cleanStaleLayouts() 393 + for state in terminalManager.activeWorktreeStates { 394 + state.setAllSurfacesOccluded() 395 + } 396 + // Un-occlude all surfaces visible on canvas (including split panes) 397 + for state in terminalManager.activeWorktreeStates { 398 + for tab in state.tabManager.tabs { 399 + for surface in state.splitTree(for: tab.id).leaves() { 400 + surface.setOcclusion(true) 401 + } 402 + } 403 + } 404 + } 405 + 406 + private func deactivateCanvas() { 407 + focusedTabID = nil 408 + for state in terminalManager.activeWorktreeStates { 409 + for tab in state.tabManager.tabs { 410 + for surface in state.splitTree(for: tab.id).leaves() { 411 + surface.setOcclusion(false) 412 + surface.focusDidChange(false) 413 + } 414 + } 415 + } 416 + } 417 + } 418 + 419 + private struct ActiveResize { 420 + let edge: CanvasCardView.CardResizeEdge 421 + var translation: CGSize 422 + } 423 + 424 + // MARK: - Scroll Container 425 + 426 + /// Wraps SwiftUI content in an NSView whose `scrollWheel` override catches 427 + /// unhandled scroll-wheel events and translates them into canvas-offset changes. 428 + /// Focused terminals consume their own scroll events (they don't call super), 429 + /// so only events over empty space or unfocused cards reach this container. 430 + private struct CanvasScrollContainer<Content: View>: NSViewRepresentable { 431 + @Binding var offset: CGSize 432 + @Binding var lastOffset: CGSize 433 + @ViewBuilder var content: Content 434 + 435 + func makeCoordinator() -> CanvasScrollCoordinator { 436 + CanvasScrollCoordinator() 437 + } 438 + 439 + func makeNSView(context: Context) -> CanvasScrollContainerView { 440 + let container = CanvasScrollContainerView() 441 + let hosting = NSHostingView(rootView: content) 442 + hosting.translatesAutoresizingMaskIntoConstraints = false 443 + container.addSubview(hosting) 444 + NSLayoutConstraint.activate([ 445 + hosting.topAnchor.constraint(equalTo: container.topAnchor), 446 + hosting.bottomAnchor.constraint(equalTo: container.bottomAnchor), 447 + hosting.leadingAnchor.constraint(equalTo: container.leadingAnchor), 448 + hosting.trailingAnchor.constraint(equalTo: container.trailingAnchor), 449 + ]) 450 + container.scrollCoordinator = context.coordinator 451 + return container 452 + } 453 + 454 + func updateNSView(_ nsView: CanvasScrollContainerView, context: Context) { 455 + context.coordinator.offset = $offset 456 + context.coordinator.lastOffset = $lastOffset 457 + if let hosting = nsView.subviews.first as? NSHostingView<Content> { 458 + hosting.rootView = content 459 + } 460 + } 461 + } 462 + 463 + private class CanvasScrollCoordinator { 464 + var offset: Binding<CGSize> = .constant(.zero) 465 + var lastOffset: Binding<CGSize> = .constant(.zero) 466 + 467 + func handleScroll(deltaX: CGFloat, deltaY: CGFloat) { 468 + let current = offset.wrappedValue 469 + let newOffset = CGSize( 470 + width: current.width + deltaX, 471 + height: current.height + deltaY 472 + ) 473 + offset.wrappedValue = newOffset 474 + lastOffset.wrappedValue = newOffset 475 + } 476 + } 477 + 478 + private class CanvasScrollContainerView: NSView { 479 + var scrollCoordinator: CanvasScrollCoordinator? 480 + 481 + override func scrollWheel(with event: NSEvent) { 482 + scrollCoordinator?.handleScroll( 483 + deltaX: event.scrollingDeltaX, 484 + deltaY: event.scrollingDeltaY 485 + ) 486 + } 487 + }
+13 -1
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 133 133 case reloadRepositories(animated: Bool) 134 134 case repositoriesLoaded([Repository], failures: [LoadFailure], roots: [URL], animated: Bool) 135 135 case selectArchivedWorktrees 136 + case selectCanvas 136 137 case setSidebarSelectedWorktreeIDs(Set<Worktree.ID>) 137 138 case openRepositories([URL]) 138 139 case openRepositoriesFinished( ··· 543 544 state.selection = .archivedWorktrees 544 545 state.sidebarSelectedWorktreeIDs = [] 545 546 return .send(.delegate(.selectedWorktreeChanged(nil))) 547 + 548 + case .selectCanvas: 549 + state.selection = .canvas 550 + state.sidebarSelectedWorktreeIDs = [] 551 + return .none 546 552 547 553 case .setSidebarSelectedWorktreeIDs(let worktreeIDs): 548 554 let validWorktreeIDs = Set(state.orderedWorktreeRows().map(\.id)) ··· 2670 2676 shouldPruneArchivedWorktreeIDs 2671 2677 ? pruneArchivedWorktreeIDs(availableWorktreeIDs: availableWorktreeIDs, state: &state) 2672 2678 : false 2673 - if !state.isShowingArchivedWorktrees, !isSelectionValid(state.selectedWorktreeID, state: state) { 2679 + if !state.isShowingArchivedWorktrees, !state.isShowingCanvas, 2680 + !isSelectionValid(state.selectedWorktreeID, state: state) 2681 + { 2674 2682 state.selection = nil 2675 2683 } 2676 2684 if state.shouldRestoreLastFocusedWorktree { ··· 2774 2782 2775 2783 var isShowingArchivedWorktrees: Bool { 2776 2784 selection == .archivedWorktrees 2785 + } 2786 + 2787 + var isShowingCanvas: Bool { 2788 + selection == .canvas 2777 2789 } 2778 2790 2779 2791 var archivedWorktreeIDSet: Set<Worktree.ID> {
+20 -1
supacode/Features/Repositories/Views/SidebarListView.swift
··· 16 16 let selection = Binding<Set<SidebarSelection>>( 17 17 get: { 18 18 var nextSelections = sidebarSelections 19 - if state.isShowingArchivedWorktrees { 19 + if state.isShowingCanvas { 20 + nextSelections = [.canvas] 21 + } else if state.isShowingArchivedWorktrees { 20 22 nextSelections = [.archivedWorktrees] 21 23 } else { 22 24 nextSelections.remove(.archivedWorktrees) 25 + nextSelections.remove(.canvas) 23 26 if let selectedWorktreeID = state.selectedWorktreeID { 24 27 nextSelections.insert(.worktree(selectedWorktreeID)) 25 28 } ··· 54 57 } 55 58 return true 56 59 }) 60 + } 61 + 62 + if nextSelections.contains(.canvas) { 63 + sidebarSelections = [.canvas] 64 + store.send(.selectCanvas) 65 + return 57 66 } 58 67 59 68 if nextSelections.contains(.archivedWorktrees) { ··· 172 181 } 173 182 if !isDragActive { 174 183 isDragActive = true 184 + } 185 + } 186 + .safeAreaInset(edge: .top) { 187 + CanvasSidebarButton( 188 + store: store, 189 + isSelected: state.isShowingCanvas 190 + ) 191 + .padding(.top, 4) 192 + .overlay(alignment: .bottom) { 193 + Divider() 175 194 } 176 195 } 177 196 .safeAreaInset(edge: .bottom) {
+2 -1
supacode/Features/Repositories/Views/SidebarSelection.swift
··· 2 2 case worktree(Worktree.ID) 3 3 case archivedWorktrees 4 4 case repository(Repository.ID) 5 + case canvas 5 6 6 7 var worktreeID: Worktree.ID? { 7 8 switch self { 8 9 case .worktree(let id): 9 10 return id 10 - case .archivedWorktrees, .repository: 11 + case .archivedWorktrees, .repository, .canvas: 11 12 return nil 12 13 } 13 14 }
+3
supacode/Features/Repositories/Views/SidebarView.swift
··· 141 141 state: RepositoriesFeature.State, 142 142 visibleWorktreeIDs: Set<Worktree.ID> 143 143 ) -> Set<SidebarSelection> { 144 + if state.isShowingCanvas { 145 + return [.canvas] 146 + } 144 147 if state.isShowingArchivedWorktrees { 145 148 return [.archivedWorktrees] 146 149 }
+18 -2
supacode/Features/Repositories/Views/WorktreeDetailView.swift
··· 45 45 ) 46 46 .toolbar(removing: .title) 47 47 .toolbar { 48 - if hasActiveWorktree, let selectedWorktree { 48 + if repositories.isShowingCanvas { 49 + ToolbarItem(placement: .navigation) { 50 + Text("Canvas") 51 + .font(.headline) 52 + } 53 + ToolbarItem(placement: .primaryAction) { 54 + ToolbarNotificationsPopoverButton( 55 + groups: notificationGroups, 56 + unseenWorktreeCount: unseenNotificationWorktreeCount, 57 + onSelectNotification: selectToolbarNotification, 58 + onDismissAll: { dismissAllToolbarNotifications(in: notificationGroups) } 59 + ) 60 + } 61 + } else if hasActiveWorktree, let selectedWorktree { 49 62 let pullRequest = repositories.worktreeInfo(for: selectedWorktree.id)?.pullRequest 50 63 let matchesBranch = 51 64 if let pullRequest { ··· 126 139 selectedWorktreeSummaries: [MultiSelectedWorktreeSummary] 127 140 ) -> Bool { 128 141 !repositories.isShowingArchivedWorktrees 142 + && !repositories.isShowingCanvas 129 143 && selectedWorktreeSummaries.count > 1 130 144 } 131 145 ··· 136 150 selectedWorktree: Worktree?, 137 151 selectedWorktreeSummaries: [MultiSelectedWorktreeSummary] 138 152 ) -> some View { 139 - if repositories.isShowingArchivedWorktrees { 153 + if repositories.isShowingCanvas { 154 + CanvasView(terminalManager: terminalManager) 155 + } else if repositories.isShowingArchivedWorktrees { 140 156 ArchivedWorktreesDetailView( 141 157 store: store.scope(state: \.repositories, action: \.repositories) 142 158 )
+4
supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift
··· 227 227 emitNotificationIndicatorCountIfNeeded() 228 228 } 229 229 230 + var activeWorktreeStates: [WorktreeTerminalState] { 231 + states.values.filter { !$0.tabManager.tabs.isEmpty } 232 + } 233 + 230 234 func stateIfExists(for worktreeID: Worktree.ID) -> WorktreeTerminalState? { 231 235 states[worktreeID] 232 236 }
+18
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 54 54 ) 55 55 } 56 56 57 + var worktreeID: Worktree.ID { worktree.id } 58 + var worktreeName: String { worktree.name } 59 + var repositoryRootURL: URL { worktree.repositoryRootURL } 60 + 61 + var activeSurfaceView: GhosttySurfaceView? { 62 + guard let selectedTabId = tabManager.selectedTabId, 63 + let surfaceId = focusedSurfaceIdByTab[selectedTabId] 64 + else { 65 + return nil 66 + } 67 + return surfaces[surfaceId] 68 + } 69 + 70 + func surfaceView(for tabId: TerminalTabID) -> GhosttySurfaceView? { 71 + guard let surfaceId = focusedSurfaceIdByTab[tabId] else { return nil } 72 + return surfaces[surfaceId] 73 + } 74 + 57 75 var taskStatus: WorktreeTaskStatus { 58 76 tabIsRunningById.values.contains(true) ? .running : .idle 59 77 }
+23 -5
supacode/Features/Terminal/Views/TerminalSplitTreeView.swift
··· 4 4 5 5 struct TerminalSplitTreeView: View { 6 6 let tree: SplitTree<GhosttySurfaceView> 7 + var pinnedSize: CGSize? = nil 7 8 let action: (Operation) -> Void 8 9 9 10 private static let dragType = UTType(exportedAs: "sh.supacode.ghosttySurfaceId") ··· 22 23 23 24 var body: some View { 24 25 if let node = tree.zoomed ?? tree.root { 25 - SubtreeView(node: node, isRoot: node == tree.root, action: action) 26 + SubtreeView(node: node, isRoot: node == tree.root, pinnedSize: pinnedSize, action: action) 26 27 .id(node.structuralIdentity) 27 28 } 28 29 } ··· 36 37 struct SubtreeView: View { 37 38 let node: SplitTree<GhosttySurfaceView>.Node 38 39 var isRoot: Bool = false 40 + var pinnedSize: CGSize? = nil 39 41 let action: (Operation) -> Void 40 42 41 43 var body: some View { 42 44 switch node { 43 45 case .leaf(let leafView): 44 - LeafView(surfaceView: leafView, isSplit: !isRoot, action: action) 46 + LeafView(surfaceView: leafView, isSplit: !isRoot, pinnedSize: pinnedSize, action: action) 45 47 case .split(let split): 46 48 let splitViewDirection: SplitView<SubtreeView, SubtreeView>.Direction = 47 49 switch split.direction { 48 50 case .horizontal: .horizontal 49 51 case .vertical: .vertical 50 52 } 53 + let leftPinned = pinnedSize.map { splitChildSize($0, ratio: split.ratio, direction: split.direction) } 54 + let rightPinned = pinnedSize.map { 55 + splitChildSize($0, ratio: 1 - split.ratio, direction: split.direction) 56 + } 51 57 SplitView( 52 58 splitViewDirection, 53 59 .init( ··· 60 66 dividerColor: .secondary, 61 67 resizeIncrements: .init(width: 1, height: 1), 62 68 left: { 63 - SubtreeView(node: split.left, action: action) 69 + SubtreeView(node: split.left, pinnedSize: leftPinned, action: action) 64 70 }, 65 71 right: { 66 - SubtreeView(node: split.right, action: action) 72 + SubtreeView(node: split.right, pinnedSize: rightPinned, action: action) 67 73 }, 68 74 onEqualize: { 69 75 action(.equalize) ··· 71 77 ) 72 78 } 73 79 } 80 + 81 + private func splitChildSize( 82 + _ size: CGSize, ratio: Double, direction: SplitTree<GhosttySurfaceView>.Direction 83 + ) -> CGSize { 84 + switch direction { 85 + case .horizontal: 86 + CGSize(width: size.width * ratio, height: size.height) 87 + case .vertical: 88 + CGSize(width: size.width, height: size.height * ratio) 89 + } 90 + } 74 91 } 75 92 76 93 struct LeafView: View { 77 94 let surfaceView: GhosttySurfaceView 78 95 let isSplit: Bool 96 + var pinnedSize: CGSize? = nil 79 97 let action: (Operation) -> Void 80 98 81 99 @State private var dropState: DropState = .idle 82 100 83 101 var body: some View { 84 102 GeometryReader { geometry in 85 - GhosttyTerminalView(surfaceView: surfaceView) 103 + GhosttyTerminalView(surfaceView: surfaceView, pinnedSize: pinnedSize) 86 104 .frame(maxWidth: .infinity, maxHeight: .infinity) 87 105 .overlay(alignment: .top) { 88 106 GhosttySurfaceProgressOverlay(state: surfaceView.bridge.state)
+19 -4
supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift
··· 762 762 763 763 func updateSurfaceSize() { 764 764 guard let surface else { return } 765 - let backingSize = convertToBacking(bounds.size) 765 + // When pinnedSize is set (canvas mode), convertToBacking() includes the 766 + // .scaleEffect() layer transform, producing scale-dependent backing sizes. 767 + // Use the pinned size with the window's raw backing scale factor instead. 768 + let backingSize: CGSize 769 + if let pinnedSize = scrollWrapper?.pinnedSize { 770 + let scale = window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0 771 + backingSize = CGSize(width: pinnedSize.width * scale, height: pinnedSize.height * scale) 772 + } else { 773 + backingSize = convertToBacking(bounds.size) 774 + } 766 775 if backingSize == lastBackingSize { 767 776 return 768 777 } ··· 1611 1620 private var lastSentRow: Int? 1612 1621 private var scrollbar: ScrollbarState? 1613 1622 1623 + /// When set, the surface renders at this fixed size regardless of the hosting 1624 + /// view's bounds. Used in canvas mode to prevent `.scaleEffect()` from causing 1625 + /// terminal reflow. 1626 + var pinnedSize: CGSize? 1627 + 1614 1628 init(surfaceView: GhosttySurfaceView) { 1615 1629 self.surfaceView = surfaceView 1616 1630 scrollView = NSScrollView() ··· 1706 1720 1707 1721 override func layout() { 1708 1722 super.layout() 1709 - scrollView.frame = bounds 1710 - surfaceView.frame.size = scrollView.bounds.size 1711 - documentView.frame.size.width = scrollView.bounds.width 1723 + let effectiveSize = pinnedSize ?? bounds.size 1724 + scrollView.frame = CGRect(origin: .zero, size: effectiveSize) 1725 + surfaceView.frame.size = effectiveSize 1726 + documentView.frame.size.width = effectiveSize.width 1712 1727 synchronizeScrollView() 1713 1728 synchronizeSurfaceView() 1714 1729 surfaceView.updateSurfaceSize()
+5 -2
supacode/Infrastructure/Ghostty/GhosttyTerminalView.swift
··· 2 2 3 3 struct GhosttyTerminalView: NSViewRepresentable { 4 4 let surfaceView: GhosttySurfaceView 5 + var pinnedSize: CGSize? 5 6 6 7 func makeNSView(context: Context) -> GhosttySurfaceScrollView { 7 - GhosttySurfaceScrollView(surfaceView: surfaceView) 8 + let view = GhosttySurfaceScrollView(surfaceView: surfaceView) 9 + view.pinnedSize = pinnedSize 10 + return view 8 11 } 9 12 10 13 func updateNSView(_ view: GhosttySurfaceScrollView, context: Context) { 11 - _ = view 14 + view.pinnedSize = pinnedSize 12 15 } 13 16 }
+2 -2
supacodeTests/AppFeatureCustomCommandTests.swift
··· 37 37 38 38 #expect( 39 39 sent.value == [ 40 - .createTabWithInput(worktree, input: "swift test", runSetupScriptIfNew: false), 40 + .createTabWithInput(worktree, input: "swift test", runSetupScriptIfNew: false) 41 41 ], 42 42 ) 43 43 } ··· 72 72 73 73 #expect( 74 74 sent.value == [ 75 - .insertText(worktree, text: "pnpm test --watch"), 75 + .insertText(worktree, text: "pnpm test --watch") 76 76 ], 77 77 ) 78 78 }