mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user