Files
Termind/TFI/Features/Snippets/MobileSnippetsView.swift

759 lines
31 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import struct TermindDomain.Host
struct MobileSnippetsView: View {
@AppStorage("mobile.compactCards") private var compactCards = false
@Environment(\.locale) private var locale
private struct SnippetEditorRequest: Identifiable {
let id = UUID()
let snippetID: Snippet.ID?
let packageID: SnippetPackage.ID?
}
private struct PackageEditorRequest: Identifiable {
let id = UUID()
let packageID: SnippetPackage.ID?
let parentID: SnippetPackage.ID?
}
private struct TargetRequest: Identifiable {
let id = UUID()
let snippetID: Snippet.ID
}
private struct ExecutionRequest: Identifiable {
let id = UUID()
let snippetID: Snippet.ID
let hostIDs: [Host.ID]
}
let app: MobileAppModel
let execute: (Snippet, [Host.ID]) -> Void
@State private var query: String
@State private var selectedSnippetID: Snippet.ID?
@State private var editorRequest: SnippetEditorRequest?
@State private var packageEditorRequest: PackageEditorRequest?
@State private var deletingSnippetID: Snippet.ID?
@State private var deletingPackage: SnippetPackage?
@State private var targetRequest: TargetRequest?
@State private var pendingTargetExecution: ExecutionRequest?
@State private var destructiveRequest: ExecutionRequest?
init(
app: MobileAppModel, initialQuery: String = "",
execute: @escaping (Snippet, [Host.ID]) -> Void
) {
self.app = app
self.execute = execute
_query = State(initialValue: initialQuery)
}
private var sections: [SnippetLibrarySection] {
SnippetLibrarySectionLayout.sections(library: app.snippets, query: query)
}
var body: some View {
MobileCardPage {
if app.snippets.snippets.isEmpty && app.snippets.snippetPackages.isEmpty {
emptyState
} else if sections.isEmpty {
ContentUnavailableView.search(text: query)
} else {
ForEach(sections) { section in
VStack(alignment: .leading, spacing: 12) {
sectionHeader(section)
if section.isExpanded != false {
MobileCardGrid(compact: compactCards) {
ForEach(section.snippets) { snippet in
snippetCard(snippet, showsPackage: !query.isEmpty)
}
}
}
}
}
}
}
.searchable(text: $query.singleLineText)
.navigationTitle(AppStrings.Navigation.snippets.swiftUIKey)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu {
Button(AppStrings.Snippets.newSnippet.swiftUIKey, systemImage: "curlybraces") {
editorRequest = SnippetEditorRequest(snippetID: nil, packageID: nil)
}
Button(AppStrings.Snippets.newPackage.swiftUIKey, systemImage: "folder.badge.plus") {
packageEditorRequest = PackageEditorRequest(packageID: nil, parentID: nil)
}
} label: {
Label(AppStrings.Common.add.swiftUIKey, systemImage: "plus").labelStyle(.iconOnly)
}
}
}
.navigationDestination(item: $selectedSnippetID) { id in
MobileSnippetDetailView(
app: app, snippetID: id,
execute: { requestExecution($0, overrideTargets: false) },
edit: {
editorRequest = SnippetEditorRequest(snippetID: id, packageID: nil)
},
duplicate: duplicate,
delete: {
app.snippets.delete(snippetIDs: [id])
reportStorageError()
})
}
.sheet(item: $editorRequest) { request in
MobileSnippetEditor(
app: app,
snippet: request.snippetID.flatMap(snippet),
packageID: request.packageID
) { saved in
selectedSnippetID = saved.id
}
}
.sheet(item: $packageEditorRequest) { request in
MobileSnippetPackageEditor(
app: app,
package: request.packageID.flatMap(app.snippets.snippetPackage),
parentID: request.parentID)
}
.sheet(item: $targetRequest, onDismiss: completeTargetSelection) { request in
MobileMultiHostPicker(
library: app.library, useCase: .snippetExecution, selected: [], title: AppStrings.Common.run,
actionTitle: AppStrings.Common.run,
emptyDescription: AppStrings.Snippets.addAHostBeforeChoosingDefaultTargets,
requiresSelection: true
) { ids in
pendingTargetExecution = ExecutionRequest(snippetID: request.snippetID, hostIDs: ids)
}
}
.confirmationDialog(
AppStrings.Snippets.runADestructiveScriptConfirmation,
isPresented: Binding(
get: { destructiveRequest != nil },
set: { if !$0 { destructiveRequest = nil } }),
titleVisibility: .visible
) {
Button(destructiveExecuteTitle, role: .destructive) {
guard let request = destructiveRequest else { return }
destructiveRequest = nil
performExecution(request)
}
Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) { destructiveRequest = nil }
} message: {
Text(destructiveMessage)
}
.onChange(of: app.snippets.snippets.map(\.id)) {
guard let selectedSnippetID,
!app.snippets.snippets.contains(where: { $0.id == selectedSnippetID })
else { return }
self.selectedSnippetID = nil
}
}
private var emptyState: some View {
ContentUnavailableView {
Label(AppStrings.Snippets.noSnippets.swiftUIKey, systemImage: "curlybraces")
} description: {
Text(AppStrings.Snippets.emptyDescription)
} actions: {
HStack {
Button(AppStrings.Snippets.newPackage.swiftUIKey) {
packageEditorRequest = PackageEditorRequest(packageID: nil, parentID: nil)
}
Button(AppStrings.Snippets.newSnippet.swiftUIKey) {
editorRequest = SnippetEditorRequest(snippetID: nil, packageID: nil)
}
.buttonStyle(.borderedProminent)
}
}
}
private func sectionHeader(_ section: SnippetLibrarySection) -> some View {
HStack(spacing: 8) {
if let isExpanded = section.isExpanded {
Button {
toggle(section, expanded: !isExpanded)
} label: {
HStack(spacing: 8) {
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.rotationEffect(.degrees(isExpanded ? 90 : 0))
Text(section.title).font(.headline)
}
.contentShape(.rect)
}
.buttonStyle(.plain)
} else {
Text(section.title).font(.headline)
}
Text(section.snippets.count, format: .number)
.font(.caption.monospacedDigit()).foregroundStyle(.secondary)
Spacer()
if case .package(let id) = section.id,
let package = app.snippets.snippetPackage(id)
{
Menu {
Button(AppStrings.Snippets.newSnippet.swiftUIKey, systemImage: "curlybraces") {
editorRequest = SnippetEditorRequest(snippetID: nil, packageID: package.id)
}
Button(AppStrings.Snippets.newSubpackage.swiftUIKey, systemImage: "folder.badge.plus") {
packageEditorRequest = PackageEditorRequest(packageID: nil, parentID: package.id)
}
Button(AppStrings.Common.edit.swiftUIKey, systemImage: "pencil") {
packageEditorRequest = PackageEditorRequest(
packageID: package.id, parentID: package.parentID)
}
Divider()
Button(AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive) {
deletingPackage = package
}
} label: {
Image(systemName: "ellipsis")
.frame(width: 44, height: 44)
}
.accessibilityLabel(AppStrings.Common.more.swiftUIKey)
.operationConfirmation(
.init(packageDeletionTitle),
isPresented: $deletingPackage.isPresenting { $0.id == package.id },
actions: [
.init(AppStrings.Snippets.deletePackageAndSnippets, role: .destructive) {
deletePackage(keepingSnippets: false)
},
.init(AppStrings.Snippets.keepSnippets) { deletePackage(keepingSnippets: true) },
.init(AppStrings.Common.cancel, role: .cancel) { deletingPackage = nil },
],
message: .init(packageDeletionMessage)
)
} else if case .ungrouped = section.id {
Menu {
Button(AppStrings.Snippets.newSnippet.swiftUIKey, systemImage: "curlybraces") {
editorRequest = SnippetEditorRequest(snippetID: nil, packageID: nil)
}
Button(AppStrings.Snippets.newPackage.swiftUIKey, systemImage: "folder.badge.plus") {
packageEditorRequest = PackageEditorRequest(packageID: nil, parentID: nil)
}
} label: {
Image(systemName: "ellipsis")
.frame(width: 44, height: 44)
}
.accessibilityLabel(AppStrings.Common.more.swiftUIKey)
}
}
.accessibilityElement(children: .contain)
}
private func snippetCard(_ snippet: Snippet, showsPackage: Bool) -> some View {
MobileActionCard(compact: compactCards) {
selectedSnippetID = snippet.id
} content: {
if compactCards {
MobileCompactSnippetCardContent(snippet: snippet)
} else {
SnippetCardContent(
snippet: snippet,
validTargetCount: app.snippets.validTargetIDs(for: snippet).count,
packagePath: showsPackage ? app.snippets.snippetPackagePath(snippet.packageID) : nil,
modifiedAt: app.snippetModifiedAt(for: snippet.id))
}
} actions: {
Button(AppStrings.Common.run.swiftUIKey, systemImage: "play.fill") {
requestExecution(snippet.id, overrideTargets: false)
}
.disabled(app.snippets.hosts.isEmpty)
Divider()
Button(AppStrings.Common.edit.swiftUIKey, systemImage: "pencil") {
editorRequest = SnippetEditorRequest(snippetID: snippet.id, packageID: nil)
}
Button(AppStrings.Common.duplicate.swiftUIKey, systemImage: "plus.square.on.square") {
duplicate(snippet.id)
}
Divider()
Button(AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive) {
deletingSnippetID = snippet.id
}
}
.operationConfirmation(
.init(AppStrings.Snippets.deleteSnippetConfirmation),
isPresented: $deletingSnippetID.isPresenting { $0 == snippet.id },
actions: [
.init(AppStrings.Snippets.deleteSnippet, role: .destructive, action: deleteSnippet),
.init(AppStrings.Common.cancel, role: .cancel) { deletingSnippetID = nil },
],
message: .init(AppStrings.Snippets.thisActionCannotBeUndone)
)
}
private func toggle(_ section: SnippetLibrarySection, expanded: Bool) {
switch section.id {
case .package(let id): app.snippets.setSnippetPackageExpanded(expanded, for: id)
case .ungrouped: app.snippets.setSnippetsUngroupedExpanded(expanded)
case .results: break
}
}
private func snippet(_ id: Snippet.ID) -> Snippet? {
app.snippets.snippets.first { $0.id == id }
}
private func duplicate(_ id: Snippet.ID) {
guard let snippet = snippet(id) else { return }
let copy = snippet.duplicated()
app.snippets.upsert(copy)
reportStorageError()
selectedSnippetID = copy.id
}
private func deleteSnippet() {
guard let id = deletingSnippetID else { return }
app.snippets.delete(snippetIDs: [id])
deletingSnippetID = nil
if selectedSnippetID == id { selectedSnippetID = nil }
reportStorageError()
}
private var packageDeletionTitle: String {
deletingPackage.map { String(localized: AppStrings.Common.deleteNamedConfirmation($0.name)) } ?? ""
}
private var packageDeletionMessage: String {
guard let package = deletingPackage else { return "" }
let packageIDs = Set(
[package.id] + app.snippets.descendantSnippetPackages(of: package.id).map(\.id))
let count = app.snippets.snippets.filter { $0.packageID.map(packageIDs.contains) == true }.count
return AppLocalization.resolve(AppStrings.Snippets.packageDeletionMessage(snippetCount: count), locale: locale)
}
private func deletePackage(keepingSnippets: Bool) {
guard let package = deletingPackage else { return }
app.snippets.delete(snippetPackageID: package.id, keepSnippets: keepingSnippets)
deletingPackage = nil
reportStorageError()
}
private func requestExecution(_ id: Snippet.ID, overrideTargets: Bool) {
guard let snippet = snippet(id) else { return }
let targets = validTargets(for: snippet)
handle(
MobileSnippetOperations.executionDecision(
for: snippet, hostIDs: targets,
requiresHostSelection: overrideTargets || targets.isEmpty),
snippetID: id)
}
private func handle(_ decision: MobileSnippetExecutionDecision, snippetID: Snippet.ID) {
switch decision {
case .chooseHosts:
pendingTargetExecution = nil
targetRequest = TargetRequest(snippetID: snippetID)
case .confirmDestructive(let hostIDs):
destructiveRequest = ExecutionRequest(snippetID: snippetID, hostIDs: hostIDs)
case .execute(let hostIDs):
performExecution(ExecutionRequest(snippetID: snippetID, hostIDs: hostIDs))
}
}
private func validTargets(for snippet: Snippet) -> [Host.ID] {
app.snippets.validTargetIDs(for: snippet)
}
private func stageExecution(snippetID: Snippet.ID, hostIDs: [Host.ID]) {
guard let snippet = snippet(snippetID), !hostIDs.isEmpty else { return }
handle(
MobileSnippetOperations.executionDecision(
for: snippet, hostIDs: hostIDs, requiresHostSelection: false),
snippetID: snippetID)
}
private func completeTargetSelection() {
guard let request = pendingTargetExecution else { return }
pendingTargetExecution = nil
stageExecution(snippetID: request.snippetID, hostIDs: request.hostIDs)
}
private func performExecution(_ request: ExecutionRequest) {
guard let snippet = snippet(request.snippetID) else { return }
execute(snippet, request.hostIDs)
}
private var destructiveExecuteTitle: String {
guard let request = destructiveRequest else { return String(localized: AppStrings.Common.run) }
return request.hostIDs.count == 1
? String(localized: AppStrings.Snippets.runOn1Host)
: String(localized: AppStrings.Snippets.runOnHosts(request.hostIDs.count))
}
private var destructiveMessage: String {
guard let request = destructiveRequest, let snippet = snippet(request.snippetID) else { return "" }
let wanted = Set(request.hostIDs)
let names = app.snippets.searchHosts("").filter { wanted.contains($0.id) }.map(\.name)
.joined(separator: ", ")
return "\(snippet.body)\n\(names)"
}
private func reportStorageError() {
if let error = app.library.storageError { app.error = error }
}
}
struct MobileSnippetDetailView: View {
let app: MobileAppModel
let snippetID: Snippet.ID
let execute: (Snippet.ID) -> Void
let edit: () -> Void
let duplicate: (Snippet.ID) -> Void
let delete: () -> Void
@Environment(\.dismiss) private var dismiss
@State private var confirmsDeletion = false
private var snippet: Snippet? { app.snippets.snippets.first { $0.id == snippetID } }
var body: some View {
Group {
if let snippet {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
MobileSnippetDetailPanel(title: String(localized: AppStrings.Snippets.script)) {
Text(snippet.body).font(.body.monospaced()).textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
riskPanel(snippet)
packagePanel(snippet)
targetPanel(snippet)
Button {
execute(snippet.id)
} label: {
Label(AppStrings.Common.run.swiftUIKey, systemImage: "play.fill")
.frame(maxWidth: .infinity).padding(.vertical, 6)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(app.snippets.hosts.isEmpty)
}
.padding(16)
}
.background(Color(uiColor: .systemGroupedBackground))
.navigationTitle(snippet.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(AppStrings.Common.edit.swiftUIKey, systemImage: "pencil", action: edit).labelStyle(
.iconOnly)
}
.mobileProminentAction()
MobileToolbarOverflow {
Button(AppStrings.Common.duplicate.swiftUIKey, systemImage: "plus.square.on.square") {
duplicate(snippet.id)
}
Divider()
Button(
AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive,
action: { confirmsDeletion = true })
}
}
} else {
ContentUnavailableView(AppStrings.Snippets.noSnippets.swiftUIKey, systemImage: "curlybraces")
}
}
.operationConfirmation(
.init(AppStrings.Snippets.deleteSnippetConfirmation),
isPresented: $confirmsDeletion,
placement: .centered,
actions: [
.init(AppStrings.Snippets.deleteSnippet, role: .destructive, action: delete),
.init(AppStrings.Common.cancel, role: .cancel) {},
],
message: .init(AppStrings.Snippets.thisActionCannotBeUndone)
)
.onChange(of: app.snippets.snippets.map(\.id)) {
if snippet == nil { dismiss() }
}
}
private func riskPanel(_ snippet: Snippet) -> some View {
let assessment = CommandRiskClassifier.assess(snippet.body)
return MobileSnippetDetailPanel(title: String(localized: AppStrings.Snippets.risk)) {
SnippetRiskChip(assessment: assessment)
ForEach(assessment.reasons, id: \.self) { reason in
Text(reason).font(.caption).foregroundStyle(.secondary)
}
}
}
private func packagePanel(_ snippet: Snippet) -> some View {
MobileSnippetDetailPanel(title: String(localized: AppStrings.Snippets.package)) {
Label(
app.snippets.snippetPackagePath(snippet.packageID) ?? String(localized: AppStrings.Snippets.unpackaged),
systemImage: "folder")
}
}
private func targetPanel(_ snippet: Snippet) -> some View {
let wanted = Set(snippet.targetHostIDs)
let hosts = app.snippets.searchHosts("").filter { wanted.contains($0.id) }
return MobileSnippetDetailPanel(title: String(localized: AppStrings.Snippets.targets)) {
if hosts.isEmpty {
Text(AppStrings.Snippets.noTargetsSelected).foregroundStyle(.secondary)
} else {
ForEach(hosts) { host in
Label {
VStack(alignment: .leading, spacing: 2) {
Text(host.name)
Text(app.snippets.loginAddress(for: host))
.font(.caption.monospaced()).foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "server.rack")
}
}
}
}
}
}
private struct MobileSnippetDetailPanel<Content: View>: View {
let title: String
@ViewBuilder let content: Content
init(title: String, @ViewBuilder content: () -> Content) {
self.title = title
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text(title).font(.headline)
content
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.mobileCardSurface()
}
}
struct MobileSnippetEditor: View {
private enum PackageChoice: Hashable {
case ungrouped
case existing(SnippetPackage.ID)
case new
}
let app: MobileAppModel
let original: Snippet
let existing: Bool
let didSave: (Snippet) -> Void
@State private var draft: Snippet
@State private var choosingTargets = false
@State private var creatingPackage = false
@Environment(\.dismiss) private var dismiss
init(
app: MobileAppModel, snippet: Snippet?, packageID: SnippetPackage.ID?,
didSave: @escaping (Snippet) -> Void
) {
self.app = app
let initial = snippet ?? Snippet(title: "", body: "", packageID: packageID)
original = initial
existing = snippet != nil
self.didSave = didSave
_draft = State(initialValue: initial)
}
private var trimmedTitle: String { draft.title.trimmingCharacters(in: .whitespacesAndNewlines) }
private var trimmedBody: String { draft.body.trimmingCharacters(in: .whitespacesAndNewlines) }
var body: some View {
MobileEditorSheet(
title: existing
? String(localized: AppStrings.Snippets.editSnippet)
: String(localized: AppStrings.Snippets.newSnippet),
isDirty: draft != original,
canSave: !trimmedTitle.isEmpty && !trimmedBody.isEmpty,
discardQuestion: !existing
? AppStrings.Common.discardNewSnippetQuestion : AppStrings.Common.discardChangesQuestion,
save: save
) {
SnippetOrganizationSection(name: $draft.title, color: $draft.colorTag) {
Picker(AppStrings.Snippets.package.swiftUIKey, selection: packageChoice) {
Text(AppStrings.Snippets.unpackaged).tag(PackageChoice.ungrouped)
ForEach(packageChoices, id: \.package.id) { choice in
Text(choice.path).tag(PackageChoice.existing(choice.package.id))
}
Divider()
Text(AppStrings.Snippets.newPackage).tag(PackageChoice.new)
}
}
SnippetScriptSection(script: $draft.body, minHeight: 220) {
let assessment = CommandRiskClassifier.assess(draft.body)
SnippetRiskChip(assessment: assessment)
ForEach(assessment.reasons, id: \.self) { reason in
Text(reason).font(.caption).foregroundStyle(.secondary)
}
}
Section(AppStrings.Snippets.targets.swiftUIKey) {
Button {
choosingTargets = true
} label: {
Label(AppStrings.Common.addHosts.swiftUIKey, systemImage: "server.rack")
}
.disabled(app.snippets.hosts.isEmpty)
if selectedHosts.isEmpty {
Text(
app.snippets.hosts.isEmpty
? String(localized: AppStrings.Snippets.noSavedHosts)
: String(localized: AppStrings.Snippets.noTargetsSelected)
)
.foregroundStyle(.secondary)
} else {
ForEach(selectedHosts) { host in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(host.name)
Text(app.snippets.loginAddress(for: host))
.font(.caption.monospaced()).foregroundStyle(.secondary)
}
Spacer()
Button {
draft.targetHostIDs.removeAll { $0 == host.id }
} label: {
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel(Text(AppStrings.Common.removeNamed(host.name)))
}
}
}
}
}
.sheet(isPresented: $choosingTargets) {
MobileMultiHostPicker(
library: app.library, useCase: .snippetExecution, selected: Set(draft.targetHostIDs),
title: AppStrings.Common.selectHosts,
actionTitle: AppStrings.Common.done,
emptyDescription: AppStrings.Snippets.addAHostBeforeChoosingDefaultTargets, requiresSelection: false
) { ids in
draft.targetHostIDs = ids
choosingTargets = false
}
}
.sheet(isPresented: $creatingPackage) {
MobileSnippetPackageEditor(app: app, package: nil, parentID: nil) { package in
draft.packageID = package.id
}
}
.onChange(of: app.snippets.snippets.map(\.id)) {
if existing && !app.snippets.snippets.contains(where: { $0.id == original.id }) {
dismiss()
}
}
}
private var packageChoices: [(package: SnippetPackage, path: String)] {
app.snippets.snippetPackages.compactMap { package in
app.snippets.snippetPackagePath(package.id).map { (package, $0) }
}.sorted { $0.path.localizedCaseInsensitiveCompare($1.path) == .orderedAscending }
}
private var packageChoice: Binding<PackageChoice> {
Binding {
draft.packageID.map(PackageChoice.existing) ?? .ungrouped
} set: { choice in
switch choice {
case .ungrouped: draft.packageID = nil
case .existing(let id): draft.packageID = id
case .new: creatingPackage = true
}
}
}
private var selectedHosts: [Host] {
let selected = Set(draft.targetHostIDs)
return app.snippets.searchHosts("").filter { selected.contains($0.id) }
}
private func save() async throws {
draft.title = trimmedTitle
draft.body = trimmedBody
draft.targetHostIDs = selectedHosts.map(\.id)
app.snippets.upsert(draft)
if let error = app.library.storageError { throw MobileEditorError.message(error) }
didSave(draft)
}
}
struct MobileSnippetPackageEditor: View {
let app: MobileAppModel
let original: SnippetPackage
let existing: Bool
let didSave: (SnippetPackage) -> Void
@State private var draft: SnippetPackage
@Environment(\.dismiss) private var dismiss
init(
app: MobileAppModel, package: SnippetPackage?, parentID: SnippetPackage.ID?,
didSave: @escaping (SnippetPackage) -> Void = { _ in }
) {
self.app = app
let initial = package ?? SnippetPackage(name: "", parentID: parentID)
original = initial
existing = package != nil
self.didSave = didSave
_draft = State(initialValue: initial)
}
private var trimmedName: String { draft.name.trimmingCharacters(in: .whitespacesAndNewlines) }
var body: some View {
MobileEditorSheet(
title: existing
? String(localized: AppStrings.Snippets.editPackage)
: String(localized: AppStrings.Snippets.newPackage),
isDirty: draft != original, canSave: !trimmedName.isEmpty, compact: true,
discardQuestion: !existing
? AppStrings.Common.discardNewPackageQuestion : AppStrings.Common.discardChangesQuestion,
save: save
) {
TextField(AppStrings.Common.name.swiftUIKey, text: $draft.name.singleLineText, axis: .horizontal)
.singleLinePaste().lineLimit(1)
.multilineTextAlignment(
.leading)
Picker(AppStrings.Common.inside.swiftUIKey, selection: $draft.parentID) {
Text(AppStrings.Common.topLevel).tag(SnippetPackage.ID?.none)
ForEach(parentChoices, id: \.package.id) { choice in
Text(choice.path).tag(SnippetPackage.ID?.some(choice.package.id))
}
}
}
.onChange(of: app.snippets.snippetPackages.map(\.id)) {
if existing && !app.snippets.snippetPackages.contains(where: { $0.id == original.id }) {
dismiss()
}
}
}
private var parentChoices: [(package: SnippetPackage, path: String)] {
let forbidden = Set(
[draft.id] + app.snippets.descendantSnippetPackages(of: draft.id).map(\.id))
return app.snippets.snippetPackages.filter { !forbidden.contains($0.id) }
.compactMap { package in
app.snippets.snippetPackagePath(package.id).map { (package, $0) }
}
.sorted { $0.path.localizedCaseInsensitiveCompare($1.path) == .orderedAscending }
}
private func save() async throws {
draft.name = trimmedName
app.snippets.upsert(draft)
if let error = app.library.storageError { throw MobileEditorError.message(error) }
didSave(draft)
}
}