mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 02:26:37 +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.
|
||||
@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
|
||||
|
||||
@@ -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