From 03bfd3ef755a07d97dc624b719a480fd63603aa8 Mon Sep 17 00:00:00 2001 From: diyaa Date: Tue, 15 Sep 2026 13:06:27 +0200 Subject: [PATCH] Add confirmed project deletion --- .../Presentation/ProjectBrowserView.swift | 107 ++++++++++++++++-- .../Persistence/ProjectStore.swift | 9 ++ .../LocalSongProjectStoreTests.swift | 22 ++++ docs/ARCHITECTURE.md | 2 +- docs/PRODUCT.md | 3 +- docs/TASKS.md | 4 + 6 files changed, 133 insertions(+), 14 deletions(-) diff --git a/Sources/MusicAssistantApp/Presentation/ProjectBrowserView.swift b/Sources/MusicAssistantApp/Presentation/ProjectBrowserView.swift index 021b8fc..677a810 100644 --- a/Sources/MusicAssistantApp/Presentation/ProjectBrowserView.swift +++ b/Sources/MusicAssistantApp/Presentation/ProjectBrowserView.swift @@ -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 { + 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 } diff --git a/Sources/MusicAssistantCore/Persistence/ProjectStore.swift b/Sources/MusicAssistantCore/Persistence/ProjectStore.swift index be8efc9..3cc19f8 100644 --- a/Sources/MusicAssistantCore/Persistence/ProjectStore.swift +++ b/Sources/MusicAssistantCore/Persistence/ProjectStore.swift @@ -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) } diff --git a/Tests/MusicAssistantCoreTests/LocalSongProjectStoreTests.swift b/Tests/MusicAssistantCoreTests/LocalSongProjectStoreTests.swift index f368c50..9fc50f3 100644 --- a/Tests/MusicAssistantCoreTests/LocalSongProjectStoreTests.swift +++ b/Tests/MusicAssistantCoreTests/LocalSongProjectStoreTests.swift @@ -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 { let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1fcb4be..3cf1507 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -31,7 +31,7 @@ macOS App ├── Integrations │ └── OpenAI └── Persistence - └── Local project/version storage + └── Local project/version storage and deletion ``` ## AI Director diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 0b43b63..f6e38fa 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -78,7 +78,8 @@ are not part of the current product requirement. ## Project Management 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 diff --git a/docs/TASKS.md b/docs/TASKS.md index 784cd6e..41af2f0 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -127,3 +127,7 @@ automatically configure the agreed song parameters, generate/edit lyrics and song planning through OpenAI, select instruments, review the complete project, save versions, and retain the approved final lyrics and style prompt in the project. + +## Current Improvements + +- [x] Add confirmed project deletion from the project browser.