Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"configurations": [
{
"type": "swift",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:symposium}/symposium/macos-app",
"name": "Debug Symposium (symposium/macos-app)",
"program": "${workspaceFolder:symposium}/symposium/macos-app/.build/debug/Symposium",
"preLaunchTask": "swift: Build Debug Symposium (symposium/macos-app)"
},
{
"type": "swift",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:symposium}/symposium/macos-app",
"name": "Release Symposium (symposium/macos-app)",
"program": "${workspaceFolder:symposium}/symposium/macos-app/.build/release/Symposium",
"preLaunchTask": "swift: Build Release Symposium (symposium/macos-app)"
}
]
}
33 changes: 27 additions & 6 deletions symposium/macos-app/Sources/Symposium/Models/ProjectManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,27 @@ class ProjectManager: ObservableObject, IpcMessageDelegate {
return (process.terminationStatus, stdout, stderr)
}

private func executeProcessAsync(
executable: String,
arguments: [String],
workingDirectory: String? = nil
) async throws -> (exitCode: Int32, stdout: String, stderr: String) {
return try await withCheckedThrowingContinuation { continuation in
Task.detached {
do {
let result = try self.executeProcess(
executable: executable,
arguments: arguments,
workingDirectory: workingDirectory
)
continuation.resume(returning: result)
} catch {
continuation.resume(throwing: error)
}
}
}
}

