Add confirmed project deletion

This commit is contained in:
diyaa
2026-09-15 13:06:27 +02:00
parent bef00b19e1
commit 03bfd3ef75
6 changed files with 133 additions and 14 deletions
@@ -5,6 +5,7 @@ import SwiftUI
struct ProjectBrowserView: View {
@StateObject private var viewModel: ProjectBrowserViewModel
@State private var selectedProjectID: String?
@State private var projectPendingDeletion: SongProjectListItem?
init(store: any ProjectStore = LocalSongProjectStore(directoryURL: ProjectBrowserStorage.projectsDirectoryURL)) {
_viewModel = StateObject(wrappedValue: ProjectBrowserViewModel(store: store))
@@ -15,8 +16,17 @@ struct ProjectBrowserView: View {
List(selection: $selectedProjectID) {
Section("Projects") {
ForEach(viewModel.projects) { project in
ProjectListRow(project: project)
ProjectListRow(project: project) {
projectPendingDeletion = project
}
.tag(project.id)
.contextMenu {
Button(role: .destructive) {
projectPendingDeletion = project
} label: {
Label("Delete Project", systemImage: "trash")
}
}
}
}
}
@@ -72,26 +82,78 @@ struct ProjectBrowserView: View {
await viewModel.openProject(id: projectID)
}
}
.alert(
deletionAlertTitle,
isPresented: deletionAlertBinding
) {
Button("Delete", role: .destructive) {
guard let project = projectPendingDeletion else { return }
Task {
await deleteProject(project)
}
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This permanently removes the project and its saved versions from this Mac.")
}
}
private var deletionAlertBinding: Binding<Bool> {
Binding(
get: { projectPendingDeletion != nil },
set: { isPresented in
if !isPresented {
projectPendingDeletion = nil
}
}
)
}
private var deletionAlertTitle: String {
guard let projectPendingDeletion else { return "Delete Project?" }
return "Delete \(projectPendingDeletion.title)?"
}
private func deleteProject(_ project: SongProjectListItem) async {
guard await viewModel.deleteProject(id: project.id) else { return }
if selectedProjectID == project.id {
selectedProjectID = viewModel.projects.first?.id
}
projectPendingDeletion = nil
}
}
private struct ProjectListRow: View {
let project: SongProjectListItem
let deleteAction: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(project.title)
.font(.headline)
.lineLimit(1)
HStack(alignment: .top, spacing: 8) {
VStack(alignment: .leading, spacing: 4) {
Text(project.title)
.font(.headline)
.lineLimit(1)
Text(project.idea.isEmpty ? "No idea yet" : project.idea)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
Text(project.idea.isEmpty ? "No idea yet" : project.idea)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
Text(project.updatedAt.formatted(date: .abbreviated, time: .shortened))
.font(.caption2)
.foregroundStyle(.tertiary)
Text(project.updatedAt.formatted(date: .abbreviated, time: .shortened))
.font(.caption2)
.foregroundStyle(.tertiary)
}
Spacer(minLength: 4)
Button(action: deleteAction) {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
.foregroundStyle(.secondary)
.help("Delete project")
.accessibilityLabel("Delete \(project.title)")
}
.padding(.vertical, 4)
}
@@ -140,6 +202,7 @@ final class ProjectBrowserViewModel: ObservableObject {
@Published private(set) var isLoading = false
@Published private(set) var isOpeningProject = false
@Published private(set) var isCreatingProject = false
@Published private(set) var isDeletingProject = false
@Published private(set) var errorMessage: String?
private let store: any ProjectStore
@@ -216,6 +279,26 @@ final class ProjectBrowserViewModel: ObservableObject {
}
}
func deleteProject(id projectID: String) async -> Bool {
guard !isDeletingProject else { return false }
isDeletingProject = true
errorMessage = nil
do {
try await store.delete(id: projectID)
projects = try await store.loadProjectList()
if selectedProject?.id == projectID {
selectedProject = nil
}
isDeletingProject = false
return true
} catch {
errorMessage = "Unable to delete this project."
isDeletingProject = false
return false
}
}
func clearSelection() {
selectedProject = nil
}
@@ -4,6 +4,7 @@ public protocol ProjectStore: Sendable {
func create(_ project: SongProject) async throws
func save(_ project: SongProject) async throws
func open(id: String) async throws -> SongProject
func delete(id: String) async throws
func loadProjectIDs() async throws -> [String]
func loadProjectList() async throws -> [SongProjectListItem]
}
@@ -69,6 +70,14 @@ public actor LocalSongProjectStore: ProjectStore {
return try decoder.decode(SongProject.self, from: data)
}
public func delete(id: String) async throws {
let url = projectURL(for: id)
guard FileManager.default.fileExists(atPath: url.path) else {
throw LocalSongProjectStoreError.projectNotFound(id)
}
try FileManager.default.removeItem(at: url)
}
public func loadProjectIDs() async throws -> [String] {
try await loadProjectList().map(\.id)
}