fix(projects): clarify remove-vs-uninstall UX

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) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-23 16:24:31 +02:00
parent 19750597cd
commit 18640293f7
3 changed files with 157 additions and 4 deletions
@@ -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)
@@ -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
}
}
@@ -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 {