diff --git a/scarf/scarf/Features/Projects/Views/MoveToFolderSheet.swift b/scarf/scarf/Features/Projects/Views/MoveToFolderSheet.swift new file mode 100644 index 0000000..d9d25eb --- /dev/null +++ b/scarf/scarf/Features/Projects/Views/MoveToFolderSheet.swift @@ -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() + } +} diff --git a/scarf/scarf/Features/Projects/Views/ProjectsSidebar.swift b/scarf/scarf/Features/Projects/Views/ProjectsSidebar.swift new file mode 100644 index 0000000..b084bb9 --- /dev/null +++ b/scarf/scarf/Features/Projects/Views/ProjectsSidebar.swift @@ -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 = [] + @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 } + } +} diff --git a/scarf/scarf/Features/Projects/Views/ProjectsView.swift b/scarf/scarf/Features/Projects/Views/ProjectsView.swift index f80be89..7c5292c 100644 --- a/scarf/scarf/Features/Projects/Views/ProjectsView.swift +++ b/scarf/scarf/Features/Projects/Views/ProjectsView.swift @@ -34,6 +34,16 @@ struct ProjectsView: View { /// drop from the registry. @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 init(context: ServerContext) { @@ -263,79 +273,47 @@ struct ProjectsView: View { // MARK: - Project List private var projectList: some View { - VStack(spacing: 0) { - List(viewModel.projects, selection: Binding( - get: { viewModel.selectedProject }, - set: { project in - if let project { - viewModel.selectProject(project) - } - } - )) { project in - HStack { - Image(systemName: viewModel.dashboard != nil && viewModel.selectedProject == project - ? "square.grid.2x2.fill" : "square.grid.2x2") - .foregroundStyle(.secondary) - Text(project.name) - } - .tag(project) - .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) - } + // Sidebar is an extracted view; this view stays the owner of + // sheet state (add / rename / move / uninstall / remove-from- + // list confirmation) and routes intents down as closures. + ProjectsSidebar( + viewModel: viewModel, + canConfigureProject: { isConfigurable($0) }, + isTemplateInstalled: { uninstaller.isTemplateInstalled(project: $0) }, + onConfigure: { configEditorProject = $0 }, + onUninstallTemplate: { project in + uninstallerViewModel.begin(project: project) + showingUninstallSheet = true + }, + onRemoveFromList: { pendingRemoveFromList = $0 }, + onRename: { renameTarget = $0 }, + onMoveToFolder: { moveTarget = $0 }, + onAddProject: { showingAddSheet = true } + ) .sheet(isPresented: $showingAddSheet) { AddProjectSheet { name, path in viewModel.addProject(name: name, path: path) 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 diff --git a/scarf/scarf/Features/Projects/Views/RenameProjectSheet.swift b/scarf/scarf/Features/Projects/Views/RenameProjectSheet.swift new file mode 100644 index 0000000..b2c7227 --- /dev/null +++ b/scarf/scarf/Features/Projects/Views/RenameProjectSheet.swift @@ -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() + } +}