Skip to content
Open
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
3 changes: 2 additions & 1 deletion Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ let sharedTestSupportSources: [Path] = [
"supacodeTests/SettingsTestStorage.swift",
"supacodeTests/ShellInvocationTestSupport.swift",
"supacodeTests/SidebarConsistency.swift",
"supacodeTests/SurfaceView+TestHelpers.swift",
"supacodeTests/WorktreeTestSupport.swift",
"supacodeTests/WritableKeyPath+Sendable.swift",
]
Expand All @@ -107,7 +108,7 @@ let terminalTestSources: [Path] = [
"supacodeTests/Ghostty*.swift",
"supacodeTests/Layouts*.swift",
"supacodeTests/SplitTree*.swift",
"supacodeTests/WorktreeTerminalManager*.swift",
"supacodeTests/WorktreeSurfaceManager*.swift",
"supacodeTests/Zmx*.swift",
]

Expand Down
24 changes: 24 additions & 0 deletions SupacodeSettingsShared/Models/Lossy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,28 @@ extension KeyedDecodingContainer {
}
return wrappers.compactMap(\.value)
}

/// Like `decodeLossyArrayIfPresent`, but also reports the ORIGINAL indices of
/// dropped elements so callers can remap positional fields (e.g. a persisted
/// selected-index) into the surviving array's coordinate space.
public nonisolated func decodeLossyArrayWithDroppedIndices<T: Decodable & Sendable>(
_ type: [T].Type = [T].self,
forKey key: Key
) -> (elements: [T], droppedIndices: [Int])? {
guard contains(key) else { return nil }
guard let wrappers = try? decode([Lossy<T>].self, forKey: key) else {
lossyLogger.warning("Could not decode lossy array at '\(key.stringValue)'; returning empty.")
return ([], [])
}
var elements: [T] = []
var droppedIndices: [Int] = []
for (index, wrapper) in wrappers.enumerated() {
if let value = wrapper.value {
elements.append(value)
} else {
droppedIndices.append(index)
}
}
return (elements, droppedIndices)
}
}
20 changes: 10 additions & 10 deletions supacode/App/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,29 +17,29 @@ import UniformTypeIdentifiers
struct ContentView: View {
@Bindable var store: StoreOf<AppFeature>
@Bindable var repositoriesStore: StoreOf<RepositoriesFeature>
let terminalManager: WorktreeTerminalManager
let surfaceManager: WorktreeSurfaceManager
@Environment(\.scenePhase) private var scenePhase
@Environment(GhosttyShortcutManager.self) private var ghosttyShortcuts
@State private var leftSidebarVisibility: NavigationSplitViewVisibility = .all

init(store: StoreOf<AppFeature>, terminalManager: WorktreeTerminalManager) {
init(store: StoreOf<AppFeature>, surfaceManager: WorktreeSurfaceManager) {
self.store = store
repositoriesStore = store.scope(state: \.repositories, action: \.repositories)
self.terminalManager = terminalManager
self.surfaceManager = surfaceManager
}

var body: some View {
#if DEBUG
let _ = contentRenderLogger.info("ContentView.body re-rendered")
#endif
return NavigationSplitView(columnVisibility: $leftSidebarVisibility) {
SidebarView(store: repositoriesStore, terminalManager: terminalManager)
SidebarView(store: repositoriesStore, surfaceManager: surfaceManager)
.navigationSplitViewColumnWidth(min: 220, ideal: 260, max: 320)
.safeAreaInset(edge: .bottom, spacing: 0) {
SidebarBottomCardView(store: store)
}
} detail: {
WorktreeDetailView(store: store, terminalManager: terminalManager)
WorktreeDetailView(store: store, surfaceManager: surfaceManager)
}
.navigationSplitViewStyle(.automatic)
.disabled(!repositoriesStore.isInitialLoadComplete)
Expand Down Expand Up @@ -141,12 +141,12 @@ struct ContentView: View {
)
}
.background(WindowTabbingDisabler())
.background(WindowTintBackdrop(runtime: terminalManager.ghosttyRuntime))
.background(WindowChromeObserver(runtime: terminalManager.ghosttyRuntime))
.background(WindowTintBackdrop(runtime: surfaceManager.ghosttyRuntime))
.background(WindowChromeObserver(runtime: surfaceManager.ghosttyRuntime))
.background(
WindowTitleHost(
repositoriesStore: repositoriesStore,
terminalManager: terminalManager
surfaceManager: surfaceManager
)
)
}
Expand Down Expand Up @@ -185,7 +185,7 @@ private struct CommandPaletteOverlayHost: View {
/// invalidations from tab renames or section title edits.
private struct WindowTitleHost: View {
let repositoriesStore: StoreOf<RepositoriesFeature>
let terminalManager: WorktreeTerminalManager
let surfaceManager: WorktreeSurfaceManager

var body: some View {
#if DEBUG
Expand All @@ -195,7 +195,7 @@ private struct WindowTitleHost: View {
.navigationTitle(
WindowTitle.compute(
repositories: repositoriesStore.state,
terminalManager: terminalManager
surfaceManager: surfaceManager
)
)
}
Expand Down
10 changes: 5 additions & 5 deletions supacode/App/WindowTitle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ enum WindowTitle {
@MainActor
static func compute(
repositories: RepositoriesFeature.State,
terminalManager: WorktreeTerminalManager
surfaceManager: WorktreeSurfaceManager
) -> String {
switch repositories.selection {
case .archivedWorktrees:
Expand All @@ -28,7 +28,7 @@ enum WindowTitle {
return worktreeTitle(
worktreeID: worktreeID,
repositories: repositories,
terminalManager: terminalManager
surfaceManager: surfaceManager
)
case .failedRepository(let repositoryID):
// A failed remote keeps a placeholder repository whose `name` is the
Expand All @@ -53,7 +53,7 @@ enum WindowTitle {
private static func worktreeTitle(
worktreeID: Worktree.ID,
repositories: RepositoriesFeature.State,
terminalManager: WorktreeTerminalManager
surfaceManager: WorktreeSurfaceManager
) -> String {
guard let repositoryID = repositories.repositoryID(containing: worktreeID),
let repository = repositories.repositories[id: repositoryID]
Expand All @@ -65,7 +65,7 @@ enum WindowTitle {
fallback: repository.name,
repositories: repositories
)
let tabTitle = terminalManager.stateIfExists(for: worktreeID).flatMap { state in
let tabTitle = surfaceManager.stateIfExists(for: worktreeID).flatMap { state in
tabDisplayTitle(in: state)
}
return format(repo: repoTitle, tab: tabTitle)
Expand All @@ -84,7 +84,7 @@ enum WindowTitle {
}

@MainActor
private static func tabDisplayTitle(in state: WorktreeTerminalState) -> String? {
private static func tabDisplayTitle(in state: WorktreeSurfaceState) -> String? {
guard let id = state.tabManager.selectedTabId,
let tab = state.tabManager.tabs.first(where: { $0.id == id })
else { return nil }
Expand Down
84 changes: 42 additions & 42 deletions supacode/App/supacodeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ final class SupacodeAppDelegate: NSObject, NSApplicationDelegate {
}
}
}
var terminalManager: WorktreeTerminalManager?
var surfaceManager: WorktreeSurfaceManager?
private var bufferedDeeplinkURLs: [URL] = []

func applicationWillTerminate(_ notification: Notification) {
Expand All @@ -59,10 +59,10 @@ final class SupacodeAppDelegate: NSObject, NSApplicationDelegate {
// embeds agent records so badges survive relaunch (agents only emit
// session_start once per process lifetime), and a second concurrent instance
// overwriting the file is an accepted dev-only last-writer-wins window.
terminalManager?.cancelPendingLayoutSaves()
surfaceManager?.cancelPendingLayoutSaves()
let agentsBySurface = appStore?.state.agentPresence.agentsBySurface() ?? [:]
terminalManager?.saveAllLayoutSnapshots(agentsBySurface: agentsBySurface)
terminalManager?.rememberSelectedWorktreeZoomOnQuit()
surfaceManager?.saveAllLayoutSnapshots(agentsBySurface: agentsBySurface)
surfaceManager?.rememberSelectedWorktreeZoomOnQuit()
}

func applicationDidFinishLaunching(_ notification: Notification) {
Expand Down Expand Up @@ -123,7 +123,7 @@ struct SupacodeApp: App {
@NSApplicationDelegateAdaptor(SupacodeAppDelegate.self) private var appDelegate
@State private var ghostty: GhosttyRuntime
@State private var ghosttyShortcuts: GhosttyShortcutManager
@State private var terminalManager: WorktreeTerminalManager
@State private var surfaceManager: WorktreeSurfaceManager
@State private var worktreeInfoWatcher: WorktreeInfoWatcherManager
@State private var commandKeyObserver: CommandKeyObserver
@State private var store: StoreOf<AppFeature>
Expand Down Expand Up @@ -177,35 +177,35 @@ struct SupacodeApp: App {
_ghostty = State(initialValue: runtime)
let shortcuts = GhosttyShortcutManager(runtime: runtime)
_ghosttyShortcuts = State(initialValue: shortcuts)
let terminalManager = Self.makeTerminalManager(runtime: runtime)
_terminalManager = State(initialValue: terminalManager)
let surfaceManager = Self.makeTerminalManager(runtime: runtime)
_surfaceManager = State(initialValue: surfaceManager)
let worktreeInfoWatcher = WorktreeInfoWatcherManager()
_worktreeInfoWatcher = State(initialValue: worktreeInfoWatcher)
let keyObserver = CommandKeyObserver()
_commandKeyObserver = State(initialValue: keyObserver)
let appStore = Self.makeStore(
initialSettings: initialSettings,
terminalManager: terminalManager,
surfaceManager: surfaceManager,
worktreeInfoWatcher: worktreeInfoWatcher
)
_store = State(initialValue: appStore)
appDelegate.appStore = appStore
appDelegate.terminalManager = terminalManager
appDelegate.surfaceManager = surfaceManager
// Source live agent badge records for incremental layout captures; the [:]
// default would clobber badges that share a surface key on every save.
terminalManager.currentAgentsBySurface = { [weak appStore] in
surfaceManager.currentAgentsBySurface = { [weak appStore] in
appStore?.state.agentPresence.agentsBySurface() ?? [:]
}
Self.configureSocketHandlers(terminalManager: terminalManager, store: appStore)
Self.configureSocketHandlers(surfaceManager: surfaceManager, store: appStore)
}

@MainActor
private static func makeTerminalManager(runtime: GhosttyRuntime) -> WorktreeTerminalManager {
let terminalManager = WorktreeTerminalManager(runtime: runtime)
runtime.focusedSurfaceBackgroundColorProvider = { [weak terminalManager] in
terminalManager?.focusedSurfaceBackground
private static func makeTerminalManager(runtime: GhosttyRuntime) -> WorktreeSurfaceManager {
let surfaceManager = WorktreeSurfaceManager(runtime: runtime)
runtime.focusedSurfaceBackgroundColorProvider = { [weak surfaceManager] in
surfaceManager?.focusedSurfaceBackground
}
terminalManager.saveLayoutSnapshot = { worktreeID, snapshot in
surfaceManager.saveLayoutSnapshot = { worktreeID, snapshot in
@Shared(.layouts) var layouts: [String: TerminalLayoutSnapshot] = [:]
$layouts.withLock { dict in
if let snapshot {
Expand All @@ -215,68 +215,68 @@ struct SupacodeApp: App {
}
}
}
terminalManager.loadLayoutSnapshot = { worktreeID in
surfaceManager.loadLayoutSnapshot = { worktreeID in
@SharedReader(.layouts) var layouts: [String: TerminalLayoutSnapshot] = [:]
return layouts[worktreeID.rawValue]
}
return terminalManager
return surfaceManager
}

@MainActor
private static func makeStore(
initialSettings: GlobalSettings,
terminalManager: WorktreeTerminalManager,
surfaceManager: WorktreeSurfaceManager,
worktreeInfoWatcher: WorktreeInfoWatcherManager
) -> StoreOf<AppFeature> {
Store(initialState: AppFeature.State(settings: SettingsFeature.State(settings: initialSettings))) {
AppFeature()
.logActions()
} withDependencies: { values in
values.terminalClient = TerminalClient(
values.surfaceClient = SurfaceClient(
send: { command in
terminalManager.handleCommand(command)
surfaceManager.handleCommand(command)
},
events: {
terminalManager.eventStream()
surfaceManager.eventStream()
},
tabExists: { worktreeID, tabID in
terminalManager.tabExists(worktreeID: worktreeID, tabID: tabID)
surfaceManager.tabExists(worktreeID: worktreeID, tabID: tabID)
},
surfaceExists: { worktreeID, tabID, surfaceID in
terminalManager.surfaceExists(worktreeID: worktreeID, tabID: tabID, surfaceID: surfaceID)
surfaceManager.surfaceExists(worktreeID: worktreeID, tabID: tabID, surfaceID: surfaceID)
},
surfaceExistsInWorktree: { worktreeID, surfaceID in
terminalManager.surfaceExistsInWorktree(worktreeID: worktreeID, surfaceID: surfaceID)
surfaceManager.surfaceExistsInWorktree(worktreeID: worktreeID, surfaceID: surfaceID)
},
tabID: { worktreeID, surfaceID in
terminalManager.tabID(forWorktreeID: worktreeID, surfaceID: surfaceID)
surfaceManager.tabID(forWorktreeID: worktreeID, surfaceID: surfaceID)
},
selectedTabID: { worktreeID in
terminalManager.stateIfExists(for: worktreeID)?.tabManager.selectedTabId
surfaceManager.stateIfExists(for: worktreeID)?.tabManager.selectedTabId
},
selectedSurfaceID: { worktreeID in
guard let state = terminalManager.stateIfExists(for: worktreeID),
guard let state = surfaceManager.stateIfExists(for: worktreeID),
let tabID = state.tabManager.selectedTabId
else { return nil }
return state.activeSurfaceID(for: tabID)
},
latestUnreadNotification: {
terminalManager.latestUnreadNotificationLocation()
surfaceManager.latestUnreadNotificationLocation()
},
markNotificationRead: { worktreeID, notificationID in
terminalManager.markNotificationRead(worktreeID: worktreeID, notificationID: notificationID)
surfaceManager.markNotificationRead(worktreeID: worktreeID, notificationID: notificationID)
},
hasInflightBlockingScripts: {
terminalManager.hasInflightBlockingScripts
surfaceManager.hasInflightBlockingScripts
},
terminateAllSessions: {
await terminalManager.terminateAllSessions()
await surfaceManager.terminateAllSessions()
},
reapOrphanSessions: { knownSurfaceIDs in
await terminalManager.reapOrphanSessions(knownSurfaceIDs: knownSurfaceIDs)
await surfaceManager.reapOrphanSessions(knownSurfaceIDs: knownSurfaceIDs)
},
saveLayoutsWithAgents: { agentsBySurface in
terminalManager.saveAllLayoutSnapshots(agentsBySurface: agentsBySurface)
surfaceManager.saveAllLayoutSnapshots(agentsBySurface: agentsBySurface)
}
)
values.worktreeInfoWatcher = WorktreeInfoWatcherClient(
Expand Down Expand Up @@ -310,18 +310,18 @@ struct SupacodeApp: App {

@MainActor
private static func configureSocketHandlers(
terminalManager: WorktreeTerminalManager,
surfaceManager: WorktreeSurfaceManager,
store: StoreOf<AppFeature>
) {
terminalManager.onDeeplinkCommand = { url, clientFD in
surfaceManager.onDeeplinkCommand = { url, clientFD in
store.send(.deeplinkReceived(url, source: .socket, responseFD: clientFD))
}
terminalManager.onQuery = { resource, params, clientFD in
surfaceManager.onQuery = { resource, params, clientFD in
Self.handleQuery(
resource: resource,
params: params,
clientFD: clientFD,
terminalManager: terminalManager,
surfaceManager: surfaceManager,
store: store
)
}
Expand All @@ -337,7 +337,7 @@ struct SupacodeApp: App {
resource: String,
params: [String: String],
clientFD: Int32,
terminalManager: WorktreeTerminalManager,
surfaceManager: WorktreeSurfaceManager,
store: StoreOf<AppFeature>
) {
let repos = store.repositories.repositories
Expand Down Expand Up @@ -367,7 +367,7 @@ struct SupacodeApp: App {
clientFD: clientFD, ok: false, error: "Missing worktreeID for tab list.")
return
}
let tabs = terminalManager.listTabs(worktreeID: worktreeID)
let tabs = surfaceManager.listTabs(worktreeID: worktreeID)
if tabs == nil {
let decoded = worktreeID.removingPercentEncoding ?? worktreeID
let worktreeExists = repos.contains { $0.worktrees.contains { $0.id.rawValue == decoded } }
Expand All @@ -384,7 +384,7 @@ struct SupacodeApp: App {
clientFD: clientFD, ok: false, error: "Missing worktreeID/tabID for surface list.")
return
}
guard let surfaces = terminalManager.listSurfaces(worktreeID: worktreeID, tabID: tabID) else {
guard let surfaces = surfaceManager.listSurfaces(worktreeID: worktreeID, tabID: tabID) else {
AgentHookSocketServer.sendCommandResponse(
clientFD: clientFD, ok: false, error: "Worktree or tab not found.")
return
Expand Down Expand Up @@ -486,7 +486,7 @@ struct SupacodeApp: App {
var body: some Scene {
Window("Supacode", id: WindowID.main) {
GhosttyColorSchemeSyncView(ghostty: ghostty) {
ContentView(store: store, terminalManager: terminalManager)
ContentView(store: store, surfaceManager: surfaceManager)
.environment(ghosttyShortcuts)
.environment(commandKeyObserver)
}
Expand Down
Loading
Loading