From 18640293f7b2762e55d20e87a58a84460a12d1fc Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Thu, 23 Apr 2026 16:24:31 +0200 Subject: [PATCH] fix(projects): clarify remove-vs-uninstall UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three UX changes addressing user feedback that "Remove from Scarf" and "Uninstall Template…" looked interchangeable, and that users were surprised when uninstall left the project folder behind. - Rename sidebar menu entries: "Uninstall Template…" → "Uninstall Template (remove installed files)…" "Remove from Scarf" → "Remove from List (keep files)…" The expanded labels carry the scope difference at the point of click. - Add a confirmation dialog for Remove from List. The sidebar's "-" button and the context-menu entry both route through it. Dialog copy explicitly spells out "Nothing on disk is touched — the folder, cron job, skills, and memory block all stay. To actually remove installed files, use 'Uninstall Template…' instead." Sidebar "-" also gains a help tooltip saying the same thing. - Post-uninstall preserved-files banner. When the uninstaller keeps the project directory (because the cron wrote a status-log.md or the user dropped files in there), the success view now shows an orange banner listing up to 8 preserved paths with a "+N more…" tail, plus a one-line explanation and a pointer to delete the folder from Finder if the user doesn't want those files. VM captures the preservation shape before nil'ing `plan` on success. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Projects/Views/ProjectsView.swift | 67 +++++++++++++++++-- .../TemplateUninstallerViewModel.swift | 34 ++++++++++ .../Views/TemplateUninstallSheet.swift | 60 +++++++++++++++++ 3 files changed, 157 insertions(+), 4 deletions(-) diff --git a/scarf/scarf/Features/Projects/Views/ProjectsView.swift b/scarf/scarf/Features/Projects/Views/ProjectsView.swift index a1ae464..f80be89 100644 --- a/scarf/scarf/Features/Projects/Views/ProjectsView.swift +++ b/scarf/scarf/Features/Projects/Views/ProjectsView.swift @@ -27,6 +27,12 @@ struct ProjectsView: View { @State private var installURLInput = "" @State private var showingUninstallSheet = false @State private var configEditorProject: ProjectEntry? + /// Project queued for the "remove from list" confirmation dialog. + /// Non-nil while the dialog is up; the `confirmationDialog` binding + /// flips based on presence. We store the full entry (not just a + /// flag) so the dialog's action closure knows which project to + /// drop from the registry. + @State private var pendingRemoveFromList: ProjectEntry? private let uninstaller: ProjectTemplateUninstaller @@ -121,6 +127,44 @@ struct ProjectsView: View { project: project ) } + // Confirmation dialog for the sidebar's "Remove from List" action. + // The action is registry-only (doesn't touch disk), but the name + // historically confused users into thinking it was a full delete. + // A confirmation with explicit wording clarifies scope before the + // click is destructive-looking but actually harmless. + .confirmationDialog( + removeFromListDialogTitle, + isPresented: Binding( + get: { pendingRemoveFromList != nil }, + set: { if !$0 { pendingRemoveFromList = nil } } + ), + titleVisibility: .visible, + presenting: pendingRemoveFromList + ) { project in + Button("Remove from List") { + viewModel.removeProject(project) + if coordinator.selectedProjectName == project.name { + coordinator.selectedProjectName = nil + } + pendingRemoveFromList = nil + } + Button("Cancel", role: .cancel) { + pendingRemoveFromList = nil + } + } message: { project in + Text( + "\(project.name) will be removed from Scarf's project list. " + + "Nothing on disk is touched — the folder, cron job, skills, and memory block all stay. " + + "To actually remove installed files, use \"Uninstall Template…\" instead." + ) + } + } + + /// Title string for the remove-from-list confirmation dialog. Kept + /// as a computed property so the dialog and any future reuse share + /// the exact same copy. + private var removeFromListDialogTitle: LocalizedStringKey { + "Remove from Scarf's project list?" } // MARK: - Toolbar @@ -242,14 +286,23 @@ struct ProjectsView: View { } } if uninstaller.isTemplateInstalled(project: project) { - Button("Uninstall Template…", systemImage: "trash") { + // "Uninstall Template…" only appears for projects + // installed from a `.scarftemplate`. Trailing + // ellipsis signals a confirmation sheet follows + // (macOS HIG convention); the sheet itself lists + // every file/cron/skill that will be removed. + Button("Uninstall Template (remove installed files)…", systemImage: "trash") { uninstallerViewModel.begin(project: project) showingUninstallSheet = true } Divider() } - Button("Remove from Scarf", systemImage: "minus.circle") { - viewModel.removeProject(project) + // "Remove from List" used to be "Remove from Scarf", + // which users read as a full delete. Clarified label + + // ellipsis + confirmation dialog all spell out that + // this is registry-only; nothing on disk is touched. + Button("Remove from List (keep files)…", systemImage: "minus.circle") { + pendingRemoveFromList = project } } } @@ -263,10 +316,16 @@ struct ProjectsView: View { .buttonStyle(.borderless) Spacer() if let selected = viewModel.selectedProject { - Button(action: { viewModel.removeProject(selected) }) { + // Route through the same confirmation dialog as the + // context-menu "Remove from List" entry. The minus + // icon is a drive-by click target right next to "+" — + // confirming before mutating the registry stops the + // "I clicked by accident and my project's gone" case. + Button(action: { pendingRemoveFromList = selected }) { Image(systemName: "minus") } .buttonStyle(.borderless) + .help("Remove \(selected.name) from Scarf's project list (files are kept on disk)") } } .padding(8) diff --git a/scarf/scarf/Features/Templates/ViewModels/TemplateUninstallerViewModel.swift b/scarf/scarf/Features/Templates/ViewModels/TemplateUninstallerViewModel.swift index 5a2b40a..349eacc 100644 --- a/scarf/scarf/Features/Templates/ViewModels/TemplateUninstallerViewModel.swift +++ b/scarf/scarf/Features/Templates/ViewModels/TemplateUninstallerViewModel.swift @@ -17,6 +17,26 @@ final class TemplateUninstallerViewModel { case failed(String) } + /// Snapshot of "what survived the uninstall" — surfaced in the + /// success screen so the user understands why the project directory + /// is or isn't gone from disk. Computed from the plan right before + /// executing it (`plan` itself is nil'd on success, so we can't + /// reach back for this info after the fact). + struct PreservedOutcome: Sendable { + /// True when the uninstaller removed the project dir (nothing + /// user-owned was left inside). In this case `preservedPaths` + /// is empty and the success view skips the banner entirely. + let projectDirRemoved: Bool + /// Absolute paths of files the uninstaller refused to touch + /// because they weren't installed by the template (typically + /// `status-log.md` after the cron ran, or anything the user + /// dropped into the project dir manually). + let preservedPaths: [String] + /// Project dir — echoed back so the success view can show the + /// user where the orphan files now live. + let projectDir: String + } + let context: ServerContext private let uninstaller: ProjectTemplateUninstaller @@ -27,11 +47,15 @@ final class TemplateUninstallerViewModel { var stage: Stage = .idle var plan: TemplateUninstallPlan? + /// Populated on transition to `.succeeded`. Nil whenever the user + /// re-enters the flow (cancel/begin both clear it). + var preservedOutcome: PreservedOutcome? /// Load the `template.lock.json` for the given project and build a /// removal plan. Moves stage to `.planned` on success. func begin(project: ProjectEntry) { stage = .loading + preservedOutcome = nil let uninstaller = uninstaller Task.detached { [weak self] in do { @@ -53,11 +77,20 @@ final class TemplateUninstallerViewModel { guard let plan else { return } stage = .uninstalling let uninstaller = uninstaller + // Capture the preservation shape before executing — the plan + // itself gets nil'd on success and we want the banner to show + // whatever was true at the moment of removal. + let outcome = PreservedOutcome( + projectDirRemoved: plan.projectDirBecomesEmpty, + preservedPaths: plan.extraProjectEntries, + projectDir: plan.project.path + ) Task.detached { [weak self] in do { try uninstaller.uninstall(plan: plan) await MainActor.run { [weak self] in guard let self else { return } + self.preservedOutcome = outcome self.stage = .succeeded(removed: plan.project) self.plan = nil } @@ -71,6 +104,7 @@ final class TemplateUninstallerViewModel { func cancel() { plan = nil + preservedOutcome = nil stage = .idle } } diff --git a/scarf/scarf/Features/Templates/Views/TemplateUninstallSheet.swift b/scarf/scarf/Features/Templates/Views/TemplateUninstallSheet.swift index 5a62e91..ceea9d3 100644 --- a/scarf/scarf/Features/Templates/Views/TemplateUninstallSheet.swift +++ b/scarf/scarf/Features/Templates/Views/TemplateUninstallSheet.swift @@ -277,6 +277,19 @@ struct TemplateUninstallSheet: View { .foregroundStyle(.green) Text("Removed \(removed.name)") .font(.title2.bold()) + + // Preserved-files banner. Only renders when the project dir + // stayed and at least one file was left behind — that's the + // case the user keeps getting surprised by ("I uninstalled + // but my project folder is still there?"). Explicit + // explanation + file list makes it obvious the files the + // user (or the cron job) created are intentionally kept. + if let outcome = viewModel.preservedOutcome, + outcome.projectDirRemoved == false, + outcome.preservedPaths.isEmpty == false { + preservedFilesBanner(outcome: outcome) + } + Button("Done") { onCompleted(removed) dismiss() @@ -285,6 +298,53 @@ struct TemplateUninstallSheet: View { .buttonStyle(.borderedProminent) } .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } + + /// Orange informational banner listing the files the uninstaller + /// left in the project directory. Caps the visible list at 8 rows + /// with a "+N more…" tail so a long log (many runs = many status + /// file entries) doesn't blow out the sheet height. + private func preservedFilesBanner( + outcome: TemplateUninstallerViewModel.PreservedOutcome + ) -> some View { + let visible = Array(outcome.preservedPaths.prefix(8)) + let overflow = outcome.preservedPaths.count - visible.count + return VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Image(systemName: "folder.badge.questionmark") + .foregroundStyle(.orange) + Text("Project folder kept") + .font(.headline) + } + Text("These files weren't installed by the template (the agent or you created them after install), so Scarf left them in place along with the folder itself.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + VStack(alignment: .leading, spacing: 2) { + ForEach(visible, id: \.self) { path in + Text(path) + .font(.caption.monospaced()) + .lineLimit(1) + .truncationMode(.head) + } + if overflow > 0 { + Text("+ \(overflow) more…") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + Text("Delete \(outcome.projectDir) from Finder if you don't need these files anymore.") + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: 520, alignment: .leading) + .padding(12) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.orange.opacity(0.10)) + ) } private func failureView(message: String) -> some View {