From 6b6c1812593c69fa76d9c48fc0cd13910eb13d3c Mon Sep 17 00:00:00 2001 From: Jiun Bae Date: Tue, 27 Oct 2026 19:31:39 +0900 Subject: [PATCH] fix: keep unsaved automation edits; Dock page leads with what works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The automation editor's draft lived in view state, so leaving the Automation page and coming back replaced unsaved edits with the saved script. It now lives in AutomationController, with an Unsaved changes mark beside the buttons. Enabling the extension runs the saved script — it used to apply and save whatever was in the editor, though the page says only Save applies edits. Save stays available while running, where it doubles as Reload. With the BarShelf Dock off, the Dock page shows profiles, the Apple Dock layout and switching first; the BarShelf Dock's appearance and items follow for when it is turned on. Found in a design/UX review with a codex peer. Co-Authored-By: Claude Opus 5.5 (1M context) --- Localization/Localizable.xcstrings | 10 ++++++ .../Automation/AutomationController.swift | 9 +++++ .../Automation/AutomationSettingsView.swift | 36 +++++++++++++------ .../MenubucketApp/Dock/DockSettingsPage.swift | 21 ++++++++--- .../MenubucketAppTests/AutomationTests.swift | 18 ++++++++++ 5 files changed, 78 insertions(+), 16 deletions(-) diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings index 3b806d1..125cc23 100644 --- a/Localization/Localizable.xcstrings +++ b/Localization/Localizable.xcstrings @@ -11036,6 +11036,16 @@ } } }, + "Unsaved changes": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "저장하지 않은 변경 사항" + } + } + } + }, "Unsupported Fn mapping: %@ → %@. Use letter keys and up/down/left/right.": { "localizations": { "en": { diff --git a/Sources/MenubucketApp/Automation/AutomationController.swift b/Sources/MenubucketApp/Automation/AutomationController.swift index f9ad501..d9f7e1b 100644 --- a/Sources/MenubucketApp/Automation/AutomationController.swift +++ b/Sources/MenubucketApp/Automation/AutomationController.swift @@ -13,6 +13,10 @@ final class AutomationController: ObservableObject { } @Published private(set) var source = AutomationScript.example @Published private(set) var isRunning = false + /// Unsaved edits in the settings editor; nil when the editor shows the + /// saved script. Kept here rather than in the view, so leaving the page + /// and coming back does not throw edits away. + @Published private(set) var draft: String? @Published private(set) var message: String? @Published private(set) var importSummary: String? /// Running bindings that macOS also uses for one of its shortcuts. @@ -59,6 +63,11 @@ final class AutomationController: ObservableObject { apply(source: source, enabled: true) } + /// Records editor text; matching the saved script clears the draft. + func setDraft(_ text: String) { + draft = text == source ? nil : text + } + @discardableResult func apply(source candidate: String, enabled: Bool) -> Bool { do { diff --git a/Sources/MenubucketApp/Automation/AutomationSettingsView.swift b/Sources/MenubucketApp/Automation/AutomationSettingsView.swift index 08642a0..a5b502f 100644 --- a/Sources/MenubucketApp/Automation/AutomationSettingsView.swift +++ b/Sources/MenubucketApp/Automation/AutomationSettingsView.swift @@ -4,7 +4,9 @@ import UniformTypeIdentifiers struct AutomationSettingsView: View { @ObservedObject private var controller: AutomationController - @State private var draft = "" + /// The editor's text: unsaved edits if there are any, else the saved script. + private var draft: String { controller.draft ?? controller.source } + private var hasUnsavedChanges: Bool { controller.draft != nil } @State private var displays: [AutomationWindows.Display] = [] init(controller: AutomationController = .shared) { @@ -17,7 +19,9 @@ struct AutomationSettingsView: View { Toggle("Enable Keyboard & Window Extension", isOn: Binding( get: { controller.isRunning }, set: { enabled in - if enabled { controller.apply(source: draft, enabled: true) } + // Enabling runs the saved script. Unsaved edits are + // applied only by Save, as the editor says. + if enabled { controller.apply(source: controller.source, enabled: true) } else { controller.disable() } } )) @@ -60,7 +64,7 @@ struct AutomationSettingsView: View { URL(fileURLWithPath: $0, isDirectory: true) } ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config") if let imported = controller.importFile(base.appendingPathComponent("karabiner/karabiner.json")) { - draft = imported + controller.setDraft(imported) } } Button("Karabiner Key Mappings Only…") { @@ -79,19 +83,30 @@ struct AutomationSettingsView: View { if let summary = controller.importSummary { Text(summary).font(.caption).textSelection(.enabled) } - TextEditor(text: $draft) + TextEditor(text: Binding(get: { draft }, set: { controller.setDraft($0) })) .font(.system(.body, design: .monospaced)) .frame(minHeight: 230) .border(Fill.hairline) .accessibilityLabel("Automation JavaScript") HStack { Button(controller.isRunning ? String(localized: "Save & Reload") : String(localized: "Save Script")) { - controller.apply(source: draft, enabled: controller.isRunning) + if controller.apply(source: draft, enabled: controller.isRunning) { + controller.setDraft(controller.source) + } } - Button("Revert Draft") { draft = controller.source } - .disabled(draft == controller.source) - Button("Load Example") { draft = AutomationScript.example } + // Running, it doubles as Reload. + .disabled(!hasUnsavedChanges && !controller.isRunning) + Button("Revert Draft") { controller.setDraft(controller.source) } + .disabled(!hasUnsavedChanges) + Button("Load Example") { controller.setDraft(AutomationScript.example) } Button("Export…") { export() } + if hasUnsavedChanges { + Label("Unsaved changes", systemImage: "circle.fill") + .labelStyle(.titleAndIcon) + .font(.caption) + .foregroundStyle(StatusTone.warning.color) + .imageScale(.small) + } } Text("Edit shortcuts and window actions in JavaScript. Save applies your changes; Revert Draft restores the saved script.") .font(.caption).foregroundStyle(.secondary) @@ -107,7 +122,6 @@ struct AutomationSettingsView: View { } header: { Text("Displays") } } .onAppear { - draft = controller.source displays = AutomationWindows.displays } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification)) { _ in @@ -168,12 +182,12 @@ struct AutomationSettingsView: View { panel.allowsMultipleSelection = false panel.canChooseDirectories = false guard panel.runModal() == .OK, let url = panel.url else { return } - if let imported = controller.importFile(url, excludingFunctionKeys: keyMappingsOnly) { draft = imported } + if let imported = controller.importFile(url, excludingFunctionKeys: keyMappingsOnly) { controller.setDraft(imported) } } private func importSettings(path: String) { let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(path) - if let imported = controller.importFile(url) { draft = imported } + if let imported = controller.importFile(url) { controller.setDraft(imported) } } private func export() { diff --git a/Sources/MenubucketApp/Dock/DockSettingsPage.swift b/Sources/MenubucketApp/Dock/DockSettingsPage.swift index 77027dc..805090e 100644 --- a/Sources/MenubucketApp/Dock/DockSettingsPage.swift +++ b/Sources/MenubucketApp/Dock/DockSettingsPage.swift @@ -46,11 +46,22 @@ struct DockSettingsPage: View { } } } - appearanceSection - profilesSection - itemsSection - appleDockSection - switchingSection + if config.mode == .off { + // With the BarShelf Dock off, profiles still switch the Apple + // Dock: lead with them, and keep the BarShelf Dock's own + // look and items for when it is turned on. + profilesSection + appleDockSection + switchingSection + appearanceSection + itemsSection + } else { + appearanceSection + profilesSection + itemsSection + appleDockSection + switchingSection + } } .alert("Add Link", isPresented: $showLinkPrompt) { TextField("https://example.com", text: $linkDraft) diff --git a/Tests/MenubucketAppTests/AutomationTests.swift b/Tests/MenubucketAppTests/AutomationTests.swift index d220fad..9737af3 100644 --- a/Tests/MenubucketAppTests/AutomationTests.swift +++ b/Tests/MenubucketAppTests/AutomationTests.swift @@ -236,6 +236,24 @@ final class AutomationTests: XCTestCase { XCTAssertFalse(restored.isRunning) } + func testUnsavedDraftOutlivesTheEditorAndClearsWhenItMatchesTheSavedScript() throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: folder) } + let controller = AutomationController(fileURL: folder.appendingPathComponent("automation.json")) + let saved = "barshelf.bind(['ctrl'], 'i', () => moveMouseToScreen(1));" + XCTAssertTrue(controller.apply(source: saved, enabled: false)) + XCTAssertNil(controller.draft) + + // An edit is held by the controller, so a settings page that is left + // and rebuilt shows it again instead of the saved script. + controller.setDraft(saved + "\n// edit") + XCTAssertEqual(controller.draft, saved + "\n// edit") + XCTAssertEqual(controller.source, saved, "a draft is not saved") + + controller.setDraft(saved) + XCTAssertNil(controller.draft, "text equal to the saved script is no draft") + } + func testImportIsOnlyADraftAndDoesNotOverwriteSavedSourceOrOriginal() throws { let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)