/// Open an existing Symposium project
func openProject(at directoryPath: String) throws {
isLoading = true
Expand Down Expand Up @@ -402,13 +423,13 @@ class ProjectManager: ObservableObject, IpcMessageDelegate {
/// - taskspaceDir = /path/task-UUID (taskspace directory)
/// - worktreeDir = /path/task-UUID/reponame (actual git worktree)
/// - Git commands must target worktreeDir and run from project.directoryPath (bare repo)
func deleteTaskspace(_ taskspace: Taskspace, deleteBranch: Bool = false) throws {
func deleteTaskspace(_ taskspace: Taskspace, deleteBranch: Bool = false) async throws {
guard let project = currentProject else {
throw ProjectError.noCurrentProject
}

isLoading = true
defer { isLoading = false }
await MainActor.run { isLoading = true }
defer { Task { @MainActor in isLoading = false } }

let taskspaceDir = taskspace.directoryPath(in: project.directoryPath)
let repoName = extractRepoName(from: project.gitURL)
Expand All @@ -422,7 +443,7 @@ class ProjectManager: ObservableObject, IpcMessageDelegate {
Logger.shared.log("Attempting to remove worktree: \(worktreeDir) from directory: \(project.directoryPath)")

do {
let result = try executeProcess(
let result = try await executeProcessAsync(
executable: "/usr/bin/git",
arguments: ["worktree", "remove", worktreeDir, "--force"],
workingDirectory: project.directoryPath
Expand All @@ -444,7 +465,7 @@ class ProjectManager: ObservableObject, IpcMessageDelegate {
// Optionally delete the branch
if deleteBranch && !branchName.isEmpty {
do {
let result = try executeProcess(
let result = try await executeProcessAsync(
executable: "/usr/bin/git",
arguments: ["branch", "-D", branchName],
workingDirectory: project.directoryPath
Expand All @@ -459,7 +480,7 @@ class ProjectManager: ObservableObject, IpcMessageDelegate {
}

// Remove from current project
DispatchQueue.main.async {
await MainActor.run {
var updatedProject = project
updatedProject.taskspaces.removeAll { $0.id == taskspace.id }
self.currentProject = updatedProject
Expand Down
63 changes: 39 additions & 24 deletions symposium/macos-app/Sources/Symposium/Views/ProjectView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,7 @@ struct TaskspaceCard: View {
@ObservedObject var projectManager: ProjectManager
@State private var showingDeleteConfirmation = false
@State private var deleteBranch = false
@State private var cachedBranchInfo: (branchName: String, isMerged: Bool, unmergedCommits: Int, hasUncommittedChanges: Bool) = ("", false, 0, false)
@State private var isHovered = false
@State private var isPressed = false

Expand Down Expand Up @@ -632,12 +633,16 @@ struct TaskspaceCard: View {
projectManager: projectManager,
deleteBranch: $deleteBranch,
onConfirm: {
do {
try projectManager.deleteTaskspace(taskspace, deleteBranch: deleteBranch)
} catch {
Logger.shared.log("Failed to delete taskspace: \(error)")
Task {
do {
try await projectManager.deleteTaskspace(taskspace, deleteBranch: deleteBranch)
} catch {
Logger.shared.log("Failed to delete taskspace: \(error)")
}
await MainActor.run {
showingDeleteConfirmation = false
}
}
showingDeleteConfirmation = false
},
onCancel: {
// Send cancellation response for pending deletion request
Expand Down Expand Up @@ -672,14 +677,8 @@ struct DeleteTaskspaceDialog: View {
let onConfirm: () -> Void
let onCancel: () -> Void

/// Computed property that gets fresh branch info when dialog renders
///
/// CRITICAL: This computes fresh data every time the dialog appears, not cached data.
/// Users may make commits between app startup and deletion, so stale info could
/// show incorrect warnings leading to accidental data loss.
private var branchInfo: (branchName: String, isMerged: Bool, unmergedCommits: Int, hasUncommittedChanges: Bool) {
projectManager.getTaskspaceBranchInfo(for: taskspace)
}
@State private var cachedBranchInfo: (branchName: String, isMerged: Bool, unmergedCommits: Int, hasUncommittedChanges: Bool) = ("", false, 0, false)
@State private var isLoadingBranchInfo = true

var body: some View {
VStack(spacing: 20) {
Expand All @@ -689,27 +688,35 @@ struct DeleteTaskspaceDialog: View {
Text("Are you sure you want to delete '\(taskspaceName)'? This will permanently remove all files and cannot be undone.")
.multilineTextAlignment(.center)

if !branchInfo.branchName.isEmpty {
if isLoadingBranchInfo {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...lol, I guess you anticipated my request...

HStack {
ProgressView()
.scaleEffect(0.8)
Text("Checking branch status...")
.font(.caption)
.foregroundColor(.secondary)
}
} else if !cachedBranchInfo.branchName.isEmpty {
VStack(alignment: .leading, spacing: 8) {
HStack {
Toggle("Also delete the branch `\(branchInfo.branchName)` from git", isOn: $deleteBranch)
Toggle("Also delete the branch `\(cachedBranchInfo.branchName)` from git", isOn: $deleteBranch)
Spacer()
}

if branchInfo.unmergedCommits > 0 || branchInfo.hasUncommittedChanges {
if cachedBranchInfo.unmergedCommits > 0 || cachedBranchInfo.hasUncommittedChanges {
VStack(alignment: .leading, spacing: 4) {
if branchInfo.unmergedCommits > 0 {
if cachedBranchInfo.unmergedCommits > 0 {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
Text("\(branchInfo.unmergedCommits) commit\(branchInfo.unmergedCommits == 1 ? "" : "s") from this branch do not appear in the main branch.")
Text("\(cachedBranchInfo.unmergedCommits) commit\(cachedBranchInfo.unmergedCommits == 1 ? "" : "s") from this branch do not appear in the main branch.")
.font(.caption)
.foregroundColor(.orange)
}
.padding(.leading, 20)
}

if branchInfo.hasUncommittedChanges {
if cachedBranchInfo.hasUncommittedChanges {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
Expand All @@ -720,7 +727,7 @@ struct DeleteTaskspaceDialog: View {
.padding(.leading, 20)
}

if branchInfo.unmergedCommits > 0 || branchInfo.hasUncommittedChanges {
if cachedBranchInfo.unmergedCommits > 0 || cachedBranchInfo.hasUncommittedChanges {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
Expand Down Expand Up @@ -761,10 +768,18 @@ struct DeleteTaskspaceDialog: View {
}
}
.onAppear {
// Set default deleteBranch toggle based on safety analysis
// Safe branches (no unmerged commits, no uncommitted changes): checked by default (encourage cleanup)
// Risky branches: unchecked by default (prevent accidental loss)
deleteBranch = (branchInfo.unmergedCommits == 0 && !branchInfo.hasUncommittedChanges)
Task {
let manager = projectManager
let ts = taskspace
cachedBranchInfo = await Task.detached {
manager.getTaskspaceBranchInfo(for: ts)
}.value

isLoadingBranchInfo = false

// Set default deleteBranch toggle based on safety analysis
deleteBranch = (cachedBranchInfo.unmergedCommits == 0 && !cachedBranchInfo.hasUncommittedChanges)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess what happens is that, initially, the cached branch name is empty, so the user sees no checkbox at all, but then it appears some time later once the git command has completed? I think it'd be nice if we displayed a message like "...determining github branch information..." to keep them up-to-date.

}
}
.padding()
.frame(width: 400)
Expand Down