From 309948383c4fb0cd0c024fe32dec780fcf85206e Mon Sep 17 00:00:00 2001 From: Matt Wise Date: Tue, 7 Apr 2026 19:45:20 -0700 Subject: [PATCH] feat(ConfigUI): add Duplicate Configuration action to sidebar context menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #4 — users can now right-click a configuration in the sidebar and select "Duplicate…" to clone it under a new name, avoiding the full wizard flow when most fields are shared between configs. Co-Authored-By: Claude Opus 4.6 (1M context) --- ConfigUI/Sources/ConfigurationsView.swift | 76 +++++++++++++++++++++++ ConfigUI/Sources/RunnerStore.swift | 30 +++++++++ 2 files changed, 106 insertions(+) diff --git a/ConfigUI/Sources/ConfigurationsView.swift b/ConfigUI/Sources/ConfigurationsView.swift index cbd0323..0482ae1 100644 --- a/ConfigUI/Sources/ConfigurationsView.swift +++ b/ConfigUI/Sources/ConfigurationsView.swift @@ -26,6 +26,10 @@ struct ConfigurationsView: View { @Environment(\.openWindow) private var openWindow @State private var showDeleteConfirmation = false @State private var configToDelete: String? = nil + @State private var showDuplicateSheet = false + @State private var configToDuplicate: String? = nil + @State private var duplicateName = "" + @State private var duplicateError: String? = nil var body: some View { NavigationSplitView { @@ -37,6 +41,9 @@ struct ConfigurationsView: View { // Destructive action confirmation — shown when the user clicks // "Delete..." from the context menu. Requires explicit confirmation // because deletion stops the runner and removes the config file. + .sheet(isPresented: $showDuplicateSheet) { + duplicateSheet + } .alert("Delete Configuration", isPresented: $showDeleteConfirmation) { Button("Delete", role: .destructive) { if let name = configToDelete { @@ -133,6 +140,12 @@ struct ConfigurationsView: View { EmptyView() } Divider() + Button("Duplicate...") { + configToDuplicate = instance.name + duplicateName = "\(instance.name)-copy" + duplicateError = nil + showDuplicateSheet = true + } Button("Delete...", role: .destructive) { configToDelete = instance.name showDeleteConfirmation = true @@ -151,6 +164,69 @@ struct ConfigurationsView: View { } } + // MARK: - Duplicate Sheet + + /// Sheet for entering the new configuration name when duplicating. + /// Validates the name for uniqueness and filesystem safety inline. + private var duplicateSheet: some View { + VStack(spacing: 16) { + Text("Duplicate Configuration") + .font(.headline) + + TextField("New configuration name", text: $duplicateName) + .textFieldStyle(.roundedBorder) + .onSubmit { performDuplicate() } + + if let error = duplicateError { + Text(error) + .foregroundColor(.red) + .font(.caption) + } + + HStack { + Button("Cancel") { + showDuplicateSheet = false + } + .keyboardShortcut(.cancelAction) + + Spacer() + + Button("Duplicate") { + performDuplicate() + } + .keyboardShortcut(.defaultAction) + .disabled(duplicateName.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .padding(20) + .frame(width: 360) + } + + /// Validates the duplicate name and performs the duplication if valid. + private func performDuplicate() { + let trimmed = duplicateName.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { + duplicateError = "Configuration name is required." + return + } + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_")) + if trimmed.unicodeScalars.contains(where: { !allowed.contains($0) }) { + duplicateError = "Name can only contain letters, numbers, hyphens, and underscores." + return + } + if FileManager.default.fileExists(atPath: AppConfig.configPath(forName: trimmed)) { + duplicateError = "A configuration named '\(trimmed)' already exists." + return + } + guard let sourceName = configToDuplicate else { return } + do { + try store.duplicateConfig(sourceName: sourceName, newName: trimmed) + showDuplicateSheet = false + } catch { + duplicateError = "Failed to duplicate: \(error.localizedDescription)" + } + } + // MARK: - Detail Pane /// Shows either the config detail (when a config is selected) or diff --git a/ConfigUI/Sources/RunnerStore.swift b/ConfigUI/Sources/RunnerStore.swift index 64f50dc..fb86d7b 100644 --- a/ConfigUI/Sources/RunnerStore.swift +++ b/ConfigUI/Sources/RunnerStore.swift @@ -172,6 +172,36 @@ class RunnerStore: ObservableObject { saveState() } + /// Duplicates an existing configuration under a new name. + /// The cloned config is created in a disabled state and is not auto-started. + /// The `prepared_image_name` field is cleared to avoid image name collisions. + /// + /// - Parameters: + /// - sourceName: The name of the configuration to clone. + /// - newName: The name for the new configuration. + /// - Throws: If the source config cannot be loaded or the new config cannot be saved. + func duplicateConfig(sourceName: String, newName: String) throws { + guard let source = instance(named: sourceName) else { + throw NSError(domain: "RunnerStore", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Configuration '\(sourceName)' not found."]) + } + + var config = try AppConfig.load(from: source.configPath) + config.name = newName + config.provisioning.preparedImageName = "" + + let newPath = AppConfig.configPath(forName: newName) + try config.save(to: newPath) + + let instance = RunnerInstance(name: newName, configPath: newPath, enabled: false) + instance.manager.onStateChange = { [weak self] in + self?.objectWillChange.send() + } + instances.append(instance) + saveState() + selectedConfigName = newName + } + /// Stops the runner, deletes the config YAML file, and removes the /// instance from the store. The state file is updated to reflect /// the removal.