Let a failed detect offer a retry instead of a dead Add Source

After detection failed, Add Source stayed enabled and tapping it just
re-ran detection into the same error — an enabled-looking button that
visibly did nothing, forever (TV8). The error state now shows the
button that can help, Retry Detection, holds a perceptible detecting
beat even when the host fails instantly, and hides the display-name
section that made the form read as submittable. Add Source itself now
requires a detected type, so it can no longer render enabled without
one.
This commit is contained in:
Arkadiusz Fal
2026-09-10 23:50:24 +02:00
parent b69fb7fb1f
commit 9562510c67

View File

@@ -44,6 +44,10 @@ struct AddRemoteServerView: View {
@State private var uiState: RemoteServerUIState = .initial
@State private var detectionTask: Task<Void, Never>?
/// A detection re-run started from the failed state. Tracked separately from
/// `uiState` so the form keeps the error and the fields on screen while the
/// retry button itself carries the progress.
@State private var isRedetecting = false
// MARK: - URL Entry
@@ -94,6 +98,13 @@ struct AddRemoteServerView: View {
return false
}
/// Detection ran and could not identify the server. There is no type to add a
/// source with, so the form offers a retry instead of the "Add Source" action.
private var hasDetectionFailed: Bool {
if case .error = uiState { return true }
return false
}
/// macOS surfaces toolbar confirmation items from a pushed-in-sheet view
/// reliably only from macOS 26 onward. On older macOS (and on iOS/tvOS) we
/// render the "Add Source" action button inline in the form instead, so it
@@ -113,6 +124,11 @@ struct AddRemoteServerView: View {
private var canAdd: Bool {
guard !urlString.isEmpty else { return false }
// A source cannot be created without a type, so nothing is addable until
// detection succeeded. Without this the button stays enabled-looking after a
// failed detection while every tap only re-runs the detection that just failed.
guard let detectedType else { return false }
// For detected Yattee Server, require credentials
if detectedType == .yatteeServer {
return !basicAuthUsername.isEmpty && !basicAuthPassword.isEmpty
@@ -208,7 +224,10 @@ struct AddRemoteServerView: View {
if isFieldsRevealed {
serverConfigurationFields
if !usesToolbarActionButton {
if hasDetectionFailed {
retryDetectionSection
} else if !usesToolbarActionButton {
actionSection
}
}
@@ -426,18 +445,24 @@ struct AddRemoteServerView: View {
@ViewBuilder
private var serverConfigurationFields: some View {
Section {
#if os(tvOS)
TVSettingsTextField(title: String(localized: "sources.field.nameOptional"), text: $name)
#elseif os(macOS)
LabeledContent(String(localized: "sources.field.name")) {
TextField("", text: $name, prompt: Text(String(localized: "sources.field.nameOptional")))
// Naming a server that could not be identified only looks like a form waiting
// to be submitted. While detection is failing the form shows the error, any
// setting that could fix it, and the retry — the typed name is kept in state
// and comes back as soon as detection succeeds.
if !hasDetectionFailed {
Section {
#if os(tvOS)
TVSettingsTextField(title: String(localized: "sources.field.nameOptional"), text: $name)
#elseif os(macOS)
LabeledContent(String(localized: "sources.field.name")) {
TextField("", text: $name, prompt: Text(String(localized: "sources.field.nameOptional")))
}
#else
TextField(String(localized: "sources.field.nameOptional"), text: $name)
#endif
} header: {
Text(String(localized: "sources.header.displayName"))
}
#else
TextField(String(localized: "sources.field.nameOptional"), text: $name)
#endif
} header: {
Text(String(localized: "sources.header.displayName"))
}
// Show detected type badge
@@ -549,10 +574,39 @@ struct AddRemoteServerView: View {
}
}
// MARK: - Retry Detection Section
/// Replaces the "Add Source" action while detection is failing: there is no
/// detected type to add, so the only useful action is running detection again —
/// after correcting the address, or after allowing an invalid certificate.
private var retryDetectionSection: some View {
Section {
Button {
retryDetection()
} label: {
if isRedetecting {
HStack {
ProgressView()
.controlSize(.small)
Text(String(localized: "sources.detecting"))
}
} else {
Text(String(localized: "sources.retryDetection"))
}
}
.accessibilityIdentifier("addRemoteServer.retryDetectionButton")
.disabled(urlString.isEmpty || isRedetecting)
#if os(tvOS)
.buttonStyle(TVSettingsButtonStyle())
#endif
}
}
// MARK: - Actions
private func handleURLChange() {
cancelDetection()
isRedetecting = false
if isFieldsRevealed || isAwaitingBasicAuth {
withAnimation {
@@ -593,6 +647,39 @@ struct AddRemoteServerView: View {
}
}
/// Runs detection again from the failed state.
///
/// Unlike `startDetection()` this keeps `uiState` on the error, so the fields,
/// the message and the button all stay put and only the button switches to a
/// progress label. A host that cannot resolve fails almost instantly, so the
/// progress state is held for a beat — otherwise a retry that fails the same way
/// changes nothing on screen and the button reads as dead.
private func retryDetection() {
guard !urlString.isEmpty, !isRedetecting else { return }
guard let url = Instance.normalizeSourceURL(urlString) else {
withAnimation {
uiState = .error(String(localized: "sources.validation.invalidURL"))
}
return
}
cancelDetection()
isRedetecting = true
detectionTask = Task {
try? await Task.sleep(for: .milliseconds(400))
if !Task.isCancelled {
await performDetection(url: url)
}
await MainActor.run {
isRedetecting = false
}
}
}
private func performDetection(url: URL, basicAuthHeader: String? = nil) async {
guard let appEnvironment else { return }