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 installURLInput = ""
|
||||||
@State private var showingUninstallSheet = false
|
@State private var showingUninstallSheet = false
|
||||||
@State private var configEditorProject: ProjectEntry?
|
@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
|
private let uninstaller: ProjectTemplateUninstaller
|
||||||
|
|
||||||
@@ -121,6 +127,44 @@ struct ProjectsView: View {
|
|||||||
project: project
|
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
|
// MARK: - Toolbar
|
||||||
@@ -242,14 +286,23 @@ struct ProjectsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if uninstaller.isTemplateInstalled(project: project) {
|
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)
|
uninstallerViewModel.begin(project: project)
|
||||||
showingUninstallSheet = true
|
showingUninstallSheet = true
|
||||||
}
|
}
|
||||||
Divider()
|
Divider()
|
||||||
}
|
}
|
||||||
Button("Remove from Scarf", systemImage: "minus.circle") {
|
// "Remove from List" used to be "Remove from Scarf",
|
||||||
viewModel.removeProject(project)
|
// 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)
|
.buttonStyle(.borderless)
|
||||||
Spacer()
|
Spacer()
|
||||||
if let selected = viewModel.selectedProject {
|
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")
|
Image(systemName: "minus")
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
|
.help("Remove \(selected.name) from Scarf's project list (files are kept on disk)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(8)
|
.padding(8)
|
||||||
|
|||||||
@@ -17,6 +17,26 @@ final class TemplateUninstallerViewModel {
|
|||||||
case failed(String)
|
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
|
let context: ServerContext
|
||||||
private let uninstaller: ProjectTemplateUninstaller
|
private let uninstaller: ProjectTemplateUninstaller
|
||||||
|
|
||||||
@@ -27,11 +47,15 @@ final class TemplateUninstallerViewModel {
|
|||||||
|
|
||||||
var stage: Stage = .idle
|
var stage: Stage = .idle
|
||||||
var plan: TemplateUninstallPlan?
|
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
|
/// Load the `template.lock.json` for the given project and build a
|
||||||
/// removal plan. Moves stage to `.planned` on success.
|
/// removal plan. Moves stage to `.planned` on success.
|
||||||
func begin(project: ProjectEntry) {
|
func begin(project: ProjectEntry) {
|
||||||
stage = .loading
|
stage = .loading
|
||||||
|
preservedOutcome = nil
|
||||||
let uninstaller = uninstaller
|
let uninstaller = uninstaller
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
do {
|
do {
|
||||||
@@ -53,11 +77,20 @@ final class TemplateUninstallerViewModel {
|
|||||||
guard let plan else { return }
|
guard let plan else { return }
|
||||||
stage = .uninstalling
|
stage = .uninstalling
|
||||||
let uninstaller = uninstaller
|
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
|
Task.detached { [weak self] in
|
||||||
do {
|
do {
|
||||||
try uninstaller.uninstall(plan: plan)
|
try uninstaller.uninstall(plan: plan)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
self.preservedOutcome = outcome
|
||||||
self.stage = .succeeded(removed: plan.project)
|
self.stage = .succeeded(removed: plan.project)
|
||||||
self.plan = nil
|
self.plan = nil
|
||||||
}
|
}
|
||||||
@@ -71,6 +104,7 @@ final class TemplateUninstallerViewModel {
|
|||||||
|
|
||||||
func cancel() {
|
func cancel() {
|
||||||
plan = nil
|
plan = nil
|
||||||
|
preservedOutcome = nil
|
||||||
stage = .idle
|
stage = .idle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,6 +277,19 @@ struct TemplateUninstallSheet: View {
|
|||||||
.foregroundStyle(.green)
|
.foregroundStyle(.green)
|
||||||
Text("Removed \(removed.name)")
|
Text("Removed \(removed.name)")
|
||||||
.font(.title2.bold())
|
.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") {
|
Button("Done") {
|
||||||
onCompleted(removed)
|
onCompleted(removed)
|
||||||
dismiss()
|
dismiss()
|
||||||
@@ -285,6 +298,53 @@ struct TemplateUninstallSheet: View {
|
|||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.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 {
|
private func failureView(message: String) -> some View {
|
||||||
|
|||||||
Reference in New Issue
Block a user