diff --git a/Core/Extensions/EnumerableExtensions.cs b/Core/Extensions/EnumerableExtensions.cs index 62f61ed76b..a16f780c44 100644 --- a/Core/Extensions/EnumerableExtensions.cs +++ b/Core/Extensions/EnumerableExtensions.cs @@ -87,6 +87,19 @@ public static IEnumerable ZipMany(this IEnumerable seq1, IEnumera public static IEnumerable DistinctBy(this IEnumerable seq, Func func) => seq.GroupBy(func).Select(grp => grp.First()); + /// + /// Generate a sequence from a linked list + /// + /// The first node + /// Function to go from one node to the next + /// All the nodes in the list as a sequence + public static IEnumerable TraverseNodes(this T start, Func getNext) + { + for (T t = start; t != null; t = getNext(t)) + { + yield return t; + } + } } /// diff --git a/Core/Games/IGame.cs b/Core/Games/IGame.cs index 1eb31a2849..7918826626 100644 --- a/Core/Games/IGame.cs +++ b/Core/Games/IGame.cs @@ -19,9 +19,10 @@ public interface IGame // What do we contain? string PrimaryModDirectoryRelative { get; } string PrimaryModDirectory(GameInstance inst); - string[] StockFolders { get; } - string[] ReservedPaths { get; } - string[] CreateableDirs { get; } + string[] StockFolders { get; } + string[] ReservedPaths { get; } + string[] CreateableDirs { get; } + string[] AutoRemovableDirs { get; } bool IsReservedDirectory(GameInstance inst, string path); bool AllowInstallationIn(string name, out string path); void RebuildSubdirectories(string absGameRoot); @@ -39,5 +40,4 @@ public interface IGame Uri DefaultRepositoryURL { get; } Uri RepositoryListURL { get; } } - } diff --git a/Core/Games/KerbalSpaceProgram.cs b/Core/Games/KerbalSpaceProgram.cs index 2ea2b5c660..c94961c168 100644 --- a/Core/Games/KerbalSpaceProgram.cs +++ b/Core/Games/KerbalSpaceProgram.cs @@ -119,6 +119,11 @@ public string PrimaryModDirectory(GameInstance inst) "GameData", "Tutorial", "Scenarios", "Missions", "Ships/Script" }; + public string[] AutoRemovableDirs => new string[] + { + "@thumbs" + }; + /// /// Checks the path against a list of reserved game directories /// diff --git a/Core/Games/KerbalSpaceProgram2.cs b/Core/Games/KerbalSpaceProgram2.cs index af1ac89c4b..4b063d7edb 100644 --- a/Core/Games/KerbalSpaceProgram2.cs +++ b/Core/Games/KerbalSpaceProgram2.cs @@ -121,6 +121,8 @@ public string PrimaryModDirectory(GameInstance inst) "BepInEx/plugins", }; + public string[] AutoRemovableDirs => new string[] { }; + /// /// Checks the path against a list of reserved game directories /// diff --git a/Core/ModuleInstaller.cs b/Core/ModuleInstaller.cs index 173d538415..040a8ddfdb 100644 --- a/Core/ModuleInstaller.cs +++ b/Core/ModuleInstaller.cs @@ -14,6 +14,7 @@ using CKAN.Extensions; using CKAN.Versioning; using CKAN.Configuration; +using CKAN.Games; namespace CKAN { @@ -762,7 +763,7 @@ private void Uninstall(string modName, ref HashSet possibleConfigOnlyDir } // Walk our registry to find all files for this mod. - IEnumerable files = mod.Files; + var files = mod.Files.ToArray(); // We need case insensitive path matching on Windows var directoriesToDelete = Platform.IsWindows @@ -826,7 +827,7 @@ private void Uninstall(string modName, ref HashSet possibleConfigOnlyDir // Sort our directories from longest to shortest, to make sure we remove child directories // before parents. GH #78. - foreach (string directory in directoriesToDelete.OrderBy(dir => dir.Length).Reverse()) + foreach (string directory in directoriesToDelete.OrderByDescending(dir => dir.Length)) { log.DebugFormat("Checking {0}...", directory); // It is bad if any of this directories gets removed @@ -838,16 +839,42 @@ private void Uninstall(string modName, ref HashSet possibleConfigOnlyDir continue; } - var contents = Directory - .EnumerateFileSystemEntries(directory, "*", SearchOption.AllDirectories) - .Select(f => ksp.ToRelativeGameDir(f)) - .Memoize(); - log.DebugFormat("Got contents: {0}", string.Join(", ", contents)); - var owners = contents.Select(f => registry.FileOwner(f)); - log.DebugFormat("Got owners: {0}", string.Join(", ", owners)); - if (!contents.Any()) + // See what's left in this folder and what we can do about it + GroupFilesByRemovable(ksp.ToRelativeGameDir(directory), + registry, files, ksp.game, + (Directory.Exists(directory) + ? Directory.EnumerateFileSystemEntries(directory, "*", SearchOption.AllDirectories) + : Enumerable.Empty()) + .Select(f => ksp.ToRelativeGameDir(f)), + out string[] removable, + out string[] notRemovable); + + // Delete the auto-removable files and dirs + foreach (var relPath in removable) { + var absPath = ksp.ToAbsoluteGameDir(relPath); + if (File.Exists(absPath)) + { + log.DebugFormat("Attempting transaction deletion of file {0}", absPath); + file_transaction.Delete(absPath); + } + else if (Directory.Exists(absPath)) + { + log.DebugFormat("Attempting deletion of directory {0}", absPath); + try + { + Directory.Delete(absPath); + } + catch + { + // There might be files owned by other mods, oh well + log.DebugFormat("Failed to delete {0}", absPath); + } + } + } + if (!notRemovable.Any()) + { // We *don't* use our file_transaction to delete files here, because // it fails if the system's temp directory is on a different device // to KSP. However we *can* safely delete it now we know it's empty, @@ -860,9 +887,10 @@ private void Uninstall(string modName, ref HashSet possibleConfigOnlyDir log.DebugFormat("Removing {0}", directory); Directory.Delete(directory); } - else if (contents.All(f => registry.FileOwner(f) == null)) + else if (notRemovable.All(f => registry.FileOwner(f) == null && !files.Contains(f))) { - log.DebugFormat("Directory {0} contains only non-registered files, ask user about it later", directory); + log.DebugFormat("Directory {0} contains only non-registered files, ask user about it later: {1}", + directory, string.Join(", ", notRemovable)); if (possibleConfigOnlyDirs == null) { possibleConfigOnlyDirs = new HashSet(); @@ -879,6 +907,35 @@ private void Uninstall(string modName, ref HashSet possibleConfigOnlyDir } } + internal static void GroupFilesByRemovable(string relRoot, + Registry registry, + string[] alreadyRemoving, + IGame game, + IEnumerable relPaths, + out string[] removable, + out string[] notRemovable) + { + log.DebugFormat("Getting contents of {0}", relRoot); + var contents = relPaths + // Split into auto-removable and not-removable + // Removable must not be owned by other mods + .GroupBy(f => registry.FileOwner(f) == null + // Also skip owned by this module since it's already deregistered + && !alreadyRemoving.Contains(f) + // Must have a removable dir name somewhere in path AFTER main dir + && f.Substring(relRoot.Length) + .Split('/') + .Where(piece => !string.IsNullOrEmpty(piece)) + .Any(piece => game.AutoRemovableDirs.Contains(piece))) + .ToDictionary(grp => grp.Key, + grp => grp.OrderByDescending(f => f.Length) + .ToArray()); + removable = contents.TryGetValue(true, out string[] val1) ? val1 : new string[] {}; + notRemovable = contents.TryGetValue(false, out string[] val2) ? val2 : new string[] {}; + log.DebugFormat("Got removable: {0}", string.Join(", ", removable)); + log.DebugFormat("Got notRemovable: {0}", string.Join(", ", notRemovable)); + } + /// /// Takes a collection of directories and adds all parent directories within the GameData structure. /// diff --git a/GUI/Controls/DeleteDirectories.Designer.cs b/GUI/Controls/DeleteDirectories.Designer.cs index d1c0f4982e..a4d2f84164 100644 --- a/GUI/Controls/DeleteDirectories.Designer.cs +++ b/GUI/Controls/DeleteDirectories.Designer.cs @@ -1,4 +1,4 @@ -namespace CKAN.GUI +namespace CKAN.GUI { partial class DeleteDirectories { @@ -159,6 +159,7 @@ private void InitializeComponent() this.DeleteButton.AutoSize = true; this.DeleteButton.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowOnly; this.DeleteButton.FlatStyle = System.Windows.Forms.FlatStyle.Flat; + this.DeleteButton.Font = new System.Drawing.Font(System.Drawing.SystemFonts.DefaultFont, System.Drawing.FontStyle.Bold); this.DeleteButton.Name = "DeleteButton"; this.DeleteButton.Size = new System.Drawing.Size(112, 30); this.DeleteButton.TabIndex = 4; diff --git a/GUI/Controls/DeleteDirectories.cs b/GUI/Controls/DeleteDirectories.cs index 4ca60fb9bf..6ddd6254bc 100644 --- a/GUI/Controls/DeleteDirectories.cs +++ b/GUI/Controls/DeleteDirectories.cs @@ -34,6 +34,7 @@ public void LoadDirs(GameInstance ksp, HashSet possibleConfigOnlyDirs) .ToArray(); Util.Invoke(this, () => { + DeleteButton.Focus(); DirectoriesListView.Items.Clear(); DirectoriesListView.Items.AddRange(items); DirectoriesListView.AutoResizeColumns(ColumnHeaderAutoResizeStyle.ColumnContent); @@ -79,6 +80,12 @@ public bool Wait(out HashSet toDelete) } } + protected override void OnResize(EventArgs e) + { + base.OnResize(e); + ExplanationLabel.Height = Util.LabelStringHeight(CreateGraphics(), ExplanationLabel); + } + /// /// Open the user guide when the user presses F1 /// diff --git a/GUI/Controls/DeleteDirectories.resx b/GUI/Controls/DeleteDirectories.resx index 27c463582a..44b15b106a 100644 --- a/GUI/Controls/DeleteDirectories.resx +++ b/GUI/Controls/DeleteDirectories.resx @@ -117,8 +117,7 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - The below directories are leftover after removing some mods. They contain files that were not installed by CKAN (probably either generated by a mod or manually installed). CKAN does not automatically delete files it did not install, but you can choose to remove them if it looks safe to do so (recommended). -Note that if you decide not to remove a directory, ModuleManager may incorrectly think that mod is still installed. + Warning, some folders have been left behind because CKAN does not know whether it is safe to delete their remaining files. Keeping these folders may break other mods! If you do not need these files, deleting them is recommended. Directories Directory Contents Click a directory at the left to see its contents diff --git a/GUI/Controls/EditModSearch.cs b/GUI/Controls/EditModSearch.cs index c87aad88e8..53cce5b881 100644 --- a/GUI/Controls/EditModSearch.cs +++ b/GUI/Controls/EditModSearch.cs @@ -143,7 +143,6 @@ private bool SkipDelayIf(object sender, EventArgs e) default: return false; } - break; } // Always refresh immediately on clear return string.IsNullOrEmpty(FilterCombinedTextBox.Text) diff --git a/GUI/Controls/ManageMods.cs b/GUI/Controls/ManageMods.cs index 5830e48c29..f2752deb3b 100644 --- a/GUI/Controls/ManageMods.cs +++ b/GUI/Controls/ManageMods.cs @@ -502,7 +502,7 @@ private void MarkAllUpdatesToolButton_Click(object sender, EventArgs e) private void ApplyToolButton_Click(object sender, EventArgs e) { - Main.Instance.tabController.ShowTab("ChangesetTabPage", 1); + StartChangeSet?.Invoke(currentChangeSet); } public void MarkModForUpdate(string identifier, bool value) @@ -759,7 +759,7 @@ private void ModGrid_CellMouseDoubleClick(object sender, DataGridViewCellMouseEv ModGrid.CommitEdit(DataGridViewDataErrorContexts.Commit); } - private async void ModGrid_CellValueChanged(object sender, DataGridViewCellEventArgs e) + private void ModGrid_CellValueChanged(object sender, DataGridViewCellEventArgs e) { int row_index = e.RowIndex; int column_index = e.ColumnIndex; @@ -804,10 +804,9 @@ private async void ModGrid_CellValueChanged(object sender, DataGridViewCellEvent gui_mod.SetReplaceChecked(row, ReplaceCol); break; } - await UpdateChangeSetAndConflicts( + UpdateChangeSetAndConflicts( Main.Instance.CurrentInstance, - RegistryManager.Instance(Main.Instance.CurrentInstance).registry - ); + RegistryManager.Instance(Main.Instance.CurrentInstance).registry); } } } @@ -1227,8 +1226,7 @@ private void _UpdateModsList(Dictionary old_modules = null) // Update our mod listing mainModList.ConstructModList(gui_mods, Main.Instance.CurrentInstance.Name, Main.Instance.CurrentInstance.game, ChangeSet); - // C# 7.0: Executes the task and discards it - _ = UpdateChangeSetAndConflicts(Main.Instance.CurrentInstance, registry); + UpdateChangeSetAndConflicts(Main.Instance.CurrentInstance, registry); Main.Instance.Wait.AddLogMessage(Properties.Resources.MainModListUpdatingFilters); @@ -1651,7 +1649,7 @@ public void InstanceUpdated(GameInstance inst) Conflicts = null; } - public async Task UpdateChangeSetAndConflicts(GameInstance inst, IRegistryQuerier registry) + public void UpdateChangeSetAndConflicts(GameInstance inst, IRegistryQuerier registry) { if (freezeChangeSet) { @@ -1662,7 +1660,6 @@ public async Task UpdateChangeSetAndConflicts(GameInstance inst, IRegistryQuerie List full_change_set = null; Dictionary new_conflicts = null; - bool too_many_provides_thrown = false; var user_change_set = mainModList.ComputeUserChangeSet(registry, inst.VersionCriteria()); try { diff --git a/GUI/Controls/ModInfo.cs b/GUI/Controls/ModInfo.cs index d78c675581..bc2eeb62fe 100644 --- a/GUI/Controls/ModInfo.cs +++ b/GUI/Controls/ModInfo.cs @@ -44,7 +44,7 @@ public GUIMod SelectedModule public void RefreshModContentsTree() { - Contents.Refresh(); + Contents.RefreshModContentsTree(); } public event Action OnDownloadClick; @@ -96,13 +96,10 @@ private void ModInfoTabControl_SelectedIndexChanged(object sender, EventArgs e) private GameInstanceManager manager => Main.Instance.Manager; - private int StringHeight(string text, Font font, int maxWidth) - => (int)CreateGraphics().MeasureString(text, font, maxWidth).Height; - private int TextBoxStringHeight(TextBox tb) => tb.Padding.Vertical + tb.Margin.Vertical - + StringHeight(tb.Text, tb.Font, - tb.Width - tb.Padding.Horizontal - tb.Margin.Horizontal); + + Util.StringHeight(CreateGraphics(), tb.Text, tb.Font, + tb.Width - tb.Padding.Horizontal - tb.Margin.Horizontal); private int DescriptionHeight => TextBoxStringHeight(MetadataModuleDescriptionTextBox); diff --git a/GUI/Controls/ModInfoTabs/Contents.cs b/GUI/Controls/ModInfoTabs/Contents.cs index f6d6265654..e3c0eec71e 100644 --- a/GUI/Controls/ModInfoTabs/Contents.cs +++ b/GUI/Controls/ModInfoTabs/Contents.cs @@ -29,7 +29,7 @@ public GUIMod SelectedModule get => selectedModule; } - public void Refresh() + public void RefreshModContentsTree() { if (currentModContentsModule != null) { diff --git a/GUI/Controls/ModInfoTabs/Metadata.cs b/GUI/Controls/ModInfoTabs/Metadata.cs index e6abe7ed0f..3cb6e3cb2d 100644 --- a/GUI/Controls/ModInfoTabs/Metadata.cs +++ b/GUI/Controls/ModInfoTabs/Metadata.cs @@ -139,16 +139,9 @@ private void LinkLabel_KeyDown(object sender, KeyEventArgs e) } } - private int StringHeight(string text, Font font, int maxWidth) - => (int)CreateGraphics().MeasureString(text, font, maxWidth).Height; - private int LinkLabelStringHeight(LinkLabel lb, int fitWidth) => lb.Padding.Vertical + lb.Margin.Vertical + 10 - + StringHeight(lb.Text, lb.Font, fitWidth); - - private int LabelStringHeight(Label lb) - => lb.Padding.Vertical + lb.Margin.Vertical + 10 - + StringHeight(lb.Text, lb.Font, lb.Width); + + Util.StringHeight(CreateGraphics(), lb.Text, lb.Font, fitWidth); protected override void OnResize(EventArgs e) { @@ -212,7 +205,7 @@ private void AddResourceLink(string label, Uri link) MetadataTable.RowStyles.Add( new RowStyle(SizeType.Absolute, Math.Max( // "Remote version file" wraps - LabelStringHeight(lbl), + Util.LabelStringHeight(CreateGraphics(), lbl), LinkLabelStringHeight(llbl, RightColumnWidth)))); } } @@ -229,7 +222,7 @@ private void ResizeResourceRows() { MetadataTable.RowStyles[row].Height = Math.Max( // "Remote version file" wraps - LabelStringHeight(lab), + Util.LabelStringHeight(CreateGraphics(), lab), LinkLabelStringHeight(link, rWidth)); } } diff --git a/GUI/Dialogs/ErrorDialog.cs b/GUI/Dialogs/ErrorDialog.cs index 1dbc198d86..6edfc6179c 100644 --- a/GUI/Dialogs/ErrorDialog.cs +++ b/GUI/Dialogs/ErrorDialog.cs @@ -29,9 +29,10 @@ public void ShowErrorDialog(string text, params object[] args) ClientSize.Width, Math.Min( maxHeight, - padding + StringHeight(ErrorMessage.Text, ErrorMessage.Width - 4) - ) - ); + padding + Util.StringHeight(CreateGraphics(), + ErrorMessage.Text, + ErrorMessage.Font, + ErrorMessage.Width - 4))); if (!Visible) { StartPosition = Main.Instance.actuallyVisible @@ -58,11 +59,6 @@ protected override void OnClosed(EventArgs e) ErrorMessage.Text = ""; } - private int StringHeight(string text, int maxWidth) - { - return (int)CreateGraphics().MeasureString(text, ErrorMessage.Font, maxWidth).Height; - } - private const int maxHeight = 600; private static readonly ILog log = LogManager.GetLogger(typeof(ErrorDialog)); } diff --git a/GUI/Dialogs/PreferredHostsDialog.cs b/GUI/Dialogs/PreferredHostsDialog.cs index 9c1f2e5896..1a1997ca33 100644 --- a/GUI/Dialogs/PreferredHostsDialog.cs +++ b/GUI/Dialogs/PreferredHostsDialog.cs @@ -66,7 +66,7 @@ private void PreferredHostsListBox_SelectedIndexChanged(object sender, EventArgs { var haveSelection = PreferredHostsListBox.SelectedIndex > -1; MoveLeftButton.Enabled = haveSelection - && PreferredHostsListBox.SelectedItem != placeholder; + && (string)PreferredHostsListBox.SelectedItem != placeholder; MoveUpButton.Enabled = PreferredHostsListBox.SelectedIndex > 0; MoveDownButton.Enabled = haveSelection && PreferredHostsListBox.SelectedIndex < PreferredHostsListBox.Items.Count - 1; @@ -115,7 +115,7 @@ private void MoveLeftButton_Click(object sender, EventArgs e) if (PreferredHostsListBox.SelectedIndex > -1) { var fromWhere = PreferredHostsListBox.SelectedIndex; - var selected = PreferredHostsListBox.SelectedItem; + var selected = (string)PreferredHostsListBox.SelectedItem; if (selected != placeholder) { PreferredHostsListBox.Items.Remove(selected); diff --git a/GUI/Dialogs/YesNoDialog.cs b/GUI/Dialogs/YesNoDialog.cs index 5b92ce04d6..18c35026bb 100644 --- a/GUI/Dialogs/YesNoDialog.cs +++ b/GUI/Dialogs/YesNoDialog.cs @@ -42,7 +42,7 @@ public Tuple ShowSuppressableYesNoDialog(Form parentForm, st private void Setup(string text, string yesText, string noText) { - var height = StringHeight(text, ClientSize.Width - 25) + 2 * 54; + var height = Util.StringHeight(CreateGraphics(), text, DescriptionLabel.Font, ClientSize.Width - 25) + 2 * 54; DescriptionLabel.Text = text; DescriptionLabel.TextAlign = text.Contains("\n") ? HorizontalAlignment.Left @@ -67,19 +67,6 @@ private void SetupSuppressable(string text, string yesText, string noText, strin SuppressCheckbox.Visible = true; } - /// - /// Simple syntactic sugar around Graphics.MeasureString - /// - /// String to measure size of - /// Number of pixels allowed horizontally - /// - /// Number of pixels needed vertically to fit the string - /// - private int StringHeight(string text, int maxWidth) - { - return (int)CreateGraphics().MeasureString(text, DescriptionLabel.Font, maxWidth).Height; - } - public void HideYesNoDialog() { Util.Invoke(this, Close); diff --git a/GUI/Main/Main.cs b/GUI/Main/Main.cs index 87c20b59a9..17e94ac1fa 100644 --- a/GUI/Main/Main.cs +++ b/GUI/Main/Main.cs @@ -28,17 +28,16 @@ public partial class Main : Form, IMessageFilter // Stuff we set in the constructor and never change public readonly GUIUser currentUser; - [Obsolete("Main.tabController should be private. Find a better way to access this object.")] - public readonly TabController tabController; - private readonly GameInstanceManager manager; - public GameInstanceManager Manager => manager; + public readonly GameInstanceManager Manager; public GameInstance CurrentInstance => Manager.CurrentInstance; - private string focusIdent; // Stuff we set when the game instance changes public GUIConfiguration configuration; public PluginController pluginController; + private readonly TabController tabController; + private string focusIdent; + private bool needRegistrySave = false; [Obsolete("Main.Instance is a global singleton. Find a better way to access this object.")] @@ -128,11 +127,11 @@ public Main(string[] cmdlineArgs, GameInstanceManager mgr) { // With a working GUI, assign a GUIUser to the GameInstanceManager to replace the ConsoleUser mgr.User = currentUser; - manager = mgr; + Manager = mgr; } else { - manager = new GameInstanceManager(currentUser); + Manager = new GameInstanceManager(currentUser); } tabController = new TabController(MainTabControl); @@ -148,13 +147,13 @@ protected override void OnLoad(EventArgs e) if (CurrentInstance == null) { // Maybe we can find an instance automatically (e.g., portable, only, default) - manager.GetPreferredInstance(); + Manager.GetPreferredInstance(); } // We need a config object to get the window geometry, but we don't need the registry lock yet configuration = GUIConfigForInstance( // Find the most recently used instance if no default instance - CurrentInstance ?? InstanceWithNewestGUIConfig(manager.Instances.Values)); + CurrentInstance ?? InstanceWithNewestGUIConfig(Manager.Instances.Values)); // This must happen before Shown, and it depends on the configuration SetStartPosition(); @@ -255,8 +254,8 @@ protected override void OnShown(EventArgs e) else { // Couldn't get the lock, there is no current instance - manager.CurrentInstance = null; - if (manager.Instances.Values.All(inst => !inst.Valid || inst.IsMaybeLocked)) + Manager.CurrentInstance = null; + if (Manager.Instances.Values.All(inst => !inst.Valid || inst.IsMaybeLocked)) { // Everything's invalid or locked, give up evt.Result = false; @@ -336,7 +335,7 @@ private void manageGameInstancesMenuItem_Click(object sender, EventArgs e) else { // Couldn't get the lock, revert to previous instance - manager.CurrentInstance = old_instance; + Manager.CurrentInstance = old_instance; CurrentInstanceUpdated(false); done = true; } @@ -443,7 +442,7 @@ protected override void OnFormClosed(FormClosedEventArgs e) } // Stop all running play time timers - foreach (var inst in manager.Instances.Values) + foreach (var inst in Manager.Instances.Values) { if (inst.Valid) { @@ -694,7 +693,7 @@ private void InstallFromCkanFiles(string[] files) private void CompatibleGameVersionsToolStripMenuItem_Click(object sender, EventArgs e) { CompatibleGameVersionsDialog dialog = new CompatibleGameVersionsDialog( - Instance.manager.CurrentInstance, + Instance.Manager.CurrentInstance, !actuallyVisible ); if (dialog.ShowDialog(this) != DialogResult.Cancel) @@ -839,7 +838,7 @@ private void Main_Resize(object sender, EventArgs e) private void openGameDirectoryToolStripMenuItem_Click(object sender, EventArgs e) { - Utilities.ProcessStartURL(manager.CurrentInstance.GameDir()); + Utilities.ProcessStartURL(Manager.CurrentInstance.GameDir()); } private void openGameToolStripMenuItem_Click(object sender, EventArgs e) diff --git a/GUI/Main/MainHistory.cs b/GUI/Main/MainHistory.cs index 63230612f6..d8d5ac9beb 100644 --- a/GUI/Main/MainHistory.cs +++ b/GUI/Main/MainHistory.cs @@ -14,7 +14,7 @@ public partial class Main { private void installationHistoryStripMenuItem_Click(object sender, EventArgs e) { - InstallationHistory.LoadHistory(manager.CurrentInstance, configuration); + InstallationHistory.LoadHistory(Manager.CurrentInstance, configuration); tabController.ShowTab("InstallationHistoryTabPage", 2); DisableMainWindow(); } diff --git a/GUI/Main/MainInstall.cs b/GUI/Main/MainInstall.cs index f717ebd784..0b80a4d2a9 100644 --- a/GUI/Main/MainInstall.cs +++ b/GUI/Main/MainInstall.cs @@ -60,7 +60,7 @@ private void InstallMods(object sender, DoWorkEventArgs e) bool canceled = false; var opts = (KeyValuePair) e.Argument; - RegistryManager registry_manager = RegistryManager.Instance(manager.CurrentInstance); + RegistryManager registry_manager = RegistryManager.Instance(Manager.CurrentInstance); Registry registry = registry_manager.registry; ModuleInstaller installer = new ModuleInstaller(CurrentInstance, Manager.Cache, currentUser); // Avoid accumulating multiple event handlers @@ -268,8 +268,10 @@ private void HandlePossibleConfigOnlyDirs(Registry registry, HashSet pos // Check again for registered files, since we may // just have installed or upgraded some possibleConfigOnlyDirs.RemoveWhere( - d => Directory.EnumerateFileSystemEntries(d, "*", SearchOption.AllDirectories) - .Any(f => registry.FileOwner(CurrentInstance.ToRelativeGameDir(f)) != null)); + d => !Directory.Exists(d) + || Directory.EnumerateFileSystemEntries(d, "*", SearchOption.AllDirectories) + .Select(absF => CurrentInstance.ToRelativeGameDir(absF)) + .Any(relF => registry.FileOwner(relF) != null)); if (possibleConfigOnlyDirs.Count > 0) { AddStatusMessage(""); @@ -392,11 +394,23 @@ private void PostInstallMods(object sender, RunWorkerCompletedEventArgs e) break; case TransactionalKraken exc: + // Thrown when the Registry tries to enlist with multiple different transactions // Want to see the stack trace for this one currentUser.RaiseMessage(exc.ToString()); currentUser.RaiseError(exc.ToString()); break; + case TransactionException texc: + // "Failed to roll back" is useless by itself, + // so show all inner exceptions too + foreach (var exc in texc.TraverseNodes(ex => ex.InnerException) + .Reverse()) + { + log.Error(exc.Message, exc); + currentUser.RaiseMessage(exc.Message); + } + break; + default: currentUser.RaiseMessage(e.Error.Message); break; diff --git a/GUI/Main/MainRepo.cs b/GUI/Main/MainRepo.cs index 04052b988c..59faa828e4 100644 --- a/GUI/Main/MainRepo.cs +++ b/GUI/Main/MainRepo.cs @@ -4,6 +4,7 @@ using System.Timers; using System.Linq; using System.Windows.Forms; +using System.Transactions; using Timer = System.Timers.Timer; using Newtonsoft.Json; @@ -11,6 +12,7 @@ using CKAN.Versioning; using CKAN.Configuration; +using CKAN.Extensions; // Don't warn if we use our own obsolete properties #pragma warning disable 0618 @@ -158,9 +160,23 @@ private void PostUpdateRepo(object sender, RunWorkerCompletedEventArgs e) EnableMainWindow(); break; - case Exception exc: + case TransactionException texc: + // "Failed to roll back" is useless by itself, + // so show all inner exceptions too + foreach (var exc in texc.TraverseNodes(ex => ex.InnerException) + .Reverse()) + { + log.Error(exc.Message, exc); + currentUser.RaiseMessage(exc.Message); + } AddStatusMessage(Properties.Resources.MainRepoFailed); + Wait.Finish(); + EnableMainWindow(); + break; + + case Exception exc: currentUser.RaiseMessage(exc.Message); + AddStatusMessage(Properties.Resources.MainRepoFailed); Wait.Finish(); EnableMainWindow(); break; diff --git a/GUI/Main/MainTime.cs b/GUI/Main/MainTime.cs index 0a5f42a13f..36256a6349 100644 --- a/GUI/Main/MainTime.cs +++ b/GUI/Main/MainTime.cs @@ -9,7 +9,7 @@ public partial class Main { private void viewPlayTimeStripMenuItem_Click(object sender, EventArgs e) { - PlayTime.loadAllPlayTime(manager); + PlayTime.loadAllPlayTime(Manager); tabController.ShowTab("PlayTimeTabPage", 2); DisableMainWindow(); } diff --git a/GUI/Main/MainUnmanaged.cs b/GUI/Main/MainUnmanaged.cs index 24bbdfbb7e..dc825ea02b 100644 --- a/GUI/Main/MainUnmanaged.cs +++ b/GUI/Main/MainUnmanaged.cs @@ -9,7 +9,7 @@ public partial class Main { private void viewUnmanagedFilesStripMenuItem_Click(object sender, EventArgs e) { - UnmanagedFiles.LoadFiles(manager.CurrentInstance, currentUser); + UnmanagedFiles.LoadFiles(Manager.CurrentInstance, currentUser); tabController.ShowTab("UnmanagedFilesTabPage", 2); DisableMainWindow(); } diff --git a/GUI/Model/GUIMod.cs b/GUI/Model/GUIMod.cs index 88c04068a5..6568e63eb7 100644 --- a/GUI/Model/GUIMod.cs +++ b/GUI/Model/GUIMod.cs @@ -41,11 +41,9 @@ public CkanModule SelectedMod } Main.Instance.ManageMods.MarkModForInstall(Identifier, selectedMod == null); - // C# 7.0: Executes the task and discards it - _ = Main.Instance.ManageMods.UpdateChangeSetAndConflicts( + Main.Instance.ManageMods.UpdateChangeSetAndConflicts( Main.Instance.Manager.CurrentInstance, - RegistryManager.Instance(Main.Instance.Manager.CurrentInstance).registry - ); + RegistryManager.Instance(Main.Instance.Manager.CurrentInstance).registry); OnPropertyChanged(); } diff --git a/GUI/Util.cs b/GUI/Util.cs index 72f292e2b3..cbf6512916 100644 --- a/GUI/Util.cs +++ b/GUI/Util.cs @@ -274,6 +274,36 @@ public static EventHandler Debounce( }; } + /// + /// Simple syntactic sugar around Graphics.MeasureString + /// + /// The graphics context + /// The font to be used for the text + /// String to measure size of + /// Number of pixels allowed horizontally + /// + /// Number of pixels needed vertically to fit the string + /// + public static int StringHeight(Graphics g, string text, Font font, int maxWidth) + => (int)g.MeasureString(text, font, (int)(maxWidth / XScale(g))).Height; + + /// + /// Calculate how much vertical space is needed to display a label's text + /// + /// The graphics context + /// The label + /// + /// Number of pixels needed vertically to show the label's full text + /// + public static int LabelStringHeight(Graphics g, Label lbl) + => (int)(YScale(g) * (lbl.Margin.Vertical + lbl.Padding.Vertical + + StringHeight(g, lbl.Text, lbl.Font, + (lbl.Width - lbl.Margin.Horizontal + - lbl.Padding.Horizontal)))); + + private static float XScale(Graphics g) => g.DpiX / 96f; + private static float YScale(Graphics g) => g.DpiY / 96f; + private static readonly ILog log = LogManager.GetLogger(typeof(Util)); } } diff --git a/Tests/Core/ModuleInstaller.cs b/Tests/Core/ModuleInstallerTests.cs similarity index 82% rename from Tests/Core/ModuleInstaller.cs rename to Tests/Core/ModuleInstallerTests.cs index 8d1d7cef89..51e515b3f2 100644 --- a/Tests/Core/ModuleInstaller.cs +++ b/Tests/Core/ModuleInstallerTests.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Text.RegularExpressions; using System.Transactions; + using ICSharpCode.SharpZipLib.Zip; using NUnit.Framework; @@ -16,7 +17,7 @@ namespace Tests.Core { [TestFixture] - public class ModuleInstaller + public class ModuleInstallerTests { private string flag_path; private string dogezip; @@ -607,6 +608,141 @@ public void UninstallEmptyDirs() } } + [Test, + // Empty dir + TestCase("GameData/SomeMod/Parts", + new string[] {}, + new string[] {}, + new string[] {}, + new string[] {}), + // A few regular files and some thumbnails + TestCase("GameData/SomeMod/Parts", + new string[] {}, + new string[] + { + "GameData/SomeMod/Parts/userfile.cfg", + "GameData/SomeMod/Parts/userfile2.cfg", + "GameData/SomeMod/Parts/@thumbs", + "GameData/SomeMod/Parts/@thumbs/part1.png", + "GameData/SomeMod/Parts/@thumbs/part3.png", + "GameData/SomeMod/Parts/@thumbs/part4.png", + }, + new string[] + { + "GameData/SomeMod/Parts/@thumbs/part1.png", + "GameData/SomeMod/Parts/@thumbs/part3.png", + "GameData/SomeMod/Parts/@thumbs/part4.png", + "GameData/SomeMod/Parts/@thumbs", + }, + new string[] + { + "GameData/SomeMod/Parts/userfile2.cfg", + "GameData/SomeMod/Parts/userfile.cfg", + }), + // Just regular files + TestCase("GameData/SomeMod/Parts", + new string[] {}, + new string[] + { + "GameData/SomeMod/Parts/userfile.cfg", + "GameData/SomeMod/Parts/userfile2.cfg", + }, + new string[] {}, + new string[] + { + "GameData/SomeMod/Parts/userfile2.cfg", + "GameData/SomeMod/Parts/userfile.cfg", + }), + // Just thumbnails + TestCase("GameData/SomeMod/Parts", + new string[] {}, + new string[] + { + "GameData/SomeMod/Parts/@thumbs", + "GameData/SomeMod/Parts/@thumbs/part1.png", + "GameData/SomeMod/Parts/@thumbs/part3.png", + "GameData/SomeMod/Parts/@thumbs/part4.png", + }, + new string[] + { + "GameData/SomeMod/Parts/@thumbs/part1.png", + "GameData/SomeMod/Parts/@thumbs/part3.png", + "GameData/SomeMod/Parts/@thumbs/part4.png", + "GameData/SomeMod/Parts/@thumbs", + }, + new string[] {}), + // A few regular files and some thumbnails, some of which are owned by another mod + TestCase("GameData/SomeMod/Parts", + new string[] + { + "GameData/SomeMod/Parts/userfile2.cfg", + "GameData/SomeMod/Parts/@thumbs/part1.png", + }, + new string[] + { + "GameData/SomeMod/Parts/userfile.cfg", + "GameData/SomeMod/Parts/userfile2.cfg", + "GameData/SomeMod/Parts/@thumbs", + "GameData/SomeMod/Parts/@thumbs/part1.png", + "GameData/SomeMod/Parts/@thumbs/part3.png", + "GameData/SomeMod/Parts/@thumbs/part4.png", + }, + new string[] + { + "GameData/SomeMod/Parts/@thumbs/part3.png", + "GameData/SomeMod/Parts/@thumbs/part4.png", + "GameData/SomeMod/Parts/@thumbs", + }, + new string[] + { + "GameData/SomeMod/Parts/@thumbs/part1.png", + "GameData/SomeMod/Parts/userfile2.cfg", + "GameData/SomeMod/Parts/userfile.cfg", + }), + ] + public void GroupFilesByRemovable_WithFiles_CorrectOutput(string relRoot, + string[] registeredFiles, + string[] relPaths, + string[] correctRemovable, + string[] correctNotRemovable) + { + // Arrange + using (var inst = new DisposableKSP()) + { + var game = new KerbalSpaceProgram(); + var registry = CKAN.RegistryManager.Instance(inst.KSP).registry; + // Make files to be registered to another mod + var absFiles = registeredFiles.Select(f => inst.KSP.ToAbsoluteGameDir(f)) + .ToArray(); + foreach (var absPath in absFiles) + { + Directory.CreateDirectory(Path.GetDirectoryName(absPath)); + File.Create(absPath).Dispose(); + } + // Register the other mod + registry.RegisterModule(CkanModule.FromJson(@"{ + ""spec_version"": 1, + ""identifier"": ""otherMod"", + ""version"": ""1.0"", + ""download"": ""https://github.com/"" + }"), + absFiles, inst.KSP, false); + + // Act + CKAN.ModuleInstaller.GroupFilesByRemovable(relRoot, + registry, + new string[] {}, + game, + relPaths, + out string[] removable, + out string[] notRemovable); + + // Assert + Assert.AreEqual(correctRemovable, removable); + Assert.AreEqual(correctNotRemovable, notRemovable); + } + } + [Test] public void ModuleManagerInstancesAreDecoupled() {