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
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ For iOS build (compile check without signing):
build
```

## Launching for Manual Testing

- 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

From repo root (`/Users/austinsmith/Developer/Repos/BitDream`), run macOS tests with:
Expand Down
4 changes: 4 additions & 0 deletions BitDream/Views/macOS/macOSServerEditor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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 {
Expand Down
88 changes: 67 additions & 21 deletions BitDream/Views/macOS/macOSServerList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import SwiftUI

#if os(macOS)
struct MacOSServerEditorNavigationState: Equatable {
enum Destination: Equatable {
enum Destination: Hashable {
case server(String)
case newServer
}
Expand All @@ -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,
Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
}
Expand All @@ -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)
Expand All @@ -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: {
Expand All @@ -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 {
Expand All @@ -342,6 +388,7 @@ private extension macOSServerList {
}
.help("Add a server")
.accessibilityLabel("Add Server")
.disabled(editorNavigation.isCreatingNew)

Button {
promptDeleteSelectedServer()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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() {
Expand All @@ -485,12 +539,12 @@ private extension macOSServerList {
editorNavigation.didSave(serverID: savedHost.serverID)
}

private var serverSelection: Binding<String?> {
private var serverSelection: Binding<MacOSServerEditorNavigationState.Destination?> {
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)
}
)
}
Expand All @@ -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?"
}
Expand Down
65 changes: 65 additions & 0 deletions BitDreamTests/Views/MacOSServerEditorNavigationStateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading