blob: b504fe45d6c6ff19e5fdf5bbc006bf6a8bf10e1e [file] [log] [blame]
// 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!
@IBOutlet var generateButton: NSButton!
@objc dynamic var numBazelPackages: Int = 0 {
didSet {
let enableAddButton = numBazelPackages > 0 && infoExtractorInitialized
addRemoveSegmentedControl.setEnabled(enableAddButton,
forSegment: SegmentedControlButtonIndex.add.rawValue)
}
}
// Whether or not the Tulsi project document is still initializing components required for
// generation.
@objc dynamic var infoExtractorInitialized: Bool = false {
didSet {
updateButtonsState()
Thread.doOnMainQueue() {
self.generateButton.title = self.infoExtractorInitialized ? "Generate" : "Initializing..."
}
}
}
@objc dynamic var numSelectedConfigs: Int = 0 {
didSet {
updateButtonsState()
}
}
override var representedObject: Any? {
didSet {
if let concreteRepresentedObject = representedObject {
bind(NSBindingName(rawValue: "numBazelPackages"),
to: concreteRepresentedObject,
withKeyPath: "bazelPackages.@count",
options: nil)
bind(NSBindingName(rawValue: "infoExtractorInitialized"),
to: concreteRepresentedObject,
withKeyPath: "infoExtractorInitialized",
options: nil)
}
}
}
deinit {
NSObject.unbind(NSBindingName(rawValue: "infoExtractorInitialized"))
NSObject.unbind(NSBindingName(rawValue: "numBazelPackages"))
NSObject.unbind(NSBindingName(rawValue: "numSelectedConfigs"))
}
override func loadView() {
ValueTransformer.setValueTransformer(IsOneValueTransformer(),
forName: NSValueTransformerName(rawValue: "IsOneValueTransformer"))
super.loadView()
bind(NSBindingName(rawValue: "numSelectedConfigs"),
to: configArrayController,
withKeyPath: "selectedObjects.@count",
options: nil)
self.generateButton.keyEquivalent = "\r"
}
// Toggle the state of the buttons depending on the current selection as well as if any required
// components are still being initialized.
func updateButtonsState() {
Thread.doOnMainQueue() {
let numSelectedConfigs = self.numSelectedConfigs
let infoExtractorInitialized = self.infoExtractorInitialized
self.addRemoveSegmentedControl.setEnabled(self.numBazelPackages > 0 && infoExtractorInitialized,
forSegment: SegmentedControlButtonIndex.add.rawValue)
self.addRemoveSegmentedControl.setEnabled(numSelectedConfigs > 0 && infoExtractorInitialized,
forSegment: SegmentedControlButtonIndex.remove.rawValue)
self.addRemoveSegmentedControl.setEnabled(numSelectedConfigs == 1 && infoExtractorInitialized,
forSegment: SegmentedControlButtonIndex.action.rawValue)
self.generateButton.isEnabled = (numSelectedConfigs == 1 && infoExtractorInitialized)
}
}
@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?) {
guard let configName = configArrayController.selectedObjects.first as? String else { return }
guard requireValidBazel({ self.doGenerate(sender) }) else { return }
let generatorController = XcodeProjectGenerationProgressViewController()
generatorController.representedObject = representedObject
presentAsSheet(generatorController)
let projectDocument = representedObject as! TulsiProjectDocument
generatorController.generateProjectForConfigName(configName) { (projectURL: URL?) in
self.dismiss(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)
}
@objc 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.")
NSSound.beep()
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)
LogMessage.displayPendingErrors()
}
/// 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)
LogMessage.displayPendingErrors()
}
}
/// 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
}
}