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:
Alan Wizemann
2026-04-23 23:00:21 +02:00
parent f1e8f3070f
commit 585d035fe8
4 changed files with 519 additions and 67 deletions
@@ -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()
}
}