From 5a8c9ba8fa0a509aae2ec13c2bea920c99128d63 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Fri, 18 Sep 2026 22:08:58 -0700 Subject: [PATCH 1/3] show new server drafts in the macos sidebar --- BitDream/Views/macOS/macOSServerEditor.swift | 4 + BitDream/Views/macOS/macOSServerList.swift | 88 ++++++++++++++----- ...acOSServerEditorNavigationStateTests.swift | 65 ++++++++++++++ 3 files changed, 136 insertions(+), 21 deletions(-) diff --git a/BitDream/Views/macOS/macOSServerEditor.swift b/BitDream/Views/macOS/macOSServerEditor.swift index 2c377c2..5ba38c1 100644 --- a/BitDream/Views/macOS/macOSServerEditor.swift +++ b/BitDream/Views/macOS/macOSServerEditor.swift @@ -25,6 +25,7 @@ struct macOSServerEditor: View { @Binding var hasUnsavedChanges: Bool @Binding var isSaving: Bool let onError: (String) -> Void + var onNameChanged: ((String) -> Void)? @State private var model = ServerFormModel() @@ -107,6 +108,9 @@ struct macOSServerEditor: View { .onChange(of: model.isSaving, initial: true) { _, newValue in isSaving = newValue } + .onChange(of: model.values.name, initial: true) { _, name in + onNameChanged?(name) + } } private var footerBar: some View { diff --git a/BitDream/Views/macOS/macOSServerList.swift b/BitDream/Views/macOS/macOSServerList.swift index e658bf1..c26d94a 100644 --- a/BitDream/Views/macOS/macOSServerList.swift +++ b/BitDream/Views/macOS/macOSServerList.swift @@ -4,7 +4,7 @@ import SwiftUI #if os(macOS) struct MacOSServerEditorNavigationState: Equatable { - enum Destination: Equatable { + enum Destination: Hashable { case server(String) case newServer } @@ -19,6 +19,7 @@ struct MacOSServerEditorNavigationState: Equatable { private(set) var isCreatingNew = false private(set) var hasUnsavedChanges = false private(set) var pendingDestination: Destination? + private var selectionBeforeCreating: String? init( selectedServerID: String? = nil, @@ -82,12 +83,31 @@ struct MacOSServerEditorNavigationState: Equatable { case .server(let serverID): selectedServerID = serverID isCreatingNew = false + selectionBeforeCreating = nil case .newServer: + if !isCreatingNew { + selectionBeforeCreating = selectedServerID + } selectedServerID = nil isCreatingNew = true } } + mutating func cancelCreating(availableServerIDs: [String], preferredServerID: String?) { + guard isCreatingNew else { return } + let previousSelection = selectionBeforeCreating + self = Self() + guard !availableServerIDs.isEmpty else { return } + + let restoredSelection = previousSelection.flatMap { serverID in + availableServerIDs.contains(serverID) ? serverID : nil + } + reconcileSelection( + availableServerIDs: availableServerIDs, + preferredServerID: restoredSelection ?? preferredServerID + ) + } + mutating func reconcileSelection( availableServerIDs: [String], preferredServerID: String? @@ -197,6 +217,7 @@ struct macOSServerList: View { @State private var confirmingDelete = false @State private var serverToDelete: Host? @State private var activeAlert: MacOSServerListAlert? + @State private var draftName = "" private var sortedHosts: [Host] { hosts.sortedByDisplayName() @@ -275,6 +296,9 @@ struct macOSServerList: View { .onChange(of: hosts.map(\.serverID)) { _, _ in syncSelection() } + .onChange(of: editorNavigation.currentDestination) { _, _ in + draftName = "" + } .onChange(of: editRequest) { _, _ in selectRequestedServer() } @@ -290,12 +314,17 @@ private extension macOSServerList { private var sidebar: some View { VStack(spacing: 0) { List(selection: serverSelection) { + if editorNavigation.isCreatingNew { + draftRow + .tag(MacOSServerEditorNavigationState.Destination.newServer) + } + ForEach(sortedHosts) { host in ServerRowLabel( host: host, isConnected: host.serverID == store.host?.serverID ) - .tag(host.serverID) + .tag(MacOSServerEditorNavigationState.Destination.server(host.serverID)) .contextMenu { Button("Connect") { connect(to: host) @@ -318,7 +347,7 @@ private extension macOSServerList { } .onDeleteCommand(perform: promptDeleteSelectedServer) .overlay { - if hosts.isEmpty { + if hosts.isEmpty && !editorNavigation.isCreatingNew { ContentUnavailableView { Label("No Servers", systemImage: "server.rack") } description: { @@ -331,6 +360,23 @@ private extension macOSServerList { } } + private var draftRow: some View { + VStack(alignment: .leading, spacing: 2) { + Text(draftDisplayName) + .lineLimit(1) + Text("Unsaved") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + .accessibilityElement(children: .combine) + } + + private var draftDisplayName: String { + let name = draftName.trimmingCharacters(in: .whitespacesAndNewlines) + return name.isEmpty ? "New Server" : name + } + private var listGutter: some View { HStack(spacing: 2) { Button { @@ -342,6 +388,7 @@ private extension macOSServerList { } .help("Add a server") .accessibilityLabel("Add Server") + .disabled(editorNavigation.isCreatingNew) Button { promptDeleteSelectedServer() @@ -376,15 +423,20 @@ private extension macOSServerList { host: selectedHost, title: nil, saveButtonTitle: editorNavigation.isCreatingNew ? "Add Server" : "Save Changes", - cancelButtonTitle: editorNavigation.isCreatingNew && !hosts.isEmpty ? "Cancel" : nil, - onCancel: editorNavigation.isCreatingNew && !hosts.isEmpty ? { cancelCreatingServer() } : nil, + cancelButtonTitle: editorNavigation.isCreatingNew ? "Cancel" : nil, + onCancel: editorNavigation.isCreatingNew ? { cancelCreatingServer() } : nil, onSaved: handleEditorSaved, onDelete: nil, onConnect: editorNavigation.isCreatingNew ? nil : { connectSelectedServer() }, canConnect: canConnectSelectedServer, hasUnsavedChanges: hasUnsavedChanges, isSaving: $isSaving, - onError: presentError + onError: presentError, + onNameChanged: { name in + if editorNavigation.isCreatingNew { + draftName = name + } + } ) } .id(editorIdentity) @@ -458,8 +510,10 @@ private extension macOSServerList { } private func cancelCreatingServer() { - guard let serverID = preferredSelectionAfterCancel else { return } - editorNavigation.apply(.server(serverID)) + editorNavigation.cancelCreating( + availableServerIDs: sortedHosts.map(\.serverID), + preferredServerID: store.host?.serverID + ) } private func promptDeleteSelectedServer() { @@ -485,12 +539,12 @@ private extension macOSServerList { editorNavigation.didSave(serverID: savedHost.serverID) } - private var serverSelection: Binding { + private var serverSelection: Binding { Binding( - get: { editorNavigation.selectedServerID }, - set: { requestedServerID in - guard let requestedServerID else { return } - requestTransition(to: .server(requestedServerID)) + get: { editorNavigation.currentDestination }, + set: { destination in + guard let destination else { return } + requestTransition(to: destination) } ) } @@ -515,14 +569,6 @@ private extension macOSServerList { ) } - private var preferredSelectionAfterCancel: String? { - if let activeServerID = store.host?.serverID, - hosts.contains(where: { $0.serverID == activeServerID }) { - return activeServerID - } - return sortedHosts.first?.serverID - } - private var dismissalConfirmationTitle: String { isSaving ? "Save in Progress" : "Discard Changes?" } diff --git a/BitDreamTests/Views/MacOSServerEditorNavigationStateTests.swift b/BitDreamTests/Views/MacOSServerEditorNavigationStateTests.swift index 59a1863..f232f9a 100644 --- a/BitDreamTests/Views/MacOSServerEditorNavigationStateTests.swift +++ b/BitDreamTests/Views/MacOSServerEditorNavigationStateTests.swift @@ -155,5 +155,70 @@ final class MacOSServerEditorNavigationStateTests: XCTestCase { XCTAssertFalse(state.isCreatingNew) XCTAssertFalse(state.hasUnsavedChanges) } + + func testDraftRemainsSelectedUntilDiscardIsConfirmed() { + var state = MacOSServerEditorNavigationState() + state.apply(.server("server-a")) + XCTAssertEqual(state.requestTransition(to: .newServer, whileSaving: false), .applied) + XCTAssertEqual(state.currentDestination, .newServer) + state.setHasUnsavedChanges(true) + + XCTAssertEqual(state.requestTransition(to: .newServer, whileSaving: false), .ignored) + XCTAssertEqual(state.requestTransition(to: .server("server-b"), whileSaving: false), .confirmationRequired) + XCTAssertEqual(state.currentDestination, .newServer) + + state.cancelPendingTransition() + XCTAssertEqual(state.currentDestination, .newServer) + XCTAssertTrue(state.hasUnsavedChanges) + + _ = state.requestTransition(to: .server("server-b"), whileSaving: false) + state.confirmDiscardAndTransition() + XCTAssertEqual(state.currentDestination, .server("server-b")) + XCTAssertFalse(state.hasUnsavedChanges) + } + + func testCancelDraftRestoresPreviousSelectionInsteadOfConnectedServer() { + var state = MacOSServerEditorNavigationState() + state.apply(.server("server-b")) + state.apply(.newServer) + state.setHasUnsavedChanges(true) + + state.cancelCreating( + availableServerIDs: ["server-a", "server-b"], + preferredServerID: "server-a" + ) + + XCTAssertEqual(state.currentDestination, .server("server-b")) + XCTAssertFalse(state.hasUnsavedChanges) + XCTAssertNil(state.pendingDestination) + } + + func testCancelDraftFallsBackWhenPreviousServerWasRemoved() { + var state = MacOSServerEditorNavigationState() + state.apply(.server("removed-server")) + state.apply(.newServer) + + state.cancelCreating( + availableServerIDs: ["server-a", "server-b"], + preferredServerID: "server-b" + ) + + XCTAssertEqual(state.currentDestination, .server("server-b")) + } + + func testCancelFirstServerDraftClearsSelectionAndAllowsStartingAgain() { + var state = MacOSServerEditorNavigationState() + state.reconcileSelection(availableServerIDs: [], preferredServerID: nil) + XCTAssertEqual(state.currentDestination, .newServer) + state.setHasUnsavedChanges(true) + + state.cancelCreating(availableServerIDs: [], preferredServerID: nil) + + XCTAssertNil(state.currentDestination) + XCTAssertFalse(state.isCreatingNew) + XCTAssertFalse(state.hasUnsavedChanges) + XCTAssertEqual(state.requestTransition(to: .newServer, whileSaving: false), .applied) + XCTAssertEqual(state.currentDestination, .newServer) + } } #endif From e8225a1edb60c05d076d9162a6b2072e1bd61f50 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Fri, 18 Sep 2026 22:09:05 -0700 Subject: [PATCH 2/3] require signed builds for manual testing --- AGENTS.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 16b3cbd..5270bc6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,6 +92,15 @@ For iOS build (compile check without signing): build ``` +## Launching for Manual Testing + +- Always launch a normally signed build when running the app for the user or performing manual UI testing. Never launch an unsigned or ad-hoc-signed build produced by automated tests or compile checks. +- `CODE_SIGNING_ALLOWED=NO` and `CODE_SIGNING_REQUIRED=NO` are only for automated validation. Do not use either override for a build that will be launched interactively. Unsigned builds can fail Keychain access and do not represent normal app behavior. +- Keep signed launch builds and unsigned validation builds in separate DerivedData directories using `-derivedDataPath`. This prevents validation artifacts, including test bundles, from contaminating the app used for manual testing. +- Before launching, build the current worktree with the normal project signing configuration and verify that exact `.app` with `codesign --verify --deep --strict` and `codesign -dv --verbose=2`. Confirm an Apple signing authority and the expected team identifier; an ad-hoc signature is not sufficient. +- If signing or signature verification fails, fix the signed build or report the blocker. Never silently disable signing to get an app running. +- Launch the exact verified app bundle. If an older instance is running, relaunch it so the user tests the new binary, preserving unsaved user input or asking before discarding it. + ## Testing From repo root (`/Users/austinsmith/Developer/Repos/BitDream`), run macOS tests with: From 0e9dc17ba13ac4ccd40e0826e4ce7cd60ce6bb72 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Fri, 18 Sep 2026 22:16:11 -0700 Subject: [PATCH 3/3] simplify manual testing guidance --- AGENTS.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5270bc6..9213cf1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,12 +94,8 @@ For iOS build (compile check without signing): ## Launching for Manual Testing -- Always launch a normally signed build when running the app for the user or performing manual UI testing. Never launch an unsigned or ad-hoc-signed build produced by automated tests or compile checks. -- `CODE_SIGNING_ALLOWED=NO` and `CODE_SIGNING_REQUIRED=NO` are only for automated validation. Do not use either override for a build that will be launched interactively. Unsigned builds can fail Keychain access and do not represent normal app behavior. -- Keep signed launch builds and unsigned validation builds in separate DerivedData directories using `-derivedDataPath`. This prevents validation artifacts, including test bundles, from contaminating the app used for manual testing. -- Before launching, build the current worktree with the normal project signing configuration and verify that exact `.app` with `codesign --verify --deep --strict` and `codesign -dv --verbose=2`. Confirm an Apple signing authority and the expected team identifier; an ad-hoc signature is not sufficient. -- If signing or signature verification fails, fix the signed build or report the blocker. Never silently disable signing to get an app running. -- Launch the exact verified app bundle. If an older instance is running, relaunch it so the user tests the new binary, preserving unsaved user input or asking before discarding it. +- For manual testing, build the current worktree with normal project signing. Never launch unsigned or ad-hoc-signed test builds. +- Use separate `-derivedDataPath` directories for signed app builds and unsigned automated tests so their output cannot mix. ## Testing