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 { struct ProjectBrowserView: View {
@StateObject private var viewModel: ProjectBrowserViewModel @StateObject private var viewModel: ProjectBrowserViewModel
@State private var selectedProjectID: String? @State private var selectedProjectID: String?
@State private var projectPendingDeletion: SongProjectListItem?
init(store: any ProjectStore = LocalSongProjectStore(directoryURL: ProjectBrowserStorage.projectsDirectoryURL)) { init(store: any ProjectStore = LocalSongProjectStore(directoryURL: ProjectBrowserStorage.projectsDirectoryURL)) {
_viewModel = StateObject(wrappedValue: ProjectBrowserViewModel(store: store)) _viewModel = StateObject(wrappedValue: ProjectBrowserViewModel(store: store))
@@ -15,8 +16,17 @@ struct ProjectBrowserView: View {
List(selection: $selectedProjectID) { List(selection: $selectedProjectID) {
Section("Projects") { Section("Projects") {
ForEach(viewModel.projects) { project in ForEach(viewModel.projects) { project in
ProjectListRow(project: project) ProjectListRow(project: project) {
projectPendingDeletion = project
}
.tag(project.id) .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) 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 { private struct ProjectListRow: View {
let project: SongProjectListItem let project: SongProjectListItem
let deleteAction: () -> Void
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 4) { HStack(alignment: .top, spacing: 8) {
Text(project.title) VStack(alignment: .leading, spacing: 4) {
.font(.headline) Text(project.title)
.lineLimit(1) .font(.headline)
.lineLimit(1)
Text(project.idea.isEmpty ? "No idea yet" : project.idea) Text(project.idea.isEmpty ? "No idea yet" : project.idea)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(2) .lineLimit(2)
Text(project.updatedAt.formatted(date: .abbreviated, time: .shortened)) Text(project.updatedAt.formatted(date: .abbreviated, time: .shortened))
.font(.caption2) .font(.caption2)
.foregroundStyle(.tertiary) .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) .padding(.vertical, 4)
} }
@@ -140,6 +202,7 @@ final class ProjectBrowserViewModel: ObservableObject {
@Published private(set) var isLoading = false @Published private(set) var isLoading = false
@Published private(set) var isOpeningProject = false @Published private(set) var isOpeningProject = false
@Published private(set) var isCreatingProject = false @Published private(set) var isCreatingProject = false
@Published private(set) var isDeletingProject = false
@Published private(set) var errorMessage: String? @Published private(set) var errorMessage: String?
private let store: any ProjectStore 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() { func clearSelection() {
selectedProject = nil selectedProject = nil
} }
@@ -4,6 +4,7 @@ public protocol ProjectStore: Sendable {
func create(_ project: SongProject) async throws func create(_ project: SongProject) async throws
func save(_ project: SongProject) async throws func save(_ project: SongProject) async throws
func open(id: String) async throws -> SongProject func open(id: String) async throws -> SongProject
func delete(id: String) async throws
func loadProjectIDs() async throws -> [String] func loadProjectIDs() async throws -> [String]
func loadProjectList() async throws -> [SongProjectListItem] func loadProjectList() async throws -> [SongProjectListItem]
} }
@@ -69,6 +70,14 @@ public actor LocalSongProjectStore: ProjectStore {
return try decoder.decode(SongProject.self, from: data) 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] { public func loadProjectIDs() async throws -> [String] {
try await loadProjectList().map(\.id) try await loadProjectList().map(\.id)
} }
@@ -175,6 +175,28 @@ final class LocalSongProjectStoreTests: XCTestCase {
} }
} }
func testDeletesSongProjectAndRemovesItFromTheProjectList() async throws {
let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL)
let project = SongProject(
id: "deletable-project",
title: "Delete Me",
idea: "Remove this local project"
)
try await store.create(project)
try await store.delete(id: project.id)
let remainingProjects = try await store.loadProjectList()
XCTAssertTrue(remainingProjects.isEmpty)
do {
_ = try await store.open(id: project.id)
XCTFail("Expected deleted project open to fail.")
} catch let error as LocalSongProjectStoreError {
XCTAssertEqual(error, .projectNotFound(project.id))
}
}
func testOpenMissingProjectThrowsNotFound() async throws { func testOpenMissingProjectThrowsNotFound() async throws {
let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL) let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL)
+1 -1
View File
@@ -31,7 +31,7 @@ macOS App
├── Integrations ├── Integrations
│ └── OpenAI │ └── OpenAI
└── Persistence └── Persistence
└── Local project/version storage └── Local project/version storage and deletion
``` ```
## AI Director ## AI Director
+2 -1
View File
@@ -78,7 +78,8 @@ are not part of the current product requirement.
## Project Management ## Project Management
Every song is stored as a project. Important changes can create versions Every song is stored as a project. Important changes can create versions
so previous states remain recoverable. so previous states remain recoverable. A project can be deleted after
explicit user confirmation.
## AI Rules ## AI Rules
+4
View File
@@ -127,3 +127,7 @@ automatically configure the agreed song parameters, generate/edit lyrics
and song planning through OpenAI, select instruments, review and song planning through OpenAI, select instruments, review
the complete project, save versions, and retain the approved final the complete project, save versions, and retain the approved final
lyrics and style prompt in the project. lyrics and style prompt in the project.
## Current Improvements
- [x] Add confirmed project deletion from the project browser.