native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
Swift
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283import AppKitimport ProwlCLISharedimport SwiftUI
/// Shared inline table editor for custom commands. Hosts pass the command/// list binding plus the command source so shortcut resolution and copy/// stay scope-aware; the editor owns all transient selection, popover, and/// shortcut-recording state itself.struct CustomCommandsEditor: View { @Binding var commands: [UserCustomCommand] let source: CustomCommandSource let keybindingUserOverrides: KeybindingUserOverrideStore let globalCommands: [UserCustomCommand] let globalCommandEnabled: ((UserCustomCommand.ID) -> Binding<Bool>)?
init( commands: Binding<[UserCustomCommand]>, source: CustomCommandSource, keybindingUserOverrides: KeybindingUserOverrideStore, globalCommands: [UserCustomCommand] = [], globalCommandEnabled: ((UserCustomCommand.ID) -> Binding<Bool>)? = nil ) { _commands = commands self.source = source self.keybindingUserOverrides = keybindingUserOverrides self.globalCommands = globalCommands self.globalCommandEnabled = globalCommandEnabled }
private enum ReorderDestination: Equatable { case before(UserCustomCommand.ID) case append }
@State private var selectedCustomCommandID: UserCustomCommand.ID? @State private var recordingCustomCommandID: UserCustomCommand.ID? @State private var recorderMonitor: Any? @State private var invalidMessageByCommandID: [UserCustomCommand.ID: String] = [:] @State private var pendingShortcutConflict: CustomCommandShortcutConflict? @State private var pendingShortcut: PendingCustomShortcut? @State private var iconPickerCommandID: UserCustomCommand.ID? @State private var customCommandsFocusAnchor: NSView? @State private var popoverRefocusTask: Task<Void, Never>? @State private var commandEditorCommandID: UserCustomCommand.ID? @State private var editingNameCommandID: UserCustomCommand.ID? @FocusState private var focusedNameEditorCommandID: UserCustomCommand.ID? @State private var reorderDestination: ReorderDestination?
private let keyTokenResolver = ShortcutKeyTokenResolver()
static let symbolPresets = [ "terminal", "terminal.fill", "play.fill", "stop.fill", "hammer.fill", "shippingbox.fill", "doc.text.fill", "sparkles", "bolt.fill", "flame.fill", "wand.and.stars", "wrench.and.screwdriver.fill", "checkmark.circle.fill", "xmark.circle.fill", "exclamationmark.triangle.fill", "ladybug.fill", "clock.fill", "repeat", "arrow.clockwise", "folder.fill", "archivebox.fill", "paperplane.fill", "cloud.fill", "tray.and.arrow.down.fill", "tray.and.arrow.up.fill", "icloud.and.arrow.up.fill", "square.and.arrow.up.fill", "arrow.triangle.2.circlepath", "folder.badge.plus", "doc.badge.plus", ]
var body: some View { VStack(alignment: .leading, spacing: 10) { VStack(spacing: 0) { customCommandsHeaderRow Divider() ScrollViewReader { proxy in ScrollView { LazyVStack(spacing: 4) { ForEach(commands) { command in customCommandRow(command) .id(command.id) } localCommandDropTarget if showsGlobalCommands { ForEach(globalCommands) { command in globalCustomCommandRow(command) .id("global-\(command.id)") } } } .padding(.horizontal, 6) .padding(.vertical, 6) } .frame(height: customCommandsListHeight) .onChange(of: editingNameCommandID) { _, commandID in // The lazy stack never builds a row below the viewport, so scroll // the row being renamed in before its field takes focus. guard let commandID else { return } var transaction = Transaction() transaction.animation = nil withTransaction(transaction) { proxy.scrollTo(commandID) } } } } .clipShape(RoundedRectangle(cornerRadius: 8))
HStack(spacing: 8) { Button { addCustomCommand() } label: { ZStack { Image(systemName: "plus") .frame(width: 16, height: 16) } .frame(width: 28, height: 28) .contentShape(Rectangle()) .accessibilityLabel("Add command") } .buttonStyle(.plain) .help("Add command")
Button { removeSelectedCustomCommand() } label: { ZStack { Image(systemName: "minus") .frame(width: 16, height: 16) } .frame(width: 28, height: 28) .contentShape(Rectangle()) .accessibilityLabel("Remove selected command") } .buttonStyle(.plain) .disabled(commands.isEmpty) .help("Remove selected command")
Spacer(minLength: 0)
Text("\(displayedCommandCount) commands") .font(.caption) .foregroundStyle(.secondary) } if let invalidMessage = selectedCommandInvalidMessage { Text(invalidMessage) .font(.caption) .foregroundStyle(.red) } else { Text( showsGlobalCommands ? "Global commands are managed in Settings → Commands." : "Click cells to edit icon, name, command, and shortcut inline." ) .font(.caption) .foregroundStyle(.secondary) } } .background { FirstResponderAnchorView { anchor in if customCommandsFocusAnchor !== anchor { customCommandsFocusAnchor = anchor } } .frame(width: 0, height: 0) } .task { syncSelectedCommandID(with: commands) } .onChange(of: commands) { _, commands in syncSelectedCommandID(with: commands) clearRemovedCommandState(using: commands) } .onChange(of: selectedCustomCommandID) { _, selectedID in if editingNameCommandID != selectedID { editingNameCommandID = nil focusedNameEditorCommandID = nil } if let iconPickerCommandID, iconPickerCommandID != selectedID { self.iconPickerCommandID = nil } if let commandEditorCommandID, commandEditorCommandID != selectedID { self.commandEditorCommandID = nil } if let recordingCustomCommandID, recordingCustomCommandID != selectedID { self.recordingCustomCommandID = nil } } .onChange(of: recordingCustomCommandID) { _, commandID in if commandID == nil { stopRecorderMonitor() } else { startRecorderMonitor() } } .onDisappear { stopRecorderMonitor() popoverRefocusTask?.cancel() popoverRefocusTask = nil focusedNameEditorCommandID = nil } .alert( "Shortcut Conflict", isPresented: isShortcutConflictAlertPresented, presenting: pendingShortcutConflict ) { _ in Button("Replace", role: .destructive) { applyPendingShortcut(replacingConflict: true) } Button("Cancel", role: .cancel) { clearPendingShortcutConflict() } } message: { conflict in Text( """ “\(conflict.newCommandTitle)” and “\(conflict.existingCommandTitle)” both use \(conflict.shortcutDisplay).
Choose Replace to keep the new shortcut and clear the conflicting command. """ ) } }
@ViewBuilder private func customCommandIconCell(_ command: UserCustomCommand) -> some View { if let binding = bindingForCustomCommand(id: command.id) { InlineEditableCellButton( isActive: iconPickerCommandID == command.id, contentAlignment: .center ) { selectCustomCommand(command.id) toggleIconEditor(for: command.id) } label: { Image(systemName: binding.wrappedValue.resolvedSystemImage) .foregroundStyle(.secondary) .frame(width: 16, alignment: .center) .accessibilityHidden(true) } .popover( isPresented: Binding( get: { iconPickerCommandID == command.id }, set: { isPresented in if !isPresented { closePopoverAndRestoreCommandFocus(for: command.id) } } ), arrowEdge: .bottom ) { iconEditorPopover(for: binding, commandID: command.id) } } else { InlineEditableCellButton( contentAlignment: .center ) { selectCustomCommand(command.id) } label: { Image(systemName: command.resolvedSystemImage) .foregroundStyle(.secondary) .frame(width: 16, alignment: .center) .accessibilityHidden(true) } } }
@ViewBuilder private func customCommandNameCell(_ command: UserCustomCommand) -> some View { let isSelected = selectedCustomCommandID == command.id if isSelected, editingNameCommandID == command.id, let binding = bindingForCustomCommand(id: command.id) { InlineEditableFieldContainer(isActive: true) { TextField("", text: binding.title) .textFieldStyle(.plain) .padding(.leading, -4) .focused($focusedNameEditorCommandID, equals: command.id) .onSubmit { endNameEditing() } } .onAppear { // A focus request made in the same update that installs the field is a // no-op — SwiftUI has no responder to move yet, and AppKit falls back // to the window's first text field (the repository name). let commandID = command.id Task { @MainActor in guard editingNameCommandID == commandID else { return } focusedNameEditorCommandID = commandID } } } else { InlineEditableCellButton { selectCustomCommand(command.id) beginNameEditing(for: command.id) } label: { Text(bindingForCustomCommand(id: command.id)?.wrappedValue.resolvedTitle ?? command.resolvedTitle) .lineLimit(1) } } }
@ViewBuilder private func customCommandCell(_ command: UserCustomCommand) -> some View { if let binding = bindingForCustomCommand(id: command.id) { InlineEditableCellButton( isActive: commandEditorCommandID == command.id ) { selectCustomCommand(command.id) toggleCommandEditor(for: command.id) } label: { VStack(alignment: .leading, spacing: 2) { Text(inlineCommandTitle(for: binding.wrappedValue.execution)) .font(.caption) .foregroundStyle(.secondary) Text(inlineCommandScriptPreview(for: binding.wrappedValue.command)) .lineLimit(1) } } .popover( isPresented: Binding( get: { commandEditorCommandID == command.id }, set: { isPresented in if !isPresented { closePopoverAndRestoreCommandFocus(for: command.id) } } ), arrowEdge: .bottom ) { commandEditorPopover(for: binding) } .help("New Tab runs in a new tab. In Place sends input to the focused terminal.") } else { InlineEditableCellButton { selectCustomCommand(command.id) } label: { VStack(alignment: .leading, spacing: 2) { Text(inlineCommandTitle(for: command.execution)) .font(.caption) .foregroundStyle(.secondary) Text(inlineCommandScriptPreview(for: command.command)) .lineLimit(1) } } } }
@ViewBuilder private func customCommandShortcutCell(_ command: UserCustomCommand) -> some View { let resolvedBinding = resolvedCustomCommandBindings.keybinding(for: customCommandBindingID(for: command.id)) let shortcutDisplay = resolvedBinding?.display ?? String(localized: "Unassigned") let isRecording = recordingCustomCommandID == command.id
InlineEditableCellButton( isActive: isRecording, activeColor: .orange ) { selectCustomCommand(command.id) toggleRecording(for: command.id) } label: { Text(isRecording ? String(localized: "Recording…") : shortcutDisplay) .font(.body.monospaced()) .foregroundStyle(isRecording ? Color.orange : (resolvedBinding == nil ? .secondary : .primary)) .lineLimit(1) } .contextMenu { if command.shortcut != nil { Button("Clear Shortcut") { clearShortcut(for: command.id) } } } .help(isRecording ? "Recording shortcut. Press Esc to cancel." : "Click to record a shortcut.") }
private var effectiveSelectedCommandID: UserCustomCommand.ID? { selectedCustomCommandID ?? editingNameCommandID ?? commandEditorCommandID ?? iconPickerCommandID ?? recordingCustomCommandID }
private var removableCommandID: UserCustomCommand.ID? { if let selectedCustomCommandID, commands.contains(where: { $0.id == selectedCustomCommandID }) { return selectedCustomCommandID } if let effectiveSelectedCommandID, commands.contains(where: { $0.id == effectiveSelectedCommandID }) { return effectiveSelectedCommandID } return commands.last?.id }
private var customCommandsHeaderRow: some View { HStack(spacing: 8) { customCommandHeaderCell("", width: customCommandsDragColumnWidth, alignment: .center) customCommandHeaderCell("", width: customCommandsEnabledColumnWidth, alignment: .center) customCommandHeaderCell("", width: customCommandsIconColumnWidth, alignment: .center) customCommandHeaderCell(String(localized: "Name"), width: customCommandsNameColumnWidth) customCommandHeaderCell(String(localized: "Command")) customCommandHeaderCell(String(localized: "Shortcut"), width: customCommandsShortcutColumnWidth) } .padding(.horizontal, 14) .padding(.vertical, 8) .font(.headline) .foregroundStyle(.secondary) }
@ViewBuilder private func customCommandRow(_ command: UserCustomCommand) -> some View { let isSelected = selectedCustomCommandID == command.id HStack(spacing: 8) { customCommandRowCell(width: customCommandsDragColumnWidth, alignment: .center) { customCommandReorderHandle(command) } customCommandRowCell(width: customCommandsEnabledColumnWidth, alignment: .center) { customCommandEnabledCell(command) } customCommandRowCell(width: customCommandsIconColumnWidth, alignment: .center) { customCommandIconCell(command) } customCommandRowCell(width: customCommandsNameColumnWidth) { customCommandNameCell(command) } customCommandRowCell { customCommandCell(command) } customCommandRowCell(width: customCommandsShortcutColumnWidth) { customCommandShortcutCell(command) } } .padding(.horizontal, 8) .padding(.vertical, 2) .background { RoundedRectangle(cornerRadius: 8) .fill(isSelected ? Color.accentColor.opacity(0.35) : .clear) } .overlay(alignment: .top) { if reorderDestination == .before(command.id) { reorderInsertionIndicator } } .contentShape(RoundedRectangle(cornerRadius: 8)) .accessibilityAddTraits(.isButton) .onTapGesture { selectCustomCommand(command.id) } .dropDestination( for: String.self, action: { commandIDs, _ in guard let commandID = commandIDs.first, commands.contains(where: { $0.id == commandID }) else { return false } moveCustomCommand(commandID, before: command.id) reorderDestination = nil return true }, isTargeted: { isTargeted in updateReorderDestination(isTargeted, destination: .before(command.id)) } ) }
@ViewBuilder private func globalCustomCommandRow(_ command: UserCustomCommand) -> some View { HStack(spacing: 8) { customCommandRowCell(width: customCommandsDragColumnWidth, alignment: .center) { Image(systemName: "lock.fill") .font(.caption2) .foregroundStyle(.tertiary) .accessibilityHidden(true) } customCommandRowCell(width: customCommandsEnabledColumnWidth, alignment: .center) { globalCommandEnabledCell(command) } customCommandRowCell(width: customCommandsIconColumnWidth, alignment: .center) { Image(systemName: command.resolvedSystemImage) .foregroundStyle(.secondary) .frame(width: 16, alignment: .center) .accessibilityHidden(true) } customCommandRowCell(width: customCommandsNameColumnWidth) { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 4) { Text(command.resolvedTitle) .lineLimit(1) Text("Global") .font(.caption2) .foregroundStyle(.secondary) } if !command.isEnabled { Text("Disabled globally") .font(.caption2) .foregroundStyle(.secondary) } } } customCommandRowCell { VStack(alignment: .leading, spacing: 2) { Text(inlineCommandTitle(for: command.execution)) .font(.caption) .foregroundStyle(.secondary) Text(inlineCommandScriptPreview(for: command.command)) .lineLimit(1) } } customCommandRowCell(width: customCommandsShortcutColumnWidth) { let binding = resolvedCustomCommandBindings.keybinding( for: customCommandBindingID(for: command.id, source: .global) ) Text(binding?.display ?? String(localized: "Unassigned")) .font(.body.monospaced()) .foregroundStyle(binding == nil ? .secondary : .primary) .lineLimit(1) } } .padding(.horizontal, 8) .padding(.vertical, 2) .opacity(command.isEnabled ? 1 : 0.6) .help("Global command. Edit it in Settings → Commands.") }
@ViewBuilder private func customCommandReorderHandle(_ command: UserCustomCommand) -> some View { Image(systemName: "line.3.horizontal") .foregroundStyle(.tertiary) .frame(width: 16, height: 16) .contentShape(Rectangle()) .accessibilityLabel("Drag \(command.resolvedTitle) to reorder") .help("Drag to reorder command") .draggable(command.id) }
@ViewBuilder private func customCommandEnabledCell(_ command: UserCustomCommand) -> some View { if let binding = bindingForCustomCommand(id: command.id) { Toggle("Enable \(command.resolvedTitle)", isOn: binding.isEnabled) .labelsHidden() .toggleStyle(.checkbox) .controlSize(.small) .help("Enable \(command.resolvedTitle)") } }
@ViewBuilder private func globalCommandEnabledCell(_ command: UserCustomCommand) -> some View { if let binding = globalCommandEnabled?(command.id) { Toggle("Enable \(command.resolvedTitle) in this repository", isOn: binding) .labelsHidden() .toggleStyle(.checkbox) .controlSize(.small) .help("Enable \(command.resolvedTitle) in this repository") } }
@ViewBuilder private func customCommandHeaderCell( _ title: String, width: CGFloat? = nil, alignment: Alignment = .leading ) -> some View { if let width { Text(title) .frame(width: width, alignment: alignment) } else { Text(title) .frame(maxWidth: .infinity, alignment: alignment) } }
@ViewBuilder private func customCommandRowCell<Content: View>( width: CGFloat? = nil, alignment: Alignment = .leading, @ViewBuilder content: () -> Content ) -> some View { if let width { content() .frame(width: width, alignment: alignment) .frame(maxHeight: .infinity, alignment: alignment) } else { content() .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment) } }
private func selectCustomCommand(_ commandID: UserCustomCommand.ID) { if selectedCustomCommandID != commandID { selectedCustomCommandID = commandID } }
private func inlineCommandTitle(for execution: UserCustomCommandExecution) -> String { switch execution { case .shellScript: return String(localized: "New Tab") case .terminalInput: return String(localized: "In Place") case .split: return String(localized: "New Split") } }
private func inlineCommandScriptPreview(for script: String) -> String { let firstLine = script .split(separator: "\n", omittingEmptySubsequences: false) .first .map(String.init)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return firstLine.isEmpty ? String(localized: "Click to set command script") : firstLine }
private func iconEditorPopover( for command: Binding<UserCustomCommand>, commandID: UserCustomCommand.ID ) -> some View { VStack(alignment: .leading, spacing: 10) { Text("Icon") .font(.headline) Text("Pick from common symbols or enter any SF Symbol name available in your system.") .font(.caption) .foregroundStyle(.secondary)
HStack(spacing: 8) { TextField("SF Symbol name", text: command.systemImage) .textFieldStyle(.roundedBorder) Button("Open SF Symbols") { openSFSymbolsReference() } }
ScrollView { LazyVGrid( columns: Array(repeating: GridItem(.fixed(24), spacing: 8), count: 10), spacing: 8 ) { ForEach(Self.symbolPresets, id: \.self) { symbol in Button { command.wrappedValue.systemImage = symbol closePopoverAndRestoreCommandFocus(for: commandID) } label: { Image(systemName: symbol) .frame(width: 24, height: 24) .accessibilityHidden(true) } .buttonStyle(.plain) .help(symbol) } } .padding(12) } .frame(maxHeight: 124) } .padding(12) .frame(width: 360) }
private func commandEditorPopover(for command: Binding<UserCustomCommand>) -> some View { VStack(alignment: .leading, spacing: 10) { Text("Command") .font(.headline) Text(commandEditorDescription) .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true)
Picker("Execution", selection: command.execution) { Text("New Tab") .tag(UserCustomCommandExecution.shellScript) Text("In Place") .tag(UserCustomCommandExecution.terminalInput) Text("New Split") .tag(UserCustomCommandExecution.split) } .pickerStyle(.segmented)
if command.wrappedValue.execution == .split { Picker("Split Direction", selection: command.splitDirection) { ForEach(UserCustomSplitDirection.allCases) { direction in Text(direction.title).tag(direction) } } .pickerStyle(.menu) .help("Direction to split the focused terminal pane.") }
PlainTextEditor( text: command.command, isMonospaced: true, shouldFocus: true, placeholder: scriptPlaceholder(for: command.wrappedValue.execution) ) .frame(height: 140)
Text(scriptDescription(for: command.wrappedValue.execution)) .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true)
if command.wrappedValue.execution.supportsCloseOnSuccess { Toggle("Close on success", isOn: command.closeOnSuccess) .help("Automatically closes the tab or split when the command exits with code 0.") .toggleStyle(.checkbox) } } .padding(12) .frame(width: 420) }
private var commandEditorDescription: String { switch source { case .repository: return String( localized: "Choose where this command runs and edit the script used by this repository custom command." ) case .global: return String( localized: "Choose where this command runs and edit the script used by this global custom command." ) } }
private var selectedCommandInvalidMessage: String? { guard let selectedCustomCommandID else { return nil } return invalidMessageByCommandID[selectedCustomCommandID] }
private func openSFSymbolsReference() { if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.SFSymbols") { let configuration = NSWorkspace.OpenConfiguration() NSWorkspace.shared.openApplication(at: appURL, configuration: configuration) { _, _ in } return } guard let url = URL(string: "https://developer.apple.com/sf-symbols/") else { return } NSWorkspace.shared.open(url) }
private func toggleIconEditor(for commandID: UserCustomCommand.ID) { if iconPickerCommandID == commandID { closePopoverAndRestoreCommandFocus(for: commandID) return } iconPickerCommandID = commandID commandEditorCommandID = nil endNameEditing() if recordingCustomCommandID == commandID { recordingCustomCommandID = nil } }
private func toggleCommandEditor(for commandID: UserCustomCommand.ID) { if commandEditorCommandID == commandID { closePopoverAndRestoreCommandFocus(for: commandID) return } commandEditorCommandID = commandID iconPickerCommandID = nil endNameEditing() if recordingCustomCommandID == commandID { recordingCustomCommandID = nil } }
private func beginNameEditing(for commandID: UserCustomCommand.ID) { editingNameCommandID = commandID iconPickerCommandID = nil commandEditorCommandID = nil if recordingCustomCommandID == commandID { recordingCustomCommandID = nil } focusedNameEditorCommandID = commandID }
private func endNameEditing() { editingNameCommandID = nil focusedNameEditorCommandID = nil }
private func closePopoverAndRestoreCommandFocus(for commandID: UserCustomCommand.ID) { popoverRefocusTask?.cancel()
var transaction = Transaction() transaction.animation = nil withTransaction(transaction) { iconPickerCommandID = nil commandEditorCommandID = nil } focusCustomCommandsArea() scheduleCommandFocusRestore(for: commandID) }
private func focusCustomCommandsArea() { guard let window = NSApp.keyWindow else { return } if let customCommandsFocusAnchor, customCommandsFocusAnchor.window === window { _ = window.makeFirstResponder(customCommandsFocusAnchor) return } _ = window.makeFirstResponder(nil) }
private func scheduleCommandFocusRestore(for commandID: UserCustomCommand.ID) { popoverRefocusTask = Task { @MainActor in await Task.yield() guard !Task.isCancelled else { return } guard iconPickerCommandID == nil, commandEditorCommandID == nil else { return } guard commands.contains(where: { $0.id == commandID }) else { return }
var transaction = Transaction() transaction.animation = nil withTransaction(transaction) { selectCustomCommand(commandID) endNameEditing() } } }
private func scriptPlaceholder(for execution: UserCustomCommandExecution) -> String { switch execution { case .shellScript: return "npm test && swift test" case .terminalInput: return "pnpm test --watch" case .split: return "tail -f logs/app.log" } }
private func scriptDescription(for execution: UserCustomCommandExecution) -> String { switch execution { case .shellScript: return String(localized: "Runs in a new terminal tab.") case .terminalInput: return String(localized: "Sends input to the currently focused terminal.") case .split: return String(localized: "Runs in a new split of the focused terminal.") } }
private var resolvedCustomCommandBindings: ResolvedKeybindingMap { let effectiveCommands = commands.map { EffectiveCustomCommand(source: source, command: $0) } + globalCommands.map { EffectiveCustomCommand(source: .global, command: $0) } let migration = LegacyCustomCommandShortcutMigration.migrate(commands: effectiveCommands) return KeybindingResolver.resolve( schema: .appResolverSchema(effectiveCustomCommands: effectiveCommands), userOverrides: keybindingUserOverrides, migratedOverrides: migration.overrides ) }
private func customCommandBindingID( for commandID: String, source: CustomCommandSource? = nil ) -> String { LegacyCustomCommandShortcutMigration.customCommandBindingID(for: commandID, source: source ?? self.source) }
private func bindingForCustomCommand(id commandID: UserCustomCommand.ID) -> Binding<UserCustomCommand>? { guard commands.contains(where: { $0.id == commandID }) else { return nil }
return Binding( get: { commands.first(where: { $0.id == commandID }) ?? UserCustomCommand( id: commandID, title: "", systemImage: "terminal", command: "", execution: .shellScript, shortcut: nil ) }, set: { updatedCommand in updateCustomCommand(id: commandID) { command in command.title = updatedCommand.title command.systemImage = updatedCommand.systemImage command.command = updatedCommand.command command.execution = updatedCommand.execution command.splitDirection = updatedCommand.splitDirection command.closeOnSuccess = updatedCommand.closeOnSuccess command.shortcut = updatedCommand.shortcut command.isEnabled = updatedCommand.isEnabled } } ) }
private func syncSelectedCommandID(with commands: [UserCustomCommand]) { guard !commands.isEmpty else { selectedCustomCommandID = nil recordingCustomCommandID = nil iconPickerCommandID = nil commandEditorCommandID = nil editingNameCommandID = nil focusedNameEditorCommandID = nil return }
if let selectedCustomCommandID, commands.contains(where: { $0.id == selectedCustomCommandID }) { return }
selectedCustomCommandID = commands[0].id }
private func clearRemovedCommandState(using commands: [UserCustomCommand]) { let validIDs = Set(commands.map(\.id))
invalidMessageByCommandID = invalidMessageByCommandID.filter { validIDs.contains($0.key) }
if let recordingCustomCommandID, !validIDs.contains(recordingCustomCommandID) { self.recordingCustomCommandID = nil }
if let iconPickerCommandID, !validIDs.contains(iconPickerCommandID) { self.iconPickerCommandID = nil }
if let commandEditorCommandID, !validIDs.contains(commandEditorCommandID) { self.commandEditorCommandID = nil }
if let editingNameCommandID, !validIDs.contains(editingNameCommandID) { self.editingNameCommandID = nil focusedNameEditorCommandID = nil } }
private func addCustomCommand() { let next = UserCustomCommand.normalizedCommands(commands + [.default(index: commands.count)]) commands = next guard let commandID = next.last?.id else { selectedCustomCommandID = nil editingNameCommandID = nil focusedNameEditorCommandID = nil return } // Hold the first responder inside the editor until the row's field exists. focusCustomCommandsArea() selectedCustomCommandID = commandID editingNameCommandID = commandID iconPickerCommandID = nil commandEditorCommandID = nil recordingCustomCommandID = nil }
private func removeSelectedCustomCommand() { guard let selectedCommandID = removableCommandID else { return }
var updatedCommands = commands let removalIndex: Int? if let index = updatedCommands.firstIndex(where: { $0.id == selectedCommandID }) { removalIndex = index updatedCommands.remove(at: index) } else if !updatedCommands.isEmpty { removalIndex = updatedCommands.count - 1 updatedCommands.removeLast() } else { removalIndex = nil }
guard let removalIndex else { return }
let normalizedCommands = UserCustomCommand.normalizedCommands(updatedCommands) commands = normalizedCommands
if normalizedCommands.isEmpty { selectedCustomCommandID = nil } else if removalIndex < normalizedCommands.count { selectedCustomCommandID = normalizedCommands[removalIndex].id } else { selectedCustomCommandID = normalizedCommands[normalizedCommands.count - 1].id } clearRemovedCommandState(using: normalizedCommands) }
private func clearShortcut(for commandID: UserCustomCommand.ID) { invalidMessageByCommandID[commandID] = nil updateCustomCommand(id: commandID) { command in command.shortcut = nil } if recordingCustomCommandID == commandID { recordingCustomCommandID = nil } }
private func updateCustomCommand( id: UserCustomCommand.ID, update: (inout UserCustomCommand) -> Void ) { var updatedCommands = commands guard let index = updatedCommands.firstIndex(where: { $0.id == id }) else { return }
update(&updatedCommands[index]) commands = UserCustomCommand.normalizedCommands(updatedCommands) }
private func moveCustomCommand( _ commandID: UserCustomCommand.ID, before destinationID: UserCustomCommand.ID? = nil ) { guard commandID != destinationID else { return } var updatedCommands = commands guard let sourceIndex = updatedCommands.firstIndex(where: { $0.id == commandID }) else { return } let command = updatedCommands.remove(at: sourceIndex) let destinationIndex = destinationID.flatMap { destinationID in updatedCommands.firstIndex(where: { $0.id == destinationID }) } ?? updatedCommands.endIndex updatedCommands.insert(command, at: destinationIndex) commands = UserCustomCommand.normalizedCommands(updatedCommands) selectCustomCommand(commandID) }
private func toggleRecording(for commandID: UserCustomCommand.ID) { invalidMessageByCommandID[commandID] = nil iconPickerCommandID = nil commandEditorCommandID = nil endNameEditing()
if recordingCustomCommandID == commandID { recordingCustomCommandID = nil return }
recordingCustomCommandID = commandID }
private func startRecorderMonitor() { stopRecorderMonitor() recorderMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { event in guard let commandID = recordingCustomCommandID else { return event } handleRecorderEvent(event, commandID: commandID) return nil } }
private func stopRecorderMonitor() { if let recorderMonitor { NSEvent.removeMonitor(recorderMonitor) self.recorderMonitor = nil } }
private func handleRecorderEvent(_ event: NSEvent, commandID: UserCustomCommand.ID) { if event.keyCode == 53 { // Escape recordingCustomCommandID = nil return }
guard let keyToken = keyTokenResolver.resolveKeyToken( keyCode: event.keyCode, charactersIgnoringModifiers: event.charactersIgnoringModifiers ) else { invalidMessageByCommandID[commandID] = String( localized: "Unsupported key. Use letters, numbers, or punctuation." ) return }
let modifiers = KeybindingModifiers( command: event.modifierFlags.contains(.command), shift: event.modifierFlags.contains(.shift), option: event.modifierFlags.contains(.option), control: event.modifierFlags.contains(.control) )
guard !modifiers.isEmpty else { invalidMessageByCommandID[commandID] = String(localized: "Shortcut must include at least one modifier key.") return }
let binding = Keybinding(key: keyToken, modifiers: modifiers) guard let shortcut = binding.userCustomShortcut else { invalidMessageByCommandID[commandID] = String( localized: "Custom command shortcuts support letters, numbers, and punctuation only." ) return }
applyRecordedShortcut(shortcut.normalized(), to: commandID) }
private func applyRecordedShortcut( _ shortcut: UserCustomShortcut, to commandID: UserCustomCommand.ID ) { invalidMessageByCommandID[commandID] = nil
guard let existingCommand = firstConflictingCommand(for: commandID, shortcut: shortcut) else { updateCustomCommand(id: commandID) { command in command.shortcut = shortcut } recordingCustomCommandID = nil return }
let newTitle = commands.first(where: { $0.id == commandID })?.resolvedTitle ?? String(localized: "Command")
pendingShortcutConflict = CustomCommandShortcutConflict( newCommandID: commandID, newCommandTitle: newTitle, existingCommandID: existingCommand.id, existingCommandTitle: existingCommand.resolvedTitle, shortcutDisplay: shortcut.display ) pendingShortcut = PendingCustomShortcut(commandID: commandID, shortcut: shortcut) recordingCustomCommandID = nil }
private func firstConflictingCommand( for commandID: UserCustomCommand.ID, shortcut: UserCustomShortcut ) -> UserCustomCommand? { commands.first { command in guard command.id != commandID else { return false } guard let existingShortcut = command.shortcut?.normalized() else { return false } return existingShortcut == shortcut } }
private func applyPendingShortcut(replacingConflict: Bool) { guard let pendingShortcut else { clearPendingShortcutConflict() return }
if replacingConflict, let existingCommandID = pendingShortcutConflict?.existingCommandID { updateCustomCommand(id: existingCommandID) { command in command.shortcut = nil } }
updateCustomCommand(id: pendingShortcut.commandID) { command in command.shortcut = pendingShortcut.shortcut }
clearPendingShortcutConflict() }
private func clearPendingShortcutConflict() { pendingShortcutConflict = nil pendingShortcut = nil }
private var isShortcutConflictAlertPresented: Binding<Bool> { Binding( get: { pendingShortcutConflict != nil }, set: { shouldPresent in if !shouldPresent { clearPendingShortcutConflict() } } ) }
private var showsGlobalCommands: Bool { source == .repository && !globalCommands.isEmpty }
private var displayedCommandCount: Int { commands.count + (showsGlobalCommands ? globalCommands.count : 0) }
private var localCommandDropTarget: some View { Color.clear .frame(height: 12) .overlay { if reorderDestination == .append { reorderInsertionIndicator } } .contentShape(Rectangle()) .dropDestination( for: String.self, action: { commandIDs, _ in guard let commandID = commandIDs.first, commands.contains(where: { $0.id == commandID }) else { return false } moveCustomCommand(commandID) reorderDestination = nil return true }, isTargeted: { isTargeted in updateReorderDestination(isTargeted, destination: .append) } ) }
private var reorderInsertionIndicator: some View { Rectangle() .fill(Color.accentColor) .frame(height: 2) .padding(.horizontal, 4) .accessibilityHidden(true) }
private func updateReorderDestination(_ isTargeted: Bool, destination: ReorderDestination) { if isTargeted { reorderDestination = destination } else if reorderDestination == destination { reorderDestination = nil } }
private var customCommandsDragColumnWidth: CGFloat { 24 }
private var customCommandsEnabledColumnWidth: CGFloat { 24 }
private var customCommandsIconColumnWidth: CGFloat { 32 }
private var customCommandsNameColumnWidth: CGFloat { 130 }
private var customCommandsShortcutColumnWidth: CGFloat { 100 }
private var customCommandsListHeight: CGFloat { 200 }}