From 104c2cd54fe1392a3ff332689ad98cf69f561301 Mon Sep 17 00:00:00 2001 From: Aaron Eline Date: Fri, 3 Oct 2025 14:47:58 -0400 Subject: [PATCH 1/4] Fix UI lag during taskspace deletion by making operations async - Made deleteTaskspace function async to prevent blocking main thread - Wrapped git operations (worktree removal, branch deletion) in background task - Updated UI calls to use MainActor for thread-safe state updates - Prevents app hanging during delete operations Co-authored-by: Claude --- .../Sources/Symposium/Models/ProjectManager.swift | 8 ++++---- .../Sources/Symposium/Views/ProjectView.swift | 14 +++++++++----- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/symposium/macos-app/Sources/Symposium/Models/ProjectManager.swift b/symposium/macos-app/Sources/Symposium/Models/ProjectManager.swift index 96382aad..b8ec7ebb 100644 --- a/symposium/macos-app/Sources/Symposium/Models/ProjectManager.swift +++ b/symposium/macos-app/Sources/Symposium/Models/ProjectManager.swift @@ -402,13 +402,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) @@ -459,7 +459,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 diff --git a/symposium/macos-app/Sources/Symposium/Views/ProjectView.swift b/symposium/macos-app/Sources/Symposium/Views/ProjectView.swift index ad830a54..174a656d 100644 --- a/symposium/macos-app/Sources/Symposium/Views/ProjectView.swift +++ b/symposium/macos-app/Sources/Symposium/Views/ProjectView.swift @@ -632,12 +632,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 From 43a2bb7f620a70db6445364269b8926ebfbc2bd6 Mon Sep 17 00:00:00 2001 From: Aaron Eline Date: Fri, 3 Oct 2025 15:05:03 -0400 Subject: [PATCH 2/4] Make git process execution truly async - Added executeProcessAsync() that runs git commands in detached tasks - Replaced synchronous executeProcess calls in deleteTaskspace - Now waitUntilExit() runs off main thread completely - Further improves UI responsiveness during delete operations Co-authored-by: Claude --- .vscode/launch.json | 22 ++++++++++++++++ .../Symposium/Models/ProjectManager.swift | 25 +++++++++++++++++-- 2 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..4b6e606f --- /dev/null +++ b/.vscode/launch.json @@ -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)" + } + ] +} \ No newline at end of file diff --git a/symposium/macos-app/Sources/Symposium/Models/ProjectManager.swift b/symposium/macos-app/Sources/Symposium/Models/ProjectManager.swift index b8ec7ebb..d71cc5bd 100644 --- a/symposium/macos-app/Sources/Symposium/Models/ProjectManager.swift +++ b/symposium/macos-app/Sources/Symposium/Models/ProjectManager.swift @@ -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 @@ -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 @@ -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 From e9b09ba9c85edae531c99ef4da88bcad2cd48bc0 Mon Sep 17 00:00:00 2001 From: Aaron Eline Date: Fri, 3 Oct 2025 15:10:45 -0400 Subject: [PATCH 3/4] Fix checkbox lag by caching branch info - Replaced computed branchInfo property with cached state - Load branch info asynchronously on dialog appear - Prevents multiple git commands on every UI refresh - Eliminates lag when clicking delete branch checkbox Co-authored-by: Claude --- .../Sources/Symposium/Views/ProjectView.swift | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/symposium/macos-app/Sources/Symposium/Views/ProjectView.swift b/symposium/macos-app/Sources/Symposium/Views/ProjectView.swift index 174a656d..28bc970f 100644 --- a/symposium/macos-app/Sources/Symposium/Views/ProjectView.swift +++ b/symposium/macos-app/Sources/Symposium/Views/ProjectView.swift @@ -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 @@ -676,14 +677,7 @@ 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) var body: some View { VStack(spacing: 20) { @@ -693,27 +687,27 @@ 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 !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) @@ -724,7 +718,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) @@ -765,10 +759,16 @@ 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 + + // Set default deleteBranch toggle based on safety analysis + deleteBranch = (cachedBranchInfo.unmergedCommits == 0 && !cachedBranchInfo.hasUncommittedChanges) + } } .padding() .frame(width: 400) From 41cb4167b5713a2cc9bef05ea9aac097625f558b Mon Sep 17 00:00:00 2001 From: Aaron Eline Date: Fri, 3 Oct 2025 15:28:48 -0400 Subject: [PATCH 4/4] Add loading indicator while fetching branch info - Shows progress spinner and 'Checking branch status...' text - Improves UX by indicating async operation in progress - Hides loading state once branch info is cached Co-authored-by: Claude --- .../Sources/Symposium/Views/ProjectView.swift | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/symposium/macos-app/Sources/Symposium/Views/ProjectView.swift b/symposium/macos-app/Sources/Symposium/Views/ProjectView.swift index 28bc970f..309c384e 100644 --- a/symposium/macos-app/Sources/Symposium/Views/ProjectView.swift +++ b/symposium/macos-app/Sources/Symposium/Views/ProjectView.swift @@ -678,6 +678,7 @@ struct DeleteTaskspaceDialog: View { let onCancel: () -> Void @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) { @@ -687,7 +688,15 @@ 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 !cachedBranchInfo.branchName.isEmpty { + if isLoadingBranchInfo { + 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 `\(cachedBranchInfo.branchName)` from git", isOn: $deleteBranch) @@ -766,6 +775,8 @@ struct DeleteTaskspaceDialog: View { manager.getTaskspaceBranchInfo(for: ts) }.value + isLoadingBranchInfo = false + // Set default deleteBranch toggle based on safety analysis deleteBranch = (cachedBranchInfo.unmergedCommits == 0 && !cachedBranchInfo.hasUncommittedChanges) }