Add confirmed project deletion
This commit is contained in:
@@ -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,13 +82,54 @@ 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 {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(project.title)
|
||||
.font(.headline)
|
||||
@@ -93,6 +144,17 @@ private struct ProjectListRow: View {
|
||||
.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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ macOS App
|
||||
├── Integrations
|
||||
│ └── OpenAI
|
||||
└── Persistence
|
||||
└── Local project/version storage
|
||||
└── Local project/version storage and deletion
|
||||
```
|
||||
|
||||
## AI Director
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user