diff --git a/Prototype.playground/Contents.swift b/Prototype.playground/Contents.swift new file mode 100644 index 0000000..c4bbbc8 --- /dev/null +++ b/Prototype.playground/Contents.swift @@ -0,0 +1,104 @@ +import Cocoa + +var urlStrings = [ + "string", + "www.example.com/host", + "www.example.com/pathwith?[];/test?query=s" +] + +var urls = urlStrings + .map { + URL(string: $0) + } + +print(urls) + +let cs = URLComponents(string: urlStrings[2]) + +print(cs) + +extension CharacterSet { + var characters: Set { + (self as NSCharacterSet).characters + } +} + + +extension NSCharacterSet { + + var characters: Set { + /// An array to hold all the found characters + var characters: Set = [] + + /// Iterate over the 17 Unicode planes (0..16) + for plane:UInt8 in 0..<17 { + /// Iterating over all potential code points of each plane could be expensive as + /// there can be as many as 2^16 code points per plane. Therefore, only search + /// through a plane that has a character within the set. + if self.hasMemberInPlane(plane) { + + /// Define the lower end of the plane (i.e. U+FFFF for beginning of Plane 0) + let planeStart = UInt32(plane) << 16 + /// Define the lower end of the next plane (i.e. U+1FFFF for beginning of + /// Plane 1) + let nextPlaneStart = (UInt32(plane) + 1) << 16 + + /// Iterate over all possible UTF32 characters from the beginning of the + /// current plane until the next plane. + for char: UTF32Char in planeStart.. + + + \ No newline at end of file diff --git a/Shared/Collection+FoundationalHelpers.swift b/Shared/Collection+FoundationalHelpers.swift new file mode 100644 index 0000000..6b8973f --- /dev/null +++ b/Shared/Collection+FoundationalHelpers.swift @@ -0,0 +1,14 @@ +// +// Collection+FoundationalHelpers.swift +// XcodeUniversalSearch +// +// Created by Sam Miller on 11/3/20. +// + +import Foundation + +extension Collection { + subscript (safe index: Index) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/Shared/Configuration.swift b/Shared/Configuration.swift new file mode 100644 index 0000000..7c446a0 --- /dev/null +++ b/Shared/Configuration.swift @@ -0,0 +1,87 @@ +// +// Configuration.swift +// XcodeUniversalSearch +// +// Created by Sam Miller on 11/3/20. +// + +import Foundation + +struct Configuration: Codable { + + struct Command: Codable { + struct Options: Codable { + let shouldEscapeForRegex: Bool + let shouldEscapeDoubleQuotes: Bool + + static var `default`: Self { + .init(shouldEscapeForRegex: false, shouldEscapeDoubleQuotes: false) + } + } + + let name: String + let urlTemplate: String + let options: Options + } + + let commands: [Command] +} + +final class ConfigurationManager { + + enum Result { + case success(_ configuration: Configuration?) + case error(_ error: Error) + + var data: Configuration? { + switch self { + case .success(let configuration): return configuration + case .error(_): return nil + } + } + } + + init?() { + guard let userDefaults = UserDefaults(suiteName: "M952V223C9.group.com.pandaprograms.XcodeUniversalSearch") else { + return nil + } + self.userDefaults = userDefaults + } + + func load() -> Result { + let storedData = userDefaults.data(forKey: StorageKey.configuration.rawValue) + guard let data = storedData else { + return .success(nil) + } + + do { + return .success(try Self.decoder.decode(Configuration.self, from: data)) + } catch { + return .error(error) + } + } + + func save(_ configuration: Configuration) -> Bool { + guard let data = try? Self.encoder.encode(configuration) else { + return false + } + + userDefaults.set(data, forKey: StorageKey.configuration.rawValue) + return true + } + + func clearStorage() { + userDefaults.removeObject(forKey: StorageKey.configuration.rawValue) + } + + // MARK: - Private + + private enum StorageKey: String { + case configuration + } + + private let userDefaults: UserDefaults + + private static let decoder = JSONDecoder() + private static let encoder = JSONEncoder() +} diff --git a/Shared/String+FoundationalHelpers.swift b/Shared/String+FoundationalHelpers.swift new file mode 100644 index 0000000..0ca1da3 --- /dev/null +++ b/Shared/String+FoundationalHelpers.swift @@ -0,0 +1,16 @@ +// +// String+FoundationalHelpers.swift +// XcodeUniversalSearch +// +// Created by Sam Miller on 11/3/20. +// + +import Foundation + +extension String { + subscript (_ range: Range) -> Substring { + let start = index(startIndex, offsetBy: range.startIndex) + let end = index(startIndex, offsetBy: range.startIndex + range.count) + return self[start.. + + + + diff --git a/XcodeUniversalSearch.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/XcodeUniversalSearch.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/XcodeUniversalSearch.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/XcodeUniversalSearch/AppDelegate.swift b/XcodeUniversalSearch/AppDelegate.swift new file mode 100644 index 0000000..17d1191 --- /dev/null +++ b/XcodeUniversalSearch/AppDelegate.swift @@ -0,0 +1,39 @@ +// +// AppDelegate.swift +// XcodeUniversalSearch +// +// Created by Sam Miller on 11/1/20. +// + +import Cocoa +import SwiftUI + +@NSApplicationMain +class AppDelegate: NSObject, NSApplicationDelegate { + + var window: NSWindow! + + + func applicationDidFinishLaunching(_ aNotification: Notification) { + // Create the SwiftUI view that provides the window contents. + let contentView = ContentView() + + // Create the window and set the content view. + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 300), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.center() + window.setFrameAutosaveName("Main Window") + window.contentView = NSHostingView(rootView: contentView) + window.makeKeyAndOrderFront(nil) + } + + func applicationWillTerminate(_ aNotification: Notification) { + // Insert code here to tear down your application + } + + +} + diff --git a/XcodeUniversalSearch/Assets.xcassets/AccentColor.colorset/Contents.json b/XcodeUniversalSearch/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/XcodeUniversalSearch/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/XcodeUniversalSearch/Assets.xcassets/AppIcon.appiconset/512x512@1x.png b/XcodeUniversalSearch/Assets.xcassets/AppIcon.appiconset/512x512@1x.png new file mode 100644 index 0000000..536345a Binary files /dev/null and b/XcodeUniversalSearch/Assets.xcassets/AppIcon.appiconset/512x512@1x.png differ diff --git a/XcodeUniversalSearch/Assets.xcassets/AppIcon.appiconset/512x512@2x.png b/XcodeUniversalSearch/Assets.xcassets/AppIcon.appiconset/512x512@2x.png new file mode 100644 index 0000000..09cc5d4 Binary files /dev/null and b/XcodeUniversalSearch/Assets.xcassets/AppIcon.appiconset/512x512@2x.png differ diff --git a/XcodeUniversalSearch/Assets.xcassets/AppIcon.appiconset/Contents.json b/XcodeUniversalSearch/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2e778dd --- /dev/null +++ b/XcodeUniversalSearch/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,60 @@ +{ + "images" : [ + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "filename" : "512x512@1x.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "512x512@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/XcodeUniversalSearch/Assets.xcassets/Contents.json b/XcodeUniversalSearch/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/XcodeUniversalSearch/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/XcodeUniversalSearch/Base.lproj/Main.storyboard b/XcodeUniversalSearch/Base.lproj/Main.storyboard new file mode 100644 index 0000000..e7e404d --- /dev/null +++ b/XcodeUniversalSearch/Base.lproj/Main.storyboard @@ -0,0 +1,683 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Default + + + + + + + Left to Right + + + + + + + Right to Left + + + + + + + + + + + Default + + + + + + + Left to Right + + + + + + + Right to Left + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/XcodeUniversalSearch/CommandOptionsView.swift b/XcodeUniversalSearch/CommandOptionsView.swift new file mode 100644 index 0000000..ed92e33 --- /dev/null +++ b/XcodeUniversalSearch/CommandOptionsView.swift @@ -0,0 +1,44 @@ +// +// CommandOptionsView.swift +// XcodeUniversalSearch +// +// Created by Sam Miller on 11/3/20. +// + +import SwiftUI + + +struct CommandOptionsView: View { + + private let saveAction: ((Configuration.Command.Options) -> ()) + + @State var escapeRegex: Bool + @State var escapeDoubleQuote: Bool + + var body: some View { + VStack(alignment: .leading) { + Toggle("Escape regex metacharacters in search string (escaped with /)", isOn: $escapeRegex) + Toggle("Escape double quotes in search string (escaped with ///)", isOn: $escapeDoubleQuote) + HStack { + Spacer() + Button("Save") { + saveAction(.init(shouldEscapeForRegex: escapeRegex, shouldEscapeDoubleQuotes: escapeDoubleQuote)) + } + } + } + .padding(20) + .fixedSize() + } + + init(initialOptions: Configuration.Command.Options, saveAction: (@escaping (Configuration.Command.Options) -> ())) { + self._escapeRegex = State(initialValue: initialOptions.shouldEscapeForRegex) + self._escapeDoubleQuote = State(initialValue: initialOptions.shouldEscapeDoubleQuotes) + self.saveAction = saveAction + } +} + +struct CommandTableViewController_Previews: PreviewProvider { + static var previews: some View { + CommandOptionsView(initialOptions: .init(shouldEscapeForRegex: false, shouldEscapeDoubleQuotes: false), saveAction: { _ in }) + } +} diff --git a/XcodeUniversalSearch/CommandTable.swift b/XcodeUniversalSearch/CommandTable.swift new file mode 100644 index 0000000..7f6b7c3 --- /dev/null +++ b/XcodeUniversalSearch/CommandTable.swift @@ -0,0 +1,64 @@ +// +// CommandTable.swift +// XcodeUniversalSearch +// +// Created by Sam Miller on 11/3/20. +// + +import SwiftUI + +struct CommandTable: NSViewControllerRepresentable { + + @Binding var canRemoveRow: Bool + @Binding var removeRowAction: (() -> ()) + @Binding var addRowAction: (() -> ()) + + typealias NSViewControllerType = CommandTableViewController + + class Coordinator: CommandTableControllerViewDelegate { + var canRemoveRow: Bool { + get { + parent.canRemoveRow + } + set { + parent.canRemoveRow = newValue + } + } + + private let parent: CommandTable + + init(_ parent: CommandTable) { + self.parent = parent + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeNSViewController( + context: NSViewControllerRepresentableContext + ) -> CommandTableViewController { + + let controller = CommandTableViewController() + + controller.viewDelegate = context.coordinator + + DispatchQueue.main.async { [controller] in + self.removeRowAction = { + controller.removeSelectedRow() + } + + self.addRowAction = { + controller.addRow() + } + } + + return controller + } + + func updateNSViewController( + _ nsViewController: CommandTableViewController, + context: NSViewControllerRepresentableContext + ) {} +} diff --git a/XcodeUniversalSearch/CommandTableController.swift b/XcodeUniversalSearch/CommandTableController.swift new file mode 100644 index 0000000..66d630b --- /dev/null +++ b/XcodeUniversalSearch/CommandTableController.swift @@ -0,0 +1,204 @@ +// +// CommandTableController.swift +// XcodeUniversalSearch +// +// Created by Sam Miller on 11/3/20. +// + +import Foundation +import AppKit + +protocol CommandTableControllerViewDelegate: class { + var canRemoveRow: Bool { get set } +} + +final class CommandTableViewController: NSViewController, NSTableViewDelegate, NSTableViewDataSource, NSTextFieldDelegate { + + weak var viewDelegate: CommandTableControllerViewDelegate? + + private var commands: [Command] = [ + .init(name: "Google", urlTemplate: "https://google.com/search?q=%s"), + .init(name: "StackOverflow", urlTemplate: "https://stackoverflow.com/search?q=%s") + ] + + private lazy var tableScrollView = NSScrollView() + private lazy var tableView = NSTableView() + + override func loadView() { + view = tableScrollView + } + + override func viewDidLoad() { + super.viewDidLoad() + + tableScrollView.documentView = tableView + + tableView.delegate = self + tableView.dataSource = self + + tableView.headerView?.isHidden = false + tableView.allowsMultipleSelection = true + + let nameColumn = NSTableColumn(identifier: .name) + nameColumn.minWidth = 150 + nameColumn.headerCell = NSTableHeaderCell(textCell: "Command name") + tableView.addTableColumn(nameColumn) + + let urlTemplateColumn = NSTableColumn(identifier: .urlTemplate) + urlTemplateColumn.minWidth = 200 + urlTemplateColumn.headerCell = NSTableHeaderCell(textCell: "URL Template") + tableView.addTableColumn(urlTemplateColumn) + + + tableView.doubleAction = #selector(handleDoubleTap(tableView:)) + } + + @objc func handleDoubleTap(tableView: NSTableView) { + print("Double click on \(tableView.clickedRow), \(tableView.clickedColumn)" ) + + if (tableView.clickedRow, tableView.clickedColumn) == (-1, -1) { + print("Double click on whitespace to add new row") + addRow() + } else { + let rowView = tableView.rowView(atRow: tableView.clickedRow, makeIfNecessary: false) + let cellView = rowView?.view(atColumn: tableView.clickedColumn) + + if let textField = (cellView as? NSTableCellView)?.textField, + textField.acceptsFirstResponder { + textField.isEditable = true + NSApp.keyWindow?.makeFirstResponder(textField) + } + } + } + + func refresh() { + tableView.reloadData() + } + + func removeSelectedRow() { + guard !tableView.selectedRowIndexes.isEmpty else { return } + + for index in tableView.selectedRowIndexes.sorted().reversed() { + commands.remove(at: index) + } + tableView.removeRows(at: tableView.selectedRowIndexes, withAnimation: .effectGap) + } + + func addRow() { + commands.append(Command(name: "Custom Command", urlTemplate: "https://example.com/search?q=%s")) + tableView.insertRows(at: IndexSet(integer: commands.count-1), withAnimation: .effectGap) + DispatchQueue.main.async { + let rowView = self.tableView.rowView(atRow: self.commands.count-1, makeIfNecessary: true) + let cellView = rowView?.view(atColumn: 0) + + if let textField = (cellView as? NSTableCellView)?.textField, + textField.acceptsFirstResponder { + textField.isEditable = true + NSApp.keyWindow?.makeFirstResponder(textField) + } + } + } + + override func viewDidAppear() { + super.viewDidAppear() + + tableView.reloadData() + } + + // MARK: - NSControlTextEditingDelegate + + func controlTextDidEndEditing(_ obj: Notification) { + guard let textField = obj.object as? NSTextField else { return } + + let col = tableView.column(for: textField) + let row = tableView.row(for: textField) + + + let command = commands[row] + let newCommand: Command? + switch tableView.tableColumns[col].identifier { + case .name: + newCommand = Command(name: textField.stringValue, urlTemplate: command.urlTemplate) + case .urlTemplate: + newCommand = Command(name: command.name, urlTemplate: textField.stringValue) + default: + newCommand = nil + } + + if let newCommand = newCommand { + commands.remove(at: row) + commands.insert(newCommand, at: row) + } else { + print("ERROR: could not create new command, could not match column identifiers") + } + } + + + // MARK: - NSTableViewDataSource + + func numberOfRows(in tableView: NSTableView) -> Int { + commands.count + } + + func tableView(_ tableView: NSTableView, setObjectValue object: Any?, for tableColumn: NSTableColumn?, row: Int) { + print(String(describing: object)) + } + + // MARK: - NSTableViewDelegate + + func tableViewSelectionDidChange(_ notification: Notification) { + print(tableView.selectedRowIndexes) + viewDelegate?.canRemoveRow = !tableView.selectedRowIndexes.isEmpty + } + + func tableView(_ tableView: NSTableView, shouldEdit tableColumn: NSTableColumn?, row: Int) -> Bool { + true + } + + + func tableView(_ tableView: NSTableView, shouldReorderColumn columnIndex: Int, toColumn newColumnIndex: Int) -> Bool { + true + } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + let rowView: NSTableCellView? = (tableView.makeView(withIdentifier: .text, owner: self) as? NSTableCellView) ?? { + // Create a text field for the cell + let textField = NSTextField() + textField.backgroundColor = .clear + textField.translatesAutoresizingMaskIntoConstraints = false + textField.isBordered = false + textField.controlSize = .small + textField.delegate = self + + // return textField + + // Create a cell + let newCell = NSTableCellView() + newCell.identifier = .text + newCell.addSubview(textField) + newCell.textField = textField + newCell.addConstraint(.init(item: textField, attribute: .height, relatedBy: .equal, toItem: newCell, attribute: .height, multiplier: 1.0, constant: 0.0)) + newCell.addConstraint(.init(item: textField, attribute: .width, relatedBy: .equal, toItem: newCell, attribute: .width, multiplier: 1.0, constant: 0.0)) + + newCell.constraints.forEach { $0.isActive = true } + + return newCell + }() + + let value: String? + + let command = commands[row] + switch tableColumn?.identifier { + case .name?: + value = command.name + case .urlTemplate?: + value = command.urlTemplate + default: + value = nil + } + + rowView?.textField?.stringValue = value ?? "" + + return rowView + } +} diff --git a/XcodeUniversalSearch/CommandTableViewController.swift b/XcodeUniversalSearch/CommandTableViewController.swift new file mode 100644 index 0000000..4dd33e5 --- /dev/null +++ b/XcodeUniversalSearch/CommandTableViewController.swift @@ -0,0 +1,306 @@ +// +// CommandTableController.swift +// XcodeUniversalSearch +// +// Created by Sam Miller on 11/3/20. +// + +import Foundation +import AppKit +import SwiftUI + +/** + TODO: Follow https://samwize.com/2018/11/27/drag-and-drop-to-reorder-nstableview/ to enable re-ordering rows + */ + +protocol CommandTableControllerViewDelegate: class { + var canRemoveRow: Bool { get set } +} + +final class CommandTableViewController: NSViewController, NSTableViewDelegate, NSTableViewDataSource, NSTextFieldDelegate { + + weak var viewDelegate: CommandTableControllerViewDelegate? + + func refresh() { + tableView.reloadData() + } + + func removeSelectedRow() { + guard !tableView.selectedRowIndexes.isEmpty else { return } + + for index in tableView.selectedRowIndexes.sorted().reversed() { + commands.remove(at: index) + } + tableView.removeRows(at: tableView.selectedRowIndexes, withAnimation: .effectGap) + synchronizeCommands() + } + + func addRow() { + let newIndex = commands.count + commands.append(.init(name: "Custom Command", urlTemplate: "https://example.com/search?q=%s", options: .default)) + tableView.insertRows(at: IndexSet(integer: newIndex), withAnimation: .effectGap) + makeTextFieldFirstResponder(atRow: newIndex, column: 0) + synchronizeCommands() + } + + // MARK: - NSViewController + + override func loadView() { + view = tableScrollView + } + + override func viewDidLoad() { + super.viewDidLoad() + + tableScrollView.documentView = tableView + + tableView.delegate = self + tableView.dataSource = self + + tableView.headerView?.isHidden = false + tableView.allowsMultipleSelection = true + + let nameColumn = NSTableColumn(identifier: .name) + nameColumn.minWidth = 150 + nameColumn.headerCell = NSTableHeaderCell(textCell: "Command name") + tableView.addTableColumn(nameColumn) + + let urlTemplateColumn = NSTableColumn(identifier: .urlTemplate) + urlTemplateColumn.minWidth = 250 + urlTemplateColumn.headerCell = NSTableHeaderCell(textCell: "URL Template") + tableView.addTableColumn(urlTemplateColumn) + + let optionsColumn = NSTableColumn(identifier: .options) + optionsColumn.minWidth = 80 + optionsColumn.headerCell = NSTableHeaderCell(textCell: "Options") + tableView.addTableColumn(optionsColumn) + + tableView.doubleAction = #selector(handleDoubleTap(tableView:)) + } + + override func viewWillAppear() { + super.viewWillAppear() + + loadFromConfiguration() + } + + override func viewDidAppear() { + super.viewDidAppear() + + tableView.reloadData() + } + + // MARK: - NSControlTextEditingDelegate + + func controlTextDidEndEditing(_ obj: Notification) { + guard let textField = obj.object as? NSTextField else { return } + + let col = tableView.column(for: textField) + let row = tableView.row(for: textField) + + + let command = commands[row] + let newCommand: Configuration.Command? + switch tableView.tableColumns[col].identifier { + case .name: + newCommand = .init(name: textField.stringValue, urlTemplate: command.urlTemplate, options: command.options) + case .urlTemplate: + newCommand = .init(name: command.name, urlTemplate: textField.stringValue, options: command.options) + default: + newCommand = nil + } + + if let newCommand = newCommand { + commands.remove(at: row) + commands.insert(newCommand, at: row) + } else { + print("ERROR: could not create new command, could not match column identifiers") + } + synchronizeCommands() + } + + + // MARK: - NSTableViewDataSource + + func numberOfRows(in tableView: NSTableView) -> Int { + commands.count + } + + // MARK: - NSTableViewDelegate + + func tableViewSelectionDidChange(_ notification: Notification) { + viewDelegate?.canRemoveRow = !tableView.selectedRowIndexes.isEmpty + } + + func tableView(_ tableView: NSTableView, shouldEdit tableColumn: NSTableColumn?, row: Int) -> Bool { + true + } + + + func tableView(_ tableView: NSTableView, shouldReorderColumn columnIndex: Int, toColumn newColumnIndex: Int) -> Bool { + true + } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + + enum CellType { + case text(_ value: String?) + case button + } + + let cellType: CellType? + + let command = commands[row] + switch tableColumn?.identifier { + case .name?: + cellType = .text(command.name) + case .urlTemplate?: + cellType = .text(command.urlTemplate) + case .options?: + cellType = .button + default: + cellType = nil + } + + switch cellType { + case .text(let value)?: + let cellView: NSTableCellView? = (tableView.makeView(withIdentifier: .text, owner: self) as? NSTableCellView) ?? { + // Create a text field for the cell + let textField = NSTextField() + textField.backgroundColor = .clear + textField.translatesAutoresizingMaskIntoConstraints = false + textField.isBordered = false + textField.controlSize = .small + textField.delegate = self + + // Create a cell + let newCell = NSTableCellView() + newCell.identifier = .text + newCell.addSubview(textField) + newCell.textField = textField + newCell.widthAnchor.constraint(equalTo: textField.widthAnchor).isActive = true + newCell.heightAnchor.constraint(equalTo: textField.heightAnchor).isActive = true + + return newCell + }() + + cellView?.textField?.stringValue = value ?? "" + + return cellView + case .button?: + let view: NSButton = (tableView.makeView(withIdentifier: .optionsButton, owner: self) as? NSButton) ?? { + let button = NSButton(title: "Options", target: nil, action: nil) + button.target = self + button.action = #selector(handleOptionsButton(sender:)) + button.identifier = .optionsButton + + return button + }() + + return view + default: + return nil + } + } + + func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { + 30 + } + + // MARK: - Private + + private var commands: [Configuration.Command] = [] + private let configurationManager = ConfigurationManager() + + private lazy var tableScrollView = NSScrollView() + private lazy var tableView = NSTableView() + + + @objc private func handleDoubleTap(tableView: NSTableView) { + if (tableView.clickedRow, tableView.clickedColumn) == (-1, -1) { + addRow() + } else { + makeTextFieldFirstResponder(atRow: tableView.clickedRow, column: tableView.clickedColumn) + } + } + + @objc private func handleOptionsButton(sender: NSButton) { + let row = tableView.row(for: sender) + + let vc = NSHostingController(rootView: CommandOptionsView(initialOptions: commands[row].options, saveAction: { [weak self] options in + guard let strongSelf = self else { return } + + let oldCommand = strongSelf.commands.remove(at: row) + + let newCommand = Configuration.Command(name: oldCommand.name, + urlTemplate: oldCommand.urlTemplate, + options: options) + strongSelf.commands.insert(newCommand, at: row) + strongSelf.synchronizeCommands() + + if let vc = strongSelf.presentedViewControllers?.first { + strongSelf.dismiss(vc) + } + })) + + vc.title = "Command Options" + + presentAsModalWindow(vc) + } + + private func makeTextFieldFirstResponder(atRow row: Int, column: Int) { + guard let view = tableView.rowView(atRow: row, makeIfNecessary: false)?.view(atColumn: column) else { + // TODO: Throw a better error + print("ERROR: Unable to retrieve view at (\(row), \(column)) to start editing") + return + } + + if let textField = (view as? NSTableCellView)?.textField, + textField.acceptsFirstResponder { + textField.window?.makeFirstResponder(textField) + } + } + + private func loadFromConfiguration() { + guard let configurationManager = configurationManager else { + print("ERROR: Cannot load data - Failed initializing storage") + return + } + + let result = configurationManager.load() + + switch result { + case .error(let error): + // TODO: Add a much better error and show in UI + print("ERROR: Encountered error loading configuration: \(error)") + case .success(let configuration): + if let config = configuration { + commands = config.commands + } else { + // This is a first launch, no configuration saved before - add defaults + commands = [ + .init(name: "Google", urlTemplate: "https://google.com/search?q=%s", options: .default), + .init(name: "StackOverflow", urlTemplate: "https://stackoverflow.com/search?q=%s", options: .default), + .init(name: "Apple Documentation", urlTemplate: "https://developer.apple.com/search?q=%s", options: .default) + ] + synchronizeCommands() + } + } + } + + private func synchronizeCommands() { + if configurationManager?.save(.init(commands: commands)) != true { + // TODO: Handle error properly and show in UI + print("ERROR: Saving commands failed") + } + } +} + +private extension NSUserInterfaceItemIdentifier { + static var name: Self { .init("name") } + static var urlTemplate: Self { .init("urlTemplate") } + static var options: Self { .init("options") } + + static var text: Self { .init("text") } + static var optionsButton: Self { .init("optionsButton") } +} diff --git a/XcodeUniversalSearch/ContentView.swift b/XcodeUniversalSearch/ContentView.swift new file mode 100644 index 0000000..5b3ab8d --- /dev/null +++ b/XcodeUniversalSearch/ContentView.swift @@ -0,0 +1,53 @@ +// +// ContentView.swift +// XcodeUniversalSearch +// +// Created by Sam Miller on 11/1/20. +// + +import SwiftUI +import Foundation +import AppKit + +struct ContentView: View { + + @State var canRemoveRow: Bool = false + @State var removeRowAction: (() -> ()) = { fatalError("Action needs to be set before being executed") } + @State var addRowAction: (() -> ()) = { fatalError("Action needs to be set before being executed") } + + var body: some View { + VStack { + CommandTable(canRemoveRow: $canRemoveRow, + removeRowAction: $removeRowAction, + addRowAction: $addRowAction) + HStack { + Button("Clear storage") { + configurationManager?.clearStorage() + } + Spacer() + Button("Delete", action: removeRowAction) + .disabled(!canRemoveRow) + Button("Add Command", action: addRowAction) + } + } + .padding(20) + .frame(minWidth: 600, + idealWidth: 800, + maxWidth: .infinity, + minHeight: 400, + idealHeight: 400, + maxHeight: .infinity, + alignment: .center) + } + + // MARK: - Private + + private let configurationManager = ConfigurationManager() +} + + +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContentView() + } +} diff --git a/XcodeUniversalSearch/Info.plist b/XcodeUniversalSearch/Info.plist new file mode 100644 index 0000000..cfbbdb7 --- /dev/null +++ b/XcodeUniversalSearch/Info.plist @@ -0,0 +1,30 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSMainStoryboardFile + Main + NSPrincipalClass + NSApplication + + diff --git a/XcodeUniversalSearch/Preview Content/Preview Assets.xcassets/Contents.json b/XcodeUniversalSearch/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/XcodeUniversalSearch/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/XcodeUniversalSearch/XcodeUniversalSearch.entitlements b/XcodeUniversalSearch/XcodeUniversalSearch.entitlements new file mode 100644 index 0000000..42cee69 --- /dev/null +++ b/XcodeUniversalSearch/XcodeUniversalSearch.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + $(TeamIdentifierPrefix)group.com.pandaprograms.XcodeUniversalSearch + + com.apple.security.files.user-selected.read-only + + + diff --git a/XcodeUniversalSearchExtension/Info.plist b/XcodeUniversalSearchExtension/Info.plist new file mode 100644 index 0000000..a7d50a5 --- /dev/null +++ b/XcodeUniversalSearchExtension/Info.plist @@ -0,0 +1,47 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + XcodeUniversalSearchExtension + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSExtension + + NSExtensionAttributes + + XCSourceEditorCommandDefinitions + + + XCSourceEditorCommandClassName + $(PRODUCT_MODULE_NAME).SourceEditorCommand + XCSourceEditorCommandIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER).SourceEditorCommand + XCSourceEditorCommandName + Source Editor Command + + + XCSourceEditorExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).SourceEditorExtension + + NSExtensionPointIdentifier + com.apple.dt.Xcode.extension.source-editor + + + diff --git a/XcodeUniversalSearchExtension/SourceEditorCommand.swift b/XcodeUniversalSearchExtension/SourceEditorCommand.swift new file mode 100644 index 0000000..b4a3ed0 --- /dev/null +++ b/XcodeUniversalSearchExtension/SourceEditorCommand.swift @@ -0,0 +1,88 @@ +// +// SourceEditorCommand.swift +// XcodeUniversalSearchExtension +// +// Created by Sam Miller on 11/1/20. +// + +import Foundation +import XcodeKit +import AppKit + +final class SourceEditorCommand: NSObject, XCSourceEditorCommand { + + func perform(with invocation: XCSourceEditorCommandInvocation, completionHandler: @escaping (Error?) -> Void ) -> Void { + print("Handling command with id: \(invocation.commandIdentifier)") + + guard let configurationManager = configurationManager else { + completionHandler(NSError(domain: "XcodeUniversalSearch", code: 0, userInfo: [NSLocalizedDescriptionKey: "Internal application error: Unable to initialize configuration storage"])) + return + } + + let result = configurationManager.load() + + let configuration: Configuration? + switch result { + case .success(let config): + configuration = config + case .error(let error): + completionHandler(NSError(domain: "XcodeUniversalSearch", code: 0, userInfo: [NSLocalizedDescriptionKey: "Internal application error: Failed to load configuration with error - \(error.localizedDescription)"])) + return + } + + guard let commandIndexString = invocation.commandIdentifier.split(separator: ".").last, + let commandIndex = Int(commandIndexString), + let command = configuration?.commands[safe: commandIndex] else { + completionHandler(NSError(domain: "XcodeUniversalSearch", code: 0, userInfo: [NSLocalizedDescriptionKey: "Internal application error: Extension failed to load correctly"])) + return + } + + // TODO: Handle selection accross multiple lines + guard let selection = invocation.buffer.selections.firstObject as? XCSourceTextRange, + let line = invocation.buffer.lines[selection.start.line] as? String else { + completionHandler(NSError(domain: "XcodeUniversalSearch", code: 0, userInfo: [NSLocalizedDescriptionKey: "Error processing the text selection"])) + return + } + + let selectedText = String(line[selection.start.column ..< selection.end.column]) + + guard let urlString = command.urlTemplate + .replacingOccurrences(of: "%s", with: processText(selectedText, with: command.options)) + // Note that this is not exactly escaping correctly escaping all characters since we are escaping the full url string for characters + // not accepted in the url query string. Really each url component should be escaped individually. So if any issues occur with the urls + // there is good chance this is an issue. + .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) + else { + completionHandler(NSError(domain: "XcodeUniversalSearch", code: 0, userInfo: [NSLocalizedDescriptionKey: "Error escaping url with selection"])) + return + } + + guard let url = URL(string: urlString) else { + completionHandler(NSError(domain: "XcodeUniversalSearch", code: 0, userInfo: [NSLocalizedDescriptionKey: "Error creating url"])) + return + } + + NSWorkspace.shared.open(url) + + completionHandler(nil) + } + + // MARK: - Private + + private let configurationManager = ConfigurationManager() + + private func processText(_ text: String, with options: Configuration.Command.Options) -> String { + + var result = text + + if options.shouldEscapeForRegex { + result = NSRegularExpression.escapedPattern(for: result) + } + + if options.shouldEscapeDoubleQuotes { + result = result.replacingOccurrences(of: "\"", with: "\\\"") + } + + return result + } +} diff --git a/XcodeUniversalSearchExtension/SourceEditorExtension.swift b/XcodeUniversalSearchExtension/SourceEditorExtension.swift new file mode 100644 index 0000000..96f28ca --- /dev/null +++ b/XcodeUniversalSearchExtension/SourceEditorExtension.swift @@ -0,0 +1,43 @@ +// +// SourceEditorExtension.swift +// XcodeUniversalSearchExtension +// +// Created by Sam Miller on 11/1/20. +// + +import Foundation +import XcodeKit + +class SourceEditorExtension: NSObject, XCSourceEditorExtension { + + func extensionDidFinishLaunching() {} + + var commandDefinitions: [[XCSourceEditorCommandDefinitionKey: Any]] { + guard let config = configurationManager?.load().data else { + return [ + [ + .classNameKey: SourceEditorCommand.className(), + .nameKey: "-- Internal extension error loading from storage --", + .identifierKey: [Bundle.main.bundleIdentifier, "\(-1)"] + .compactMap { $0 } + .joined(separator: "."), + ] + ] + } + + return config.commands + .enumerated() + .map { idx, command in + [ + .classNameKey: SourceEditorCommand.className(), + .nameKey: command.name, + .identifierKey: [Bundle.main.bundleIdentifier, "\(idx)"].compactMap { $0 }.joined(separator: ".") + ] + } + + } + + // MARK: - Private + + private let configurationManager = ConfigurationManager() +} diff --git a/XcodeUniversalSearchExtension/XcodeUniversalSearchExtension.entitlements b/XcodeUniversalSearchExtension/XcodeUniversalSearchExtension.entitlements new file mode 100644 index 0000000..e1e954e --- /dev/null +++ b/XcodeUniversalSearchExtension/XcodeUniversalSearchExtension.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + $(TeamIdentifierPrefix)group.com.pandaprograms.XcodeUniversalSearch + + + diff --git a/XcodeUniversalSearchTests/Info.plist b/XcodeUniversalSearchTests/Info.plist new file mode 100644 index 0000000..64d65ca --- /dev/null +++ b/XcodeUniversalSearchTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/XcodeUniversalSearchTests/XcodeUniversalSearchTests.swift b/XcodeUniversalSearchTests/XcodeUniversalSearchTests.swift new file mode 100644 index 0000000..8aac7e5 --- /dev/null +++ b/XcodeUniversalSearchTests/XcodeUniversalSearchTests.swift @@ -0,0 +1,33 @@ +// +// XcodeUniversalSearchTests.swift +// XcodeUniversalSearchTests +// +// Created by Sam Miller on 11/1/20. +// + +import XCTest +@testable import XcodeUniversalSearch + +class XcodeUniversalSearchTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + func testPerformanceExample() throws { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } + +} diff --git a/XcodeUniversalSearchUITests/Info.plist b/XcodeUniversalSearchUITests/Info.plist new file mode 100644 index 0000000..64d65ca --- /dev/null +++ b/XcodeUniversalSearchUITests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/XcodeUniversalSearchUITests/XcodeUniversalSearchUITests.swift b/XcodeUniversalSearchUITests/XcodeUniversalSearchUITests.swift new file mode 100644 index 0000000..db0050d --- /dev/null +++ b/XcodeUniversalSearchUITests/XcodeUniversalSearchUITests.swift @@ -0,0 +1,42 @@ +// +// XcodeUniversalSearchUITests.swift +// XcodeUniversalSearchUITests +// +// Created by Sam Miller on 11/1/20. +// + +import XCTest + +class XcodeUniversalSearchUITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use recording to get started writing UI tests. + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + func testLaunchPerformance() throws { + if #available(macOS 10.15, iOS 13.0, tvOS 13.0, *) { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } + } +}