Skip to content
Merged
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
7 changes: 4 additions & 3 deletions Packages/macOS/CmuxNext/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -531,11 +531,12 @@ let packageTargets: [Target] = uiLayerPreviewTargets + [
swiftSettings: daemonSwiftSettings
),
// The cards' center: the scan schedule, the job runner and the `import.*` actions, for
// Settings › Import (no Home or New Tab suggestion). The App supplies `ImportCardsHost`.
// Settings › Import (no Home or New Tab suggestion), and the sidebar's Conductor note
// (CmuxNextSidebar's notice card). The App supplies `ImportCardsHost`.
.target(
name: "CmuxNextImportCards",
dependencies: ["CmuxNextActions", "CmuxNextBrowserImport", "CmuxNextCompat", "CmuxNextDesign",
"CmuxNextImportSuggestions", "CmuxNextOnboarding", "CmuxNextSettings"],
dependencies: ["CmuxNextActions", "CmuxNextBrowserImport", "CmuxNextCompat", "CmuxNextDaemon", "CmuxNextDesign",
"CmuxNextImportSuggestions", "CmuxNextOnboarding", "CmuxNextSettings", "CmuxNextSidebar"],
swiftSettings: uiSwiftSettings
),
.target(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import CmuxNextAppCore
import CmuxNextDaemon
import CmuxNextImportCards

extension AppServices: ConductorImportHost {
var conductorDaemon: DaemonConnection? { daemon.connection }
func conductorWorkspaceKey(_ id: String) -> WorkspaceKey? { daemon.store.workspace(id: id)?.key }
func openConductorWorkspace(folder: String, name: String, key: WorkspaceKey) async throws -> String { try await windows.createWorkspace(WorkspaceSpawn(cwd: folder, name: name, key: key)) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import CmuxNextCompat
import CmuxNextDaemon
import CmuxNextDesign
import CmuxNextHomeEngine
import CmuxNextImportSuggestions
import CmuxNextPages
import CmuxNextSettings
import Foundation
Expand Down Expand Up @@ -83,6 +84,7 @@ enum ChiefMemoryImportHandlers {
return try CmuxNextSettings.JSONValue.parse(Data(output.stdout.utf8))
}
provider.restart = { [weak services] in _ = services?.home.brain.ensure(asked: true) }
provider.notice = { ImportScanner(home: FileManager.default.homeDirectoryForCurrentUser).conductorOnboardingLine(now: Date()) }
let native = AppPageNativeProvider(services: services, page: .importHistory)
let routes = [PageRoute(prefix: "cmux.importHistory.", provider: provider), PageRoute(prefix: "cmux.app.", provider: native)]
guard let page = PageWebView(descriptor: .importHistory, routes: routes) else { return nil }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,15 @@ enum OnboardingHandlers {
})
}

/// What the import cards run through the App: the Import from Browser window's import run
/// and Import Workspaces from Classic cmux.
/// What the import cards run through the App: the Import from Browser window's import run,
/// Import Workspaces from Classic cmux and Import from Conductor.
static func importCardsHost(_ services: AppServices) -> ImportCardsHost {
ImportCardsHost(
runImport: { plan, keys, progress in try await AppOnboardingServices(owner: services.onboarding).runImport(plan, keys: keys, progress: progress) },
importedProfiles: { Set(await services.onboarding.importStore.sources().map(\.sourceKey)) },
openSettings: { route in try? services.settingsWindow.show(section: nil, route: route) },
importClassic: { progress in try await importClassic(services, in: nil, progress: progress)?.opened })
importClassic: { progress in try await importClassic(services, in: nil, progress: progress)?.opened },
importConductor: { progress in try await ConductorImport(host: services).run(progress: progress) })
}

/// Import Workspaces from Classic cmux as every entrypoint runs it (the action, the cmux
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import AppKit
import CmuxNextDesign
import CmuxNextOnboarding

/// The preview and mandatory answer before a classic snapshot is restored.
@MainActor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,3 @@ final class ClassicMigrationRunState {
progress?(Double(handledCount) / Double(max(plan.workspaces.count, 1)))
}
}

