Merge nucleic/plucky-umber-viper into dev
This commit is contained in:
@@ -113,6 +113,9 @@ final class HostConnection {
|
|||||||
var macPairRequested: (WireMacPairRequest) -> Void = { _ in }
|
var macPairRequested: (WireMacPairRequest) -> Void = { _ in }
|
||||||
/// The forwarded Mac-pair (by deviceID) was answered/withdrawn — dismiss the prompt.
|
/// The forwarded Mac-pair (by deviceID) was answered/withdrawn — dismiss the prompt.
|
||||||
var macPairResolved: (String) -> Void = { _ in }
|
var macPairResolved: (String) -> Void = { _ in }
|
||||||
|
/// The outcome of a `createProject` this phone sent (CLOUD_RUNTIME §4.3), correlated by
|
||||||
|
/// requestID — settle the Add Project sheet (the project row rides the dashboard push).
|
||||||
|
var projectCreated: (WireProjectCreated) -> Void = { _ in }
|
||||||
}
|
}
|
||||||
private let callbacks: Callbacks
|
private let callbacks: Callbacks
|
||||||
|
|
||||||
@@ -640,6 +643,10 @@ final class HostConnection {
|
|||||||
case .macPairResolved(let deviceID, _):
|
case .macPairResolved(let deviceID, _):
|
||||||
// Answered on this Mac or another device (or timed out) — dismiss the prompt.
|
// Answered on this Mac or another device (or timed out) — dismiss the prompt.
|
||||||
callbacks.macPairResolved(deviceID)
|
callbacks.macPairResolved(deviceID)
|
||||||
|
case .projectCreated(let outcome):
|
||||||
|
// The host settled a createProject we sent — hand it up so the Add Project sheet
|
||||||
|
// resolves (success or failure). Correlation by requestID happens in RemoteStore.
|
||||||
|
callbacks.projectCreated(outcome)
|
||||||
case .intelligenceRequest, .credentialNeeded, .credentialUpdate:
|
case .intelligenceRequest, .credentialNeeded, .credentialUpdate:
|
||||||
// Antimatter runner verbs (docs/ANTIMATTER_RUNNER.md §5–6): a runner host delegating
|
// Antimatter runner verbs (docs/ANTIMATTER_RUNNER.md §5–6): a runner host delegating
|
||||||
// intelligence work or asking for / mirroring sealed credentials. Inert here until the
|
// intelligence work or asking for / mirroring sealed credentials. Inert here until the
|
||||||
|
|||||||
@@ -120,6 +120,22 @@ final class RemoteStore: ObservableObject {
|
|||||||
/// its pairing window.
|
/// its pairing window.
|
||||||
private var pairingMintHostID: String?
|
private var pairingMintHostID: String?
|
||||||
|
|
||||||
|
/// "Add a project" (Projects tab): the phone asks a connected host advertising
|
||||||
|
/// `canCreateProjects` to clone a git URL and register it (CLOUD_RUNTIME §4.3) — how a
|
||||||
|
/// fresh Antimatter runner gets its first project. The outcome arrives asynchronously as
|
||||||
|
/// `HostMsg.projectCreated`, correlated by the request id below; the project row itself
|
||||||
|
/// rides the dashboard push.
|
||||||
|
enum AddProjectState: Equatable {
|
||||||
|
case idle
|
||||||
|
case creating // waiting on the host's `projectCreated` reply
|
||||||
|
case created(String) // success — the new project's display name
|
||||||
|
case failed(String) // the host's human-readable failure (clone error, bad URL, …)
|
||||||
|
}
|
||||||
|
@Published private(set) var addProject: AddProjectState = .idle
|
||||||
|
/// The in-flight request's id — replies are matched on it, so a late/stale `projectCreated`
|
||||||
|
/// (the user dismissed the sheet and started another) can't settle the wrong request.
|
||||||
|
private var createProjectRequestID: String?
|
||||||
|
|
||||||
/// A Mac trying to join via a code this phone shared, awaiting the user's allow/deny — the Mac
|
/// A Mac trying to join via a code this phone shared, awaiting the user's allow/deny — the Mac
|
||||||
/// forwarded its pairing confirm here (`HostMsg.macPairRequested`) so it can be approved from
|
/// forwarded its pairing confirm here (`HostMsg.macPairRequested`) so it can be approved from
|
||||||
/// the phone. The "add a device" sheet renders an allow/deny dialog for it.
|
/// the phone. The "add a device" sheet renders an allow/deny dialog for it.
|
||||||
@@ -887,6 +903,18 @@ final class RemoteStore: ObservableObject {
|
|||||||
self.pendingMacPairRequest = nil
|
self.pendingMacPairRequest = nil
|
||||||
self.pendingMacPairHostID = nil
|
self.pendingMacPairHostID = nil
|
||||||
}
|
}
|
||||||
|
cb.projectCreated = { [weak self] outcome in
|
||||||
|
guard let self else { return }
|
||||||
|
// Only the reply to the request in flight settles the sheet — a stale one (the user
|
||||||
|
// dismissed and retried, or another device's create) is dropped.
|
||||||
|
guard let pending = self.createProjectRequestID, outcome.requestID == pending else { return }
|
||||||
|
self.createProjectRequestID = nil
|
||||||
|
if let error = outcome.error {
|
||||||
|
self.addProject = .failed(error)
|
||||||
|
} else {
|
||||||
|
self.addProject = .created(outcome.name ?? "Project")
|
||||||
|
}
|
||||||
|
}
|
||||||
return cb
|
return cb
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1400,6 +1428,49 @@ final class RemoteStore: ObservableObject {
|
|||||||
/// A stand-in join code for the offline demo so the QR/copy sheet renders without a Mac.
|
/// A stand-in join code for the offline demo so the QR/copy sheet renders without a Mac.
|
||||||
private static let demoPairingCode = "nucleic://pair?d=demo"
|
private static let demoPairingCode = "nucleic://pair?d=demo"
|
||||||
|
|
||||||
|
// MARK: - Add a project (CLOUD_RUNTIME §4.3)
|
||||||
|
|
||||||
|
/// Hosts that can register a project right now (live + advertising `canCreateProjects`) —
|
||||||
|
/// the Add Project sheet's destination picker. Name-sorted for a stable picker.
|
||||||
|
var projectCreationHosts: [(hostID: String, name: String)] {
|
||||||
|
connections.values
|
||||||
|
.filter { $0.connectivity.isLive && $0.capabilities.canCreateProjects }
|
||||||
|
.map { ($0.hostID, $0.hostName) }
|
||||||
|
.sorted { $0.name < $1.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the Add Project affordance should appear: a capable host is reachable (or demo).
|
||||||
|
var canCreateProject: Bool { demoMode || !projectCreationHosts.isEmpty }
|
||||||
|
|
||||||
|
/// Ask `hostID` (or the first capable host) to clone `gitURL` and register it as a project.
|
||||||
|
/// The outcome arrives asynchronously as `.created`/`.failed` via the host connection; the
|
||||||
|
/// new project row follows on the dashboard push.
|
||||||
|
func createProject(gitURL: String, name: String?, branch: String?, onHost hostID: String?) {
|
||||||
|
if demoMode {
|
||||||
|
addProject = .created(name?.isEmpty == false ? name! : "Project")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let conn = hostID.flatMap { connections[$0] }
|
||||||
|
?? connections.values.first { $0.connectivity.isLive && $0.capabilities.canCreateProjects }
|
||||||
|
guard let conn, conn.connectivity.isLive, conn.capabilities.canCreateProjects else {
|
||||||
|
addProject = .failed("No connected host can add projects right now.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let requestID = UUID().uuidString
|
||||||
|
createProjectRequestID = requestID
|
||||||
|
addProject = .creating
|
||||||
|
conn.send(.createProject(WireCreateProjectRequest(
|
||||||
|
gitURL: gitURL, branch: branch?.isEmpty == false ? branch : nil,
|
||||||
|
name: name?.isEmpty == false ? name : nil, requestID: requestID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Add Project sheet closed — drop any in-flight correlation (a late reply is ignored)
|
||||||
|
/// and reset the state for the next open.
|
||||||
|
func resetAddProject() {
|
||||||
|
createProjectRequestID = nil
|
||||||
|
addProject = .idle
|
||||||
|
}
|
||||||
|
|
||||||
/// Record that the user looked at this session now (clears its unseen-completion wash).
|
/// Record that the user looked at this session now (clears its unseen-completion wash).
|
||||||
func markOpened(_ sessionID: SessionID) {
|
func markOpened(_ sessionID: SessionID) {
|
||||||
lastOpenedAt[sessionID] = Date()
|
lastOpenedAt[sessionID] = Date()
|
||||||
|
|||||||
@@ -5,13 +5,23 @@ import NucleicProtocol
|
|||||||
/// counts; tap through to that project's sessions + a scoped composer.
|
/// counts; tap through to that project's sessions + a scoped composer.
|
||||||
struct ProjectsView: View {
|
struct ProjectsView: View {
|
||||||
@EnvironmentObject var store: RemoteStore
|
@EnvironmentObject var store: RemoteStore
|
||||||
|
@State private var showAddProject = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Group {
|
Group {
|
||||||
if store.dashboard.projects.isEmpty {
|
if store.dashboard.projects.isEmpty {
|
||||||
ContentUnavailableView("No projects", systemImage: "folder",
|
ContentUnavailableView {
|
||||||
description: Text("Add a project on the Mac to see it here."))
|
Label("No projects", systemImage: "folder")
|
||||||
|
} description: {
|
||||||
|
Text(store.canCreateProject
|
||||||
|
? "Add a project here, or on the Mac."
|
||||||
|
: "Add a project on the Mac to see it here.")
|
||||||
|
} actions: {
|
||||||
|
if store.canCreateProject {
|
||||||
|
Button("Add Project") { showAddProject = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
List(store.dashboard.projects) { project in
|
List(store.dashboard.projects) { project in
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
@@ -40,9 +50,106 @@ struct ProjectsView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle("Projects")
|
.navigationTitle("Projects")
|
||||||
.refreshable { store.refreshSessions() }
|
.refreshable { store.refreshSessions() }
|
||||||
|
.toolbar {
|
||||||
|
// "Add a project" (CLOUD_RUNTIME §4.3): clone a git URL on a connected host —
|
||||||
|
// how a fresh Antimatter runner gets its first project. Hidden when no live
|
||||||
|
// host advertises `canCreateProjects` (an older Mac would reject the verb).
|
||||||
|
if store.canCreateProject {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button { showAddProject = true } label: {
|
||||||
|
Label("Add Project", systemImage: "plus")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showAddProject) {
|
||||||
|
AddProjectSheet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clone-and-register a project on a connected host: git URL (+ optional name/branch) and,
|
||||||
|
/// with more than one capable host, a destination picker. The request settles asynchronously
|
||||||
|
/// (`RemoteStore.addProject`); the sheet shows progress, the failure message, or dismisses on
|
||||||
|
/// success — the new project row arrives via the dashboard push.
|
||||||
|
private struct AddProjectSheet: View {
|
||||||
|
@EnvironmentObject var store: RemoteStore
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var gitURL = ""
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var branch = ""
|
||||||
|
@State private var hostID: String?
|
||||||
|
|
||||||
|
private var hosts: [(hostID: String, name: String)] { store.projectCreationHosts }
|
||||||
|
private var creating: Bool { store.addProject == .creating }
|
||||||
|
private var canSubmit: Bool {
|
||||||
|
!gitURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !creating
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
TextField("Git URL", text: $gitURL, prompt: Text("https://github.com/you/repo"))
|
||||||
|
.textContentType(.URL)
|
||||||
|
.keyboardType(.URL)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
} footer: {
|
||||||
|
Text("The host clones this repository and manages the checkout itself.")
|
||||||
|
}
|
||||||
|
Section("Options") {
|
||||||
|
TextField("Name (optional)", text: $name)
|
||||||
|
TextField("Branch (optional)", text: $branch)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
}
|
||||||
|
if hosts.count > 1 {
|
||||||
|
Section("Create on") {
|
||||||
|
Picker("Host", selection: $hostID) {
|
||||||
|
ForEach(hosts, id: \.hostID) { host in
|
||||||
|
Text(host.name).tag(Optional(host.hostID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if case .failed(let message) = store.addProject {
|
||||||
|
Section {
|
||||||
|
Label(message, systemImage: "exclamationmark.triangle")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Add Project")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.interactiveDismissDisabled(creating)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }.disabled(creating)
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
if creating {
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
Button("Create") {
|
||||||
|
store.createProject(
|
||||||
|
gitURL: gitURL.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
name: name, branch: branch, onHost: hostID)
|
||||||
|
}
|
||||||
|
.disabled(!canSubmit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear { if hostID == nil { hostID = hosts.first?.hostID } }
|
||||||
|
.onChange(of: store.addProject) { _, state in
|
||||||
|
if case .created = state { dismiss() }
|
||||||
|
}
|
||||||
|
.onDisappear { store.resetAddProject() }
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ProjectDetailView: View {
|
struct ProjectDetailView: View {
|
||||||
@EnvironmentObject var store: RemoteStore
|
@EnvironmentObject var store: RemoteStore
|
||||||
|
|||||||
Reference in New Issue
Block a user