From a192a63c82da6e3dbf266834e8c65338f70f02d6 Mon Sep 17 00:00:00 2001
From: mircearoata <mircearoatapalade@gmail.com>
Date: Wed, 6 Dec 2023 20:37:33 +0100
Subject: [PATCH] feat: add mod updating (#42)

* feat: add mod updating

* fix: refactor for previous changes

* test: add mod update tests

---------

Co-authored-by: Vilsol <me@vil.so>
---
 README.md                      | 102 ++++-----
 cli/context.go                 |  23 ++
 cli/disk/main.go               |  21 ++
 cli/installations.go           |  52 +++++
 cli/lockfile.go                |   8 +
 cli/resolving_test.go          |  61 +++++
 tea/scenes/main_menu.go        |   7 +
 tea/scenes/mods/update_mods.go | 393 +++++++++++++++++++++++++++++++++
 8 files changed, 616 insertions(+), 51 deletions(-)
 create mode 100644 tea/scenes/mods/update_mods.go

diff --git a/README.md b/README.md
index 70b62f9..eb3ca0f 100644
--- a/README.md
+++ b/README.md
@@ -4,58 +4,58 @@ A CLI tool for managing mods for the game Satisfactory
 
 ## Installation
 
-### Windows
+<table>
+  <tr>
+    <th></th>
+    <th>amd64</th>
+    <th>386</th>
+    <th>arm64</th>
+    <th>armv7</th>
+    <th>ppc64le</th>
+  </tr>
+  <tr>
+    <th>Windows</th>
+    <td><a href="https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_windows_amd64.exe">amd64</a></td>
+    <td><a href="https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_windows_386.exe">386</a></td>
+    <td><a href="https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_windows_arm64.exe">arm64</a></td>
+    <td><a href="https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_windows_armv7.exe">armv7</a></td>
+    <td>N/A</td>
+  </tr>
+  <tr>
+    <th>Arch</th>
+    <td colspan="5" style="text-align: center"><a href="https://aur.archlinux.org/packages/ficsit-cli-bin"><code>yay -S ficsit-cli-bin</code></a></td>
+  </tr>
+  <tr>
+    <th>Debian</th>
+    <td><a href="https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_amd64.deb">amd64</a></td>
+    <td><a href="https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_386.deb">386</a></td>
+    <td><a href="https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_arm64.deb">arm64</a></td>
+    <td><a href="https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_armv7.deb">armv7</a></td>
+    <td><a href="https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_ppc64le.deb">ppc64le</a></td>
+  </tr>
+  <tr>
+    <th>Fedora</th>
+    <td><a href="https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_amd64.rpm">amd64</a></td>
+    <td><a href="https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_386.rpm">386</a></td>
+    <td><a href="https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_arm64.rpm">arm64</a></td>
+    <td><a href="https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_armv7.rpm">armv7</a></td>
+    <td><a href="https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_ppc64le.rpm">ppc64le</a></td>
+  </tr>
+  <tr>
+    <th>Alpine</th>
+    <td><a href="https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_amd64.apk">amd64</a></td>
+    <td><a href="https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_386.apk">386</a></td>
+    <td><a href="https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_arm64.apk">arm64</a></td>
+    <td><a href="https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_armv7.apk">armv7</a></td>
+    <td><a href="https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_ppc64le.apk">ppc64le</a></td>
+  </tr>
+  <tr>
+    <th>macOS</th>
+    <td colspan="4" style="text-align: center"><a href="https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_darwin_all">darwin_all</a></td>
+    <td>N/A</td>
+  </tr>
+</table>
 
-Download the appropriate `.exe` for your CPU architecture.
-
-* [AMD64 (64-bit)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_windows_amd64.exe)
-* [386 (32-bit)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_windows_386.exe)
-* [ARM64 (64-bit ARM)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_windows_arm64.exe)
-* [ARMv7 (32-bit ARM)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_windows_armv7.exe)
-
-### Linux
-
-#### Arch
-
-A package is published to AUR under the name [`ficsit-cli-bin`](https://aur.archlinux.org/packages/ficsit-cli-bin)
-
-```shell
-yay -S ficsit-cli-bin
-```
-
-#### Debian (inc. Ubuntu, Mint, PopOS!, etc)
-
-Download the appropriate `.deb` for your CPU architecture.
-
-* [AMD64 (64-bit)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_amd64.deb)
-* [386 (32-bit)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_386.deb)
-* [ARM64 (64-bit ARM)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_arm64.deb)
-* [ARMv7 (32-bit ARM)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_armv7.deb)
-* [PowerPC64](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_ppc64le.deb)
-
-#### Fedora
-
-Download the appropriate `.rpm` for your CPU architecture.
-
-* [AMD64 (64-bit)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_amd64.rpm)
-* [386 (32-bit)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_386.rpm)
-* [ARM64 (64-bit ARM)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_arm64.rpm)
-* [ARMv7 (32-bit ARM)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_armv7.rpm)
-* [PowerPC64](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_ppc64le.rpm)
-
-#### Alpine
-
-Download the appropriate `.apk` for your CPU architecture.
-
-* [AMD64 (64-bit)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_amd64.apk)
-* [386 (32-bit)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_386.apk)
-* [ARM64 (64-bit ARM)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_arm64.apk)
-* [ARMv7 (32-bit ARM)](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_armv7.apk)
-* [PowerPC64](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_linux_ppc64le.apk)
-
-### macOS
-
-Download the "all" build [here](https://github.com/Vilsol/ficsit-cli/releases/latest/download/ficsit_darwin_all).
 
 ## Usage
 
diff --git a/cli/context.go b/cli/context.go
index 7352f8c..0bf1ed7 100644
--- a/cli/context.go
+++ b/cli/context.go
@@ -64,6 +64,29 @@ func InitCLI(apiOnly bool) (*GlobalContext, error) {
 	return globalContext, nil
 }
 
+// Wipe will remove any trace of ficsit anywhere
+func (g *GlobalContext) Wipe() error {
+	// Wipe all installations
+	for _, installation := range g.Installations.Installations {
+		if err := installation.Wipe(); err != nil {
+			return errors.Wrap(err, "failed wiping installation")
+		}
+
+		if err := g.Installations.DeleteInstallation(installation.Path); err != nil {
+			return errors.Wrap(err, "failed deleting installation")
+		}
+	}
+
+	// Wipe all profiles
+	for _, profile := range g.Profiles.Profiles {
+		if err := g.Profiles.DeleteProfile(profile.Name); err != nil {
+			return errors.Wrap(err, "failed deleting profile")
+		}
+	}
+
+	return g.Save()
+}
+
 func (g *GlobalContext) Save() error {
 	if err := g.Installations.Save(); err != nil {
 		return errors.Wrap(err, "failed to save installations")
diff --git a/cli/disk/main.go b/cli/disk/main.go
index c438352..018a2ae 100644
--- a/cli/disk/main.go
+++ b/cli/disk/main.go
@@ -9,14 +9,35 @@ import (
 )
 
 type Disk interface {
+	// Exists checks if the provided file or directory exists
 	Exists(path string) error
+
+	// Read returns the entire file as a byte buffer
+	//
+	// Returns error if provided path is not a file
 	Read(path string) ([]byte, error)
+
+	// Write writes provided byte buffer to the path
 	Write(path string, data []byte) error
+
+	// Remove deletes the provided file or directory recursively
 	Remove(path string) error
+
+	// MkDir creates the provided directory recursively
 	MkDir(path string) error
+
+	// ReadDir returns all entries within the directory
+	//
+	// Returns error if provided path is not a directory
 	ReadDir(path string) ([]Entry, error)
+
+	// IsNotExist returns true if provided error is a not-exist type error
 	IsNotExist(err error) bool
+
+	// IsExist returns true if provided error is a does-exist type error
 	IsExist(err error) bool
+
+	// Open opens provided path for writing
 	Open(path string, flag int) (io.WriteCloser, error)
 }
 
diff --git a/cli/installations.go b/cli/installations.go
index 235167e..403beb3 100644
--- a/cli/installations.go
+++ b/cli/installations.go
@@ -312,6 +312,20 @@ func (i *Installation) WriteLockFile(ctx *GlobalContext, lockfile LockFile) erro
 	return nil
 }
 
+func (i *Installation) Wipe() error {
+	d, err := i.GetDisk()
+	if err != nil {
+		return err
+	}
+
+	modsDirectory := filepath.Join(i.BasePath(), "FactoryGame", "Mods")
+	if err := d.Remove(modsDirectory); err != nil {
+		return errors.Wrap(err, "failed removing Mods directory")
+	}
+
+	return nil
+}
+
 func (i *Installation) ResolveProfile(ctx *GlobalContext) (LockFile, error) {
 	lockFile, err := i.LockFile(ctx)
 	if err != nil {
@@ -466,6 +480,44 @@ func (i *Installation) Install(ctx *GlobalContext, updates chan InstallUpdate) e
 	return nil
 }
 
+func (i *Installation) UpdateMods(ctx *GlobalContext, mods []string) error {
+	if err := i.Validate(ctx); err != nil {
+		return errors.Wrap(err, "failed to validate installation")
+	}
+
+	lockFile, err := i.LockFile(ctx)
+	if err != nil {
+		return errors.Wrap(err, "failed to read lock file")
+	}
+
+	resolver := NewDependencyResolver(ctx.Provider)
+
+	gameVersion, err := i.GetGameVersion(ctx)
+	if err != nil {
+		return errors.Wrap(err, "failed to detect game version")
+	}
+
+	profile := ctx.Profiles.GetProfile(i.Profile)
+	if profile == nil {
+		return errors.New("could not find profile " + i.Profile)
+	}
+
+	for _, modReference := range mods {
+		lockFile = lockFile.Remove(modReference)
+	}
+
+	newLockFile, err := profile.Resolve(resolver, lockFile, gameVersion)
+	if err != nil {
+		return errors.Wrap(err, "failed to resolve dependencies")
+	}
+
+	if err := i.WriteLockFile(ctx, newLockFile); err != nil {
+		return errors.Wrap(err, "failed to write lock file")
+	}
+
+	return nil
+}
+
 func (i *Installation) SetProfile(ctx *GlobalContext, profile string) error {
 	found := false
 	for _, p := range ctx.Profiles.Profiles {
diff --git a/cli/lockfile.go b/cli/lockfile.go
index 9e90e40..f68e2d7 100644
--- a/cli/lockfile.go
+++ b/cli/lockfile.go
@@ -16,3 +16,11 @@ func (l LockFile) Clone() LockFile {
 	}
 	return lockFile
 }
+
+func (l *LockFile) Remove(modID ...string) *LockFile {
+	out := *l
+	for _, s := range modID {
+		delete(out, s)
+	}
+	return &out
+}
diff --git a/cli/resolving_test.go b/cli/resolving_test.go
index 8060621..ad586a9 100644
--- a/cli/resolving_test.go
+++ b/cli/resolving_test.go
@@ -2,6 +2,7 @@ package cli
 
 import (
 	"math"
+	"os"
 	"testing"
 
 	"github.com/MarvinJWendt/testza"
@@ -69,3 +70,63 @@ func TestResolutionNonExistentMod(t *testing.T) {
 
 	testza.AssertEqual(t, "failed resolving profile dependencies: failed to solve dependencies: failed to make decision: failed to get package versions: mod ThisModDoesNotExist$$$ not found", err.Error())
 }
+
+func TestUpdateMods(t *testing.T) {
+	ctx, err := InitCLI(false)
+	testza.AssertNoError(t, err)
+
+	err = ctx.Wipe()
+	testza.AssertNoError(t, err)
+
+	resolver := NewDependencyResolver(ctx.Provider)
+
+	oldLockfile, err := (&Profile{
+		Name: DefaultProfileName,
+		Mods: map[string]ProfileMod{
+			"AreaActions": {
+				Version: "1.6.5",
+				Enabled: true,
+			},
+		},
+	}).Resolve(resolver, nil, math.MaxInt)
+
+	testza.AssertNoError(t, err)
+	testza.AssertNotNil(t, oldLockfile)
+	testza.AssertLen(t, oldLockfile, 2)
+
+	profileName := "UpdateTest"
+	profile, err := ctx.Profiles.AddProfile(profileName)
+	testza.AssertNoError(t, err)
+	testza.AssertNoError(t, profile.AddMod("AreaActions", "<=1.6.6"))
+
+	serverLocation := os.Getenv("SF_DEDICATED_SERVER")
+	if serverLocation != "" {
+		installation, err := ctx.Installations.AddInstallation(ctx, serverLocation, profileName)
+		testza.AssertNoError(t, err)
+		testza.AssertNotNil(t, installation)
+
+		err = installation.WriteLockFile(ctx, oldLockfile)
+		testza.AssertNoError(t, err)
+
+		err = installation.Install(ctx, nil)
+		testza.AssertNoError(t, err)
+
+		lockFile, err := installation.LockFile(ctx)
+		testza.AssertNoError(t, err)
+
+		testza.AssertEqual(t, 2, len(*lockFile))
+		testza.AssertEqual(t, "1.6.5", (*lockFile)["AreaActions"].Version)
+
+		err = installation.UpdateMods(ctx, []string{"AreaActions"})
+		testza.AssertNoError(t, err)
+
+		lockFile, err = installation.LockFile(ctx)
+		testza.AssertNoError(t, err)
+
+		testza.AssertEqual(t, 2, len(*lockFile))
+		testza.AssertEqual(t, "1.6.6", (*lockFile)["AreaActions"].Version)
+
+		err = installation.Install(ctx, nil)
+		testza.AssertNoError(t, err)
+	}
+}
diff --git a/tea/scenes/main_menu.go b/tea/scenes/main_menu.go
index 1d017da..5d9c0cb 100644
--- a/tea/scenes/main_menu.go
+++ b/tea/scenes/main_menu.go
@@ -101,6 +101,13 @@ func NewMainMenu(root components.RootModel) tea.Model {
 				return newModel, newModel.Init()
 			},
 		},
+		utils.SimpleItem[mainMenu]{
+			ItemTitle: "Update Mods",
+			Activate: func(msg tea.Msg, currentModel mainMenu) (tea.Model, tea.Cmd) {
+				newModel := mods.NewUpdateMods(root, currentModel)
+				return newModel, newModel.Init()
+			},
+		},
 		utils.SimpleItem[mainMenu]{
 			ItemTitle: "Apply Changes",
 			Activate: func(msg tea.Msg, currentModel mainMenu) (tea.Model, tea.Cmd) {
diff --git a/tea/scenes/mods/update_mods.go b/tea/scenes/mods/update_mods.go
new file mode 100644
index 0000000..d691a0b
--- /dev/null
+++ b/tea/scenes/mods/update_mods.go
@@ -0,0 +1,393 @@
+package mods
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"sort"
+	"time"
+
+	"github.com/charmbracelet/bubbles/key"
+	"github.com/charmbracelet/bubbles/list"
+	"github.com/charmbracelet/bubbles/spinner"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/muesli/reflow/truncate"
+
+	"github.com/satisfactorymodding/ficsit-cli/cli"
+	"github.com/satisfactorymodding/ficsit-cli/ficsit"
+	"github.com/satisfactorymodding/ficsit-cli/tea/components"
+	"github.com/satisfactorymodding/ficsit-cli/tea/scenes/keys"
+	"github.com/satisfactorymodding/ficsit-cli/tea/utils"
+)
+
+var _ tea.Model = (*updateModsList)(nil)
+
+type updateModsList struct {
+	root   components.RootModel
+	list   list.Model
+	parent tea.Model
+	items  chan listUpdate
+
+	err   chan string
+	error *components.ErrorComponent
+
+	selectedMods []string
+}
+
+func NewUpdateMods(root components.RootModel, parent tea.Model) tea.Model {
+	if root.GetCurrentProfile() == nil {
+		return parent
+	}
+	if root.GetCurrentInstallation() == nil {
+		return parent
+	}
+
+	l := list.New([]list.Item{}, updateModsListDelegate{ItemDelegate: utils.NewItemDelegate(), selectedMods: []string{}}, root.Size().Width, root.Size().Height-root.Height())
+	l.SetShowStatusBar(true)
+	l.SetShowFilter(true)
+	l.SetFilteringEnabled(true)
+	l.SetSpinner(spinner.MiniDot)
+	l.Title = "Update Mods"
+	l.Styles = utils.ListStyles
+	l.SetSize(l.Width(), l.Height())
+	l.KeyMap.Quit.SetHelp("q", "back")
+	l.DisableQuitKeybindings()
+
+	l.AdditionalShortHelpKeys = func() []key.Binding {
+		return []key.Binding{
+			key.NewBinding(key.WithHelp("q", "back")),
+			key.NewBinding(key.WithHelp("space", "select")),
+			key.NewBinding(key.WithHelp("enter", "confirm")),
+		}
+	}
+
+	l.AdditionalFullHelpKeys = func() []key.Binding {
+		return []key.Binding{
+			key.NewBinding(key.WithHelp("q", "back")),
+			key.NewBinding(key.WithHelp("space", "select")),
+			key.NewBinding(key.WithHelp("enter", "confirm")),
+		}
+	}
+
+	return &updateModsList{
+		root:   root,
+		list:   l,
+		parent: parent,
+		items:  make(chan listUpdate),
+		err:    make(chan string),
+	}
+}
+
+func (m updateModsList) Init() tea.Cmd {
+	go m.LoadModData()
+	return utils.Ticker()
+}
+
+type modUpdate struct {
+	Reference string
+	From      string
+	To        string
+}
+
+type modToggleMsg struct {
+	reference string
+}
+
+func (m updateModsList) LoadModData() {
+	currentInstallation := m.root.GetCurrentInstallation()
+	currentProfile := m.root.GetCurrentProfile()
+
+	currentLockfile, err := m.root.GetCurrentInstallation().LockFile(m.root.GetGlobal())
+	if err != nil {
+		return
+	}
+	if currentLockfile == nil {
+		return
+	}
+
+	gameVersion, err := currentInstallation.GetGameVersion(m.root.GetGlobal())
+	if err != nil {
+		return
+	}
+
+	resolver := cli.NewDependencyResolver(m.root.GetProvider())
+
+	updatedLockfile, err := currentProfile.Resolve(resolver, nil, gameVersion)
+	if err != nil {
+		return
+	}
+
+	items := make([]list.Item, 0)
+	i := 0
+	for reference, currentLockedMod := range *currentLockfile {
+		r := reference
+		updatedLockedMod, ok := updatedLockfile[reference]
+		if !ok {
+			continue
+		}
+		if updatedLockedMod.Version == currentLockedMod.Version {
+			continue
+		}
+		items = append(items, utils.SimpleItemExtra[updateModsList, modUpdate]{
+			SimpleItem: utils.SimpleItem[updateModsList]{
+				ItemTitle: fmt.Sprintf("%s - %s -> %s", reference, currentLockedMod.Version, updatedLockedMod.Version),
+				Activate: func(msg tea.Msg, currentModel updateModsList) (tea.Model, tea.Cmd) {
+					return currentModel, func() tea.Msg {
+						return modToggleMsg{reference: r}
+					}
+				},
+			},
+			Extra: modUpdate{
+				Reference: r,
+				From:      currentLockedMod.Version,
+				To:        updatedLockedMod.Version,
+			},
+		})
+		i++
+	}
+
+	sort.Slice(items, func(i, j int) bool {
+		a := items[i].(utils.SimpleItemExtra[updateModsList, modUpdate])
+		b := items[j].(utils.SimpleItemExtra[updateModsList, modUpdate])
+		return ascDesc(sortOrderDesc, a.ItemTitle < b.ItemTitle)
+	})
+
+	m.items <- listUpdate{
+		Items: items,
+		Done:  false,
+	}
+
+	m.loadModNames(items)
+}
+
+func (m updateModsList) loadModNames(items []list.Item) {
+	if len(items) == 0 {
+		m.items <- listUpdate{
+			Items: items,
+			Done:  true,
+		}
+		return
+	}
+
+	references := make([]string, len(items))
+	i := 0
+	for _, item := range items {
+		references[i] = item.(utils.SimpleItemExtra[updateModsList, modUpdate]).Extra.Reference
+		i++
+	}
+
+	mods, err := ficsit.Mods(context.TODO(), m.root.GetAPIClient(), ficsit.ModFilter{
+		References: references,
+	})
+	if err != nil {
+		m.err <- err.Error()
+		return
+	}
+
+	if len(mods.Mods.Mods) == 0 {
+		return
+	}
+
+	newItems := make([]list.Item, len(mods.Mods.Mods))
+	for i, mod := range mods.Mods.Mods {
+		// Re-reference struct
+		mod := mod
+		var currentModUpdate modUpdate
+		for _, item := range items {
+			currentModUpdate = item.(utils.SimpleItemExtra[updateModsList, modUpdate]).Extra
+			if currentModUpdate.Reference == mod.Mod_reference {
+				break
+			}
+		}
+		newItems[i] = utils.SimpleItemExtra[updateModsList, modUpdate]{
+			SimpleItem: utils.SimpleItem[updateModsList]{
+				ItemTitle: fmt.Sprintf("%s - %s -> %s", mod.Name, currentModUpdate.From, currentModUpdate.To),
+				Activate: func(msg tea.Msg, currentModel updateModsList) (tea.Model, tea.Cmd) {
+					return currentModel, func() tea.Msg {
+						return modToggleMsg{reference: mod.Mod_reference}
+					}
+				},
+			},
+			Extra: currentModUpdate,
+		}
+	}
+
+	sort.Slice(newItems, func(i, j int) bool {
+		a := newItems[i].(utils.SimpleItemExtra[updateModsList, modUpdate])
+		b := newItems[j].(utils.SimpleItemExtra[updateModsList, modUpdate])
+		return ascDesc(sortOrderDesc, a.Extra.Reference < b.Extra.Reference)
+	})
+
+	m.items <- listUpdate{
+		Items: newItems,
+		Done:  true,
+	}
+}
+
+func (m updateModsList) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	// List enables its own keybindings when they were previously disabled
+	m.list.DisableQuitKeybindings()
+
+	cmds := make([]tea.Cmd, 0)
+
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		if m.list.SettingFilter() {
+			var cmd tea.Cmd
+			m.list, cmd = m.list.Update(msg)
+			return m, cmd
+		}
+
+		switch keypress := msg.String(); keypress {
+		case keys.KeyControlC:
+			return m, tea.Quit
+		case "q":
+			if m.parent != nil {
+				m.parent.Update(m.root.Size())
+				return m.parent, nil
+			}
+			return m, tea.Quit
+		case " ":
+			i, ok := m.list.SelectedItem().(utils.SimpleItem[updateModsList])
+			if ok {
+				return m.processActivation(i, msg)
+			}
+			i2, ok := m.list.SelectedItem().(utils.SimpleItemExtra[updateModsList, modUpdate])
+			if ok {
+				return m.processActivation(i2.SimpleItem, msg)
+			}
+			return m, nil
+		case keys.KeyEnter:
+			if len(m.selectedMods) > 0 {
+				err := m.root.GetCurrentInstallation().UpdateMods(m.root.GetGlobal(), m.selectedMods)
+				if err != nil {
+					m.err <- err.Error()
+					return m, nil
+				}
+			}
+			if m.parent != nil {
+				m.parent.Update(m.root.Size())
+				return m.parent, nil
+			}
+			return m, tea.Quit
+		}
+	case tea.WindowSizeMsg:
+		top, right, bottom, left := lipgloss.NewStyle().Margin(m.root.Height(), 2, 0).GetMargin()
+		m.list.SetSize(msg.Width-left-right, msg.Height-top-bottom)
+		m.root.SetSize(msg)
+	case utils.TickMsg:
+		select {
+		case items := <-m.items:
+			cmd := m.list.SetItems(items.Items)
+			if items.Done {
+				m.list.StopSpinner()
+				return m, cmd
+			}
+			return m, tea.Batch(utils.Ticker(), cmd)
+		case err := <-m.err:
+			errorComponent, cmd := components.NewErrorComponent(err, time.Second*5)
+			m.error = errorComponent
+			return m, cmd
+		default:
+			start := m.list.StartSpinner()
+			return m, tea.Batch(utils.Ticker(), start)
+		}
+	case modToggleMsg:
+		idx := -1
+		for i, mod := range m.selectedMods {
+			if mod == msg.reference {
+				idx = i
+				break
+			}
+		}
+		if idx != -1 {
+			m.selectedMods = append(m.selectedMods[:idx], m.selectedMods[idx+1:]...)
+		} else {
+			m.selectedMods = append(m.selectedMods, msg.reference)
+		}
+		cmds = append(cmds, func() tea.Msg { return selectedModsUpdateMsg{selectedMods: m.selectedMods} })
+	}
+
+	newList, listCmd := m.list.Update(msg)
+	m.list = newList
+	cmds = append(cmds, listCmd)
+
+	return m, tea.Batch(cmds...)
+}
+
+func (m updateModsList) View() string {
+	m.list.SetSize(m.list.Width(), m.root.Size().Height-m.root.Height())
+	return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.list.View())
+}
+
+func (m updateModsList) processActivation(item utils.SimpleItem[updateModsList], msg tea.Msg) (tea.Model, tea.Cmd) {
+	if item.Activate != nil {
+		newModel, cmd := item.Activate(msg, m)
+		if newModel != nil || cmd != nil {
+			if newModel == nil {
+				newModel = m
+			}
+			return newModel, cmd
+		}
+		return m, nil
+	}
+	return m, nil
+}
+
+type updateModsListDelegate struct {
+	list.ItemDelegate
+	selectedMods []string
+}
+
+type selectedModsUpdateMsg struct {
+	selectedMods []string
+}
+
+func (c updateModsListDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
+	if msg, ok := msg.(selectedModsUpdateMsg); ok {
+		c.selectedMods = msg.selectedMods
+		m.SetDelegate(c)
+	}
+	return nil
+}
+
+func (c updateModsListDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
+	realItem := item.(utils.SimpleItemExtra[updateModsList, modUpdate])
+	realDelegate := c.ItemDelegate.(list.DefaultDelegate)
+
+	title := realItem.Title()
+
+	s := &realDelegate.Styles
+
+	if m.Width() <= 0 {
+		return
+	}
+
+	textwidth := uint(m.Width() - s.NormalTitle.GetPaddingLeft() - s.NormalTitle.GetPaddingRight())
+	title = truncate.StringWithTail(title, textwidth, "…")
+
+	isSelected := index == m.Index()
+
+	isUpdating := false
+	for _, mod := range c.selectedMods {
+		if mod == realItem.Extra.Reference {
+			isUpdating = true
+		}
+	}
+
+	var checkbox string
+	if isUpdating {
+		checkbox = lipgloss.NewStyle().Foreground(lipgloss.Color("40")).Render("[✓]")
+	} else {
+		checkbox = lipgloss.NewStyle().Foreground(lipgloss.Color("40")).Render("[ ]")
+	}
+
+	if isSelected {
+		title = s.SelectedTitle.UnsetBorderLeft().UnsetPaddingLeft().Render(title)
+	} else {
+		title = s.NormalTitle.UnsetPaddingLeft().Render(title)
+	}
+
+	fmt.Fprintf(w, "%s %s", checkbox, title)
+}