// MonoGame - Copyright (C) The MonoGame Team // This file is subject to the terms and conditions defined in // file 'LICENSE.txt', which is part of this source code package. using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Reflection; using System.Text; using System.Threading.Tasks; using MGCB; using PathHelper = MonoGame.Framework.Content.Pipeline.Builder.PathHelper; namespace MonoGame.Tools.Pipeline { public partial class PipelineController : IController { public static PipelineController Instance; private PipelineProject _project; private Task _buildTask; private Process _buildProcess; private readonly List _templateItems; private static readonly string [] _mgcbSearchPaths = new [] { "/Library/Frameworks/MonoGame.framework/Current/Tools", #if DEBUG "../../../../../MGCB/bin/Windows/AnyCPU/Debug", Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "../../../../../MGCB/bin/Windows/AnyCPU/Debug"), #else "../../../../../MGCB/bin/Windows/AnyCPU/Release", Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "../../../../../MGCB/bin/Windows/AnyCPU/Release"), #endif "../MGCB", Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "../MGCB"), Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "", }; public IEnumerable Templates { get { return _templateItems; } } public PipelineProject ProjectItem { get { return _project; } } public string ProjectLocation { get { var ret = _project.Location; if (!_project.Location.EndsWith(Path.DirectorySeparatorChar.ToString())) ret += Path.DirectorySeparatorChar; return ret; } } public string ProjectOutputDir { get { return _project.OutputDir; } } public List SelectedItems { get; private set; } public IProjectItem SelectedItem { get; private set; } public bool ProjectOpen { get; private set; } public bool ProjectDirty { get; set; } public bool ProjectBuilding { get { return _buildTask != null && !_buildTask.IsCompleted; } } public IView View { get; private set; } public event Action OnProjectLoading; public event Action OnProjectLoaded; private PipelineController(IView view) { Instance = this; SelectedItems = new List(); _actionStack = new ActionStack(this); View = view; View.Attach(this); ProjectOpen = false; _templateItems = new List(); var root = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); if (Directory.Exists(Path.Combine (root, "..", "Resources", "Templates"))) { root = Path.Combine(root, "..", "Resources"); } LoadTemplates(Path.Combine(root, "Templates")); UpdateMenu(); view.UpdateRecentList(PipelineSettings.Default.ProjectHistory); } public static PipelineController Create(IView view) { return new PipelineController(view); } public void OnProjectModified() { Debug.Assert(ProjectOpen, "OnProjectModified called with no project open?"); ProjectDirty = true; } public void OnReferencesModified() { Debug.Assert(ProjectOpen, "OnReferencesModified called with no project open?"); ProjectDirty = true; ResolveTypes(); View.UpdateProperties(); } public void OnItemModified(ContentItem contentItem) { Debug.Assert(ProjectOpen, "OnItemModified called with no project open?"); ProjectDirty = true; View.BeginTreeUpdate(); View.UpdateTreeItem(contentItem); View.EndTreeUpdate(); } public void NewProject() { // Make sure we give the user a chance to // save the project if they need too. if (!AskSaveProject()) return; // A project needs a root directory or it is impossible to resolve relative paths. // So we need the user to choose that location even though the project has not // yet actually been saved to disk. var projectFilePath = Environment.CurrentDirectory; if (!View.AskSaveName(ref projectFilePath, "New Project")) return; CloseProject(); if (OnProjectLoading != null) OnProjectLoading(); // Clear existing project data, initialize to a new blank project. _actionStack.Clear(); _project = new PipelineProject(); PipelineTypes.Load(_project); // Save the new project. _project.OriginalPath = projectFilePath; ProjectOpen = true; ProjectDirty = true; UpdateTree(); if (OnProjectLoaded != null) OnProjectLoaded(); UpdateMenu(); } public void ImportProject() { // Make sure we give the user a chance to // save the project if they need too. if (!AskSaveProject()) return; string projectFilePath; if (!View.AskImportProject(out projectFilePath)) return; CloseProject(); if (OnProjectLoading != null) OnProjectLoading(); #if SHIPPING try #endif { _actionStack.Clear(); _project = new PipelineProject(); var parser = new PipelineProjectParser(this, _project); parser.ImportProject(projectFilePath); ResolveTypes(); ProjectOpen = true; ProjectDirty = true; } #if SHIPPING catch (Exception e) { View.ShowError("Open Project", "Failed to open project!"); return; } #endif UpdateTree(); View.UpdateTreeItem(_project); if (OnProjectLoaded != null) OnProjectLoaded(); UpdateMenu(); } public void OpenProject() { // Make sure we give the user a chance to // save the project if they need too. if (!AskSaveProject()) return; string projectFilePath; if (!View.AskOpenProject(out projectFilePath)) return; OpenProject(projectFilePath); } public void OpenProject(string projectFilePath) { CloseProject(); if (OnProjectLoading != null) OnProjectLoading(); var errortext = "Failed to open the project due to an unknown error."; try { _actionStack.Clear(); _project = new PipelineProject(); var parser = new PipelineProjectParser(this, _project); var errorCallback = new MGBuildParser.ErrorCallback((msg, args) => { errortext = string.Format(msg, args); throw new Exception(); }); parser.OpenProject(projectFilePath, errorCallback); ResolveTypes(); ProjectOpen = true; ProjectDirty = false; PipelineSettings.Default.AddProjectHistory(projectFilePath); PipelineSettings.Default.StartupProject = projectFilePath; PipelineSettings.Default.Save(); View.UpdateRecentList(PipelineSettings.Default.ProjectHistory); } catch (Exception) { View.ShowError("Error Opening Project", Path.GetFileName(projectFilePath) + ": " + errortext); return; } UpdateTree(); View.UpdateTreeItem(_project); if (OnProjectLoaded != null) OnProjectLoaded(); UpdateMenu(); } public void ClearRecentList() { PipelineSettings.Default.ProjectHistory.Clear(); PipelineSettings.Default.Save(); View.UpdateRecentList(PipelineSettings.Default.ProjectHistory); } public void CloseProject() { if (!ProjectOpen) return; // Make sure we give the user a chance to // save the project if they need too. if (!AskSaveProject()) return; ProjectOpen = false; ProjectDirty = false; _project = null; _actionStack.Clear(); View.OutputClear(); PipelineSettings.Default.StartupProject = null; PipelineSettings.Default.Save(); UpdateTree(); UpdateMenu(); } public bool MoveProject(string newname) { string opath = _project.OriginalPath; string ext = Path.GetExtension(opath); PipelineSettings.Default.ProjectHistory.Remove(opath); try { File.Delete(_project.OriginalPath); } catch { View.ShowError("Error", "Could not delete old project file."); return false; } _project.OriginalPath = Path.GetDirectoryName(opath) + Path.DirectorySeparatorChar + newname + ext; if (!SaveProject(false)) { _project.OriginalPath = opath; SaveProject(false); View.ShowError("Error", "Could not save the new project file."); return false; } View.SetTreeRoot(_project); return true; } public bool SaveProject(bool saveAs) { // Do we need file name? if (saveAs || string.IsNullOrEmpty(_project.OriginalPath)) { string newFilePath = _project.OriginalPath; if (!View.AskSaveName(ref newFilePath, null)) return false; _project.OriginalPath = newFilePath; View.SetTreeRoot(_project); } // Do the save. ProjectDirty = false; var parser = new PipelineProjectParser(this, _project); parser.SaveProject(); // Note: This is where a project loaded via 'new project' or 'import project' // get recorded into PipelineSettings because up until this point they did not // exist as files on disk. PipelineSettings.Default.AddProjectHistory(_project.OriginalPath); PipelineSettings.Default.StartupProject = _project.OriginalPath; PipelineSettings.Default.Save(); View.UpdateRecentList(PipelineSettings.Default.ProjectHistory); UpdateMenu(); return true; } public void Build(bool rebuild) { var commands = string.Format("/@:\"{0}\" {1}", _project.OriginalPath, rebuild ? "/rebuild" : string.Empty); if (PipelineSettings.Default.DebugMode) commands += " /launchdebugger"; BuildCommand(commands); } private IEnumerable GetItems(IProjectItem dir) { foreach (var item in _project.ContentItems) if (item.OriginalPath.StartsWith(dir.OriginalPath + "/")) yield return item; } public void RebuildItems() { var items = new List(); // If the project itself was selected, just // rebuild the entire project if (items.Contains(_project)) { Build(true); return; } // Convert selected DirectoryItems into ContentItems foreach (var item in SelectedItems) { if (item is ContentItem) { if (!items.Contains(item)) items.Add(item); continue; } foreach (var subitem in GetItems(item)) if (!items.Contains(subitem)) items.Add(subitem); } // Create a unique file within the same folder as // the normal project to store this incremental build. var uniqueName = Guid.NewGuid().ToString(); var tempPath = Path.Combine(Path.GetDirectoryName(_project.OriginalPath), uniqueName); // Write the incremental project file limiting the // content to just the files we want to rebuild. using (var io = File.CreateText(tempPath)) { var parser = new PipelineProjectParser(this, _project); parser.SaveProject(io, (i) => !items.Contains(i)); } // Run the build the command. var commands = string.Format("/@:\"{0}\" /rebuild /incremental", tempPath); if (PipelineSettings.Default.DebugMode) commands += " /launchdebugger"; BuildCommand(commands); // Cleanup the temp file once we're done. _buildTask.ContinueWith((e) => File.Delete(tempPath)); } private void BuildCommand(string commands) { Debug.Assert(_buildTask == null || _buildTask.IsCompleted, "The previous build wasn't completed!"); if (ProjectDirty) SaveProject(false); View.OutputClear(); _buildTask = Task.Factory.StartNew(() => DoBuild(commands)); _buildTask.ContinueWith((e) => View.Invoke(UpdateMenu)); UpdateMenu(); } public void Clean() { Debug.Assert(_buildTask == null || _buildTask.IsCompleted, "The previous build wasn't completed!"); // Make sure we save first! if (!AskSaveProject()) return; View.OutputClear(); var commands = string.Format("/clean /intermediateDir:\"{0}\" /outputDir:\"{1}\"", _project.IntermediateDir, _project.OutputDir); if (PipelineSettings.Default.DebugMode) commands += " /launchdebugger"; _buildTask = Task.Factory.StartNew(() => DoBuild(commands)); _buildTask.ContinueWith((e) => View.Invoke(UpdateMenu)); UpdateMenu(); } private string FindMGCB() { foreach (var root in _mgcbSearchPaths) { var mgcbPath = Path.Combine(root, "MGCB.exe"); if (File.Exists(mgcbPath)) return Path.GetFullPath(mgcbPath); } throw new FileNotFoundException("MGCB.exe is not in the search path!"); } private void DoBuild(string commands) { Encoding encoding; try { encoding = Encoding.GetEncoding(CultureInfo.CurrentCulture.TextInfo.OEMCodePage); } catch (NotSupportedException) { encoding = Encoding.UTF8; } var currentDir = Environment.CurrentDirectory; try { // Prepare the process. _buildProcess = View.CreateProcess(FindMGCB(), commands); _buildProcess.StartInfo.WorkingDirectory = Path.GetDirectoryName(_project.OriginalPath); _buildProcess.StartInfo.CreateNoWindow = true; _buildProcess.StartInfo.WindowStyle = ProcessWindowStyle.Hidden; _buildProcess.StartInfo.UseShellExecute = false; _buildProcess.StartInfo.RedirectStandardOutput = true; _buildProcess.StartInfo.StandardOutputEncoding = encoding; _buildProcess.OutputDataReceived += (sender, args) => View.OutputAppend(args.Data); // Fire off the process. Environment.CurrentDirectory = _buildProcess.StartInfo.WorkingDirectory; _buildProcess.Start(); _buildProcess.BeginOutputReadLine(); _buildProcess.WaitForExit(); } catch (Exception ex) { // If we got a message assume it has everything the user needs to know. if (!string.IsNullOrEmpty(ex.Message)) View.OutputAppend("Build failed: " + ex.Message); else { // Else we need to get verbose. View.OutputAppend("Build failed:" + Environment.NewLine); View.OutputAppend(ex.ToString()); } } finally { Environment.CurrentDirectory = currentDir; } // Clear the process pointer, so that cancel // can run after we've already finished. lock (_buildTask) _buildProcess = null; } public void CancelBuild() { if (_buildTask == null || _buildTask.IsCompleted) return; lock (_buildTask) { if (_buildProcess == null) return; _buildProcess.Kill(); _buildProcess = null; View.OutputAppend("Build terminated!"); } } /// /// Prompt the user if they wish to save the project. /// Save it if yes is chosen. /// Return true if yes or no is chosen. /// Return false if cancel is chosen. /// private bool AskSaveProject() { // If the project is not dirty or open // then we can simply skip it. if (!ProjectDirty) return true; // Ask the user if they want to save or cancel. var result = View.AskSaveOrCancel(); // Did we cancel the exit? if (result == AskResult.Cancel) return false; // Did we want to skip saving? if (result == AskResult.No) return true; return SaveProject(false); } private void UpdateTree() { View.BeginTreeUpdate(); if (_project == null || string.IsNullOrEmpty(_project.OriginalPath)) View.SetTreeRoot(null); else { View.SetTreeRoot(_project); foreach (var item in _project.ContentItems) View.AddTreeItem(item); } View.EndTreeUpdate(); } public bool Exit() { // Can't exit if we're building! if (ProjectBuilding) { View.ShowMessage("You cannot exit while the project is building!"); return false; } // Make sure we give the user a chance to // save the project if they need too. var ret = AskSaveProject(); if (ret) PipelineSettings.Default.Save(); return ret; } public void DragDrop(string initialDirectory, string[] folders, string[] files) { initialDirectory = GetFullPath(initialDirectory); //IncludeFolder(initialDirectory, folders); //Include(initialDirectory, files); } private string GetCurrentPath() { if (SelectedItem is DirectoryItem) return SelectedItem.OriginalPath; if (SelectedItem is ContentItem) return SelectedItem.Location; return string.Empty; } public void Include() { var path = GetFullPath(GetCurrentPath()); List files; if (View.ChooseContentFile(path, out files)) { var items = new List(); var repeat = false; var action = IncludeType.Copy; if (!IncludeFiles(items, path, files.ToArray(), ref repeat, ref action)) return; if (items.Count == 0) return; var includeaction = new IncludeAction(items); if (includeaction.Do()) _actionStack.Add(includeaction); } } public void IncludeFolder() { var path = GetFullPath(GetCurrentPath()); string folder; if (!View.ChooseContentFolder(path, out folder)) return; var items = new List(); var repeat = false; var action = IncludeType.Copy; if (!IncludeDirectory(items, path, folder, ref repeat, ref action)) return; if (items.Count == 0) return; var includeaction = new IncludeAction(items); if (includeaction.Do()) _actionStack.Add(includeaction); } private bool IncludeDirectory(List items, string initialDirectory, string folder, ref bool repeat, ref IncludeType action) { var relative = Util.GetRelativePath(initialDirectory, ProjectLocation); if (!IncludeFiles(items, initialDirectory, Directory.GetFiles(folder), ref repeat, ref action)) return false; foreach(var dir in Directory.GetDirectories(folder)) { var dirname = Path.GetFileName(dir); var initdir = Path.Combine(initialDirectory, dirname); var diritem = new IncludeItem { SourcePath = initdir, IsDirectory = true, IncludeType = IncludeType.Create, RelativeDestPath = Path.Combine(relative, dirname) }; items.Add(diritem); if (!IncludeDirectory(items, initdir, dir, ref repeat, ref action)) return false; } return true; } private bool IncludeFiles(List items, string initialDirectory, string[] files, ref bool repeat, ref IncludeType action) { var relative = Util.GetRelativePath(initialDirectory, ProjectLocation); foreach (var file in files) { var item = new IncludeItem(); item.SourcePath = file; if (file.StartsWith(ProjectLocation)) { // If the file is in the same directory as the .mgcb file, just add it and skip showing file dialogs item.RelativeDestPath = PathHelper.GetRelativePath(ProjectLocation, file); item.IncludeType = IncludeType.Link; } else { item.RelativeDestPath = Path.Combine(relative, Path.GetFileName(file)); if (!repeat) { if (!View.CopyOrLinkFile(file, File.Exists(Path.Combine(ProjectLocation, item.RelativeDestPath)), out action, out repeat)) return false; } if (action == IncludeType.Skip) continue; if (action == IncludeType.Copy && File.Exists(Path.Combine(ProjectLocation, item.RelativeDestPath))) item.IncludeType = IncludeType.Link; else item.IncludeType = action; } items.Add(item); } return true; } private List GetFiles(string folder) { List ret = new List(); string[] directories = Directory.GetDirectories(folder); foreach (string d in directories) ret.AddRange(GetFiles(d)); ret.AddRange(Directory.GetFiles(folder)); return ret; } private List GetDirectories(string folder) { List ret = new List(); string[] directories = Directory.GetDirectories(folder); foreach (string d in directories) { ret.Add(d); ret.AddRange(GetDirectories(d)); } return ret; } public void Exclude(bool delete) { // We don't want to show a delete confirmation for any items outside the project folder var filteredItems = new List(SelectedItems.Where(i => !i.OriginalPath.Contains(".."))); if (filteredItems.Count > 0 && delete && !View.ShowDeleteDialog(filteredItems)) return; // Still need to pass all items to the Exclude action so it can remove them from the view. // Filtering is done internally so it only deletes files in the project folder var action = new ExcludeAction(this, SelectedItems, delete); if(action.Do()) _actionStack.Add(action); UpdateMenu(); } public void NewItem() { string name; ContentItemTemplate template; if (!View.ChooseItemTemplate(GetFullPath(GetCurrentPath()), out template, out name)) return; var destpath = Path.Combine(GetCurrentPath(), name); var action = new IncludeAction( new IncludeItem { SourcePath = GetFullPath(destpath), RelativeDestPath = destpath, IncludeType = IncludeType.Create, ItemTemplate = template } ); if(action.Do()) _actionStack.Add(action); } public void NewFolder() { string name; if (!View.ShowEditDialog("New Folder", "Folder Name:", "", true, out name)) return; var action = new IncludeAction(new IncludeItem { SourcePath = Path.Combine(GetFullPath(GetCurrentPath()), name), RelativeDestPath = Path.Combine(GetCurrentPath(), name), IncludeType = IncludeType.Create, IsDirectory = true }); if (action.Do()) _actionStack.Add(action); } public void Rename() { string name; if (SelectedItem == null || !View.ShowEditDialog("Rename Item", "New Name:", Path.GetFileName(SelectedItem.DestinationPath), true, out name)) return; var action = new MoveAction(SelectedItem, name); if (action.Do()) _actionStack.Add(action); } public void AddAction(IProjectAction action) { _actionStack.Add(action); if (!ProjectDirty) { ProjectDirty = true; UpdateMenu(); } } public IProjectItem GetItem(string originalPath) { if (_project.OriginalPath.Equals(originalPath, StringComparison.OrdinalIgnoreCase)) return _project; foreach (var i in _project.ContentItems) { if (string.Equals(i.OriginalPath, originalPath, StringComparison.OrdinalIgnoreCase)) { return i; } } return null; } public void CopyAssetPath() { var item = SelectedItem as ContentItem; if (item != null) { var path = item.OriginalPath; path = path.Remove(path.Length - Path.GetExtension(path).Length); path = path.Replace('\\', '/'); View.SetClipboard(path); } } #region Undo, Redo private readonly ActionStack _actionStack; public bool CanUndo { get { return _actionStack.CanUndo; } } public bool CanRedo { get { return _actionStack.CanRedo; } } public void Undo() { _actionStack.Undo(); } public void Redo() { _actionStack.Redo(); } #endregion private void ResolveTypes() { PipelineTypes.Load(_project); foreach (var i in _project.ContentItems) { i.Observer = this; i.ResolveTypes(); } View.UpdateProperties(); LoadTemplates(Path.Combine(_project.Location, "MGTemplates")); } private void LoadTemplates(string path) { if (!Directory.Exists(path)) return; var files = Directory.GetFiles(path, "*.template", SearchOption.AllDirectories); foreach (var f in files) { var lines = File.ReadAllLines(f); if (lines.Length != 5) throw new Exception("Invalid template"); var item = new ContentItemTemplate() { Label = lines[0], Icon = lines[1], ImporterName = lines[2], ProcessorName = lines[3], TemplateFile = lines[4], }; if (_templateItems.Any(i => i.Label == item.Label)) continue; var fpath = Path.GetDirectoryName(f); item.TemplateFile = Path.GetFullPath(Path.Combine(fpath, item.TemplateFile)); _templateItems.Add(item); } } public string GetFullPath(string filePath) { if (_project == null || Path.IsPathRooted(filePath)) { if (filePath.Length == 2 && filePath[0] != '/') filePath += "\\"; return filePath; } filePath = filePath.Replace("/", Path.DirectorySeparatorChar.ToString()); if (filePath.StartsWith("\\")) filePath = filePath.Substring(1); return _project.Location + Path.DirectorySeparatorChar + filePath; } public string GetRelativePath(string path) { if (!ProjectOpen) return path; var dirUri = new Uri(ProjectLocation); var fileUri = new Uri(path); var relativeUri = dirUri.MakeRelativeUri(fileUri); if (relativeUri == null) return path; return Uri.UnescapeDataString(relativeUri.ToString().Replace('/', Path.DirectorySeparatorChar)); } public void SelectionChanged(List items) { SelectedItems = items; if (items.Count < 2) { if (items.Count == 1) SelectedItem = items[0]; else SelectedItem = _project; } else SelectedItem = null; UpdateContextMenu(); View.UpdateCommands(info); View.UpdateProperties(); } MenuInfo info; public void UpdateMenu() { var notBuilding = !ProjectBuilding; var projectOpenAndNotBuilding = ProjectOpen && notBuilding; info = new MenuInfo(); info.New = notBuilding; info.Open = notBuilding; info.Import = notBuilding; info.Save = projectOpenAndNotBuilding; info.SaveAs = projectOpenAndNotBuilding; info.Close = projectOpenAndNotBuilding; info.Exit = notBuilding; info.Undo = _actionStack.CanUndo; info.Redo = _actionStack.CanRedo; info.Build = projectOpenAndNotBuilding; info.Rebuild = projectOpenAndNotBuilding; info.Clean = projectOpenAndNotBuilding; info.Cancel = ProjectBuilding; UpdateContextMenu(); View.UpdateCommands(info); } private void UpdateContextMenu() { var oneselected = SelectedItems.Count == 1; var somethingselected = SelectedItems.Count > 0; var exists = true; info.OpenItem = exists && oneselected && SelectedItem is ContentItem; info.OpenItemWith = exists && oneselected && !(SelectedItem is DirectoryItem); info.OpenItemLocation = exists && oneselected; info.OpenOutputItemLocation = exists && oneselected && SelectedItem is ContentItem; info.CopyAssetPath = exists && oneselected && SelectedItem is ContentItem; info.Add = (exists && oneselected && !(SelectedItem is ContentItem)) || !somethingselected && ProjectOpen; info.Exclude = somethingselected && !SelectedItems.Contains(_project); info.Rename = exists && oneselected && !(SelectedItem is PipelineProject); info.Delete = exists && info.Exclude; info.RebuildItem = exists && somethingselected && !ProjectBuilding; } } }