| // Copyright 2016 The Tulsi Authors. All rights reserved. | |
| // | |
| // Licensed under the Apache License, Version 2.0 (the "License"); | |
| // you may not use this file except in compliance with the License. | |
| // You may obtain a copy of the License at | |
| // | |
| // http://www.apache.org/licenses/LICENSE-2.0 | |
| // | |
| // Unless required by applicable law or agreed to in writing, software | |
| // distributed under the License is distributed on an "AS IS" BASIS, | |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| // See the License for the specific language governing permissions and | |
| // limitations under the License. | |
| import Cocoa | |
| import TulsiGenerator | |
| /// View controller for the editor that allows management of Generator Configs associated with a | |
| /// project. | |
| final class ProjectEditorConfigManagerViewController: NSViewController { | |
| /// Indices into the Add/Remove/Action SegmentedControl (as built by Interface Builder). | |
| private enum SegmentedControlButtonIndex: Int { | |
| case add = 0 | |
| case remove = 1 | |
| case action = 2 | |
| } | |
| // Context indicating that a new config should be added after a document save completes. | |
| private static var PostSaveContextAddConfig = 0 | |
| @IBOutlet var configArrayController: NSArrayController! | |
| @IBOutlet weak var addRemoveSegmentedControl: NSSegmentedControl! | |
| dynamic var numBazelPackages: Int = 0 { | |
| didSet { | |
| let enableAddButton = numBazelPackages > 0 | |
| addRemoveSegmentedControl.setEnabled(enableAddButton, | |
| forSegment: SegmentedControlButtonIndex.add.rawValue) | |
| } | |
| } | |
| dynamic var numSelectedConfigs: Int = 0 { | |
| didSet { | |
| addRemoveSegmentedControl.setEnabled(numSelectedConfigs > 0, | |
| forSegment: SegmentedControlButtonIndex.remove.rawValue) | |
| addRemoveSegmentedControl.setEnabled(numSelectedConfigs == 1, | |
| forSegment: SegmentedControlButtonIndex.action.rawValue) | |
| } | |
| } | |
| override var representedObject: Any? { | |
| didSet { | |
| if let concreteRepresentedObject = representedObject { | |
| bind("numBazelPackages", | |
| to: concreteRepresentedObject, | |
| withKeyPath: "bazelPackages.@count", | |
| options: nil) | |
| } | |
| } | |
| } | |
| deinit { | |
| unbind("numBazelPackages") | |
| unbind("numSelectedConfigs") | |
| } | |
| override func loadView() { | |
| ValueTransformer.setValueTransformer(IsOneValueTransformer(), | |
| forName: NSValueTransformerName(rawValue: "IsOneValueTransformer")) | |
| super.loadView() | |
| bind("numSelectedConfigs", | |
| to: configArrayController, | |
| withKeyPath: "selectedObjects.@count", | |
| options: nil) | |
| } | |
| @IBAction func didClickAddRemoveSegmentedControl(_ sender: NSSegmentedCell) { | |
| // Ignore mouse up messages. | |
| if sender.selectedSegment < 0 { return } | |
| guard let button = SegmentedControlButtonIndex(rawValue: sender.selectedSegment) else { | |
| assertionFailure("Unexpected add/remove button index \(sender.selectedSegment)") | |
| return | |
| } | |
| switch button { | |
| case .add: | |
| didClickAddConfig(sender) | |
| case .remove: | |
| didClickRemoveSelectedConfigs(sender) | |
| case .action: | |
| didClickAction(sender) | |
| } | |
| } | |
| @IBAction func doGenerate(_ sender: AnyObject?) { | |
| // TODO(abaire): Make it clear to the user that only the first selection is generated. | |
| guard let configName = configArrayController.selectedObjects.first as? String else { return } | |
| guard requireValidBazel({ self.doGenerate(sender) }) else { return } | |
| let generatorController = XcodeProjectGenerationProgressViewController() | |
| generatorController.representedObject = representedObject | |
| presentViewControllerAsSheet(generatorController) | |
| let projectDocument = representedObject as! TulsiProjectDocument | |
| generatorController.generateProjectForConfigName(configName) { (projectURL: URL?) in | |
| self.dismissViewController(generatorController) | |
| if let projectURL = projectURL { | |
| LogMessage.postInfo("Opening generated project in Xcode", | |
| context: projectDocument.projectName) | |
| NSWorkspace.shared().open(projectURL) | |
| } | |
| } | |
| } | |
| @IBAction func didDoubleClickConfigRow(_ sender: NSTableView) { | |
| guard requireValidBazel({ self.didDoubleClickConfigRow(sender) }) else { return } | |
| let clickedRow = sender.clickedRow | |
| guard clickedRow >= 0 else { return } | |
| let configName = (configArrayController.arrangedObjects as! [String])[clickedRow] | |
| editConfigNamed(configName) | |
| } | |
| func document(_ doc:NSDocument, didSave:Bool, contextInfo: UnsafeMutableRawPointer) { | |
| if contextInfo == &ProjectEditorConfigManagerViewController.PostSaveContextAddConfig { | |
| if didSave { | |
| didClickAddConfig(nil) | |
| } | |
| } | |
| } | |
| // MARK: - Private methods | |
| private func didClickAddConfig(_ sender: AnyObject?) { | |
| guard requireValidBazel({ self.didClickAddConfig(sender) }) else { return } | |
| let projectDocument = representedObject as! TulsiProjectDocument | |
| // Adding a config to a project with no bazel packages is disallowed. | |
| guard let bazelPackages = projectDocument.bazelPackages, !bazelPackages.isEmpty else { | |
| // This should be prevented by the UI, so spawn a bug message and beep. | |
| LogMessage.postInfo("Bug: Add config invoked on a project with no packages.") | |
| NSBeep() | |
| return | |
| } | |
| let errorInfo: String | |
| do { | |
| let additionalFilePaths = bazelPackages.map() { "\($0)/BUILD" } | |
| guard let projectName = projectDocument.projectName, | |
| let generatorConfigFolderURL = projectDocument.generatorConfigFolderURL else { | |
| projectDocument.save(withDelegate: self, | |
| didSave: #selector(ProjectEditorConfigManagerViewController.document(_:didSave:contextInfo:)), | |
| contextInfo: &ProjectEditorConfigManagerViewController.PostSaveContextAddConfig) | |
| return | |
| } | |
| let optionSet = projectDocument.optionSet ?? TulsiOptionSet() | |
| let configDocument = try TulsiGeneratorConfigDocument.makeDocumentWithProjectRuleEntries(projectDocument.ruleInfos, | |
| optionSet: optionSet, | |
| projectName: projectName, | |
| saveFolderURL: generatorConfigFolderURL, | |
| infoExtractor: projectDocument.infoExtractor, | |
| messageLog: projectDocument, | |
| additionalFilePaths: additionalFilePaths, | |
| bazelURL: projectDocument.bazelURL) | |
| projectDocument.trackChildConfigDocument(configDocument) | |
| configDocument.delegate = projectDocument | |
| configDocument.makeWindowControllers() | |
| configDocument.showWindows() | |
| return | |
| } catch let e as NSError { | |
| errorInfo = e.localizedDescription | |
| } catch { | |
| errorInfo = "Unexpected exception" | |
| } | |
| let msg = NSLocalizedString("Error_GeneralCriticalFailure", | |
| comment: "A general, critical failure without a more fitting descriptive message.") | |
| LogMessage.postError(msg, details: errorInfo, context: projectDocument.projectName) | |
| } | |
| /// Verifies that the project's Bazel URL seems valid, forcing the user to select a valid path if | |
| /// necessary and invoking the given retryHandler once they've finished selection. | |
| /// Returns true if Bazel is already valid, false if the Bazel picker is being shown. | |
| private func requireValidBazel(_ retryHandler: @escaping () -> Void) -> Bool { | |
| let projectDocument = representedObject as! TulsiProjectDocument | |
| guard let bazelURL = projectDocument.bazelURL, | |
| FileManager.default.isExecutableFile(atPath: bazelURL.path) else { | |
| BazelSelectionPanel.beginSheetModalBazelSelectionPanelForWindow(self.view.window!, | |
| document: projectDocument) { | |
| (bazelURL: URL?) in | |
| if bazelURL != nil { | |
| retryHandler() | |
| } | |
| } | |
| return false | |
| } | |
| return true | |
| } | |
| private func didClickRemoveSelectedConfigs(_ sender: AnyObject?) { | |
| let document = representedObject as! TulsiProjectDocument | |
| let selectedConfigNames = configArrayController.selectedObjects as! [String] | |
| document.deleteConfigsNamed(selectedConfigNames) | |
| } | |
| private func didClickAction(_ sender: AnyObject?) { | |
| let selectedConfigNames = configArrayController.selectedObjects as! [String] | |
| if let configName = selectedConfigNames.first { | |
| editConfigNamed(configName) | |
| } | |
| } | |
| private func editConfigNamed(_ name: String) { | |
| let projectDocument = representedObject as! TulsiProjectDocument | |
| let errorInfo: String | |
| do { | |
| let configDocument = try projectDocument.loadConfigDocumentNamed(name) { (_) in | |
| // Nothing in particular has to be done when the config doc is loaded, the editor UI already | |
| // handles this via the document's processing state. | |
| } | |
| configDocument.makeWindowControllers() | |
| configDocument.showWindows() | |
| return | |
| } catch TulsiProjectDocument.DocumentError.noSuchConfig { | |
| errorInfo = "No URL for config named '\(name)'" | |
| } catch TulsiProjectDocument.DocumentError.configLoadFailed(let info) { | |
| errorInfo = info | |
| } catch TulsiProjectDocument.DocumentError.invalidWorkspace(let info) { | |
| errorInfo = "Invalid workspace: \(info)" | |
| } catch { | |
| errorInfo = "An unexpected exception occurred while loading config named '\(name)'" | |
| } | |
| let msg = NSLocalizedString("Error_ConfigLoadFailed", | |
| comment: "Error when a TulsiGeneratorConfig failed to be reloaded.") | |
| LogMessage.postError(msg, details: errorInfo) | |
| } | |
| } | |
| /// Transformer that returns true if the value is equal to 1. | |
| final class IsOneValueTransformer : ValueTransformer { | |
| override class func transformedValueClass() -> AnyClass { | |
| return NSString.self | |
| } | |
| override class func allowsReverseTransformation() -> Bool { | |
| return false | |
| } | |
| override func transformedValue(_ value: Any?) -> Any? { | |
| if let intValue = value as? Int, intValue == 1 { | |
| return true | |
| } | |
| return false | |
| } | |
| } |