• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

KSP-CKAN / CKAN / 15432034930

04 Jun 2025 02:19AM UTC coverage: 30.322% (+0.04%) from 30.28%
15432034930

Pull #4386

github

web-flow
Merge f8b59bcd0 into 4cf303cc8
Pull Request #4386: Mod list multi-select

4063 of 14340 branches covered (28.33%)

Branch coverage included in aggregate %.

55 of 170 new or added lines in 12 files covered. (32.35%)

59 existing lines in 5 files now uncovered.

13712 of 44281 relevant lines covered (30.97%)

0.63 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/GUI/Controls/ManageMods.cs
1
using System;
2
using System.Linq;
3
using System.Drawing;
4
using System.Collections.Generic;
5
using System.Windows.Forms;
6
using System.ComponentModel;
7
#if NET5_0_OR_GREATER
8
using System.Runtime.Versioning;
9
#endif
10

11
using Autofac;
12
using log4net;
13

14
using CKAN.Configuration;
15
using CKAN.IO;
16
using CKAN.Extensions;
17
using CKAN.GUI.Attributes;
18

19
namespace CKAN.GUI
20
{
21
    #if NET5_0_OR_GREATER
22
    [SupportedOSPlatform("windows")]
23
    #endif
24
    public partial class ManageMods : UserControl, ISearchableControl
25
    {
26
        public ManageMods()
×
27
        {
×
28
            InitializeComponent();
×
29

30
            ToolTip.SetToolTip(InstallAllCheckbox, Properties.Resources.ManageModsInstallAllCheckboxTooltip);
×
31
            FilterCompatibleButton.ToolTipText      = Properties.Resources.FilterLinkToolTip;
×
32
            FilterInstalledButton.ToolTipText       = Properties.Resources.FilterLinkToolTip;
×
33
            FilterInstalledUpdateButton.ToolTipText = Properties.Resources.FilterLinkToolTip;
×
34
            FilterReplaceableButton.ToolTipText     = Properties.Resources.FilterLinkToolTip;
×
35
            FilterCachedButton.ToolTipText          = Properties.Resources.FilterLinkToolTip;
×
36
            FilterUncachedButton.ToolTipText        = Properties.Resources.FilterLinkToolTip;
×
37
            FilterNewButton.ToolTipText             = Properties.Resources.FilterLinkToolTip;
×
38
            FilterNotInstalledButton.ToolTipText    = Properties.Resources.FilterLinkToolTip;
×
39
            FilterIncompatibleButton.ToolTipText    = Properties.Resources.FilterLinkToolTip;
×
40

41
            mainModList = new ModList();
×
42
            mainModList.ModFiltersUpdated += UpdateFilters;
×
43
            FilterToolButton.MouseHover += (sender, args) => FilterToolButton.ShowDropDown();
×
44
            ApplyToolButton.MouseHover += (sender, args) => ApplyToolButton.ShowDropDown();
×
45
            ApplyToolButton.Enabled = false;
×
NEW
46
            ModGrid.SelectionChanged += new EventHandler(
×
NEW
47
                                            Util.Debounce<EventArgs>((sender, e) => {},
×
NEW
48
                                                                     (sender, e) => false,
×
NEW
49
                                                                     (sender, e) => false,
×
50
                                                                     ModGrid_SelectionChanged,
51
                                                                     100));
52

53
            repoData = ServiceLocator.Container.Resolve<RepositoryDataManager>();
×
54

55
            // History is read-only until the UI is started. We switch
56
            // out of it at the end of OnLoad() when we call NavInit().
57
            navHistory = new NavigationHistory<GUIMod> { IsReadOnly = true };
×
58

59
            // Initialize navigation. This should be called as late as
60
            // possible, once the UI is "settled" from its initial load.
61
            NavInit();
×
62

63
            if (Platform.IsMono)
×
64
            {
×
65
                Toolbar.Renderer = new FlatToolStripRenderer();
×
66
                FilterToolButton.DropDown.Renderer = new FlatToolStripRenderer();
×
67
                FilterTagsToolButton.DropDown.Renderer = new FlatToolStripRenderer();
×
68
                FilterLabelsToolButton.DropDown.Renderer = new FlatToolStripRenderer();
×
69
                LaunchGameToolStripMenuItem.DropDown.Renderer = new FlatToolStripRenderer();
×
70
                ModListContextMenuStrip.Renderer = new FlatToolStripRenderer();
×
71
                ModListHeaderContextMenuStrip.Renderer = new FlatToolStripRenderer();
×
72
                LabelsContextMenuStrip.Renderer = new FlatToolStripRenderer();
×
73

74
                ModGrid.ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.DisableResizing;
×
75
                ResizeColumnHeaders();
×
76
                ModGrid.ColumnWidthChanged += (sender, e) => ResizeColumnHeaders();
×
77
            }
×
78
        }
×
79

80
        private void ResizeColumnHeaders()
81
        {
×
82
            var g = CreateGraphics();
×
83
            ModGrid.ColumnHeadersHeight = ModGrid.Columns.OfType<DataGridViewColumn>().Max(col =>
×
84
                ModGrid.ColumnHeadersDefaultCellStyle.Padding.Vertical
×
85
                + Util.StringHeight(g, col.HeaderText,
86
                                    col.HeaderCell?.Style?.Font ?? ModGrid.ColumnHeadersDefaultCellStyle.Font,
87
                                    col.Width - (2 * ModGrid.ColumnHeadersDefaultCellStyle.Padding.Horizontal)));
88
        }
×
89

90
        private static readonly ILog log = LogManager.GetLogger(typeof(ManageMods));
×
91
        private readonly RepositoryDataManager repoData;
92
        private DateTime lastSearchTime;
93
        private string? lastSearchKey;
94
        private readonly NavigationHistory<GUIMod> navHistory;
95
        private static readonly Font uninstallingFont = new Font(SystemFonts.DefaultFont, FontStyle.Strikeout);
×
96

97
        private List<ModChange>?            currentChangeSet;
98
        private Dictionary<GUIMod, string>? conflicts;
99
        private bool freezeChangeSet = false;
×
100

101
        public event Action<string>?  RaiseMessage;
102
        public event Action<string>?  RaiseError;
103
        public event Action<string>?  SetStatusBar;
104
        public event Action?          ClearStatusBar;
105
        public event Action<string>?  LaunchGame;
106
        public event Action?          EditCommandLines;
107

108
        public readonly ModList mainModList;
109
        private List<string> SortColumns
110
        {
111
            get
112
            {
×
113
                if (guiConfig == null)
×
114
                {
×
115
                    return new List<string>();
×
116
                }
117
                // Make sure we don't return any column the GUI doesn't know about.
118
                var unknownCols = guiConfig.SortColumns.Where(col => !ModGrid.Columns.Contains(col))
×
119
                                                       .ToList();
120
                foreach (var unknownCol in unknownCols)
×
121
                {
×
122
                    int index = guiConfig.SortColumns.IndexOf(unknownCol);
×
123
                    guiConfig.SortColumns.RemoveAt(index);
×
124
                    guiConfig.MultiSortDescending.RemoveAt(index);
×
125
                }
×
126
                return guiConfig.SortColumns;
×
127
            }
×
128
        }
129

130
        private static GUIConfiguration?    guiConfig       => Main.Instance?.configuration;
×
131
        private static GameInstance?        currentInstance => Main.Instance?.CurrentInstance;
×
132
        private static GameInstanceManager? manager         => Main.Instance?.Manager;
×
133
        private static IUser?               user            => Main.Instance?.currentUser;
×
134

135
        private List<bool> descending => guiConfig?.MultiSortDescending ?? new List<bool>();
×
136

137
        public event Action<GUIMod>? OnSelectedModuleChanged;
138
        public event Action<List<ModChange>?, Dictionary<GUIMod, string>?>? OnChangeSetChanged;
139
        public event Action? OnRegistryChanged;
140

141
        public event Action<List<ModChange>?, Dictionary<GUIMod, string>?>? StartChangeSet;
142
        public event Action<IEnumerable<GUIMod>>? LabelsAfterUpdate;
143

144
        private void EditModSearches_ShowError(string error)
145
        {
×
146
            RaiseError?.Invoke(error);
×
147
        }
×
148

149
        private List<ModChange>? ChangeSet
150
        {
151
            get => currentChangeSet;
×
152
            [ForbidGUICalls]
153
            set
154
            {
×
155
                var orig = currentChangeSet;
×
156
                currentChangeSet = value;
×
157
                if (!ReferenceEquals(orig, value))
×
158
                {
×
159
                    ChangeSetUpdated();
×
160
                }
×
161
            }
×
162
        }
163

164
        [ForbidGUICalls]
165
        private void ChangeSetUpdated()
166
        {
×
167
            Util.Invoke(this, () =>
×
168
            {
×
169
                if (ChangeSet != null && ChangeSet.Count != 0)
×
170
                {
×
171
                    ApplyToolButton.Enabled = true;
×
172
                }
×
173
                else
174
                {
×
175
                    ApplyToolButton.Enabled = false;
×
176
                    InstallAllCheckbox.Checked = true;
×
177
                }
×
178
                OnChangeSetChanged?.Invoke(ChangeSet, Conflicts);
×
179

180
                var removing = changeIdentifiersOfType(GUIModChangeType.Remove)
×
181
                               .Except(changeIdentifiersOfType(GUIModChangeType.Install))
182
                               .ToHashSet();
183
                foreach ((string ident, DataGridViewRow row) in mainModList.full_list_of_mod_rows)
×
184
                {
×
185
                    if (removing.Contains(ident))
×
186
                    {
×
187
                        // Set strikeout font for rows being uninstalled
188
                        row.DefaultCellStyle.Font = uninstallingFont;
×
189
                    }
×
190
                    else if (row.DefaultCellStyle.Font != null)
×
191
                    {
×
192
                        // Clear strikeout font for rows not being uninstalled
193
                        row.DefaultCellStyle.Font = null;
×
194
                    }
×
195
                }
×
196
            });
×
197
        }
×
198

199
        private IEnumerable<string> changeIdentifiersOfType(GUIModChangeType changeType)
200
            => (currentChangeSet ?? Enumerable.Empty<ModChange>())
×
201
                .Where(ch => ch?.ChangeType == changeType)
×
202
                .Select(ch => ch.Mod.identifier);
×
203

204
        private Dictionary<GUIMod, string>? Conflicts
205
        {
206
            get => conflicts;
×
207
            [ForbidGUICalls]
208
            set
209
            {
×
210
                var orig = conflicts;
×
211
                conflicts = value;
×
212
                if (orig != value)
×
213
                {
×
214
                    Util.Invoke(this, () => ConflictsUpdated(orig));
×
215
                }
×
216
            }
×
217
        }
218

219
        private void ConflictsUpdated(Dictionary<GUIMod, string>? prevConflicts)
220
        {
×
221
            if (Conflicts == null)
×
222
            {
×
223
                // Clear status bar if no conflicts
224
                ClearStatusBar?.Invoke();
×
225
            }
×
226

227
            if (currentInstance != null)
×
228
            {
×
229
                var registry = RegistryManager.Instance(currentInstance, repoData).registry;
×
230
                if (prevConflicts != null)
×
231
                {
×
232
                    // Mark old conflicts as non-conflicted
233
                    // (rows that are _still_ conflicted will be marked as such in the next loop)
234
                    foreach (GUIMod guiMod in prevConflicts.Keys)
×
235
                    {
×
236
                        SetUnsetRowConflicted(guiMod, false, null, currentInstance, registry);
×
237
                    }
×
238
                }
×
239
                if (Conflicts != null)
×
240
                {
×
241
                    // Mark current conflicts as conflicted
242
                    foreach ((GUIMod guiMod, string conflict_text) in Conflicts)
×
243
                    {
×
244
                        SetUnsetRowConflicted(guiMod, true, conflict_text, currentInstance, registry);
×
245
                    }
×
246
                }
×
247
            }
×
248
        }
×
249

250
        private void SetUnsetRowConflicted(GUIMod       guiMod,
251
                                           bool         conflicted,
252
                                           string?      tooltip,
253
                                           GameInstance inst,
254
                                           Registry     registry)
255
        {
×
256
            var row = mainModList.ReapplyLabels(guiMod, conflicted,
×
257
                                                inst.Name, inst.game, registry);
258
            if (row != null)
×
259
            {
×
260
                foreach (DataGridViewCell cell in row.Cells)
×
261
                {
×
262
                    cell.ToolTipText = tooltip;
×
263
                }
×
264
                if (row.Visible)
×
265
                {
×
266
                    ModGrid.InvalidateRow(row.Index);
×
267
                }
×
268
            }
×
269
        }
×
270

271
        private void RefreshToolButton_Click(object? sender, EventArgs? e)
272
        {
×
273
            // If user is holding Shift or Ctrl, force a full update
274
            Main.Instance?.UpdateRepo(ModifierKeys.HasAnyFlag(Keys.Control, Keys.Shift));
×
275
        }
×
276

277
        #region Filter dropdown
278

279
        private void FilterToolButton_DropDown_Opening(object? sender, CancelEventArgs? e)
280
        {
×
281
            // The menu items' dropdowns can't be accessed if they're empty
282
            FilterTagsToolButton_DropDown_Opening(null, null);
×
283
            FilterLabelsToolButton_DropDown_Opening(null, null);
×
284
        }
×
285

286
        private void FilterTagsToolButton_DropDown_Opening(object? sender, CancelEventArgs? e)
287
        {
×
288
            if (currentInstance != null)
×
289
            {
×
290
                var registry = RegistryManager.Instance(currentInstance, repoData).registry;
×
291
                FilterTagsToolButton.DropDownItems.Clear();
×
292
                FilterTagsToolButton.DropDownItems.AddRange(
×
293
                    registry.Tags.OrderBy(kvp => kvp.Key)
×
294
                                 .Select(kvp => new ToolStripMenuItem(
×
295
                                                    $"{kvp.Key} ({kvp.Value.ModuleIdentifiers.Count})",
296
                                                    null, tagFilterButton_Click)
297
                                                {
298
                                                    Tag         = kvp.Value,
299
                                                    ToolTipText = Properties.Resources.FilterLinkToolTip,
300
                                                })
301
                                 .OfType<ToolStripItem>()
302
                                 .Append(untaggedFilterToolStripSeparator)
303
                                 .Append(new ToolStripMenuItem(
304
                                             string.Format(Properties.Resources.MainLabelsUntagged,
305
                                                           registry.Untagged.Count),
306
                                             null, tagFilterButton_Click)
307
                                         {
308
                                             Tag = null
309
                                         })
310
                                 .ToArray());
311
            }
×
312
        }
×
313

314
        private void FilterLabelsToolButton_DropDown_Opening(object? sender, CancelEventArgs? e)
315
        {
×
316
            if (currentInstance != null)
×
317
            {
×
318
                FilterLabelsToolButton.DropDownItems.Clear();
×
319
                FilterLabelsToolButton.DropDownItems.AddRange(
×
320
                    ModuleLabelList.ModuleLabels
321
                                   .LabelsFor(currentInstance.Name)
322
                                   .Select(mlbl => new ToolStripMenuItem(
×
323
                                                       $"{mlbl.Name} ({mlbl.ModuleCount(currentInstance.game)})",
324
                                                       null, customFilterButton_Click)
325
                                                   {
326
                                                       Tag         = mlbl,
327
                                                       BackColor   = mlbl.Color ?? Color.Transparent,
328
                                                       ForeColor   = mlbl.Color?.ForeColorForBackColor()
329
                                                                               ?? SystemColors.ControlText,
330
                                                       ToolTipText = Properties.Resources.FilterLinkToolTip,
331
                                                   })
332
                                   .ToArray());
333
            }
×
334
        }
×
335

336
        #endregion
337

338
        #region Filter right click menu
339

340
        private void LabelsContextMenuStrip_Opening(object? sender, CancelEventArgs? e)
341
        {
×
NEW
342
            if (e != null && currentInstance != null
×
343
                && SelectedModules.ToArray() is { Length: > 0 } and var modules)
344
            {
×
345
                LabelsContextMenuStrip.Items.Clear();
×
NEW
346
                foreach (var mlbl in ModuleLabelList.ModuleLabels.LabelsFor(currentInstance.Name))
×
347
                {
×
NEW
348
                    var idents = mlbl.IdentifiersFor(currentInstance.game)
×
349
                                     .ToHashSet();
NEW
350
                    var groups = modules.GroupBy(m => idents.Contains(m.Identifier))
×
NEW
351
                                        .OrderBy(grp => grp.Key)
×
352
                                        .ToArray();
NEW
353
                    foreach (var grp in groups)
×
NEW
354
                    {
×
NEW
355
                        LabelsContextMenuStrip.Items.Add(
×
356
                            new ToolStripMenuItem(groups.Length == 1
357
                                                      ? mlbl.Name
358
                                                      : string.Format(
359
                                                            grp.Key
360
                                                                ? Properties.Resources.ManageModsLabelRemoveMultiple
361
                                                                : Properties.Resources.ManageModsLabelAddMultiple,
362
                                                            mlbl.Name, grp.Count()),
363
                                                  null, labelMenuItem_Click)
364
                            {
365
                                BackColor    = mlbl.Color ?? Color.Transparent,
366
                                ForeColor    = mlbl.Color?.ForeColorForBackColor()
367
                                                         ?? SystemColors.ControlText,
368
                                Checked      = grp.Key,
369
                                CheckOnClick = true,
370
                                Tag          = (label:   mlbl,
371
                                                modules: grp.ToArray()),
372
                            });
NEW
373
                    }
×
374
                }
×
375
                LabelsContextMenuStrip.Items.Add(labelToolStripSeparator);
×
376
                LabelsContextMenuStrip.Items.Add(editLabelsToolStripMenuItem);
×
377
                e.Cancel = false;
×
378
            }
×
379
        }
×
380

381
        private void labelMenuItem_Click(object? sender, EventArgs? e)
382
        {
×
383
            if (currentInstance != null && SelectedModule != null
×
384
                && sender is ToolStripMenuItem { Tag: (ModuleLabel mlbl, GUIMod[] modules) })
385
            {
×
NEW
386
                ToggleModuleLabel(mlbl, currentInstance, modules);
×
387
            }
×
388
        }
×
389

390
        private void editLabelsToolStripMenuItem_Click(object? sender, EventArgs? e)
391
        {
×
392
            if (user != null && manager != null && currentInstance != null)
×
393
            {
×
394
                var eld = new EditLabelsDialog(user, manager, ModuleLabelList.ModuleLabels);
×
395
                eld.ShowDialog(this);
×
396
                eld.Dispose();
×
397
                ModuleLabelList.ModuleLabels.Save(ModuleLabelList.DefaultPath);
×
398
                var registry = RegistryManager.Instance(currentInstance, repoData).registry;
×
399
                foreach (var module in mainModList.Modules)
×
400
                {
×
401
                    mainModList.ReapplyLabels(module, Conflicts?.ContainsKey(module) ?? false,
×
402
                                              currentInstance.Name, currentInstance.game, registry);
403
                }
×
404
                UpdateHiddenTagsAndLabels();
×
405
                UpdateCol.Visible = UpdateAllToolButton.Enabled =
×
406
                    mainModList.ResetHasUpdate(currentInstance, registry, ChangeSet, ModGrid.Rows);
407
            }
×
408
        }
×
409

410
        #endregion
411

412
        private void tagFilterButton_Click(object? sender, EventArgs? e)
413
        {
×
414
            var clicked = sender as ToolStripMenuItem;
×
415
            var merge = ModifierKeys.HasAnyFlag(Keys.Control, Keys.Shift);
×
416
            Filter(ModList.FilterToSavedSearch(currentInstance!, GUIModFilter.Tag, clicked?.Tag as ModuleTag, null), merge);
×
417
        }
×
418

419
        private void customFilterButton_Click(object? sender, EventArgs? e)
420
        {
×
421
            var clicked = sender as ToolStripMenuItem;
×
422
            var merge = ModifierKeys.HasAnyFlag(Keys.Control, Keys.Shift);
×
423
            Filter(ModList.FilterToSavedSearch(currentInstance!, GUIModFilter.CustomLabel, null, clicked?.Tag as ModuleLabel), merge);
×
424
        }
×
425

426
        private void FilterCompatibleButton_Click(object? sender, EventArgs? e)
427
        {
×
428
            var merge = ModifierKeys.HasAnyFlag(Keys.Control, Keys.Shift);
×
429
            Filter(ModList.FilterToSavedSearch(currentInstance!, GUIModFilter.Compatible), merge);
×
430
        }
×
431

432
        private void FilterInstalledButton_Click(object? sender, EventArgs? e)
433
        {
×
434
            var merge = ModifierKeys.HasAnyFlag(Keys.Control, Keys.Shift);
×
435
            Filter(ModList.FilterToSavedSearch(currentInstance!, GUIModFilter.Installed), merge);
×
436
        }
×
437

438
        private void FilterInstalledUpdateButton_Click(object? sender, EventArgs? e)
439
        {
×
440
            var merge = ModifierKeys.HasAnyFlag(Keys.Control, Keys.Shift);
×
441
            Filter(ModList.FilterToSavedSearch(currentInstance!, GUIModFilter.InstalledUpdateAvailable), merge);
×
442
        }
×
443

444
        private void FilterReplaceableButton_Click(object? sender, EventArgs? e)
445
        {
×
446
            var merge = ModifierKeys.HasAnyFlag(Keys.Control, Keys.Shift);
×
447
            Filter(ModList.FilterToSavedSearch(currentInstance!, GUIModFilter.Replaceable), merge);
×
448
        }
×
449

450
        private void FilterCachedButton_Click(object? sender, EventArgs? e)
451
        {
×
452
            var merge = ModifierKeys.HasAnyFlag(Keys.Control, Keys.Shift);
×
453
            Filter(ModList.FilterToSavedSearch(currentInstance!, GUIModFilter.Cached), merge);
×
454
        }
×
455

456
        private void FilterUncachedButton_Click(object? sender, EventArgs? e)
457
        {
×
458
            var merge = ModifierKeys.HasAnyFlag(Keys.Control, Keys.Shift);
×
459
            Filter(ModList.FilterToSavedSearch(currentInstance!, GUIModFilter.Uncached), merge);
×
460
        }
×
461

462
        private void FilterNewButton_Click(object? sender, EventArgs? e)
463
        {
×
464
            var merge = ModifierKeys.HasAnyFlag(Keys.Control, Keys.Shift);
×
465
            Filter(ModList.FilterToSavedSearch(currentInstance!, GUIModFilter.NewInRepository), merge);
×
466
        }
×
467

468
        private void FilterNotInstalledButton_Click(object? sender, EventArgs? e)
469
        {
×
470
            var merge = ModifierKeys.HasAnyFlag(Keys.Control, Keys.Shift);
×
471
            Filter(ModList.FilterToSavedSearch(currentInstance!, GUIModFilter.NotInstalled), merge);
×
472
        }
×
473

474
        private void FilterIncompatibleButton_Click(object? sender, EventArgs? e)
475
        {
×
476
            var merge = ModifierKeys.HasAnyFlag(Keys.Control, Keys.Shift);
×
477
            Filter(ModList.FilterToSavedSearch(currentInstance!, GUIModFilter.Incompatible), merge);
×
478
        }
×
479

480
        private void FilterAllButton_Click(object? sender, EventArgs? e)
481
        {
×
482
            Filter(ModList.FilterToSavedSearch(currentInstance!, GUIModFilter.All), false);
×
483
        }
×
484

485
        /// <summary>
486
        /// Called when the ModGrid filter (all, compatible, incompatible...) is changed.
487
        /// </summary>
488
        /// <param name="search">Search string</param>
489
        /// <param name="merge">If true, merge with current searches, else replace</param>
490
        public void Filter(SavedSearch search, bool merge)
491
        {
×
492
            if (currentInstance != null)
×
493
            {
×
494
                var searches = search.Values
×
495
                                     .Select(s => ModSearch.Parse(currentInstance!, s))
×
496
                                     .OfType<ModSearch>()
497
                                     .ToList();
498

499
                Util.Invoke(ModGrid, () =>
×
500
                {
×
501
                    if (merge)
×
502
                    {
×
503
                        EditModSearches.MergeSearches(searches);
×
504
                    }
×
505
                    else
506
                    {
×
507
                        EditModSearches.SetSearches(searches);
×
508
                    }
×
509
                    ShowHideColumns(searches);
×
510
                });
×
511
            }
×
512
        }
×
513

514
        public void SetSearches(List<ModSearch> searches)
515
        {
×
516
            Util.Invoke(ModGrid, () =>
×
517
            {
×
518
                mainModList.SetSearches(searches);
×
519
                EditModSearches.SetSearches(searches);
×
520
                ShowHideColumns(searches);
×
521
            });
×
522
        }
×
523

524
        private void ShowHideColumns(List<ModSearch> searches)
525
        {
×
526
            // Ask the configuration which columns to show.
527
            foreach (DataGridViewColumn col in ModGrid.Columns)
×
528
            {
×
529
                // Some columns are always shown, and others are handled by UpdateModsList()
530
                if (col.Name != "Installed" && col.Name != "UpdateCol" && col.Name != "ReplaceCol"
×
531
                    && !installedColumnNames.Contains(col.Name))
532
                {
×
533
                    col.Visible = !guiConfig?.HiddenColumnNames.Contains(col.Name) ?? true;
×
534
                }
×
535
            }
×
536

537
            // If these columns aren't hidden by the user, show them if the search includes installed modules
538
            setInstalledColumnsVisible(mainModList.HasAnyInstalled
×
539
                                       && !SearchesExcludeInstalled(searches)
540
                                       && mainModList.HasVisibleInstalled());
541
        }
×
542

543
        private static readonly string[] installedColumnNames = new string[]
×
544
        {
545
            "AutoInstalled", "InstalledVersion", "InstallDate"
546
        };
547

548
        private void setInstalledColumnsVisible(bool visible)
549
        {
×
550
            if (guiConfig != null)
×
551
            {
×
552
                var hiddenColumnNames = guiConfig.HiddenColumnNames;
×
553
                foreach (var colName in installedColumnNames.Where(ModGrid.Columns.Contains))
×
554
                {
×
555
                    ModGrid.Columns[colName].Visible = visible && !hiddenColumnNames.Contains(colName);
×
556
                }
×
557
            }
×
558
        }
×
559

560
        private static bool SearchesExcludeInstalled(List<ModSearch> searches)
561
            => searches.Count > 0 && searches.All(s => s?.Installed == false);
×
562

563
        public void MarkAllUpdates()
564
        {
×
565
            WithFrozenChangeset(() =>
×
566
            {
×
567
                var checkboxes = mainModList.full_list_of_mod_rows
×
568
                                            .Values
569
                                            .Where(row => row.Tag is GUIMod {Identifier: string ident}
×
570
                                                          && (!Main.Instance?.LabelsHeld(ident) ?? false))
571
                                            .SelectWithCatch(row => row.Cells[UpdateCol.Index],
×
572
                                                             (row, exc) => null)
×
573
                                            .OfType<DataGridViewCheckBoxCell>();
574
                foreach (var checkbox in checkboxes)
×
575
                {
×
576
                    checkbox.Value = true;
×
577
                }
×
578
                ModGrid.CommitEdit(DataGridViewDataErrorContexts.Commit);
×
579

580
                // only sort by Update column if checkbox in settings checked
581
                if (guiConfig?.AutoSortByUpdate ?? false)
×
582
                {
×
583
                    // Retain their current sort as secondaries
584
                    AddSort(UpdateCol, true);
×
585
                    UpdateFilters();
×
586
                    // Select the top row and scroll the list to it.
587
                    if (//ModGrid.Rows is [DataGridViewRow row, ..]
×
588
                        ModGrid.Rows.Count > 0
589
                        && ModGrid.Rows[0] is DataGridViewRow row)
590
                    {
×
591
                        ModGrid.CurrentCell = row.Cells[SelectableColumnIndex()];
×
592
                    }
×
593
                }
×
594
            });
×
595
        }
×
596

597
        private void UpdateAllToolButton_Click(object? sender, EventArgs? e)
598
        {
×
599
            MarkAllUpdates();
×
600
        }
×
601

602
        private void ApplyToolButton_Click(object? sender, EventArgs? e)
603
        {
×
604
            StartChangeSet?.Invoke(currentChangeSet, Conflicts);
×
605
        }
×
606

607
        private void LaunchGameToolStripMenuItem_MouseHover(object? sender, EventArgs? e)
608
        {
×
609
            if (guiConfig != null)
×
610
            {
×
611
                LaunchGameToolStripMenuItem.ShowDropDown();
×
612
            }
×
613
        }
×
614

615
        private void LaunchGameToolStripMenuItem_DropDown_Opening(object? sender, CancelEventArgs? e)
616
        {
×
617
            if (guiConfig != null)
×
618
            {
×
619
                var cmdLines = guiConfig.CommandLines;
×
620
                LaunchGameToolStripMenuItem.DropDownItems.Clear();
×
621
                LaunchGameToolStripMenuItem.DropDownItems.AddRange(
×
622
                    cmdLines.Select(cmdLine => (ToolStripItem)
×
623
                                               new ToolStripMenuItem(cmdLine, null,
624
                                                                     LaunchGameToolStripMenuItem_Click)
625
                                               {
626
                                                   Tag = cmdLine,
627
                                                   ShortcutKeyDisplayString = CmdLineHelp(cmdLine),
628
                                               })
629
                            .Append(CommandLinesToolStripSeparator)
630
                            .Append(EditCommandLinesToolStripMenuItem)
631
                            .ToArray());
632
            }
×
633
        }
×
634

635
        private string CmdLineHelp(string cmdLine)
636
            => manager?.SteamLibrary.Games.Length > 0
×
637
                ? SteamLibrary.IsSteamCmdLine(cmdLine)
638
                    ? Properties.Resources.ManageModsSteamPlayTimeYesTooltip
639
                    : Properties.Resources.ManageModsSteamPlayTimeNoTooltip
640
                : ""
641
            ?? "";
642

643
        private void LaunchGameToolStripMenuItem_Click(object? sender, EventArgs? e)
644
        {
×
645
            if (sender is ToolStripMenuItem menuItem
×
646
                && (menuItem.Tag as string
647
                    ?? guiConfig?.CommandLines.First()) is string cmd)
648
            {
×
649
                if (!SteamLibrary.IsSteamCmdLine(cmd))
×
650
                {
×
651
                    SetPlayButtonActiveInactive(true);
×
652
                }
×
653
                LaunchGame?.Invoke(cmd);
×
654
            }
×
655
        }
×
656

657
        public void OnGameExit()
658
        {
×
659
            SetPlayButtonActiveInactive(false);
×
660
        }
×
661

662
        private void SetPlayButtonActiveInactive(bool playing)
663
        {
×
664
            LaunchGameToolStripMenuItem.Text = playing
×
665
                ? Properties.Resources.ManageModsPlaying
666
                : new SingleAssemblyComponentResourceManager(typeof(ManageMods))
667
                      .GetString($"{LaunchGameToolStripMenuItem.Name}.{nameof(LaunchGameToolStripMenuItem.Text)}");
668
            LaunchGameToolStripMenuItem.Enabled = !playing;
×
669
        }
×
670

671
        private void EditCommandLinesToolStripMenuItem_Click(object? sender, EventArgs? e)
672
        {
×
673
            EditCommandLines?.Invoke();
×
674
        }
×
675

676
        private void NavBackwardToolButton_Click(object? sender, EventArgs? e)
677
        {
×
678
            NavGoBackward();
×
679
        }
×
680

681
        private void NavForwardToolButton_Click(object? sender, EventArgs? e)
682
        {
×
683
            NavGoForward();
×
684
        }
×
685

686
        private void ModGrid_SelectionChanged(object? sender, EventArgs? e)
687
        {
×
688
            // Skip if already disposed (i.e. after the form has been closed).
689
            // Needed for TransparentTextBoxes
690
            if (IsDisposed)
×
691
            {
×
692
                return;
×
693
            }
694

695
            var module = SelectedModule;
×
696
            if (module != null)
×
697
            {
×
698
                OnSelectedModuleChanged?.Invoke(module);
×
699
                NavSelectMod(module);
×
700
            }
×
701
        }
×
702

703
        /// <summary>
704
        /// Called when there's a click on the ModGrid header row.
705
        /// Handles sorting and the header right click context menu.
706
        /// </summary>
707
        private void ModGrid_HeaderMouseClick(object? sender, DataGridViewCellMouseEventArgs? e)
708
        {
×
709
            // Left click -> sort by new column / change sorting direction.
710
            if (e?.Button == MouseButtons.Left)
×
711
            {
×
712
                if (ModifierKeys.HasFlag(Keys.Shift))
×
713
                {
×
714
                    AddSort(ModGrid.Columns[e.ColumnIndex]);
×
715
                }
×
716
                else
717
                {
×
718
                    SetSort(ModGrid.Columns[e.ColumnIndex]);
×
719
                }
×
720
                UpdateFilters();
×
721
            }
×
722
            // Right click -> Bring up context menu to change visibility of columns.
723
            else if (e?.Button == MouseButtons.Right)
×
724
            {
×
725
                ShowHeaderContextMenu();
×
726
            }
×
727
        }
×
728

729
        private void ShowHeaderContextMenu(bool columns = true,
730
                                           bool tags    = true)
731
        {
×
732
            if (!columns && !tags)
×
733
            {
×
734
                // Don't show a blank menu
735
                return;
×
736
            }
737

738
            // Start from scratch: clear the entire item list, then add all options again
739
            ModListHeaderContextMenuStrip.Items.Clear();
×
740

741
            if (columns)
×
742
            {
×
743
                // Add columns
744
                ModListHeaderContextMenuStrip.Items.AddRange(
×
745
                    ModGrid.Columns
746
                           .OfType<DataGridViewColumn>()
747
                           .Where(col => col.Name is not "Installed"
×
748
                                                 and not "UpdateCol"
749
                                                 and not "ReplaceCol")
750
                           .Select(col => new ToolStripMenuItem()
×
751
                           {
752
                               Name    = col.Name,
753
                               Text    = col.HeaderText,
754
                               Checked = col.Visible,
755
                               Tag     = col
756
                           })
757
                           .ToArray());
758
            }
×
759

760
            if (columns && tags)
×
761
            {
×
762
                // Separator
763
                ModListHeaderContextMenuStrip.Items.Add(new ToolStripSeparator());
×
764
            }
×
765

766
            if (tags && currentInstance != null)
×
767
            {
×
768
                // Add tags
769
                var registry = RegistryManager.Instance(currentInstance, repoData).registry;
×
770
                ModListHeaderContextMenuStrip.Items.AddRange(
×
771
                    registry.Tags.OrderBy(kvp => kvp.Key)
×
772
                    .Select(kvp => new ToolStripMenuItem()
×
773
                    {
774
                        Name    = kvp.Key,
775
                        Text    = kvp.Key,
776
                        Checked = !ModuleTagList.ModuleTags.HiddenTags.Contains(kvp.Key),
777
                        Tag     = kvp.Value,
778
                    })
779
                    .ToArray()
780
                );
781
            }
×
782

783
            // Show the context menu on cursor position.
784
            ModListHeaderContextMenuStrip.Show(Cursor.Position);
×
785
        }
×
786

787
        /// <summary>
788
        /// Called if a ToolStripButton of the header context menu is pressed.
789
        /// </summary>
790
        private void ModListHeaderContextMenuStrip_ItemClicked(object? sender, ToolStripItemClickedEventArgs? e)
791
        {
×
792
            // ClickedItem is of type ToolStripItem, we need ToolStripButton.
793
            var clickedItem = e?.ClickedItem as ToolStripMenuItem;
×
794

795
            if (clickedItem?.Tag is DataGridViewColumn col)
×
796
            {
×
797
                col.Visible = !clickedItem.Checked;
×
798
                guiConfig?.SetColumnVisibility(col.Name, !clickedItem.Checked);
×
799
                if (col.Index == 0)
×
800
                {
×
801
                    InstallAllCheckbox.Visible = col.Visible;
×
802
                }
×
803
            }
×
804
            else if (clickedItem?.Tag is ModuleTag tag)
×
805
            {
×
806
                if (!clickedItem.Checked)
×
807
                {
×
808
                    ModuleTagList.ModuleTags.HiddenTags.Remove(tag.Name);
×
809
                }
×
810
                else
811
                {
×
812
                    ModuleTagList.ModuleTags.HiddenTags.Add(tag.Name);
×
813
                }
×
814
                ModuleTagList.ModuleTags.Save(ModuleTagList.DefaultPath);
×
815
                UpdateFilters();
×
816
                UpdateHiddenTagsAndLabels();
×
817
            }
×
818
        }
×
819

820
        /// <summary>
821
        /// Called on key down when the mod list is focused.
822
        /// Makes the Home/End keys go to the top/bottom of the list respectively.
823
        /// </summary>
824
        private void ModGrid_KeyDown(object? sender, KeyEventArgs? e)
825
        {
×
826
            switch (e?.KeyCode)
×
827
            {
828
                case Keys.Home:
829
                    // First row.
830
                    // Handles for empty filters
831
                    if (//ModGrid.Rows is [DataGridViewRow top, ..]
×
832
                        ModGrid.Rows.Count > 0
833
                        && ModGrid.Rows[0] is DataGridViewRow top)
834
                    {
×
835
                        ModGrid.CurrentCell = top.Cells[SelectableColumnIndex()];
×
836
                    }
×
837

838
                    e.Handled = true;
×
839
                    break;
×
840

841
                case Keys.End:
842
                    // Last row.
843
                    // Handles for empty filters
844
                    if (//ModGrid.Rows is [.., DataGridViewRow bottom]
×
845
                        ModGrid.Rows.Count > 0
846
                        && ModGrid.Rows[^1] is DataGridViewRow bottom)
847
                    {
×
848
                        ModGrid.CurrentCell = bottom.Cells[SelectableColumnIndex()];
×
849
                    }
×
850

851
                    e.Handled = true;
×
852
                    break;
×
853

854
                case Keys.Space:
855
                    // If they've selected one row and focused one of the checkbox columns,
856
                    // don't intercept
NEW
857
                    if (ModGrid.SelectedRows   is { Count: > 1 }
×
858
                        || ModGrid.CurrentCell is { ColumnIndex: > 3 })
859
                    {
×
860
                        // Toggle Update column if enabled, otherwise Install
NEW
861
                        var cols = Enumerable.Range(Installed.Index,
×
862
                                                    UpdateCol.Index - Installed.Index + 1)
863
                                             // Don't toggle auto-installed on space
864
                                             .Except(Enumerable.Repeat(AutoInstalled.Index, 1))
865
                                             .Reverse()
866
                                             .ToArray();
NEW
867
                        WithFrozenChangeset(() =>
×
868
                        {
×
NEW
869
                            foreach (var row in ModGrid.SelectedRows.OfType<DataGridViewRow>())
×
870
                            {
×
NEW
871
                                if (cols.Select(col => row.Cells[col])
×
872
                                        .OfType<DataGridViewCheckBoxCell>()
873
                                        .FirstOrDefault()
874
                                    is DataGridViewCheckBoxCell cell)
NEW
875
                                {
×
876
                                    // Need to change the state here, because the user hasn't clicked on a checkbox
NEW
877
                                    cell.Value = !(bool)cell.Value;
×
NEW
878
                                }
×
879
                            }
×
NEW
880
                        });
×
NEW
881
                        ModGrid.CommitEdit(DataGridViewDataErrorContexts.Commit);
×
NEW
882
                        e.Handled = true;
×
883
                    }
×
884
                    break;
×
885

886
                case Keys.Apps:
887
                    ShowModContextMenu();
×
888
                    e.Handled = true;
×
889
                    break;
×
890
            }
891
        }
×
892

893
        /// <summary>
894
        /// Called on key press when the mod is focused. Scrolls to the first mod with name
895
        /// beginning with the key pressed. If more than one unique keys are pressed in under
896
        /// a second, it searches for the combination of the keys pressed. If the same key is
897
        /// being pressed repeatedly, it cycles through mods names beginning with that key.
898
        /// If space is pressed, the checkbox at the current row is toggled.
899
        /// </summary>
900
        private void ModGrid_KeyPress(object? sender, KeyPressEventArgs? e)
901
        {
×
902
            if (e != null)
×
903
            {
×
904
                // Don't search for spaces or newlines
905
                if (e.KeyChar is ((char)Keys.Space) or ((char)Keys.Enter))
×
906
                {
×
907
                    return;
×
908
                }
909

910
                var key = e.KeyChar.ToString();
×
911
                // Determine time passed since last key press.
912
                TimeSpan interval = DateTime.Now - lastSearchTime;
×
913
                if (interval.TotalSeconds < 1)
×
914
                {
×
915
                    // Last keypress was < 1 sec ago, so combine the last and current keys.
916
                    key = lastSearchKey + key;
×
917
                }
×
918

919
                // Remember the current time and key.
920
                lastSearchTime = DateTime.Now;
×
921
                lastSearchKey = key;
×
922

923
                if (key.Distinct().Count() == 1)
×
924
                {
×
925
                    // Treat repeating and single keypresses the same.
926
                    key = key[..1];
×
927
                }
×
928

929
                FocusMod(key, false);
×
930
                e.Handled = true;
×
931
            }
×
932
        }
×
933

934
        /// <summary>
935
        /// I'm pretty sure this is what gets called when the user clicks on a ticky in the mod list.
936
        /// </summary>
937
        private void ModGrid_CellContentClick(object? sender, DataGridViewCellEventArgs? e)
938
        {
×
939
            ModGrid.CommitEdit(DataGridViewDataErrorContexts.Commit);
×
940
        }
×
941

942
        private void ModGrid_CellMouseDoubleClick(object? sender, DataGridViewCellMouseEventArgs? e)
943
        {
×
944
            if (e?.Button != MouseButtons.Left)
×
945
            {
×
946
                return;
×
947
            }
948
            if (e.RowIndex < 0)
×
949
            {
×
950
                return;
×
951
            }
952

953
            DataGridViewRow row = ModGrid.Rows[e.RowIndex];
×
954
            if (//row.Cells is not [DataGridViewCheckBoxCell cell, ..]
×
955
                row.Cells.Count < 1
956
                || row.Cells[0] is not DataGridViewCheckBoxCell cell)
957
            {
×
958
                return;
×
959
            }
960

961
            // Need to change the state here, because the user hasn't clicked on a checkbox.
962
            cell.Value = !(bool)cell.Value;
×
963
            ModGrid.CommitEdit(DataGridViewDataErrorContexts.Commit);
×
964
        }
×
965

966
        private void ModGrid_CellValueChanged(object? sender, DataGridViewCellEventArgs? e)
967
        {
×
968
            if (currentInstance != null && e?.RowIndex >= 0)
×
969
            {
×
970
                var row = ModGrid.Rows?[e.RowIndex];
×
971
                switch (row?.Cells[e.ColumnIndex])
×
972
                {
973
                    case DataGridViewLinkCell linkCell:
974
                        // Launch URLs if found in grid
975
                        var cmd = linkCell.Value.ToString();
×
976
                        if (!string.IsNullOrEmpty(cmd))
×
977
                        {
×
978
                            Utilities.ProcessStartURL(cmd);
×
979
                        }
×
980
                        break;
×
981

982
                    case DataGridViewCheckBoxCell checkCell:
983
                        // checked is a keyword in C#
984
                        var nowChecked = (bool)checkCell.Value;
×
985
                        if (row?.Tag is GUIMod gmod)
×
986
                        {
×
987
                            switch (ModGrid.Columns[e.ColumnIndex].Name)
×
988
                            {
989
                                case "Installed":
990
                                    gmod.SelectedMod = nowChecked ? gmod.SelectedMod
×
991
                                                                    ?? gmod.InstalledMod?.Module
992
                                                                    ?? gmod.LatestCompatibleMod
993
                                                                  : null;
994
                                    break;
×
995
                                case "UpdateCol":
996
                                    gmod.SelectedMod = nowChecked
×
997
                                        ? gmod.SelectedMod != null
998
                                          && (gmod.InstalledMod == null
999
                                              || gmod.InstalledMod.Module.version < gmod.SelectedMod.version)
1000
                                            ? gmod.SelectedMod
1001
                                            : gmod.LatestCompatibleMod
1002
                                        : gmod.InstalledMod?.Module;
1003

1004
                                    if (gmod.SelectedMod == gmod.LatestCompatibleMod)
×
1005
                                    {
×
1006
                                        // Reinstall, force update without change
1007
                                        UpdateChangeSetAndConflicts(currentInstance,
×
1008
                                            RegistryManager.Instance(currentInstance, repoData).registry);
1009
                                    }
×
1010
                                    break;
×
1011
                                case "AutoInstalled":
1012
                                    gmod.SetAutoInstallChecked(row, AutoInstalled);
×
1013
                                    OnRegistryChanged?.Invoke();
×
1014
                                    break;
×
1015
                                case "ReplaceCol":
1016
                                    UpdateChangeSetAndConflicts(currentInstance,
×
1017
                                        RegistryManager.Instance(currentInstance, repoData).registry);
1018
                                    break;
×
1019
                            }
1020
                        }
×
1021
                        break;
×
1022
                }
1023
            }
×
1024
        }
×
1025

1026
        private void guiModule_PropertyChanged(object? sender, PropertyChangedEventArgs? e)
1027
        {
×
1028
            if (currentInstance != null
×
1029
                && sender is GUIMod gmod
1030
                && mainModList.full_list_of_mod_rows.TryGetValue(gmod.Identifier,
1031
                                                                 out DataGridViewRow? row))
1032
            {
×
1033
                switch (e?.PropertyName)
×
1034
                {
1035
                    case nameof(GUIMod.SelectedMod):
1036
                        Util.Invoke(this, () =>
×
1037
                        {
×
1038
                            if (row.Cells[Installed.Index] is DataGridViewCheckBoxCell instCell)
×
1039
                            {
×
1040
                                bool newVal = gmod.SelectedMod != null;
×
1041
                                if ((bool)instCell.Value != newVal)
×
1042
                                {
×
1043
                                    instCell.Value = newVal;
×
1044
                                }
×
1045
                            }
×
1046
                            if (row.Cells[UpdateCol.Index] is DataGridViewCheckBoxCell upgCell)
×
1047
                            {
×
1048
                                bool newVal = gmod.SelectedMod != null
×
1049
                                              && (gmod.InstalledMod == null
1050
                                                  || gmod.InstalledMod.Module.version < gmod.SelectedMod.version);
1051
                                if ((bool)upgCell.Value != newVal)
×
1052
                                {
×
1053
                                    upgCell.Value = newVal;
×
1054
                                }
×
1055
                            }
×
1056

1057
                            if (Platform.IsWindows)
×
1058
                            {
×
1059
                                // This call is needed to force the UI to update on Windows,
1060
                                // otherwise the checkboxes can look checked when unchecked or vice versa.
1061
                                // Unfortunately, it crashes on Mono.
1062
                                ModGrid.RefreshEdit();
×
1063
                            }
×
1064
                            // Update the changeset
1065
                            UpdateChangeSetAndConflicts(currentInstance,
×
1066
                                RegistryManager.Instance(currentInstance, repoData).registry);
1067
                        });
×
1068
                        break;
×
1069

1070
                    case nameof(GUIMod.IsAutoInstalled):
1071
                        // Update the changeset
1072
                        UpdateChangeSetAndConflicts(currentInstance,
×
1073
                            RegistryManager.Instance(currentInstance, repoData).registry);
1074
                        break;
×
1075

1076
                    case nameof(GUIMod.IsCached):
1077
                        row.Visible = mainModList.IsVisible(gmod,
×
1078
                                                            currentInstance.Name,
1079
                                                            currentInstance.game,
1080
                                                            RegistryManager.Instance(currentInstance, repoData).registry);
1081
                        if (row.Visible && !ModGrid.Rows.Contains(row))
×
1082
                        {
×
1083
                            // UpdateFilters only adds visible rows, so we may need to add if newly visible
1084
                            ModGrid.Rows.Add(row);
×
1085
                        }
×
1086
                        break;
×
1087
                }
1088
            }
×
1089
        }
×
1090

1091
        public void RemoveChangesetItem(ModChange change)
1092
        {
×
1093
            if (currentInstance != null
×
1094
                && currentChangeSet != null
1095
                && currentChangeSet.Contains(change)
1096
                && change.IsRemovable
1097
                && mainModList.full_list_of_mod_rows.TryGetValue(change.Mod.identifier,
1098
                                                                 out DataGridViewRow? row)
1099
                && row.Tag is GUIMod guiMod)
1100
            {
×
1101
                if (change.IsAutoRemoval)
×
1102
                {
×
1103
                    guiMod.SetAutoInstallChecked(row, AutoInstalled, false);
×
1104
                    OnRegistryChanged?.Invoke();
×
1105
                }
×
1106
                else if (change.IsUserRequested)
×
1107
                {
×
1108
                    guiMod.SelectedMod = guiMod.InstalledMod?.Module;
×
1109
                    switch (change.ChangeType)
×
1110
                    {
1111
                        case GUIModChangeType.Replace:
1112
                            if (row.Cells[ReplaceCol.Index] is DataGridViewCheckBoxCell checkCell)
×
1113
                            {
×
1114
                                checkCell.Value = false;
×
1115
                            }
×
1116
                            break;
×
1117
                        case GUIModChangeType.Update:
1118
                            if (row.Cells[UpdateCol.Index] is DataGridViewCheckBoxCell updateCell)
×
1119
                            {
×
1120
                                updateCell.Value = false;
×
1121
                            }
×
1122
                            break;
×
1123
                    }
1124
                }
×
1125
                UpdateChangeSetAndConflicts(
×
1126
                    currentInstance, RegistryManager.Instance(currentInstance, repoData).registry);
1127
            }
×
1128
        }
×
1129

1130
        private void ModGrid_GotFocus(object? sender, EventArgs? e)
1131
        {
×
1132
            Util.Invoke(this, () =>
×
1133
            {
×
1134
                // Give the selected row the standard highlight color
1135
                ModGrid.RowsDefaultCellStyle.SelectionBackColor = SystemColors.Highlight;
×
1136
                ModGrid.RowsDefaultCellStyle.SelectionForeColor = SystemColors.HighlightText;
×
1137
            });
×
1138
        }
×
1139

1140
        private void ModGrid_LostFocus(object? sender, EventArgs? e)
1141
        {
×
1142
            Util.Invoke(this, () =>
×
1143
            {
×
1144
                // Gray out the selected row so you can tell the mod list is not focused
1145
                ModGrid.RowsDefaultCellStyle.SelectionBackColor = SystemColors.Control;
×
1146
                ModGrid.RowsDefaultCellStyle.SelectionForeColor = SystemColors.ControlText;
×
1147
            });
×
1148
        }
×
1149

1150
        private void InstallAllCheckbox_CheckChanged(object? sender, EventArgs? e)
1151
        {
×
1152
            WithFrozenChangeset(() =>
×
1153
            {
×
1154
                if (InstallAllCheckbox.Checked)
×
1155
                {
×
1156
                    // Reset changeset
1157
                    ClearChangeSet();
×
1158
                }
×
1159
                else
1160
                {
×
1161
                    // Uninstall all and cancel upgrades
1162
                    foreach (var row in mainModList.full_list_of_mod_rows.Values)
×
1163
                    {
×
1164
                        if (row.Tag is GUIMod gmod)
×
1165
                        {
×
1166
                            gmod.SelectedMod = null;
×
1167
                        }
×
1168
                    }
×
1169
                }
×
1170
            });
×
1171
        }
×
1172

1173
        public void ClearChangeSet()
1174
        {
×
1175
            WithFrozenChangeset(() =>
×
1176
            {
×
1177
                foreach (DataGridViewRow row in mainModList.full_list_of_mod_rows.Values)
×
1178
                {
×
1179
                    if (row.Tag is GUIMod gmod)
×
1180
                    {
×
1181
                        gmod.SelectedMod = gmod.InstalledMod?.Module;
×
1182
                    }
×
1183
                    if (row.Cells[ReplaceCol.Index] is DataGridViewCheckBoxCell checkCell)
×
1184
                    {
×
1185
                        checkCell.Value = false;
×
1186
                    }
×
1187
                    if (row.Cells[UpdateCol.Index] is DataGridViewCheckBoxCell updateCell)
×
1188
                    {
×
1189
                        updateCell.Value = false;
×
1190
                    }
×
1191
                }
×
1192
                if (ChangeSet != null && currentInstance != null)
×
1193
                {
×
1194
                    var registry  = RegistryManager.Instance(currentInstance, repoData).registry;
×
1195
                    var removable = registry.FindRemovableAutoInstalled(currentInstance)
×
1196
                                            .Select(im => im.Module)
×
1197
                                            .ToHashSet();
1198
                    removable.RemoveWhere(m => removable.Any(other => other.depends is List<RelationshipDescriptor> deps
×
1199
                                                                      && deps.Any(dep => dep.MatchesAny(new CkanModule[] { m },
×
1200
                                                                                                        null, null))));
1201
                    // Marking a mod as AutoInstalled can immediately queue it for removal if there is no dependent mod.
1202
                    // Reset the state of the AutoInstalled checkbox for these by deducing it from the changeset.
1203
                    var noLongerUsed = ChangeSet.Where(ch => ch.ChangeType == GUIModChangeType.Remove
×
1204
                                                             && ch.Reasons.Any(r => r is SelectionReason.NoLongerUsed))
×
1205
                                                .Select(ch => ch.Mod)
×
1206
                                                .Intersect(removable)
1207
                                                .ToArray();
1208
                    foreach (var mod in noLongerUsed)
×
1209
                    {
×
1210
                        if (mainModList.full_list_of_mod_rows.TryGetValue(mod.identifier, out DataGridViewRow? row)
×
1211
                            && row.Tag is GUIMod gmod)
1212
                        {
×
1213
                            gmod.SetAutoInstallChecked(row, AutoInstalled, false);
×
1214
                        }
×
1215
                    }
×
1216
                }
×
1217
            });
×
1218
        }
×
1219

1220
        private void WithFrozenChangeset(Action action)
1221
        {
×
1222
            if (freezeChangeSet)
×
1223
            {
×
1224
                // Already frozen by some outer block, let it handle the cleanup
1225
                action?.Invoke();
×
1226
            }
×
1227
            else
1228
            {
×
1229
                freezeChangeSet = true;
×
1230
                try
1231
                {
×
1232
                    action?.Invoke();
×
1233
                }
×
1234
                finally
1235
                {
×
1236
                    // Don't let anything ever prevent us from unfreezing the changeset
1237
                    freezeChangeSet = false;
×
1238
                    ModGrid.Refresh();
×
1239
                    if (currentInstance != null)
×
1240
                    {
×
1241
                        UpdateChangeSetAndConflicts(currentInstance,
×
1242
                                                    RegistryManager.Instance(currentInstance, repoData).registry);
1243
                    }
×
1244
                }
×
1245
            }
×
1246
        }
×
1247

1248
        /// <summary>
1249
        /// Find a column of the grid that can contain the CurrentCell.
1250
        /// Can't be hidden or an exception is thrown.
1251
        /// Shouldn't be a checkbox because we don't want the space bar to toggle.
1252
        /// </summary>
1253
        /// <returns>
1254
        /// Index of the column to use.
1255
        /// </returns>
1256
        private int SelectableColumnIndex()
1257
            // First try the currently active cell's column
1258
            => ModGrid.CurrentCell?.ColumnIndex
×
1259
                // If there's no currently active cell, use the first visible non-checkbox column
1260
                ?? ModGrid.Columns.Cast<DataGridViewColumn>()
1261
                    .FirstOrDefault(c => c is DataGridViewTextBoxColumn && c.Visible)?.Index
×
1262
                // Otherwise use the Installed checkbox column since it can't be hidden
1263
                ?? Installed.Index;
1264

1265
        public void FocusMod(string key, bool exactMatch, bool showAsFirst = false)
1266
        {
×
1267
            DataGridViewRow current_row = ModGrid.CurrentRow;
×
1268
            int currentIndex = current_row?.Index ?? 0;
×
1269
            DataGridViewRow? first_match = null;
×
1270

1271
            var does_name_begin_with_key = new Func<DataGridViewRow, bool>(row =>
×
1272
            {
×
1273
                var mod = row.Tag as GUIMod;
×
1274
                bool row_match;
1275
                if (exactMatch)
×
1276
                {
×
1277
                    row_match = mod?.Name == key || mod?.Identifier == key;
×
1278
                }
×
1279
                else
1280
                {
×
1281
                    row_match = mod != null
×
1282
                                && (mod.Name.StartsWith(key, StringComparison.OrdinalIgnoreCase)
1283
                                    || mod.Abbrevation.StartsWith(key, StringComparison.OrdinalIgnoreCase)
1284
                                    || mod.Identifier.StartsWith(key, StringComparison.OrdinalIgnoreCase));
1285
                }
×
1286

1287
                if (row_match && first_match == null)
×
1288
                {
×
1289
                    // Remember the first match to allow cycling back to it if necessary.
1290
                    first_match = row;
×
1291
                }
×
1292

1293
                if (key.Length == 1 && row_match && row.Index <= currentIndex)
×
1294
                {
×
1295
                    // Keep going forward if it's a single key match and not ahead of the current row.
1296
                    return false;
×
1297
                }
1298

1299
                return row_match;
×
1300
            });
×
1301

1302
            ModGrid.ClearSelection();
×
1303
            var match = ModGrid.Rows
×
1304
                               .OfType<DataGridViewRow>()
1305
                               .Where(row => row.Visible)
×
1306
                               .FirstOrDefault(does_name_begin_with_key);
1307
            if (match == null && first_match != null)
×
1308
            {
×
1309
                // If there were no matches after the first match, cycle over to the beginning.
1310
                match = first_match;
×
1311
            }
×
1312

1313
            if (match != null)
×
1314
            {
×
1315
                match.Selected = true;
×
1316

1317
                ModGrid.CurrentCell = match.Cells[SelectableColumnIndex()];
×
1318
                if (showAsFirst)
×
1319
                {
×
1320
                    ModGrid.FirstDisplayedScrollingRowIndex = match.Index;
×
1321
                }
×
1322
            }
×
1323
            else
1324
            {
×
1325
                RaiseMessage?.Invoke(Properties.Resources.MainNotFound);
×
1326
            }
×
1327
        }
×
1328

1329
        private void ModGrid_MouseDown(object? sender, MouseEventArgs e)
1330
        {
×
1331
            // Ignore header column to prevent errors
NEW
1332
            if (ModGrid.HitTest(e.X, e.Y) is { Type:     DataGridViewHitTestType.Cell,
×
1333
                                               RowIndex: > -1 and int rowIndex }
1334
                && ModGrid.Rows[rowIndex] is DataGridViewRow row
1335
                && e is { Button: MouseButtons.Right})
UNCOV
1336
            {
×
NEW
1337
                if (!row.Selected
×
1338
                    && !ModifierKeys.HasAnyFlag(Keys.Control, Keys.Shift))
NEW
1339
                {
×
NEW
1340
                    ModGrid.ClearSelection();
×
NEW
1341
                    row.Selected = true;
×
NEW
1342
                }
×
1343

1344
                // Show the context menu
1345
                ShowModContextMenu();
×
1346
            }
×
1347
        }
×
1348

1349
        private bool ShowModContextMenu()
1350
        {
×
NEW
1351
            if (SelectedModules.ToArray() is { Length: > 0 } and var modules)
×
1352
            {
×
NEW
1353
                var downloadable = modules.Where(m => !m.ToModule().IsMetapackage)
×
1354
                                          .ToArray();
1355
                // Set the menu options
NEW
1356
                downloadContentsToolStripMenuItem.Enabled = downloadable.Any(m => !m.IsCached);
×
NEW
1357
                purgeContentsToolStripMenuItem.Enabled    = downloadable.Any(m =>  m.IsCached);
×
NEW
1358
                reinstallToolStripMenuItem.Enabled        = modules.Any(m => m.IsInstalled && !m.IsAutodetected);
×
NEW
1359
                ModListContextMenuStrip.Show(Cursor.Position);
×
UNCOV
1360
                return true;
×
1361
            }
1362
            return false;
×
1363
        }
×
1364

1365
        private void ModGrid_Resize(object? sender, EventArgs? e)
1366
        {
×
1367
            InstallAllCheckbox.Top = ModGrid.Top - InstallAllCheckbox.Height;
×
1368
        }
×
1369

1370
        private void reinstallToolStripMenuItem_Click(object? sender, EventArgs? e)
1371
        {
×
NEW
1372
            if (SelectedModules.Where(m => m.IsInstalled && !m.IsAutodetected)
×
NEW
1373
                               .Select(m => m.ToModule())
×
1374
                               .ToArray()
1375
                is { Length: > 0 } and var modules
1376
                && currentInstance != null)
1377
            {
×
NEW
1378
                var registry = RegistryManager.Instance(currentInstance, repoData).registry;
×
NEW
1379
                var config = ServiceLocator.Container.Resolve<IConfiguration>();
×
NEW
1380
                StartChangeSet?.Invoke(
×
1381
                    modules.Select(module =>
1382
                                       // "Upgrade" to latest metadata for same module version
1383
                                       // (avoids removing and re-installing dependencies)
NEW
1384
                                       new ModUpgrade(module,
×
1385
                                                      registry.GetModuleByVersion(module.identifier,
1386
                                                                                  module.version)
1387
                                                              ?? module,
1388
                                                      true, false, config)
1389
                                       as ModChange)
1390
                           .ToList(),
1391
                    null);
1392
            }
×
1393
        }
×
1394

1395
        [ForbidGUICalls]
1396
        public Dictionary<string, GUIMod> AllGUIMods()
1397
            => mainModList.Modules.ToDictionary(guiMod => guiMod.Identifier,
×
1398
                                                guiMod => guiMod);
×
1399

1400
        private void purgeContentsToolStripMenuItem_Click(object? sender, EventArgs? e)
1401
        {
×
1402
            // Purge other versions as well since the user is likely to want that
1403
            // and has no other way to achieve it
NEW
1404
            if (currentInstance != null && manager?.Cache != null
×
NEW
1405
                && SelectedModules.Where(m => !m.ToModule().IsMetapackage
×
1406
                                              && m.IsCached)
1407
                                  .ToArray()
1408
                   is { Length: > 0 } and var modules)
1409
            {
×
NEW
1410
                UseWaitCursor = true;
×
UNCOV
1411
                manager.Cache.Purge(
×
NEW
1412
                    modules.SelectMany(m => RegistryManager.Instance(currentInstance, repoData)
×
1413
                                                           .registry
1414
                                                           .AvailableByIdentifier(m.Identifier))
1415
                           .ToArray());
NEW
1416
                UseWaitCursor = false;
×
1417
            }
×
1418
        }
×
1419

1420
        private void downloadContentsToolStripMenuItem_Click(object? sender, EventArgs? e)
1421
        {
×
NEW
1422
            if (SelectedModules.Where(m => !m.ToModule().IsMetapackage
×
1423
                                           && !m.IsCached)
1424
                               .ToArray()
1425
                is { Length: > 0 } and var modules)
NEW
1426
            {
×
NEW
1427
                Main.Instance?.StartDownloads(modules);
×
NEW
1428
            }
×
UNCOV
1429
        }
×
1430

1431
        private void EditModSearches_ApplySearches(List<ModSearch> searches)
1432
        {
×
1433
            mainModList.SetSearches(searches);
×
1434

1435
            // If these columns aren't hidden by the user, show them if the search includes installed modules
1436
            setInstalledColumnsVisible(mainModList.HasAnyInstalled
×
1437
                                       && !SearchesExcludeInstalled(searches)
1438
                                       && mainModList.HasVisibleInstalled());
1439
        }
×
1440

1441
        private void EditModSearches_SurrenderFocus()
1442
        {
×
1443
            Util.Invoke(this, () => ModGrid.Focus());
×
1444
        }
×
1445

1446
        [ForbidGUICalls]
1447
        private void UpdateFilters()
1448
        {
×
1449
            Util.Invoke(this, _UpdateFilters);
×
1450
        }
×
1451

1452
        private void _UpdateFilters()
1453
        {
×
1454
            if (ModGrid == null || mainModList?.full_list_of_mod_rows == null || currentInstance == null)
×
1455
            {
×
1456
                return;
×
1457
            }
1458

1459
            // Each time a row in DataGridViewRow is changed, DataGridViewRow updates the view. Which is slow.
1460
            // To make the filtering process faster, Copy the list of rows. Filter out the hidden and replace the
1461
            // rows in DataGridView.
1462

1463
            var rows = new DataGridViewRow[mainModList.full_list_of_mod_rows.Count];
×
1464
            mainModList.full_list_of_mod_rows.Values.CopyTo(rows, 0);
×
1465
            // Try to remember the current scroll position and selected mod
1466
            var scroll_col = Math.Max(0, ModGrid.FirstDisplayedScrollingColumnIndex);
×
1467
            GUIMod? selected_mod = null;
×
1468
            if (ModGrid.CurrentRow != null)
×
1469
            {
×
1470
                selected_mod = ModGrid.CurrentRow.Tag as GUIMod;
×
1471
            }
×
1472

1473
            var registry = RegistryManager.Instance(currentInstance, repoData).registry;
×
1474
            ModGrid.Rows.Clear();
×
1475
            var instName = currentInstance.Name;
×
1476
            var instGame = currentInstance.game;
×
1477
            rows.AsParallel().ForAll(row =>
×
1478
                row.Visible = row.Tag is GUIMod gmod
×
1479
                && mainModList.IsVisible(gmod, instName, instGame, registry));
1480
            ApplyHeaderGlyphs();
×
1481
            ModGrid.Rows.AddRange(Sort(rows.Where(row => row.Visible)).ToArray());
×
1482

1483
            // Find and select the previously selected row
1484
            if (selected_mod != null)
×
1485
            {
×
1486
                var selected_row = ModGrid.Rows.Cast<DataGridViewRow>()
×
1487
                    .FirstOrDefault(row => selected_mod.Identifier.Equals((row.Tag as GUIMod)?.Identifier));
×
1488
                if (selected_row != null)
×
1489
                {
×
1490
                    ModGrid.CurrentCell = selected_row.Cells[scroll_col];
×
1491
                }
×
1492
            }
×
1493
        }
×
1494

1495
        [ForbidGUICalls]
1496
        public void Update(object? sender, DoWorkEventArgs? e)
1497
        {
×
1498
            if (e != null)
×
1499
            {
×
1500
                e.Result = _UpdateModsList(e.Argument as Dictionary<string, bool>);
×
1501
            }
×
1502
        }
×
1503

1504
        [ForbidGUICalls]
1505
        private bool _UpdateModsList(Dictionary<string, bool>? old_modules = null)
1506
        {
×
1507
            if (currentInstance == null || guiConfig == null)
×
1508
            {
×
1509
                return false;
×
1510
            }
1511

1512
            log.Info("Updating the mod list");
×
1513

1514
            var regMgr = RegistryManager.Instance(currentInstance, repoData);
×
1515
            IRegistryQuerier registry = regMgr.registry;
×
1516

1517
            repoData.Prepopulate(
×
1518
                registry.Repositories.Values.ToList(),
1519
                new ProgressImmediate<int>(p => user?.RaiseProgress(
×
1520
                    Properties.Resources.LoadingCachedRepoData, p)));
1521

1522
            if (!regMgr.registry.HasAnyAvailable())
×
1523
            {
×
1524
                // Abort the refresh so we can update the repo data
1525
                return false;
×
1526
            }
1527

1528
            RaiseMessage?.Invoke(Properties.Resources.MainRepoScanning);
×
1529
            regMgr.ScanUnmanagedFiles();
×
1530

1531
            RaiseMessage?.Invoke(Properties.Resources.MainModListLoadingInstalled);
×
1532

1533
            var guiMods = mainModList.GetGUIMods(registry, repoData, currentInstance, guiConfig)
×
1534
                                     .ToHashSet();
1535

1536
            foreach (var gmod in mainModList.full_list_of_mod_rows
×
1537
                                            ?.Values
1538
                                             .Select(row => row.Tag)
×
1539
                                             .OfType<GUIMod>()
1540
                                            ?? Enumerable.Empty<GUIMod>())
1541
            {
×
1542
                gmod.PropertyChanged -= guiModule_PropertyChanged;
×
1543
            }
×
1544
            foreach (var gmod in guiMods)
×
1545
            {
×
1546
                gmod.PropertyChanged += guiModule_PropertyChanged;
×
1547
            }
×
1548

1549
            RaiseMessage?.Invoke(Properties.Resources.MainModListPreservingNew);
×
1550
            var toNotify = new HashSet<GUIMod>();
×
1551
            if (old_modules != null)
×
1552
            {
×
1553
                foreach (GUIMod gm in guiMods)
×
1554
                {
×
1555
                    if (old_modules.TryGetValue(gm.Identifier, out bool oldIncompat))
×
1556
                    {
×
1557
                        // Found it; check if newly compatible
1558
                        if (!gm.IsIncompatible && oldIncompat)
×
1559
                        {
×
1560
                            gm.IsNew = true;
×
1561
                            toNotify.Add(gm);
×
1562
                        }
×
1563
                    }
×
1564
                    else
1565
                    {
×
1566
                        // Newly indexed, show regardless of compatibility
1567
                        gm.IsNew = true;
×
1568
                    }
×
1569
                }
×
1570
            }
×
1571
            else
1572
            {
×
1573
                // Copy the new mod flag from the old list.
1574
                var oldNewMods = mainModList.Modules.Where(m => m.IsNew)
×
1575
                                                    .ToHashSet();
1576
                foreach (var guiMod in guiMods.Intersect(oldNewMods))
×
1577
                {
×
1578
                    guiMod.IsNew = true;
×
1579
                }
×
1580
            }
×
1581
            LabelsAfterUpdate?.Invoke(toNotify);
×
1582

1583
            RaiseMessage?.Invoke(Properties.Resources.MainModListPopulatingList);
×
1584
            // Update our mod listing
1585
            mainModList.ConstructModList(guiMods, currentInstance.Name, currentInstance.game, ChangeSet);
×
1586

1587
            UpdateChangeSetAndConflicts(currentInstance, registry);
×
1588

1589
            RaiseMessage?.Invoke(Properties.Resources.MainModListUpdatingFilters);
×
1590

1591
            var has_unheld_updates = mainModList.Modules.Any(mod => mod.HasUpdate
×
1592
                                                                    && (!Main.Instance?.LabelsHeld(mod.Identifier) ?? true));
1593
            Util.Invoke(Toolbar, () =>
×
1594
            {
×
1595
                FilterCompatibleButton.Text = string.Format(Properties.Resources.MainModListCompatible,
×
1596
                    mainModList.CountModsByFilter(currentInstance, GUIModFilter.Compatible));
1597
                FilterInstalledButton.Text = string.Format(Properties.Resources.MainModListInstalled,
×
1598
                    mainModList.CountModsByFilter(currentInstance, GUIModFilter.Installed));
1599
                FilterInstalledUpdateButton.Text = string.Format(Properties.Resources.MainModListUpgradeable,
×
1600
                    mainModList.CountModsByFilter(currentInstance, GUIModFilter.InstalledUpdateAvailable));
1601
                FilterReplaceableButton.Text = string.Format(Properties.Resources.MainModListReplaceable,
×
1602
                    mainModList.CountModsByFilter(currentInstance, GUIModFilter.Replaceable));
1603
                FilterCachedButton.Text = string.Format(Properties.Resources.MainModListCached,
×
1604
                    mainModList.CountModsByFilter(currentInstance, GUIModFilter.Cached));
1605
                FilterUncachedButton.Text = string.Format(Properties.Resources.MainModListUncached,
×
1606
                    mainModList.CountModsByFilter(currentInstance, GUIModFilter.Uncached));
1607
                FilterNewButton.Text = string.Format(Properties.Resources.MainModListNewlyCompatible,
×
1608
                    mainModList.CountModsByFilter(currentInstance, GUIModFilter.NewInRepository));
1609
                FilterNotInstalledButton.Text = string.Format(Properties.Resources.MainModListNotInstalled,
×
1610
                    mainModList.CountModsByFilter(currentInstance, GUIModFilter.NotInstalled));
1611
                FilterIncompatibleButton.Text = string.Format(Properties.Resources.MainModListIncompatible,
×
1612
                    mainModList.CountModsByFilter(currentInstance, GUIModFilter.Incompatible));
1613
                FilterAllButton.Text = string.Format(Properties.Resources.MainModListAll,
×
1614
                    mainModList.CountModsByFilter(currentInstance, GUIModFilter.All));
1615

1616
                UpdateAllToolButton.Enabled = has_unheld_updates;
×
1617
            });
×
1618

1619
            UpdateFilters();
×
1620

1621
            // Hide update and replacement columns if not needed.
1622
            // Write it to the configuration, else they are hidden again after a filter change.
1623
            // After the update / replacement, they are hidden again.
1624
            Util.Invoke(ModGrid, () =>
×
1625
            {
×
1626
                UpdateCol.Visible  = has_unheld_updates;
×
1627
                ReplaceCol.Visible = mainModList.Modules.Any(mod => mod.IsInstalled && mod.HasReplacement);
×
1628
            });
×
1629

1630
            UpdateHiddenTagsAndLabels();
×
1631

1632
            var timeSinceUpdate = guiConfig.RefreshOnStartup ? TimeSpan.Zero
×
1633
                                                             : repoData.LastUpdate(registry.Repositories.Values);
1634
            Util.Invoke(this, () =>
×
1635
            {
×
1636
                if (timeSinceUpdate < RepositoryDataManager.TimeTillStale)
×
1637
                {
×
1638
                    RefreshToolButton.Image = EmbeddedImages.refresh;
×
1639
                    RefreshToolButton.ToolTipText = new SingleAssemblyComponentResourceManager(typeof(ManageMods))
×
1640
                                                    .GetString($"{RefreshToolButton.Name}.ToolTipText");
1641
                }
×
1642
                else if (timeSinceUpdate < RepositoryDataManager.TimeTillVeryStale)
×
1643
                {
×
1644
                    // Gradually turn the dot from yellow to red as the user ignores it longer and longer
1645
                    RefreshToolButton.Image = Util.LerpBitmaps(
×
1646
                        EmbeddedImages.refreshStale,
1647
                        EmbeddedImages.refreshVeryStale,
1648
                        (float)((timeSinceUpdate - RepositoryDataManager.TimeTillStale).TotalSeconds
1649
                                / (RepositoryDataManager.TimeTillVeryStale
1650
                                   - RepositoryDataManager.TimeTillStale).TotalSeconds));
1651
                    RefreshToolButton.ToolTipText = string.Format(Properties.Resources.ManageModsRefreshStaleToolTip,
×
1652
                                                                  Math.Round(timeSinceUpdate.TotalDays));
1653
                }
×
1654
                else
1655
                {
×
1656
                    RefreshToolButton.Image = EmbeddedImages.refreshVeryStale;
×
1657
                    RefreshToolButton.ToolTipText = string.Format(Properties.Resources.ManageModsRefreshVeryStaleToolTip,
×
1658
                                                                  Math.Round(timeSinceUpdate.TotalDays));
1659
                }
×
1660
            });
×
1661

1662
            ClearStatusBar?.Invoke();
×
1663
            Util.Invoke(this, () => ModGrid.Focus());
×
1664
            return true;
×
1665
        }
×
1666

1667
        private void ModGrid_CurrentCellDirtyStateChanged(object? sender, EventArgs? e)
1668
        {
×
1669
            ModGrid_CellContentClick(sender, null);
×
1670
        }
×
1671

1672
        private void SetSort(DataGridViewColumn col)
1673
        {
×
1674
            if (//SortColumns is [string colName]
×
1675
                SortColumns.Count == 1
1676
                && SortColumns[0] is string colName
1677
                && colName == col.Name)
1678
            {
×
1679
                descending[0] = !descending[0];
×
1680
            }
×
1681
            else
1682
            {
×
1683
                SortColumns.Clear();
×
1684
                descending.Clear();
×
1685
                AddSort(col);
×
1686
            }
×
1687
        }
×
1688

1689
        private void AddSort(DataGridViewColumn col, bool atStart = false)
1690
        {
×
1691
            if (SortColumns.Count > 0 && SortColumns[^1] == col.Name)
×
1692
            {
×
1693
                descending[^1] = !descending[^1];
×
1694
            }
×
1695
            else
1696
            {
×
1697
                int middlePosition = SortColumns.IndexOf(col.Name);
×
1698
                if (middlePosition > -1)
×
1699
                {
×
1700
                    SortColumns.RemoveAt(middlePosition);
×
1701
                    descending.RemoveAt(middlePosition);
×
1702
                }
×
1703
                if (atStart)
×
1704
                {
×
1705
                    SortColumns.Insert(0, col.Name);
×
1706
                    descending.Insert(0, false);
×
1707
                }
×
1708
                else
1709
                {
×
1710
                    SortColumns.Add(col.Name);
×
1711
                    descending.Add(false);
×
1712
                }
×
1713
            }
×
1714
        }
×
1715

1716
        private IEnumerable<DataGridViewRow> Sort(IEnumerable<DataGridViewRow> rows)
1717
        {
×
1718
            var sorted = rows.ToList();
×
1719
            sorted.Sort(CompareRows);
×
1720
            return sorted;
×
1721
        }
×
1722

1723
        private void ApplyHeaderGlyphs()
1724
        {
×
1725
            foreach (DataGridViewColumn col in ModGrid.Columns)
×
1726
            {
×
1727
                col.HeaderCell.SortGlyphDirection = SortOrder.None;
×
1728
            }
×
1729
            for (int i = 0; i < SortColumns.Count; ++i)
×
1730
            {
×
1731
                if (!ModGrid.Columns.Contains(SortColumns[i]))
×
1732
                {
×
1733
                    // Shouldn't be possible, but better safe than sorry.
1734
                    continue;
×
1735
                }
1736
                ModGrid.Columns[SortColumns[i]].HeaderCell.SortGlyphDirection = descending[i]
×
1737
                    ? SortOrder.Descending : SortOrder.Ascending;
1738
            }
×
1739
        }
×
1740

1741
        private int CompareRows(DataGridViewRow a, DataGridViewRow b)
1742
        {
×
1743
            for (int i = 0; i < SortColumns.Count; ++i)
×
1744
            {
×
1745
                var val = CompareColumn(a, b, ModGrid.Columns[SortColumns[i]]);
×
1746
                if (val != 0)
×
1747
                {
×
1748
                    return descending[i] ? -val : val;
×
1749
                }
1750
            }
×
1751
            return CompareColumn(a, b, ModName);
×
1752
        }
×
1753

1754
        /// <summary>
1755
        /// Compare two rows based on one of their columns
1756
        /// </summary>
1757
        /// <param name="a">First row</param>
1758
        /// <param name="b">Second row</param>
1759
        /// <param name="col">The column to compare</param>
1760
        /// <returns>
1761
        /// -1 if a&lt;b, 1 if a&gt;b, 0 if a==b
1762
        /// </returns>
1763
        private int CompareColumn(DataGridViewRow a, DataGridViewRow b, DataGridViewColumn col)
1764
        {
×
1765
            var gmodA = a.Tag as GUIMod;
×
1766
            var gmodB = b.Tag as GUIMod;
×
1767
            var modA = gmodA?.ToModule();
×
1768
            var modB = gmodB?.ToModule();
×
1769
            var cellA = a.Cells[col.Index];
×
1770
            var cellB = b.Cells[col.Index];
×
1771
            if (col is DataGridViewCheckBoxColumn)
×
1772
            {
×
1773
                // Checked < non-"-" text < unchecked < "-" text
1774
                if (cellA is DataGridViewCheckBoxCell checkboxA)
×
1775
                {
×
1776
                    return cellB is DataGridViewCheckBoxCell checkboxB
×
1777
                            ? -((bool)checkboxA.Value).CompareTo((bool)checkboxB.Value)
1778
                        : (bool)checkboxA.Value || ((string)cellB.Value == "-") ? -1
1779
                        : 1;
1780
                }
1781
                else
1782
                {
×
1783
                    return cellB is DataGridViewCheckBoxCell ? -CompareColumn(b, a, col)
×
1784
                        : (string)cellA.Value == (string)cellB.Value ? 0
1785
                        : (string)cellA.Value == "-" ? 1
1786
                        : (string)cellB.Value == "-" ? -1
1787
                        : ((string)cellA.Value).CompareTo((string)cellB.Value);
1788
                }
1789
            }
1790
            else if (gmodA != null && gmodB != null && modA != null && modB != null)
×
1791
            {
×
1792
                switch (col.Name)
×
1793
                {
1794
                    case "ModName":           return gmodA.Name.CompareTo(gmodB.Name);
×
1795
                    case "GameCompatibility": return GameCompatComparison(a, b);
×
1796
                    case "InstallDate":       return CompareToNullable(gmodA.InstallDate,
×
1797
                                                                       gmodB.InstallDate);
1798
                    case "ReleaseDate":       return CompareToNullable(modA.release_date,
×
1799
                                                                       modB.release_date);
1800
                    case "DownloadSize":      return modA.download_size.CompareTo(modB.download_size);
×
1801
                    case "InstallSize":       return modA.install_size.CompareTo(modB.install_size);
×
1802
                    case "DownloadCount":     return CompareToNullable(gmodA.DownloadCount,
×
1803
                                                                       gmodB.DownloadCount);
1804
                    default:
1805
                        var valA = (cellA.Value as string) ?? "";
×
1806
                        var valB = (cellB.Value as string) ?? "";
×
1807
                        return valA.CompareTo(valB);
×
1808
                }
1809
            }
1810
            return 0;
×
1811
        }
×
1812

1813
        private static int CompareToNullable<T>(T? a, T? b) where T : struct, IComparable
1814
            => a.HasValue ? b.HasValue ? a.Value.CompareTo(b.Value)
×
1815
                                       : 1
1816
                          : b.HasValue ? -1
1817
                                       : 0;
1818

1819
        /// <summary>
1820
        /// Compare two rows' GameVersions as max versions.
1821
        /// GameVersion.CompareTo sorts IsAny to the beginning instead
1822
        /// of the end, and we can't change that without breaking many things.
1823
        /// Similarly, 1.8 should sort after 1.8.0.
1824
        /// </summary>
1825
        /// <param name="a">First row to compare</param>
1826
        /// <param name="b">Second row to compare</param>
1827
        /// <returns>
1828
        /// Positive to sort as a lessthan b, negative to sort as b lessthan a
1829
        /// </returns>
1830
        private int GameCompatComparison(DataGridViewRow a, DataGridViewRow b)
1831
        {
×
1832
            var verA = (a.Tag as GUIMod)?.GameCompatibilityVersion;
×
1833
            var verB = (b.Tag as GUIMod)?.GameCompatibilityVersion;
×
1834
            if (verA == null)
×
1835
            {
×
1836
                return verB == null ? 0 : -1;
×
1837
            }
1838
            else if (verB == null)
×
1839
            {
×
1840
                return 1;
×
1841
            }
1842
            var majorCompare = VersionPieceCompare(verA.IsMajorDefined, verA.Major, verB.IsMajorDefined, verB.Major);
×
1843
            if (majorCompare != 0)
×
1844
            {
×
1845
                return majorCompare;
×
1846
            }
1847
            else
1848
            {
×
1849
                var minorCompare = VersionPieceCompare(verA.IsMinorDefined, verA.Minor, verB.IsMinorDefined, verB.Minor);
×
1850
                if (minorCompare != 0)
×
1851
                {
×
1852
                    return minorCompare;
×
1853
                }
1854
                else
1855
                {
×
1856
                    var patchCompare = VersionPieceCompare(verA.IsPatchDefined, verA.Patch, verB.IsPatchDefined, verB.Patch);
×
1857
                    return patchCompare != 0
×
1858
                        ? patchCompare
1859
                        : VersionPieceCompare(verA.IsBuildDefined, verA.Build, verB.IsBuildDefined, verB.Build);
1860
                }
1861
            }
1862
        }
×
1863

1864
        /// <summary>
1865
        /// Compare pieces of two versions, each of which may be undefined,
1866
        /// sorting undefined toward the end.
1867
        /// </summary>
1868
        /// <param name="definedA">true if the first version piece is defined, false if undefined</param>
1869
        /// <param name="valA">Value of the first version piece</param>
1870
        /// <param name="definedB">true if the second version piece is defined, false if undefined</param>
1871
        /// <param name="valB">Value of the second version piece</param>
1872
        /// <returns>
1873
        /// Positive to sort a lessthan b, negative to sort b lessthan a
1874
        /// </returns>
1875
        private static int VersionPieceCompare(bool definedA, int valA, bool definedB, int valB)
1876
            => definedA
×
1877
                ? (definedB ? valA.CompareTo(valB) : -1)
1878
                : (definedB ? 1                    :  0);
1879

1880
        public void ResetFilterAndSelectModOnList(CkanModule module)
1881
        {
×
1882
            EditModSearches.Clear();
×
1883
            FocusMod(module.identifier, true);
×
1884
        }
×
1885

NEW
1886
        public GUIMod? SelectedModule => SelectedModules.FirstOrDefault();
×
1887

NEW
1888
        public IEnumerable<GUIMod> SelectedModules => ModGrid.SelectedRows
×
1889
                                                             .OfType<DataGridViewRow>()
NEW
1890
                                                             .Select(r => r.Tag)
×
1891
                                                             .OfType<GUIMod>();
1892

1893
        public void CloseSearch(Point screenCoords)
1894
        {
×
1895
            EditModSearches.CloseSearch(screenCoords);
×
1896
        }
×
1897

1898
        public void ParentMoved()
1899
        {
×
1900
            EditModSearches.ParentMoved();
×
1901
        }
×
1902

1903
        #region Hidden tags and labels links
1904

1905
        [ForbidGUICalls]
1906
        private void UpdateHiddenTagsAndLabels()
1907
        {
×
1908
            if (currentInstance != null)
×
1909
            {
×
1910
                var registry = RegistryManager.Instance(currentInstance, repoData).registry;
×
1911
                var tags = ModuleTagList.ModuleTags.HiddenTags
×
1912
                                                   .Intersect(registry.Tags.Keys)
1913
                                                   .OrderByDescending(tagName => tagName)
×
1914
                                                   .Select(tagName => registry.Tags[tagName])
×
1915
                                                   .ToList();
1916
                var labels = ModuleLabelList.ModuleLabels.LabelsFor(currentInstance.Name)
×
1917
                                                         .Where(l => l.Hide && l.ModuleCount(currentInstance.game) > 0)
×
1918
                                                         .ToList();
1919
                hiddenTagsLabelsLinkList.UpdateTagsAndLabels(tags, labels);
×
1920
                Util.Invoke(hiddenTagsLabelsLinkList, () =>
×
1921
                {
×
1922
                    hiddenTagsLabelsLinkList.Visible = tags.Count > 0 || labels.Count > 0;
×
1923
                    if (tags.Count > 0 || labels.Count > 0)
×
1924
                    {
×
1925
                        hiddenTagsLabelsLinkList.Controls.Add(new Label()
×
1926
                        {
1927
                            Text = tags.Count   == 0 ? Properties.Resources.ManageModsHiddenLabels
1928
                                 : labels.Count == 0 ? Properties.Resources.ManageModsHiddenTags
1929
                                 :                     Properties.Resources.ManageModsHiddenLabelsAndTags,
1930
                            AutoSize = true,
1931
                            Padding  = new Padding(0),
1932
                            Margin   = new Padding(0, 2, 0, 2),
1933
                        });
1934
                    }
×
1935
                });
×
1936
            }
×
1937
        }
×
1938

1939
        private void hiddenTagsLabelsLinkList_TagClicked(ModuleTag tag, bool merge)
1940
        {
×
1941
            ShowHeaderContextMenu(columns: false);
×
1942
        }
×
1943

1944
        private void hiddenTagsLabelsLinkList_LabelClicked(ModuleLabel label, bool merge)
1945
        {
×
1946
            Filter(ModList.FilterToSavedSearch(currentInstance!, GUIModFilter.CustomLabel, null, label),
×
1947
                   merge);
1948
        }
×
1949

1950
        public void TagsLabelsLinkList_ShowHideTag(ModuleTag t)
1951
        {
×
1952
            if (ModuleTagList.ModuleTags.HiddenTags.Contains(t.Name))
×
1953
            {
×
1954
                ModuleTagList.ModuleTags.HiddenTags.Remove(t.Name);
×
1955
            }
×
1956
            else
1957
            {
×
1958
                ModuleTagList.ModuleTags.HiddenTags.Add(t.Name);
×
1959
            }
×
1960
            ModuleTagList.ModuleTags.Save(ModuleTagList.DefaultPath);
×
1961
            UpdateFilters();
×
1962
            UpdateHiddenTagsAndLabels();
×
1963
        }
×
1964

1965
        public void TagsLabelsLinkList_AddRemoveModuleLabel(ModuleLabel l)
1966
        {
×
1967
            if (SelectedModule != null && currentInstance != null)
×
1968
            {
×
1969
                ToggleModuleLabel(l, currentInstance, SelectedModule);
×
1970
            }
×
1971
        }
×
1972

1973
        private void ToggleModuleLabel(ModuleLabel label, GameInstance instance,
1974
                                       IEnumerable<GUIMod> modules)
1975
        {
×
NEW
1976
            var registry = RegistryManager.Instance(instance, repoData).registry;
×
NEW
1977
            foreach (var module in modules)
×
1978
            {
×
NEW
1979
                if (label.ContainsModule(instance.game, module.Identifier))
×
NEW
1980
                {
×
NEW
1981
                    label.Remove(instance.game, module.Identifier);
×
NEW
1982
                }
×
1983
                else
NEW
1984
                {
×
NEW
1985
                    label.Add(instance.game, module.Identifier);
×
NEW
1986
                }
×
NEW
1987
                mainModList.ReapplyLabels(module, Conflicts?.ContainsKey(module) ?? false,
×
1988
                                          instance.Name, instance.game, registry);
NEW
1989
                if (module == SelectedModule)
×
NEW
1990
                {
×
NEW
1991
                    OnSelectedModuleChanged?.Invoke(module);
×
NEW
1992
                }
×
1993
            }
×
1994
            ModuleLabelList.ModuleLabels.Save(ModuleLabelList.DefaultPath);
×
1995
            UpdateHiddenTagsAndLabels();
×
1996
            if (label.HoldVersion || label.IgnoreMissingFiles)
×
1997
            {
×
1998
                UpdateCol.Visible = UpdateAllToolButton.Enabled =
×
1999
                    mainModList.ResetHasUpdate(instance, registry, ChangeSet, ModGrid.Rows);
2000
            }
×
2001
        }
×
2002

2003
        private void ToggleModuleLabel(ModuleLabel label, GameInstance instance,
2004
                                       GUIMod module)
NEW
2005
        {
×
NEW
2006
            ToggleModuleLabel(label, instance, Enumerable.Repeat(module, 1));
×
NEW
2007
        }
×
2008

2009
        #endregion
2010

2011
        #region Navigation History
2012

2013
        private void NavInit()
2014
        {
×
2015
            navHistory.OnHistoryChange += NavOnHistoryChange;
×
2016
            navHistory.IsReadOnly = false;
×
2017
            var currentMod = SelectedModule;
×
2018
            if (currentMod != null)
×
2019
            {
×
2020
                navHistory.AddToHistory(currentMod);
×
2021
            }
×
2022
        }
×
2023

2024
        private void NavSelectMod(GUIMod module)
2025
        {
×
2026
            navHistory.AddToHistory(module);
×
2027
        }
×
2028

2029
        public void NavGoBackward()
2030
        {
×
2031
            if (navHistory.TryGoBackward(out GUIMod? newCurrentItem))
×
2032
            {
×
2033
                NavGoToMod(newCurrentItem);
×
2034
            }
×
2035
        }
×
2036

2037
        public void NavGoForward()
2038
        {
×
2039
            if (navHistory.TryGoForward(out GUIMod? newCurrentItem))
×
2040
            {
×
2041
                NavGoToMod(newCurrentItem);
×
2042
            }
×
2043
        }
×
2044

2045
        private void NavGoToMod(GUIMod module)
2046
        {
×
2047
            // Focusing on a mod also causes navigation, but we don't want
2048
            // this to affect the history, so we switch to read-only mode.
2049
            navHistory.IsReadOnly = true;
×
2050
            FocusMod(module.Name, true);
×
2051
            navHistory.IsReadOnly = false;
×
2052
        }
×
2053

2054
        private void NavOnHistoryChange()
2055
        {
×
2056
            NavBackwardToolButton.Enabled = navHistory.CanNavigateBackward;
×
2057
            NavForwardToolButton.Enabled = navHistory.CanNavigateForward;
×
2058
        }
×
2059

2060
        #endregion
2061

2062
        protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
2063
        {
×
2064
            switch (keyData)
×
2065
            {
2066
                case Keys.Control | Keys.S:
2067
                    if (ChangeSet != null && ChangeSet.Count != 0)
×
2068
                    {
×
2069
                        ApplyToolButton_Click(null, null);
×
2070
                    }
×
2071

2072
                    return true;
×
2073
            }
2074

2075
            return base.ProcessCmdKey(ref msg, keyData);
×
2076
        }
×
2077

2078
        public void FocusSearch(bool expandCollapse = false)
2079
        {
×
2080
            ActiveControl = EditModSearches;
×
2081
            EditModSearches.Focus();
×
2082
            if (expandCollapse)
×
2083
            {
×
2084
                EditModSearches.ExpandCollapse();
×
2085
            }
×
2086
        }
×
2087

2088
        public bool AllowClose()
2089
        {
×
2090
            if (Conflicts != null && Conflicts.Count != 0)
×
2091
            {
×
2092
                // Ask if they want to resolve conflicts
2093
                string confDescrip = Conflicts
×
2094
                    .Select(kvp => kvp.Value)
×
2095
                    .Aggregate((a, b) => $"{a}, {b}");
×
2096
                if (!Main.Instance?.YesNoDialog(string.Format(Properties.Resources.MainQuitWithConflicts, confDescrip),
×
2097
                    Properties.Resources.MainQuit,
2098
                    Properties.Resources.MainGoBack) ?? false)
2099
                {
×
2100
                    return false;
×
2101
                }
2102
            }
×
2103
            else if (ChangeSet?.Any() ?? false)
×
2104
            {
×
2105
                // Ask if they want to discard the change set
2106
                string changeDescrip = ChangeSet
×
2107
                    .GroupBy(ch => ch.ChangeType, ch => ch.Mod.name)
×
2108
                    .Select(grp => $"{grp.Key}: "
×
2109
                        + grp.Aggregate((a, b) => $"{a}, {b}"))
×
2110
                    .Aggregate((a, b) => $"{a}\r\n{b}");
×
2111
                if (!Main.Instance?.YesNoDialog(string.Format(Properties.Resources.MainQuitWithUnappliedChanges, changeDescrip),
×
2112
                    Properties.Resources.MainQuit,
2113
                    Properties.Resources.MainGoBack) ?? false)
2114
                {
×
2115
                    return false;
×
2116
                }
2117
            }
×
2118
            return true;
×
2119
        }
×
2120

2121
        public void InstanceUpdated()
2122
        {
×
2123
            Conflicts = null;
×
2124
            ChangeSet = null;
×
2125
            ModGrid.CurrentCell = null;
×
2126
            SetPlayButtonActiveInactive(false);
×
2127
        }
×
2128

2129
        public HashSet<ModChange> ComputeUserChangeSet()
2130
            => currentInstance == null
×
2131
                ? new HashSet<ModChange>()
2132
                : mainModList.ComputeUserChangeSet(
2133
                      RegistryManager.Instance(currentInstance, repoData).registry,
2134
                      currentInstance.VersionCriteria(),
2135
                      currentInstance,
2136
                      UpdateCol, ReplaceCol);
2137

2138
        [ForbidGUICalls]
2139
        public void UpdateChangeSetAndConflicts(GameInstance inst, IRegistryQuerier registry)
2140
        {
×
2141
            if (freezeChangeSet)
×
2142
            {
×
2143
                log.Debug("Skipping refresh because changeset is frozen");
×
2144
                return;
×
2145
            }
2146

2147
            List<ModChange>? full_change_set = null;
×
2148
            Dictionary<GUIMod, string>? new_conflicts = null;
×
2149

2150
            var gameVersion = inst.VersionCriteria();
×
2151
            var user_change_set = mainModList.ComputeUserChangeSet(registry, gameVersion, inst, UpdateCol, ReplaceCol);
×
2152
            try
2153
            {
×
2154
                // Set the target versions of upgrading mods based on what's actually allowed
2155
                foreach (var ch in user_change_set.OfType<ModUpgrade>())
×
2156
                {
×
2157
                    if (mainModList.full_list_of_mod_rows[ch.Mod.identifier].Tag is GUIMod gmod)
×
2158
                    {
×
2159
                       // This setter calls UpdateChangeSetAndConflicts, so there's a risk of
2160
                       // an infinite loop here. Tread lightly!
2161
                       gmod.SelectedMod = ch.targetMod;
×
2162
                    }
×
2163
                }
×
2164
                var tuple = mainModList.ComputeFullChangeSetFromUserChangeSet(registry, user_change_set, inst.game,
×
2165
                                                                              inst.StabilityToleranceConfig, gameVersion);
2166
                full_change_set = tuple.Item1.ToList();
×
2167
                new_conflicts = tuple.Item2.ToDictionary(
×
2168
                    item => new GUIMod(item.Key, repoData, registry, inst.StabilityToleranceConfig, gameVersion, null,
×
2169
                                       guiConfig?.HideEpochs ?? false,
2170
                                       guiConfig?.HideV      ?? false),
2171
                    item => item.Value);
×
2172
                if (new_conflicts.Count > 0)
×
2173
                {
×
2174
                    SetStatusBar?.Invoke(string.Join("; ", tuple.Item3));
×
2175
                }
×
2176
                else
2177
                {
×
2178
                    // Clear the conflict area if no conflicts
2179
                    ClearStatusBar?.Invoke();
×
2180
                }
×
2181
            }
×
2182
            catch (DependenciesNotSatisfiedKraken k)
×
2183
            {
×
2184
                RaiseError?.Invoke(k.Message);
×
2185
                var identifiers = k.unsatisfied
×
2186
                                   .SelectMany(uns => uns.Select(rr => rr.source.identifier))
×
2187
                                   .Distinct();
2188

2189
                foreach (var ident in identifiers)
×
2190
                {
×
2191
                    // Uncheck the box
2192
                    if (mainModList.full_list_of_mod_rows[ident].Tag is GUIMod gmod)
×
2193
                    {
×
2194
                        gmod.SelectedMod = null;
×
2195
                    }
×
2196
                }
×
2197
            }
×
2198

2199
            Conflicts = new_conflicts;
×
2200
            ChangeSet = full_change_set;
×
2201
        }
×
2202

2203
    }
2204
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc