feat(client/apple): the scope dropdown carries the colours, the icons, and Delete

Every profile row in the layer picker now shows its colour chip, so the dropdown is
where you SEE the catalog rather than read it. It stays a Picker for that: the
platform draws the selection checkmark in its own column, which leaves each row's
icon free to be the chip. Hand-rolling the selection would have cost one or the
other.

Delete comes back to the menu it was taken from, next to New, Edit and Duplicate —
all four with icons now. Its warning goes with it: the count of hosts that fall back
and pinned cards that disappear is in the question, not discovered afterwards. The
editor sheet drops its own delete section rather than offering the same destructive
action in two places.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-29 12:06:41 +02:00
co-authored by Claude Opus 5
parent 586da1451c
commit 8233722e9e
3 changed files with 80 additions and 63 deletions
@@ -47,13 +47,12 @@ struct ProfileEditorSheet: View {
@ObservedObject private var profiles = ProfileStore.shared
let draft: ProfileDraft
/// Where the settings surface should be pointing afterwards at the new profile, or back at
/// the defaults when this one was deleted.
/// Where the settings surface should be pointing afterwards at the profile that was just
/// created, so you land in the layer you made rather than back on the defaults.
let onScope: (SettingsScope) -> Void
@State private var name: String
@State private var accent: String?
@State private var confirmingDelete = false
init(draft: ProfileDraft, onScope: @escaping (SettingsScope) -> Void) {
self.draft = draft
@@ -64,7 +63,7 @@ struct ProfileEditorSheet: View {
var body: some View {
#if os(macOS)
withDeletePrompt(VStack(spacing: 0) {
VStack(spacing: 0) {
form
HStack {
Button("Cancel", role: .cancel) { dismiss() }
@@ -78,9 +77,9 @@ struct ProfileEditorSheet: View {
.padding(16)
}
.frame(width: 420)
.fixedSize(horizontal: false, vertical: true))
.fixedSize(horizontal: false, vertical: true)
#else
withDeletePrompt(NavigationStack {
NavigationStack {
form
.navigationTitle(draft.title)
#if os(iOS)
@@ -97,7 +96,7 @@ struct ProfileEditorSheet: View {
}
}
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible))
.presentationDragIndicator(.visible)
#endif
}
@@ -132,15 +131,6 @@ struct ProfileEditorSheet: View {
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
if draft.editingID != nil {
Section {
Button("Delete Profile", role: .destructive) { confirmingDelete = true }
} footer: {
Text(deleteFootnote)
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
}
.formStyle(.grouped)
#if os(macOS)
@@ -224,26 +214,6 @@ struct ProfileEditorSheet: View {
: "Hosts and pinned cards follow this profile by id, so renaming keeps them attached."
}
private var deleteFootnote: String {
guard let id = draft.editingID else { return "" }
let (bound, pinned) = profiles.usage(of: id)
var parts: [String] = []
if bound > 0 {
parts.append("\(bound) host\(bound == 1 ? "" : "s") use\(bound == 1 ? "s" : "") it")
}
if pinned > 0 {
parts.append("\(pinned) pinned card\(pinned == 1 ? "" : "s") show\(pinned == 1 ? "s" : "") it")
}
guard !parts.isEmpty else { return "Nothing uses this profile yet." }
return parts.joined(separator: " and ") + "."
}
private var deleteMessage: String {
let usage = deleteFootnote
return "Hosts using it fall back to Default settings and its pinned cards disappear."
+ (usage.isEmpty ? "" : " \(usage)")
}
private func commit() {
guard isNameAcceptable else { return }
if let id = draft.editingID {
@@ -259,22 +229,4 @@ struct ProfileEditorSheet: View {
}
dismiss()
}
private func delete() {
guard let id = draft.editingID else { return }
profiles.delete(id)
onScope(.defaults)
dismiss()
}
/// Deleting warns with what it changes (§6): bindings fall back to Default settings and
/// pinned cards disappear. Neither is an error; neither should be a surprise.
private func withDeletePrompt<Content: View>(_ content: Content) -> some View {
content.alert("Delete “\(draft.name)”?", isPresented: $confirmingDelete) {
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) { delete() }
} message: {
Text(deleteMessage)
}
}
}
@@ -295,10 +295,20 @@ extension SettingsView {
/// remote does well, and the pattern should prove itself on the primary surfaces first.
@ViewBuilder
var scopeMenuContent: some View {
// A Picker rather than hand-rolled buttons: the platform owns the selection checkmark
// and draws it in its OWN column, which leaves each row's icon free to be the profile's
// colour chip. Rolling the selection by hand would have cost one or the other.
Picker("Editing", selection: scopeSelection) {
Text("Default settings").tag(SettingsScope.defaults)
Label("Default settings", systemImage: "gearshape")
.tag(SettingsScope.defaults)
ForEach(profiles.profiles) { profile in
Text(profile.name).tag(SettingsScope.profile(profile.id))
Label {
Text(profile.name)
} icon: {
Image(systemName: "circle.fill")
.foregroundStyle(profile.accentColor)
}
.tag(SettingsScope.profile(profile.id))
}
}
.pickerStyle(.inline)
@@ -306,11 +316,27 @@ extension SettingsView {
// Three actions, one sheet. A profile is a name and a colour; deciding them in separate
// menu items each raising its own bare alert is how "make a Work profile" became four
// trips through this menu.
Button("New Profile…") { profileDraft = .create() }
Button {
profileDraft = .create()
} label: {
Label("New Profile…", systemImage: "plus")
}
if let active = activeProfile {
Button("Edit “\(active.name)”…") { profileDraft = .edit(active) }
Button("Duplicate “\(active.name)”…") {
Button {
profileDraft = .edit(active)
} label: {
Label("Edit “\(active.name)”…", systemImage: "pencil")
}
Button {
profileDraft = .duplicate(active, name: Self.copyName(of: active.name, in: profiles))
} label: {
Label("Duplicate “\(active.name)”…", systemImage: "plus.square.on.square")
}
Divider()
Button(role: .destructive) {
profilePendingDelete = active
} label: {
Label("Delete “\(active.name)”…", systemImage: "trash")
}
}
}
@@ -389,11 +415,48 @@ extension SettingsView {
}
#endif
/// The editor, attached wherever the menu lives.
/// The editor and the delete confirmation, attached wherever the menu lives.
private func profilePrompts<Content: View>(_ content: Content) -> some View {
content.sheet(item: $profileDraft) { draft in
ProfileEditorSheet(draft: draft) { scope = $0 }
content
.sheet(item: $profileDraft) { draft in
ProfileEditorSheet(draft: draft) { scope = $0 }
}
// Deleting warns with what it changes (§6): bound hosts fall back to Default settings
// and pinned cards disappear. Neither is an error, but neither should be a surprise
// so the counts are in the question, not discovered afterwards.
.alert(
"Delete “\(profilePendingDelete?.name ?? "")”?",
isPresented: profileDeletePresented,
presenting: profilePendingDelete
) { profile in
Button("Cancel", role: .cancel) { profilePendingDelete = nil }
Button("Delete", role: .destructive) {
profiles.delete(profile.id)
scope = .defaults
profilePendingDelete = nil
}
} message: { profile in
Text(deleteWarning(for: profile))
}
}
private var profileDeletePresented: Binding<Bool> {
Binding(
get: { profilePendingDelete != nil },
set: { if !$0 { profilePendingDelete = nil } })
}
private func deleteWarning(for profile: StreamProfile) -> String {
let (bound, pinned) = profiles.usage(of: profile.id)
var parts: [String] = []
if bound > 0 {
parts.append("\(bound) host\(bound == 1 ? "" : "s") will fall back to Default settings")
}
if pinned > 0 {
parts.append("\(pinned) pinned card\(pinned == 1 ? "" : "s") will disappear")
}
guard !parts.isEmpty else { return "Nothing uses this profile yet." }
return parts.joined(separator: ", ") + "."
}
/// Switching scope is a plain state change the rows are built by one code path and simply
@@ -26,8 +26,10 @@ struct SettingsView: View {
// profiles and render pinned cards, but don't edit them (design §5.4).
@ObservedObject var profiles = ProfileStore.shared
@State var scope: SettingsScope = .defaults
/// The profile editor (create / duplicate / edit / delete), when it is open.
/// The profile editor (create / duplicate / edit), when it is open, and the profile a delete
/// is being confirmed for.
@State var profileDraft: ProfileDraft?
@State var profilePendingDelete: StreamProfile?
#if os(macOS)
@State private var macTab: MacTab = .general
#endif