/// Classic's session as read, the save it came from, and the files to back up.
struct ClassicSnapshotRead {
var snapshot: ClassicSessionSnapshot
var source: ClassicMigrationSource
var backups: [URL]
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import CmuxNextCompat
import Foundation
import CmuxNextActions
import CmuxNextAgentLanes
import CmuxNextImportCards
import CmuxNextNowPlaying
import CmuxNextPages
import CmuxNextSettings
Expand Down Expand Up @@ -86,6 +87,8 @@ enum SidebarCardFeed {
case .setAutomaticUpdates(let on): services.updater.setAutomaticUpdates(on)
case .openUpdateLink(let url): openUpdateLink(url, services: services)
case .openUpdateChanges: _ = services.updater.openStagedChangelog()
case .noticeAction(let card, let action) where ImportSuggestionsCenter.shared.answerConductorNote(card, action: action): break
case .dismissNotice(let card) where ImportSuggestionsCenter.shared.answerConductorNote(card, action: nil): break
// A question waiting on the person (an agent's Cloud machine request, ``SidebarAsks``) takes its answer first.
case .noticeAction(let card, let action) where !services.machineAsks.answer(card, action: action):
noticeAction(card, action, updater: services.updater, open: { openUpdateLink($0, services: services) })
Expand Down Expand Up @@ -168,9 +171,9 @@ enum SidebarCardFeed {

/// The shared notice card (Lawrence 2026-10-09), one message at a time:
/// the update status (a check's progress and result, a found update),
/// then the active test feed, then the newest announcement, then today's
/// "Did you know" tip. Nil while the staged update card shows;
/// announcements and the tip also wait for the "cmux Updated!" card.
/// then the active test feed, then Import from Conductor's note (until Nov 20), then the newest
/// announcement, then today's "Did you know" tip. Nil while the staged update card shows;
/// the note, announcements and the tip also wait for the "cmux Updated!" card.
static func noticeCard(_ updater: UpdaterService, registry: ActionRegistry?) -> SidebarNoticeCard? {
guard updater.readyCard == nil else { return nil }
if let shown = updater.cardPresentation { return notice(shown, asked: updater.flow.userAsked) }
Expand All @@ -179,6 +182,7 @@ enum SidebarCardFeed {
actions: [SidebarNoticeCard.Action(id: useRealFeedActionID, title: text.useRealFeed)])
}
guard !updater.whatsNew.showsUpdatedCard else { return nil }
if let note = ImportSuggestionsCenter.shared.conductorNote { return note }
if let item = updater.announcements.first {
let actions = item.action.flatMap { id in
PageDescriptor.changelogTryItActions.contains(id) ? [SidebarNoticeCard.Action(id: id, title: UpdaterService.announcementActionTitle)] : nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ struct WorkspaceSpawner {
/// sends the daemon's own next default (`SidebarMapping.defaultWorkspaceName`),
/// so the row's title is the daemon's from the first frame.
func start() -> () async throws -> String {
let key = WorkspaceKey.generate(), daemon = daemon, services = windows.services, machine = MachineID(daemon.machineID)
let key = spawn.key ?? WorkspaceKey.generate(), daemon = daemon, services = windows.services, machine = MachineID(daemon.machineID)
if let windowID { windows.claimNew(workspaceID: key.rawValue, window: windowID, frame: frame) }
guard daemon.connection != nil else { return { [windows] in throw windows.createFailed(key.rawValue, DaemonError.notConnected) } }
var row = spawn.slot == nil ? NewWorkspacePlacements.rule(for: windowID, in: windows) : nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ public protocol WorkspaceSpawnSidebar: AnyObject {
/// name, command, and extra environment through `newTab`'s arguments.
public struct WorkspaceSpawn: Sendable {
public var cwd: String?
/// The new workspace's key, fixed by a caller that records the workspace before it exists (Import
/// from Conductor, so a run cut off midway knows which workspace it half made); nil: a new key.
public var key: WorkspaceKey?
public var inheritedCwd: String?
public var name: String?
public var command: String?
Expand All @@ -41,8 +44,9 @@ public struct WorkspaceSpawn: Sendable {
public var onFirstTerminal: (@Sendable (DaemonConnection, CreateTerminalResult) async throws -> Void)?

public init(cwd: String? = nil, name: String? = nil, command: String? = nil, env: [String: String] = [:], keep: Bool = false,
profile: ProfileID? = nil, newTabPage: Bool = false) {
profile: ProfileID? = nil, newTabPage: Bool = false, key: WorkspaceKey? = nil) {
opensNewTabPage = newTabPage
self.key = key
self.cwd = cwd
self.name = name
self.command = command
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import Foundation

/// Import from Conductor on the daemon (`conductor_import.*`, the
/// `cmux-conductor-import` crate): the dry run of what Conductor's repositories
/// and workspaces become here, and the ledger that keeps a rerun from making
/// anything twice. The daemon reads Conductor's files read-only; the app makes
/// the projects and workspaces the person confirms.
public struct ConductorImportClient: Sendable {
public let connection: DaemonConnection

public init(connection: DaemonConnection) {
self.connection = connection
}

/// `conductor_import.plan`: what an import would make now. Nil plan when
/// nothing of Conductor's is on this machine.
public func plan() async throws -> ConductorImportReply {
try await connection.resourceRequest({ id in
ResourceRequestEnvelope(id: id, operation: "conductor_import.plan", params: [:], idempotencyKey: nil)
}, as: ConductorImportReply.self)
}

/// `conductor_import.record`: Conductor workspace `workspace` is being made
/// (`pending`, with the cmux workspace once there is one) or made (`done`).
public func record(workspace: String, stage: ConductorImportStage, cmuxWorkspace: String?,
runCompleted: Bool = false, idempotencyKey: String) async throws {
var params: [String: JSONValue] = ["workspace": .string(workspace), "stage": .string(stage.rawValue)]
if let cmuxWorkspace { params["cmux_workspace"] = .string(cmuxWorkspace) }
if runCompleted { params["run_completed"] = .bool(true) }
let fields = params
_ = try await connection.resourceRequest({ id in
ResourceRequestEnvelope(id: id, operation: "conductor_import.record", params: fields, idempotencyKey: idempotencyKey)
}, as: ResourceMutationResult<JSONValue>.self)
}
}

public enum ConductorImportStage: String, Sendable {
case pending, done
}

public struct ConductorImportReply: Decodable, Sendable {
public var plan: ConductorImportPlan?
public var error: String?
}

/// The dry run, as `cmux_conductor_import::plan::Plan` serializes it.
public struct ConductorImportPlan: Decodable, Sendable, Equatable {
public struct Project: Decodable, Sendable, Equatable {
public var repoID: String
public var name: String
public var path: String?
/// Why it is not imported (hidden in Conductor, folder gone).
public var skipped: String?
public var setup: String?
public var teardown: String?
public var worktreeinclude: [String]
public var envNames: [String]
public var workspaces: [Workspace]

enum CodingKeys: String, CodingKey {
case repoID = "repo_id", name, path, skipped, setup, teardown, worktreeinclude, envNames = "env_names", workspaces
}
}

public struct Workspace: Decodable, Sendable, Equatable {
public var id: String
public var name: String
public var branch: String?
public var path: String?
public var disposition: Disposition
/// What the import makes without being asked: live workspaces not imported before.
public var checked: Bool
public var chats: Chats?
}

public struct Disposition: Decodable, Sendable, Equatable {
/// `adopt`, `recoverable`, `already-imported` or `skip`.
public var kind: String
/// The commit or branch a recoverable workspace comes back from.
public var from: String?
public var reason: String?
}

public struct Chats: Decodable, Sendable, Equatable {
public var transcripts: Int
}

public struct Warning: Decodable, Sendable, Equatable {
public var code: String
public var detail: String
}

public var cloudShutdown: String
public var projects: [Project]
/// cmux workspaces a cut-off run half made: closed before they are made again.
public var cleanup: [String]
public var warnings: [Warning]

enum CodingKeys: String, CodingKey {
case cloudShutdown = "cloud_shutdown", projects, cleanup, warnings
}

/// The workspaces this run adopts, with their project.
public var adopting: [(project: Project, workspace: Workspace)] {
projects.filter { $0.skipped == nil }.flatMap { project in
project.workspaces.filter { $0.checked && $0.disposition.kind == "adopt" }.map { (project, $0) }
}
}

/// Archived workspaces whose commit or branch still exists: listed, not made.
public var recoverable: [Workspace] {
projects.flatMap(\.workspaces).filter { $0.disposition.kind == "recoverable" }
}
}
Loading
Loading