Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions ConfigUI/Sources/ConfigurationsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
30 changes: 30 additions & 0 deletions ConfigUI/Sources/RunnerStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading