mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
feat(projects): folder hierarchy + rename/archive/search in the sidebar
Second of three v2.3 commits. Replaces the flat projects sidebar with a hierarchical view that honors the folder + archived fields introduced in commit 1. ProjectsView's inline 70-line `projectList` becomes a one-call invocation of a new extracted `ProjectsSidebar` view. The parent keeps all sheet state (add / rename / move / uninstall / remove- from-list confirmation); the sidebar routes user intent up via closures. That separation means future sidebar changes (drag- and-drop, tags, color labels from the roadmap) don't need to touch ProjectsView's sheet wiring. ProjectsSidebar.swift renders, top to bottom: - Search field (filters by name / path / folder label, live) - Top-level projects (folder is nil or empty, not archived) - One DisclosureGroup per folder, alphabetically sorted, expanded by default on first render; collapsed state persists per view instance. Newly-created folders auto-expand so moves are visibly reflected. - An "Archived (N)" DisclosureGroup at the bottom, surfaced only when the Show Archived toggle in the bottom bar is on. Archived rows render at 0.7 opacity for a subtle visual cue. Bottom bar gains a Show Archived toggle next to the existing + button, using the archivebox SF Symbol (filled when on). Context menu gets three new entries alongside the existing ones: - Rename… — opens RenameProjectSheet with duplicate-name + empty-name validation. - Move to Folder… — opens MoveToFolderSheet with current folder pre-selected; picker lists Top Level, existing folders, and a "New folder…" option that gates on a text field. - Archive / Unarchive — flips the archived bit via the VM. Both new sheets live as standalone files (RenameProjectSheet, MoveToFolderSheet) for reuse — the wiki doesn't need updating; these are pure UI refinements. Selection binding round-trips through `viewModel.selectedProject` unchanged, so the existing dashboard / Site tab routing is unaffected. Sidebar matches use localizedCaseInsensitiveCompare so folder labels and project names sort the way users expect in non-English locales. 73/73 Swift tests still pass (no new tests in this commit — the VM verbs already exercised in ProjectsViewModelTests; the UI is visual and will be validated by the manual smoke test at the end of the branch). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,113 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Sheet for assigning a project to a folder in the sidebar. Folders
|
||||||
|
/// are implicit — they exist because at least one project references
|
||||||
|
/// them via its `folder` field. The "create" action here just seeds
|
||||||
|
/// a new label the user types; it becomes real once any project is
|
||||||
|
/// assigned to it.
|
||||||
|
struct MoveToFolderSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let project: ProjectEntry
|
||||||
|
/// Existing folder labels in the registry, sorted. Computed by
|
||||||
|
/// the caller via `ProjectsViewModel.folders`.
|
||||||
|
let existingFolders: [String]
|
||||||
|
/// Called with the chosen folder. `nil` means "move back to top
|
||||||
|
/// level". Caller wires this through
|
||||||
|
/// `ProjectsViewModel.moveProject(_:toFolder:)`.
|
||||||
|
let onMove: (String?) -> Void
|
||||||
|
|
||||||
|
@State private var mode: Mode
|
||||||
|
@State private var newFolderName: String = ""
|
||||||
|
|
||||||
|
private enum Mode: Hashable {
|
||||||
|
case topLevel
|
||||||
|
case existing(String)
|
||||||
|
case new
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
project: ProjectEntry,
|
||||||
|
existingFolders: [String],
|
||||||
|
onMove: @escaping (String?) -> Void
|
||||||
|
) {
|
||||||
|
self.project = project
|
||||||
|
self.existingFolders = existingFolders
|
||||||
|
self.onMove = onMove
|
||||||
|
// Start selection on the project's current folder if any,
|
||||||
|
// otherwise "Top Level". Feels right — Move sheet should
|
||||||
|
// reflect where the project currently lives.
|
||||||
|
if let current = project.folder, existingFolders.contains(current) {
|
||||||
|
_mode = State(initialValue: .existing(current))
|
||||||
|
} else {
|
||||||
|
_mode = State(initialValue: .topLevel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canMove: Bool {
|
||||||
|
switch mode {
|
||||||
|
case .topLevel, .existing:
|
||||||
|
return true
|
||||||
|
case .new:
|
||||||
|
return !newFolderName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Move \"\(project.name)\" to folder").font(.headline)
|
||||||
|
Text("Folders only affect how projects are grouped in Scarf's sidebar. Nothing on disk changes.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
Picker("Destination", selection: $mode) {
|
||||||
|
Text("Top Level").tag(Mode.topLevel)
|
||||||
|
if !existingFolders.isEmpty {
|
||||||
|
Section {
|
||||||
|
ForEach(existingFolders, id: \.self) { folder in
|
||||||
|
Text(folder).tag(Mode.existing(folder))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("New folder…").tag(Mode.new)
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.pickerStyle(.inline)
|
||||||
|
|
||||||
|
if case .new = mode {
|
||||||
|
TextField("New folder name", text: $newFolderName)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.onSubmit {
|
||||||
|
if canMove { commit() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
Spacer()
|
||||||
|
Button("Move") { commit() }
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(!canMove)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(minWidth: 420, minHeight: 320)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func commit() {
|
||||||
|
switch mode {
|
||||||
|
case .topLevel:
|
||||||
|
onMove(nil)
|
||||||
|
case .existing(let folder):
|
||||||
|
onMove(folder)
|
||||||
|
case .new:
|
||||||
|
let trimmed = newFolderName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return }
|
||||||
|
onMove(trimmed)
|
||||||
|
}
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Sidebar view for the Projects feature. Renders the registry as:
|
||||||
|
/// - A search field at the top (⌘F focus).
|
||||||
|
/// - Top-level (folder-less) projects.
|
||||||
|
/// - Collapsible DisclosureGroups, one per folder.
|
||||||
|
/// - An "Archived" DisclosureGroup at the bottom, hidden unless the
|
||||||
|
/// Show Archived toggle is on.
|
||||||
|
///
|
||||||
|
/// Selection is bound to `viewModel.selectedProject` so the
|
||||||
|
/// dashboard area stays in sync with clicks anywhere in the hierarchy.
|
||||||
|
/// Context-menu actions delegate back to the parent view via closures
|
||||||
|
/// so the sheets / confirmation dialogs stay co-located with the rest
|
||||||
|
/// of ProjectsView's state.
|
||||||
|
struct ProjectsSidebar: View {
|
||||||
|
@Bindable var viewModel: ProjectsViewModel
|
||||||
|
|
||||||
|
// Predicates hoisted from the parent — avoid reaching down into
|
||||||
|
// service objects from this view.
|
||||||
|
let canConfigureProject: (ProjectEntry) -> Bool
|
||||||
|
let isTemplateInstalled: (ProjectEntry) -> Bool
|
||||||
|
|
||||||
|
// Context-menu + bottom-bar callbacks. Parent owns sheet state
|
||||||
|
// (install, uninstall, rename, move-to-folder, remove-from-list
|
||||||
|
// confirmation dialog) — this view just routes user intent.
|
||||||
|
let onConfigure: (ProjectEntry) -> Void
|
||||||
|
let onUninstallTemplate: (ProjectEntry) -> Void
|
||||||
|
let onRemoveFromList: (ProjectEntry) -> Void
|
||||||
|
let onRename: (ProjectEntry) -> Void
|
||||||
|
let onMoveToFolder: (ProjectEntry) -> Void
|
||||||
|
let onAddProject: () -> Void
|
||||||
|
|
||||||
|
/// Per-view UI state — filter text, show-archived toggle, and
|
||||||
|
/// which folders are expanded. Folder expansion defaults to all
|
||||||
|
/// open so a new user sees everything; they can collapse what
|
||||||
|
/// they don't want.
|
||||||
|
@State private var filterText: String = ""
|
||||||
|
@State private var showArchived: Bool = false
|
||||||
|
@State private var expandedFolders: Set<String> = []
|
||||||
|
@FocusState private var searchFocused: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
searchField
|
||||||
|
Divider()
|
||||||
|
list
|
||||||
|
Divider()
|
||||||
|
bottomBar
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
// Start with every folder expanded on first render. If
|
||||||
|
// users collapse, that choice persists for the lifetime
|
||||||
|
// of the view instance (window open).
|
||||||
|
expandedFolders = Set(viewModel.folders)
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.folders) { _, newFolders in
|
||||||
|
// When a new folder appears (user just moved a project
|
||||||
|
// into one), start it expanded so the move is visibly
|
||||||
|
// reflected.
|
||||||
|
expandedFolders.formUnion(newFolders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Search
|
||||||
|
|
||||||
|
private var searchField: some View {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
TextField("Filter projects", text: $filterText)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.focused($searchFocused)
|
||||||
|
.font(.caption)
|
||||||
|
if !filterText.isEmpty {
|
||||||
|
Button {
|
||||||
|
filterText = ""
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - List
|
||||||
|
|
||||||
|
private var list: some View {
|
||||||
|
List(selection: Binding(
|
||||||
|
get: { viewModel.selectedProject },
|
||||||
|
set: { if let p = $0 { viewModel.selectProject(p) } }
|
||||||
|
)) {
|
||||||
|
// Top-level projects first — matches the Finder-like
|
||||||
|
// mental model where top-level items sit above folders.
|
||||||
|
ForEach(topLevelVisible) { project in
|
||||||
|
projectRow(project)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-folder collapsible sections.
|
||||||
|
ForEach(visibleFolders, id: \.self) { folder in
|
||||||
|
let children = folderProjects(folder)
|
||||||
|
if !children.isEmpty {
|
||||||
|
DisclosureGroup(
|
||||||
|
isExpanded: Binding(
|
||||||
|
get: { expandedFolders.contains(folder) },
|
||||||
|
set: { expanded in
|
||||||
|
if expanded {
|
||||||
|
expandedFolders.insert(folder)
|
||||||
|
} else {
|
||||||
|
expandedFolders.remove(folder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
ForEach(children) { project in
|
||||||
|
projectRow(project)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label(folder, systemImage: "folder")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archived section — only surfaces under the toggle.
|
||||||
|
if showArchived, !archivedVisible.isEmpty {
|
||||||
|
DisclosureGroup {
|
||||||
|
ForEach(archivedVisible) { project in
|
||||||
|
projectRow(project)
|
||||||
|
.opacity(0.7)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Archived (\(archivedVisible.count))", systemImage: "archivebox")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.sidebar)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func projectRow(_ project: ProjectEntry) -> some View {
|
||||||
|
HStack {
|
||||||
|
Image(
|
||||||
|
systemName: viewModel.dashboard != nil
|
||||||
|
&& viewModel.selectedProject == project
|
||||||
|
? "square.grid.2x2.fill"
|
||||||
|
: "square.grid.2x2"
|
||||||
|
)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(project.name)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
|
.tag(project)
|
||||||
|
.contextMenu {
|
||||||
|
projectContextMenu(project)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func projectContextMenu(_ project: ProjectEntry) -> some View {
|
||||||
|
if canConfigureProject(project) {
|
||||||
|
Button("Configuration…", systemImage: "slider.horizontal.3") {
|
||||||
|
onConfigure(project)
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
Button("Rename…", systemImage: "pencil") { onRename(project) }
|
||||||
|
Button("Move to Folder…", systemImage: "folder") { onMoveToFolder(project) }
|
||||||
|
if project.archived {
|
||||||
|
Button("Unarchive", systemImage: "tray.and.arrow.up") {
|
||||||
|
viewModel.unarchiveProject(project)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button("Archive", systemImage: "archivebox") {
|
||||||
|
viewModel.archiveProject(project)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
if isTemplateInstalled(project) {
|
||||||
|
Button("Uninstall Template (remove installed files)…", systemImage: "trash") {
|
||||||
|
onUninstallTemplate(project)
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
Button("Remove from List (keep files)…", systemImage: "minus.circle") {
|
||||||
|
onRemoveFromList(project)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bottom bar
|
||||||
|
|
||||||
|
private var bottomBar: some View {
|
||||||
|
HStack {
|
||||||
|
Button(action: onAddProject) {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.help("Add a project")
|
||||||
|
|
||||||
|
Toggle(isOn: $showArchived) {
|
||||||
|
Image(systemName: showArchived ? "archivebox.fill" : "archivebox")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.toggleStyle(.button)
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.help(showArchived ? "Hide archived projects" : "Show archived projects")
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let selected = viewModel.selectedProject {
|
||||||
|
Button(action: { onRemoveFromList(selected) }) {
|
||||||
|
Image(systemName: "minus")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.help("Remove \(selected.name) from Scarf's project list (files are kept on disk)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Derived data
|
||||||
|
|
||||||
|
/// Fuzzy-match on name + path + folder label. Case-insensitive,
|
||||||
|
/// substring — not a true fuzzy search, but matches the project
|
||||||
|
/// count scale (tens, not thousands). Upgradable to a Levenshtein
|
||||||
|
/// scorer later without changing the call sites.
|
||||||
|
private func matches(_ project: ProjectEntry) -> Bool {
|
||||||
|
let needle = filterText
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.lowercased()
|
||||||
|
guard !needle.isEmpty else { return true }
|
||||||
|
if project.name.lowercased().contains(needle) { return true }
|
||||||
|
if project.path.lowercased().contains(needle) { return true }
|
||||||
|
if let folder = project.folder, folder.lowercased().contains(needle) { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Visible top-level projects (no folder, not archived, passes
|
||||||
|
/// the current filter). Sort is stable by name — the registry
|
||||||
|
/// already preserves insertion order, but showing a sorted list
|
||||||
|
/// of homogeneous top-level entries feels cleaner.
|
||||||
|
private var topLevelVisible: [ProjectEntry] {
|
||||||
|
viewModel.projects
|
||||||
|
.filter { ($0.folder ?? "").isEmpty && !$0.archived && matches($0) }
|
||||||
|
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Folders that currently have at least one matching, non-
|
||||||
|
/// archived project. Folders with only archived projects move
|
||||||
|
/// into the Archived section's items; empty folders disappear.
|
||||||
|
private var visibleFolders: [String] {
|
||||||
|
viewModel.folders.filter { !folderProjects($0).isEmpty }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func folderProjects(_ folder: String) -> [ProjectEntry] {
|
||||||
|
viewModel.projects
|
||||||
|
.filter { $0.folder == folder && !$0.archived && matches($0) }
|
||||||
|
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var archivedVisible: [ProjectEntry] {
|
||||||
|
viewModel.projects
|
||||||
|
.filter { $0.archived && matches($0) }
|
||||||
|
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,16 @@ struct ProjectsView: View {
|
|||||||
/// drop from the registry.
|
/// drop from the registry.
|
||||||
@State private var pendingRemoveFromList: ProjectEntry?
|
@State private var pendingRemoveFromList: ProjectEntry?
|
||||||
|
|
||||||
|
/// Project queued for the rename sheet (v2.3). Sheet state lives
|
||||||
|
/// on the parent view so the sidebar stays a pure presentation
|
||||||
|
/// layer; rename logic routes through `ProjectsViewModel.renameProject`.
|
||||||
|
@State private var renameTarget: ProjectEntry?
|
||||||
|
|
||||||
|
/// Project queued for the move-to-folder sheet (v2.3). Same
|
||||||
|
/// pattern as renameTarget: parent owns sheet state, sidebar
|
||||||
|
/// delegates up.
|
||||||
|
@State private var moveTarget: ProjectEntry?
|
||||||
|
|
||||||
private let uninstaller: ProjectTemplateUninstaller
|
private let uninstaller: ProjectTemplateUninstaller
|
||||||
|
|
||||||
init(context: ServerContext) {
|
init(context: ServerContext) {
|
||||||
@@ -263,79 +273,47 @@ struct ProjectsView: View {
|
|||||||
// MARK: - Project List
|
// MARK: - Project List
|
||||||
|
|
||||||
private var projectList: some View {
|
private var projectList: some View {
|
||||||
VStack(spacing: 0) {
|
// Sidebar is an extracted view; this view stays the owner of
|
||||||
List(viewModel.projects, selection: Binding(
|
// sheet state (add / rename / move / uninstall / remove-from-
|
||||||
get: { viewModel.selectedProject },
|
// list confirmation) and routes intents down as closures.
|
||||||
set: { project in
|
ProjectsSidebar(
|
||||||
if let project {
|
viewModel: viewModel,
|
||||||
viewModel.selectProject(project)
|
canConfigureProject: { isConfigurable($0) },
|
||||||
}
|
isTemplateInstalled: { uninstaller.isTemplateInstalled(project: $0) },
|
||||||
}
|
onConfigure: { configEditorProject = $0 },
|
||||||
)) { project in
|
onUninstallTemplate: { project in
|
||||||
HStack {
|
uninstallerViewModel.begin(project: project)
|
||||||
Image(systemName: viewModel.dashboard != nil && viewModel.selectedProject == project
|
showingUninstallSheet = true
|
||||||
? "square.grid.2x2.fill" : "square.grid.2x2")
|
},
|
||||||
.foregroundStyle(.secondary)
|
onRemoveFromList: { pendingRemoveFromList = $0 },
|
||||||
Text(project.name)
|
onRename: { renameTarget = $0 },
|
||||||
}
|
onMoveToFolder: { moveTarget = $0 },
|
||||||
.tag(project)
|
onAddProject: { showingAddSheet = true }
|
||||||
.contextMenu {
|
)
|
||||||
if isConfigurable(project) {
|
|
||||||
Button("Configuration…", systemImage: "slider.horizontal.3") {
|
|
||||||
configEditorProject = project
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if uninstaller.isTemplateInstalled(project: project) {
|
|
||||||
// "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()
|
|
||||||
}
|
|
||||||
// "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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listStyle(.sidebar)
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
HStack {
|
|
||||||
Button(action: { showingAddSheet = true }) {
|
|
||||||
Image(systemName: "plus")
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderless)
|
|
||||||
Spacer()
|
|
||||||
if let selected = viewModel.selectedProject {
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showingAddSheet) {
|
.sheet(isPresented: $showingAddSheet) {
|
||||||
AddProjectSheet { name, path in
|
AddProjectSheet { name, path in
|
||||||
viewModel.addProject(name: name, path: path)
|
viewModel.addProject(name: name, path: path)
|
||||||
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(item: $renameTarget) { target in
|
||||||
|
RenameProjectSheet(
|
||||||
|
project: target,
|
||||||
|
existingNames: viewModel.projects
|
||||||
|
.filter { $0.name != target.name }
|
||||||
|
.map(\.name)
|
||||||
|
) { newName in
|
||||||
|
viewModel.renameProject(target, to: newName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $moveTarget) { target in
|
||||||
|
MoveToFolderSheet(
|
||||||
|
project: target,
|
||||||
|
existingFolders: viewModel.folders
|
||||||
|
) { newFolder in
|
||||||
|
viewModel.moveProject(target, toFolder: newFolder)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Dashboard Area
|
// MARK: - Dashboard Area
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Sheet for renaming a project in the registry. Preserves the
|
||||||
|
/// project's `path`, `folder`, and `archived` fields — the rename
|
||||||
|
/// only changes the user-visible name (and therefore the Identifiable
|
||||||
|
/// id). Duplicate-name / empty-name rejection lives in the VM.
|
||||||
|
struct RenameProjectSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let project: ProjectEntry
|
||||||
|
/// Current set of project names in the registry, used to flag
|
||||||
|
/// duplicates before the user tries to Save. Excludes the
|
||||||
|
/// project being renamed so same-name is a no-op (accepted).
|
||||||
|
let existingNames: [String]
|
||||||
|
/// Called with the trimmed new name. Caller is responsible for
|
||||||
|
/// calling `ProjectsViewModel.renameProject(_:to:)`; this sheet
|
||||||
|
/// just gathers input + validates inline.
|
||||||
|
let onSave: (String) -> Void
|
||||||
|
|
||||||
|
@State private var newName: String
|
||||||
|
|
||||||
|
init(
|
||||||
|
project: ProjectEntry,
|
||||||
|
existingNames: [String],
|
||||||
|
onSave: @escaping (String) -> Void
|
||||||
|
) {
|
||||||
|
self.project = project
|
||||||
|
self.existingNames = existingNames
|
||||||
|
self.onSave = onSave
|
||||||
|
_newName = State(initialValue: project.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validation for the live input. Empty / whitespace-only / a
|
||||||
|
/// collision with another project's name all disable Save.
|
||||||
|
private var validation: (isValid: Bool, message: String?) {
|
||||||
|
let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.isEmpty {
|
||||||
|
return (false, nil) // no error message — just disabled
|
||||||
|
}
|
||||||
|
if trimmed != project.name && existingNames.contains(trimmed) {
|
||||||
|
return (false, String(localized: "A project named \"\(trimmed)\" already exists."))
|
||||||
|
}
|
||||||
|
return (true, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Rename project").font(.headline)
|
||||||
|
Text("The project directory on disk isn't changed — only the label Scarf shows in the sidebar.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
TextField("Project name", text: $newName)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.onSubmit {
|
||||||
|
if validation.isValid {
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let message = validation.message {
|
||||||
|
Label(message, systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
Spacer()
|
||||||
|
Button("Save") { save() }
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(!validation.isValid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(minWidth: 420)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
onSave(trimmed)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user