diff --git a/cli/dependency_resolver.go b/cli/dependency_resolver.go index 0751f07..61a1cac 100644 --- a/cli/dependency_resolver.go +++ b/cli/dependency_resolver.go @@ -9,7 +9,6 @@ import ( "github.com/Masterminds/semver/v3" "github.com/pkg/errors" "github.com/satisfactorymodding/ficsit-cli/ficsit" - "github.com/satisfactorymodding/ficsit-cli/utils" "github.com/spf13/viper" ) @@ -29,10 +28,15 @@ func (d DependencyResolver) ResolveModDependencies(constraints map[string]string return nil, errors.Wrap(err, "failed fetching SMl versions") } + copied := make(map[string][]string, len(constraints)) + for k, v := range constraints { + copied[k] = []string{v} + } + instance := &resolvingInstance{ Resolver: d, InputLock: lockFile, - ToResolve: utils.CopyMap(constraints), + ToResolve: copied, OutputLock: make(LockFile), SMLVersions: smlVersionsDB, GameVersion: gameVersion, @@ -50,7 +54,7 @@ type resolvingInstance struct { InputLock *LockFile - ToResolve map[string]string + ToResolve map[string][]string OutputLock LockFile @@ -69,17 +73,19 @@ func (r *resolvingInstance) Step() error { if id != "SML" { converted = append(converted, ficsit.ModVersionConstraint{ ModIdOrReference: id, - Version: constraint, + Version: constraint[0], }) } else { - smlVersionConstraint, _ := semver.NewConstraint(constraint) if existingSML, ok := r.OutputLock[id]; ok { - if !smlVersionConstraint.Check(semver.MustParse(existingSML.Version)) { - return errors.Errorf("mod %s version %s does not match constraint %s", - id, - existingSML.Version, - constraint, - ) + for _, cs := range constraint { + smlVersionConstraint, _ := semver.NewConstraint(cs) + if !smlVersionConstraint.Check(semver.MustParse(existingSML.Version)) { + return errors.Errorf("mod %s version %s does not match constraint %s", + id, + existingSML.Version, + constraint, + ) + } } } @@ -90,7 +96,18 @@ func (r *resolvingInstance) Step() error { } currentVersion := semver.MustParse(version.Version) - if smlVersionConstraint.Check(currentVersion) { + + matches := true + for _, cs := range constraint { + smlVersionConstraint, _ := semver.NewConstraint(cs) + + if !smlVersionConstraint.Check(currentVersion) { + matches = false + break + } + } + + if matches { if chosenSMLVersion == nil || currentVersion.GreaterThan(chosenSMLVersion) { chosenSMLVersion = currentVersion } @@ -98,7 +115,7 @@ func (r *resolvingInstance) Step() error { } if chosenSMLVersion == nil { - return fmt.Errorf("could not find an SML version that matches constraint %s and game version %d", constraint, r.GameVersion) + return errors.Errorf("could not find an SML version that matches constraint %s and game version %d", constraint, r.GameVersion) } r.OutputLock[id] = LockedMod{ @@ -109,7 +126,7 @@ func (r *resolvingInstance) Step() error { } } - r.ToResolve = make(map[string]string) + nextResolve := make(map[string][]string) // TODO Cache dependencies, err := ficsit.ResolveModDependencies(context.TODO(), r.Resolver.apiClient, converted) @@ -147,7 +164,27 @@ func (r *resolvingInstance) Step() error { // Pick latest version // TODO Clone and branch - selectedVersion := modVersions[0] + var selectedVersion ModVersion + for _, version := range modVersions { + matches := true + + for _, cs := range r.ToResolve[mod.Mod_reference] { + resolvingConstraint, _ := semver.NewConstraint(cs) + if !resolvingConstraint.Check(semver.MustParse(version.Version)) { + matches = false + break + } + } + + if matches { + selectedVersion = version + break + } + } + + if selectedVersion.Version == "" { + return errors.Errorf("no version of %s matches constraints", mod.Mod_reference) + } if _, ok := r.OutputLock[mod.Mod_reference]; ok { if r.OutputLock[mod.Mod_reference].Version != selectedVersion.Version { @@ -181,16 +218,19 @@ func (r *resolvingInstance) Step() error { } } - if resolving, ok := r.ToResolve[dependency.ModReference]; ok { + if resolving, ok := nextResolve[dependency.ModReference]; ok { constraint, _ := semver.NewConstraint(dependency.Constraint) - resolvingConstraint, _ := semver.NewConstraint(resolving) - intersects, _ := constraint.Intersects(resolvingConstraint) - if !intersects { - return errors.Errorf("mod %s constraint %s does not intersect with %s", - dependency.ModReference, - resolving, - dependency.Constraint, - ) + + for _, cs := range resolving { + resolvingConstraint, _ := semver.NewConstraint(cs) + intersects, _ := constraint.Intersects(resolvingConstraint) + if !intersects { + return errors.Errorf("mod %s constraint %s does not intersect with %s", + dependency.ModReference, + resolving, + dependency.Constraint, + ) + } } } @@ -198,10 +238,12 @@ func (r *resolvingInstance) Step() error { continue } - r.ToResolve[dependency.ModReference] = dependency.Constraint + nextResolve[dependency.ModReference] = append(nextResolve[dependency.ModReference], dependency.Constraint) } } + r.ToResolve = nextResolve + for _, constraint := range converted { if _, ok := r.OutputLock[constraint.ModIdOrReference]; !ok { return errors.New("failed resolving dependency: " + constraint.ModIdOrReference) @@ -221,7 +263,7 @@ func (r *resolvingInstance) Step() error { func (r *resolvingInstance) LockStep(viewed map[string]bool) error { added := false if r.InputLock != nil { - for modReference, version := range r.ToResolve { + for modReference, constraints := range r.ToResolve { if _, ok := viewed[modReference]; ok { continue } @@ -229,22 +271,36 @@ func (r *resolvingInstance) LockStep(viewed map[string]bool) error { viewed[modReference] = true if locked, ok := (*r.InputLock)[modReference]; ok { - constraint, _ := semver.NewConstraint(version) - if constraint.Check(semver.MustParse(locked.Version)) { + passes := true + + for _, cs := range constraints { + constraint, _ := semver.NewConstraint(cs) + if !constraint.Check(semver.MustParse(locked.Version)) { + passes = false + break + } + } + + if passes { delete(r.ToResolve, modReference) r.OutputLock[modReference] = locked for k, v := range locked.Dependencies { if alreadyResolving, ok := r.ToResolve[k]; ok { - cs1, _ := semver.NewConstraint(v) - cs2, _ := semver.NewConstraint(alreadyResolving) - intersects, _ := cs1.Intersects(cs2) - if !intersects { - return errors.Errorf("mod %s constraint %s does not intersect with %s", - k, - v, - alreadyResolving, - ) + newConstraint, _ := semver.NewConstraint(v) + for _, resolvingConstraint := range alreadyResolving { + cs2, _ := semver.NewConstraint(resolvingConstraint) + intersects, _ := newConstraint.Intersects(cs2) + if !intersects { + return errors.Errorf("mod %s constraint %s does not intersect with %s", + k, + v, + alreadyResolving, + ) + } } + + r.ToResolve[k] = append(r.ToResolve[k], v) + continue } @@ -260,7 +316,7 @@ func (r *resolvingInstance) LockStep(viewed map[string]bool) error { continue } - r.ToResolve[k] = v + r.ToResolve[k] = append(r.ToResolve[k], v) added = true } } diff --git a/cli/installations.go b/cli/installations.go index ca262f9..88528fd 100644 --- a/cli/installations.go +++ b/cli/installations.go @@ -6,6 +6,8 @@ import ( "os" "path" "path/filepath" + "regexp" + "strings" "github.com/satisfactorymodding/ficsit-cli/utils" @@ -224,30 +226,84 @@ func (i *Installation) Validate(ctx *GlobalContext) error { return nil } -func (i *Installation) Install(ctx *GlobalContext) error { - if err := i.Validate(ctx); err != nil { - return errors.Wrap(err, "failed to validate installation") - } +var ( + lockFileCleaner = regexp.MustCompile(`[^a-zA-Z\d]]`) + matchFirstCap = regexp.MustCompile(`(.)([A-Z][a-z]+)`) + matchAllCap = regexp.MustCompile(`([a-z\d])([A-Z])`) +) +func (i *Installation) LockFilePath(ctx *GlobalContext) (string, error) { platform, err := i.GetPlatform(ctx) if err != nil { - return err + return "", err } - lockfilePath := path.Join(i.Path, platform.LockfilePath) + lockFileName := ctx.Profiles.Profiles[i.Profile].Name + lockFileName = matchFirstCap.ReplaceAllString(lockFileName, "${1}_${2}") + lockFileName = matchAllCap.ReplaceAllString(lockFileName, "${1}_${2}") + lockFileName = lockFileCleaner.ReplaceAllLiteralString(lockFileName, "-") + lockFileName = strings.ToLower(lockFileName) + "-lock.json" + + return path.Join(i.Path, platform.LockfilePath, lockFileName), nil +} + +func (i *Installation) LockFile(ctx *GlobalContext) (*LockFile, error) { + lockfilePath, err := i.LockFilePath(ctx) + if err != nil { + return nil, err + } var lockFile *LockFile lockFileJSON, err := os.ReadFile(lockfilePath) if err != nil { if !os.IsNotExist(err) { - return errors.Wrap(err, "failed reading lockfile") + return nil, errors.Wrap(err, "failed reading lockfile") } } else { if err := json.Unmarshal(lockFileJSON, &lockFile); err != nil { - return errors.Wrap(err, "failed parsing lockfile") + return nil, errors.Wrap(err, "failed parsing lockfile") } } + return lockFile, nil +} + +func (i *Installation) WriteLockFile(ctx *GlobalContext, lockfile LockFile) error { + lockfilePath, err := i.LockFilePath(ctx) + if err != nil { + return err + } + + marshaledLockfile, err := json.MarshalIndent(lockfile, "", " ") + + if err != nil { + return errors.Wrap(err, "failed to serialize lockfile json") + } + + if err := os.WriteFile(lockfilePath, marshaledLockfile, 0777); err != nil { + return errors.Wrap(err, "failed writing lockfile") + } + + return nil +} + +type InstallUpdate struct { + ModName string + OverallProgress float64 + DownloadProgress float64 + ExtractProgress float64 +} + +func (i *Installation) Install(ctx *GlobalContext, updates chan InstallUpdate) 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 err + } + resolver := NewDependencyResolver(ctx.APIClient) gameVersion, err := i.GetGameVersion(ctx) @@ -266,28 +322,79 @@ func (i *Installation) Install(ctx *GlobalContext) error { return errors.Wrap(err, "failed creating Mods directory") } - for modReference, version := range lockfile { - // Only install if a link is provided, otherwise assume mod is already installed - if version.Link != "" { - reader, size, err := utils.DownloadOrCache(modReference+"_"+version.Version+".zip", version.Hash, version.Link) - if err != nil { - return errors.Wrap(err, "failed to download "+modReference+" from: "+version.Link) - } + dir, err := os.ReadDir(modsDirectory) + if err != nil { + return errors.Wrap(err, "failed to read mods directory") + } - if err := utils.ExtractMod(reader, size, path.Join(modsDirectory, modReference)); err != nil { - return errors.Wrap(err, "could not extract "+modReference) + for _, entry := range dir { + if entry.IsDir() { + if _, ok := lockfile[entry.Name()]; !ok { + if err := os.RemoveAll(path.Join(modsDirectory, entry.Name())); err != nil { + return errors.Wrap(err, "failed to delete mod directory") + } } } } - marshaledLockfile, err := json.MarshalIndent(lockfile, "", " ") + completed := 0 + for modReference, version := range lockfile { + // Only install if a link is provided, otherwise assume mod is already installed + if version.Link != "" { + downloading := true - if err != nil { - return errors.Wrap(err, "failed to serialize lockfile json") + var genericUpdates chan utils.GenericUpdate + if updates != nil { + genericUpdates = make(chan utils.GenericUpdate) + + go func() { + update := InstallUpdate{ + ModName: modReference, + OverallProgress: float64(completed) / float64(len(lockfile)), + DownloadProgress: 0, + ExtractProgress: 0, + } + + select { + case updates <- update: + default: + } + + for up := range genericUpdates { + if downloading { + update.DownloadProgress = up.Progress + } else { + update.DownloadProgress = 1 + update.ExtractProgress = up.Progress + } + + select { + case updates <- update: + default: + } + } + }() + } + + reader, size, err := utils.DownloadOrCache(modReference+"_"+version.Version+".zip", version.Hash, version.Link, genericUpdates) + if err != nil { + return errors.Wrap(err, "failed to download "+modReference+" from: "+version.Link) + } + + downloading = false + + if err := utils.ExtractMod(reader, size, path.Join(modsDirectory, modReference), genericUpdates); err != nil { + return errors.Wrap(err, "could not extract "+modReference) + } + + close(genericUpdates) + } + + completed++ } - if err := os.WriteFile(lockfilePath, marshaledLockfile, 0777); err != nil { - return errors.Wrap(err, "failed writing lockfile") + if err := i.WriteLockFile(ctx, lockfile); err != nil { + return err } return nil diff --git a/cli/installations_test.go b/cli/installations_test.go index 805e665..9c16877 100644 --- a/cli/installations_test.go +++ b/cli/installations_test.go @@ -34,7 +34,7 @@ func TestAddInstallation(t *testing.T) { testza.AssertNoError(t, err) testza.AssertNotNil(t, installation) - err = installation.Install(ctx) + err = installation.Install(ctx, nil) testza.AssertNoError(t, err) } } diff --git a/cli/platforms.go b/cli/platforms.go index acc54fb..831d7ff 100644 --- a/cli/platforms.go +++ b/cli/platforms.go @@ -10,14 +10,14 @@ type Platform struct { var platforms = []Platform{ { VersionPath: path.Join("Engine", "Binaries", "Linux", "UE4Server-Linux-Shipping.version"), - LockfilePath: path.Join("FactoryGame", "Mods", "mods-lock.json"), + LockfilePath: path.Join("FactoryGame", "Mods"), }, { VersionPath: path.Join("Engine", "Binaries", "Win64", "UE4Server-Win64-Shipping.version"), - LockfilePath: path.Join("FactoryGame", "Mods", "mods-lock.json"), + LockfilePath: path.Join("FactoryGame", "Mods"), }, { VersionPath: path.Join("Engine", "Binaries", "Win64", "FactoryGame-Win64-Shipping.version"), - LockfilePath: path.Join("FactoryGame", "Mods", "mods-lock.json"), + LockfilePath: path.Join("FactoryGame", "Mods"), }, } diff --git a/cmd/root.go b/cmd/root.go index a825313..58d19f3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -95,10 +95,8 @@ func init() { switch runtime.GOOS { case "windows": baseLocalDir = os.Getenv("APPDATA") - break case "linux": baseLocalDir = path.Join(os.Getenv("HOME"), ".local", "share") - break default: panic("unsupported platform: " + runtime.GOOS) } diff --git a/go.mod b/go.mod index 064a4a8..825369b 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.18 require ( github.com/JohannesKaufmann/html-to-markdown v1.3.3 github.com/Khan/genqlient v0.4.0 - github.com/MarvinJWendt/testza v0.4.1 + github.com/MarvinJWendt/testza v0.4.2 github.com/Masterminds/semver/v3 v3.1.1 github.com/PuerkitoBio/goquery v1.8.0 github.com/charmbracelet/bubbles v0.10.3 @@ -30,6 +30,7 @@ require ( github.com/atomicgo/cursor v0.0.1 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymerick/douceur v0.2.0 // indirect + github.com/charmbracelet/harmonica v0.1.0 // indirect github.com/containerd/console v1.0.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.4.0 // indirect diff --git a/go.sum b/go.sum index 3b5ecd6..2a6ff0c 100644 --- a/go.sum +++ b/go.sum @@ -50,8 +50,8 @@ github.com/MarvinJWendt/testza v0.2.10/go.mod h1:pd+VWsoGUiFtq+hRKSU1Bktnn+DMCSr github.com/MarvinJWendt/testza v0.2.12/go.mod h1:JOIegYyV7rX+7VZ9r77L/eH6CfJHHzXjB69adAhzZkI= github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/2oUqKc6bF2c= github.com/MarvinJWendt/testza v0.3.5/go.mod h1:ExbTpWmA1z2E9HSskvrNcwApoX4F9bID692s10nuHRY= -github.com/MarvinJWendt/testza v0.4.1 h1:bqidLqFVtySvyq7D+xIfFKefl+AfJtDpivXC9fx3hm4= -github.com/MarvinJWendt/testza v0.4.1/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE= +github.com/MarvinJWendt/testza v0.4.2 h1:Vbw9GkSB5erJI2BPnBL9SVGV9myE+XmUSFahBGUhW2Q= +github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE= github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= @@ -89,6 +89,7 @@ github.com/charmbracelet/bubbletea v0.20.0 h1:/b8LEPgCbNr7WWZ2LuE/BV1/r4t5PyYJtD github.com/charmbracelet/bubbletea v0.20.0/go.mod h1:zpkze1Rioo4rJELjRyGlm9T2YNou1Fm4LIJQSa5QMEM= github.com/charmbracelet/glamour v0.5.0 h1:wu15ykPdB7X6chxugG/NNfDUbyyrCLV9XBalj5wdu3g= github.com/charmbracelet/glamour v0.5.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5UyZBbQsMQN0XIqc= +github.com/charmbracelet/harmonica v0.1.0 h1:lFKeSd6OAckQ/CEzPVd2mqj+YMEubQ/3FM2IYY3xNm0= github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM= github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= diff --git a/tea/components/error.go b/tea/components/error.go new file mode 100644 index 0000000..8b726fc --- /dev/null +++ b/tea/components/error.go @@ -0,0 +1,48 @@ +package components + +import ( + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/satisfactorymodding/ficsit-cli/tea/utils" +) + +var _ tea.Model = (*ErrorComponent)(nil) + +type ErrorComponent struct { + message string + labelStyle lipgloss.Style +} + +func NewErrorComponent(message string, duration time.Duration) (*ErrorComponent, tea.Cmd) { + timer := time.NewTimer(duration) + + return &ErrorComponent{ + message: message, + labelStyle: utils.LabelStyle, + }, func() tea.Msg { + <-timer.C + return ErrorComponentTimeoutMsg{} + } +} + +func (e ErrorComponent) Init() tea.Cmd { + return nil +} + +func (e ErrorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return e, nil +} + +func (e ErrorComponent) View() string { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). + BorderStyle(lipgloss.ThickBorder()). + BorderForeground(lipgloss.Color("196")). + Padding(0, 1). + Margin(0, 0, 1, 2). + Render(e.message) +} + +type ErrorComponentTimeoutMsg struct{} diff --git a/tea/scenes/apply.go b/tea/scenes/apply.go new file mode 100644 index 0000000..39d0f76 --- /dev/null +++ b/tea/scenes/apply.go @@ -0,0 +1,207 @@ +package scenes + +import ( + "github.com/charmbracelet/bubbles/progress" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/satisfactorymodding/ficsit-cli/cli" + "github.com/satisfactorymodding/ficsit-cli/tea/components" + "github.com/satisfactorymodding/ficsit-cli/tea/utils" +) + +var _ tea.Model = (*apply)(nil) + +type update struct { + completed []string + + installName string + installTotal int + installCurrent int + + modName string + modTotal int + modCurrent int + + done bool +} + +type apply struct { + root components.RootModel + parent tea.Model + title string + error *components.ErrorComponent + overall progress.Model + sub progress.Model + + status update + updateChannel chan update + errorChannel chan error +} + +func NewApply(root components.RootModel, parent tea.Model) tea.Model { + overall := progress.New(progress.WithSolidFill("118")) + sub := progress.New(progress.WithSolidFill("202")) + + updateChannel := make(chan update) + errorChannel := make(chan error) + + model := &apply{ + root: root, + parent: parent, + title: utils.NonListTitleStyle.MarginTop(1).MarginBottom(1).Render("Applying Changes"), + overall: overall, + sub: sub, + status: update{ + completed: []string{}, + + installName: "", + installTotal: 100, + installCurrent: 0, + + modName: "", + modTotal: 100, + modCurrent: 0, + + done: false, + }, + updateChannel: updateChannel, + errorChannel: errorChannel, + } + + go func() { + result := &update{ + completed: make([]string, 0), + + installName: "", + installTotal: 100, + installCurrent: 0, + + modName: "", + modTotal: 100, + modCurrent: 0, + + done: false, + } + updateChannel <- *result + + for _, installation := range root.GetGlobal().Installations.Installations { + result.installName = installation.Path + updateChannel <- *result + + installChannel := make(chan cli.InstallUpdate) + + go func() { + for data := range installChannel { + result.installName = installation.Path + result.installCurrent = int(data.OverallProgress * 100) + + if data.DownloadProgress < 1 { + result.modName = "Downloading: " + data.ModName + result.modCurrent = int(data.DownloadProgress * 100) + } else { + result.modName = "Extracting: " + data.ModName + result.modCurrent = int(data.ExtractProgress * 100) + } + + updateChannel <- *result + } + }() + + if err := installation.Install(root.GetGlobal(), installChannel); err != nil { + errorChannel <- err + return + } + + close(installChannel) + + result.modName = "" + result.installTotal = 100 + result.completed = append(result.completed, installation.Path) + updateChannel <- *result + } + + result.done = true + result.installName = "" + updateChannel <- *result + }() + + return model +} + +func (m apply) Init() tea.Cmd { + return utils.Ticker() +} + +func (m apply) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch keypress := msg.String(); keypress { + case KeyControlC: + return m, tea.Quit + case KeyEscape: + // TODO Cancel + return m, nil + case KeyEnter: + if m.status.done { + if m.parent != nil { + return m.parent, nil + } + } + return m, nil + } + case tea.WindowSizeMsg: + m.root.SetSize(msg) + case components.ErrorComponentTimeoutMsg: + m.error = nil + case utils.TickMsg: + select { + case newStatus := <-m.updateChannel: + m.status = newStatus + break + case err := <-m.errorChannel: + errorComponent, _ := components.NewErrorComponent(err.Error(), 0) + m.error = errorComponent + break + default: + // Skip if nothing there + break + } + return m, utils.Ticker() + } + + return m, nil +} + +func (m apply) View() string { + strs := make([]string, 0) + for _, s := range m.status.completed { + strs = append(strs, lipgloss.NewStyle().Foreground(lipgloss.Color("22")).Render("✓ ")+s) + } + + if m.status.installName != "" { + marginTop := 0 + if len(m.status.completed) > 0 { + marginTop = 1 + } + + strs = append(strs, lipgloss.NewStyle().MarginTop(marginTop).Render(m.status.installName)) + strs = append(strs, m.overall.ViewAs(float64(m.status.installCurrent)/float64(m.status.installTotal))) + } + + if m.status.modName != "" { + strs = append(strs, lipgloss.NewStyle().MarginTop(1).Render(m.status.modName)) + strs = append(strs, m.sub.ViewAs(float64(m.status.modCurrent)/float64(m.status.modTotal))) + } + + if m.status.done { + strs = append(strs, utils.LabelStyle.Padding(0).Margin(1).Render("Done! Press Enter to return")) + } + + result := lipgloss.NewStyle().MarginLeft(1).Render(lipgloss.JoinVertical(lipgloss.Left, strs...)) + + if m.error != nil { + return lipgloss.JoinVertical(lipgloss.Left, m.title, (*m.error).View(), result) + } + + return lipgloss.JoinVertical(lipgloss.Left, m.title, result) +} diff --git a/tea/scenes/installation.go b/tea/scenes/installation.go index b16d806..deb21d1 100644 --- a/tea/scenes/installation.go +++ b/tea/scenes/installation.go @@ -4,6 +4,7 @@ import ( "fmt" "time" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -20,6 +21,7 @@ type installation struct { parent tea.Model installation *cli.Installation hadRenamed bool + error *components.ErrorComponent } func NewInstallation(root components.RootModel, parent tea.Model, installationData *cli.Installation) tea.Model { @@ -34,7 +36,9 @@ func NewInstallation(root components.RootModel, parent tea.Model, installationDa ItemTitle: "Select", Activate: func(msg tea.Msg, currentModel installation) (tea.Model, tea.Cmd) { if err := root.SetCurrentInstallation(installationData); err != nil { - panic(err) // TODO Handle Error + errorComponent, cmd := components.NewErrorComponent(err.Error(), time.Second*5) + currentModel.error = errorComponent + return currentModel, cmd } return currentModel.parent, nil @@ -44,7 +48,9 @@ func NewInstallation(root components.RootModel, parent tea.Model, installationDa ItemTitle: "Delete", Activate: func(msg tea.Msg, currentModel installation) (tea.Model, tea.Cmd) { if err := root.GetGlobal().Installations.DeleteInstallation(installationData.Path); err != nil { - panic(err) // TODO Handle Error + errorComponent, cmd := components.NewErrorComponent(err.Error(), time.Second*5) + currentModel.error = errorComponent + return currentModel, cmd } return currentModel.parent, updateInstallationListCmd @@ -61,6 +67,18 @@ func NewInstallation(root components.RootModel, parent tea.Model, installationDa model.list.StatusMessageLifetime = time.Second * 3 model.list.DisableQuitKeybindings() + model.list.AdditionalShortHelpKeys = func() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithHelp("q", "back")), + } + } + + model.list.AdditionalFullHelpKeys = func() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithHelp("q", "back")), + } + } + return model } @@ -112,11 +130,20 @@ func (m installation) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case updateInstallationNames: m.hadRenamed = true m.list.Title = fmt.Sprintf("Installation: %s", m.installation.Path) + case components.ErrorComponentTimeoutMsg: + m.error = nil } return m, nil } func (m installation) View() string { + if m.error != nil { + err := (*m.error).View() + m.list.SetSize(m.list.Width(), m.root.Size().Height-m.root.Height()-lipgloss.Height(err)) + return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), err, m.list.View()) + } + + m.list.SetSize(m.list.Width(), m.root.Size().Height-m.root.Height()) return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.list.View()) } diff --git a/tea/scenes/installations.go b/tea/scenes/installations.go index a097c57..9d613ae 100644 --- a/tea/scenes/installations.go +++ b/tea/scenes/installations.go @@ -31,6 +31,7 @@ func NewInstallations(root components.RootModel, parent tea.Model) tea.Model { l.AdditionalShortHelpKeys = func() []key.Binding { return []key.Binding{ + key.NewBinding(key.WithHelp("q", "back")), key.NewBinding(key.WithHelp("n", "new installation")), } } diff --git a/tea/scenes/main_menu.go b/tea/scenes/main_menu.go index 7b5fc73..611f64f 100644 --- a/tea/scenes/main_menu.go +++ b/tea/scenes/main_menu.go @@ -14,8 +14,9 @@ import ( var _ tea.Model = (*mainMenu)(nil) type mainMenu struct { - root components.RootModel - list list.Model + root components.RootModel + list list.Model + error *components.ErrorComponent } func NewMainMenu(root components.RootModel) tea.Model { @@ -48,8 +49,15 @@ func NewMainMenu(root components.RootModel) tea.Model { utils.SimpleItem[mainMenu]{ ItemTitle: "Apply Changes", Activate: func(msg tea.Msg, currentModel mainMenu) (tea.Model, tea.Cmd) { - // TODO Apply changes to all changed profiles - return nil, nil + if err := root.GetGlobal().Save(); err != nil { + log.Error().Err(err).Msg(ErrorFailedAddMod) + errorComponent, cmd := components.NewErrorComponent(err.Error(), time.Second*5) + currentModel.error = errorComponent + return currentModel, cmd + } + + newModel := NewApply(root, currentModel) + return newModel, newModel.Init() }, }, utils.SimpleItem[mainMenu]{ @@ -57,7 +65,8 @@ func NewMainMenu(root components.RootModel) tea.Model { Activate: func(msg tea.Msg, currentModel mainMenu) (tea.Model, tea.Cmd) { if err := root.GetGlobal().Save(); err != nil { log.Error().Err(err).Msg(ErrorFailedAddMod) - cmd := currentModel.list.NewStatusMessage(ErrorFailedAddMod) + errorComponent, cmd := components.NewErrorComponent(err.Error(), time.Second*5) + currentModel.error = errorComponent return currentModel, cmd } return nil, nil @@ -76,8 +85,6 @@ func NewMainMenu(root components.RootModel) tea.Model { model.list.SetFilteringEnabled(false) model.list.Title = "Main Menu" model.list.Styles = utils.ListStyles - model.list.SetSize(model.list.Width(), model.list.Height()) - model.list.StatusMessageLifetime = time.Second * 3 model.list.DisableQuitKeybindings() return model @@ -119,11 +126,20 @@ func (m mainMenu) Update(msg tea.Msg) (tea.Model, tea.Cmd) { top, right, bottom, left := lipgloss.NewStyle().Margin(2, 2).GetMargin() m.list.SetSize(msg.Width-left-right, msg.Height-top-bottom) m.root.SetSize(msg) + case components.ErrorComponentTimeoutMsg: + m.error = nil } return m, nil } func (m mainMenu) View() string { + if m.error != nil { + err := (*m.error).View() + m.list.SetSize(m.list.Width(), m.root.Size().Height-m.root.Height()-lipgloss.Height(err)) + return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), err, m.list.View()) + } + + m.list.SetSize(m.list.Width(), m.root.Size().Height-m.root.Height()) return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.list.View()) } diff --git a/tea/scenes/mod.go b/tea/scenes/mod.go index ea471e6..dbbe223 100644 --- a/tea/scenes/mod.go +++ b/tea/scenes/mod.go @@ -3,6 +3,7 @@ package scenes import ( "time" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -85,6 +86,18 @@ func NewModMenu(root components.RootModel, parent tea.Model, mod utils.Mod) tea. model.list.StatusMessageLifetime = time.Second * 3 model.list.DisableQuitKeybindings() + model.list.AdditionalShortHelpKeys = func() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithHelp("q", "back")), + } + } + + model.list.AdditionalFullHelpKeys = func() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithHelp("q", "back")), + } + } + return model } diff --git a/tea/scenes/mod_info.go b/tea/scenes/mod_info.go index 536eb82..e0ce1e5 100644 --- a/tea/scenes/mod_info.go +++ b/tea/scenes/mod_info.go @@ -4,6 +4,7 @@ import ( "context" "strconv" "strings" + "time" "github.com/rs/zerolog/log" @@ -30,9 +31,11 @@ type modInfo struct { spinner spinner.Model parent tea.Model modData chan ficsit.GetModGetMod + modError chan string ready bool help help.Model keys modInfoKeyMap + error *components.ErrorComponent } type modInfoKeyMap struct { @@ -65,6 +68,7 @@ func NewModInfo(root components.RootModel, parent tea.Model, mod utils.Mod) tea. spinner: spinner.New(), parent: parent, modData: make(chan ficsit.GetModGetMod), + modError: make(chan string), ready: false, help: help.New(), keys: modInfoKeyMap{ @@ -86,11 +90,13 @@ func NewModInfo(root components.RootModel, parent tea.Model, mod utils.Mod) tea. fullMod, err := ficsit.GetMod(context.TODO(), root.GetAPIClient(), mod.ID) if err != nil { - panic(err) // TODO Handle Error + model.modError <- err.Error() + return } if fullMod == nil { - panic("mod is nil") // TODO Handle Error + model.modError <- "unknown error (mod is nil)" + return } model.modData <- fullMod.GetMod @@ -204,6 +210,10 @@ func (m modInfo) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd m.viewport, cmd = m.viewport.Update(msg) return m, cmd + case err := <-m.modError: + errorComponent, cmd := components.NewErrorComponent(err, time.Second*5) + m.error = errorComponent + return m, cmd default: return m, utils.Ticker() } @@ -213,6 +223,11 @@ func (m modInfo) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m modInfo) View() string { + if m.error != nil { + helpBar := lipgloss.NewStyle().Padding(1, 2).Render(m.help.View(m.keys)) + return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), (*m.error).View(), m.viewport.View(), helpBar) + } + if m.viewport.Height == 0 { spinnerView := lipgloss.NewStyle().Padding(0, 2, 1).Render(m.spinner.View() + " Loading...") return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), spinnerView) diff --git a/tea/scenes/mod_semver.go b/tea/scenes/mod_semver.go index c5e59ce..f668290 100644 --- a/tea/scenes/mod_semver.go +++ b/tea/scenes/mod_semver.go @@ -1,6 +1,8 @@ package scenes import ( + "time" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -16,6 +18,7 @@ type modSemver struct { input textinput.Model title string mod utils.Mod + error *components.ErrorComponent } func NewModSemver(root components.RootModel, parent tea.Model, mod utils.Mod) tea.Model { @@ -49,7 +52,9 @@ func (m modSemver) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case KeyEnter: err := m.root.GetCurrentProfile().AddMod(m.mod.Reference, m.input.Value()) if err != nil { - panic(err) // TODO Handle Error + errorComponent, cmd := components.NewErrorComponent(err.Error(), time.Second*5) + m.error = errorComponent + return m, cmd } return m.parent, nil default: @@ -59,6 +64,8 @@ func (m modSemver) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.WindowSizeMsg: m.root.SetSize(msg) + case components.ErrorComponentTimeoutMsg: + m.error = nil } return m, nil @@ -66,5 +73,10 @@ func (m modSemver) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m modSemver) View() string { inputView := lipgloss.NewStyle().Padding(1, 2).Render(m.input.View()) + + if m.error != nil { + return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.title, (*m.error).View(), inputView) + } + return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.title, inputView) } diff --git a/tea/scenes/mod_version.go b/tea/scenes/mod_version.go index b7aafdc..db9013f 100644 --- a/tea/scenes/mod_version.go +++ b/tea/scenes/mod_version.go @@ -3,6 +3,7 @@ package scenes import ( "time" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -69,6 +70,18 @@ func NewModVersion(root components.RootModel, parent tea.Model, mod utils.Mod) t model.list.StatusMessageLifetime = time.Second * 3 model.list.DisableQuitKeybindings() + model.list.AdditionalShortHelpKeys = func() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithHelp("q", "back")), + } + } + + model.list.AdditionalFullHelpKeys = func() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithHelp("q", "back")), + } + } + return model } diff --git a/tea/scenes/mods.go b/tea/scenes/mods.go index e6b9eb0..027d47c 100644 --- a/tea/scenes/mods.go +++ b/tea/scenes/mods.go @@ -3,6 +3,7 @@ package scenes import ( "context" "sort" + "time" "github.com/charmbracelet/bubbles/key" @@ -40,6 +41,9 @@ type modsList struct { showSortOrderList bool sortOrderList list.Model + + err chan string + error *components.ErrorComponent } func NewMods(root components.RootModel, parent tea.Model) tea.Model { @@ -57,6 +61,7 @@ func NewMods(root components.RootModel, parent tea.Model) tea.Model { l.AdditionalShortHelpKeys = func() []key.Binding { return []key.Binding{ + key.NewBinding(key.WithHelp("q", "back")), key.NewBinding(key.WithHelp("s", "sort")), key.NewBinding(key.WithHelp("o", "order")), } @@ -64,6 +69,7 @@ func NewMods(root components.RootModel, parent tea.Model) tea.Model { l.AdditionalFullHelpKeys = func() []key.Binding { return []key.Binding{ + key.NewBinding(key.WithHelp("q", "back")), key.NewBinding(key.WithHelp("s", "sort")), key.NewBinding(key.WithHelp("o", "order")), } @@ -145,6 +151,7 @@ func NewMods(root components.RootModel, parent tea.Model) tea.Model { sortingOrder: sortOrderDesc, sortFieldList: sortFieldList, sortOrderList: sortOrderList, + err: make(chan string), } go func() { @@ -160,7 +167,8 @@ func NewMods(root components.RootModel, parent tea.Model) tea.Model { }) if err != nil { - panic(err) // TODO Handle Error + m.err <- err.Error() + return } if len(mods.GetMods.Mods) == 0 { @@ -273,6 +281,10 @@ func (m modsList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.list.StopSpinner() cmd := m.list.SetItems(items) return m, 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) @@ -295,16 +307,23 @@ func (m modsList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m modsList) View() string { - var bottom string + var bottomList list.Model if m.showSortFieldList { - bottom = m.sortFieldList.View() + bottomList = m.sortFieldList } else if m.showSortOrderList { - bottom = m.sortOrderList.View() + bottomList = m.sortOrderList } else { - bottom = m.list.View() + bottomList = m.list } - return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), bottom) + if m.error != nil { + err := (*m.error).View() + bottomList.SetSize(bottomList.Width(), m.root.Size().Height-m.root.Height()-lipgloss.Height(err)) + return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), err, bottomList.View()) + } + + bottomList.SetSize(bottomList.Width(), m.root.Size().Height-m.root.Height()) + return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), bottomList.View()) } func sortItems(items []list.Item, field string, direction sortOrder) []list.Item { diff --git a/tea/scenes/new_installation.go b/tea/scenes/new_installation.go index 39b9be2..005c0e6 100644 --- a/tea/scenes/new_installation.go +++ b/tea/scenes/new_installation.go @@ -1,6 +1,8 @@ package scenes import ( + "time" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -15,6 +17,7 @@ type newInstallation struct { parent tea.Model input textinput.Model title string + error *components.ErrorComponent } func NewNewInstallation(root components.RootModel, parent tea.Model) tea.Model { @@ -30,6 +33,7 @@ func NewNewInstallation(root components.RootModel, parent tea.Model) tea.Model { // TODO Tab-completion for input field // TODO Directory listing + // TODO SSH/FTP/SFTP support return model } @@ -48,7 +52,9 @@ func (m newInstallation) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.parent, nil case KeyEnter: if _, err := m.root.GetGlobal().Installations.AddInstallation(m.root.GetGlobal(), m.input.Value(), m.root.GetGlobal().Profiles.SelectedProfile); err != nil { - panic(err) // TODO Handle Error + errorComponent, cmd := components.NewErrorComponent(err.Error(), time.Second*5) + m.error = errorComponent + return m, cmd } return m.parent, updateInstallationListCmd @@ -59,6 +65,8 @@ func (m newInstallation) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.WindowSizeMsg: m.root.SetSize(msg) + case components.ErrorComponentTimeoutMsg: + m.error = nil } return m, nil @@ -66,5 +74,10 @@ func (m newInstallation) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m newInstallation) View() string { inputView := lipgloss.NewStyle().Padding(1, 2).Render(m.input.View()) + + if m.error != nil { + return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.title, (*m.error).View(), inputView) + } + return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.title, inputView) } diff --git a/tea/scenes/new_profile.go b/tea/scenes/new_profile.go index 0770ad0..c09a67b 100644 --- a/tea/scenes/new_profile.go +++ b/tea/scenes/new_profile.go @@ -1,6 +1,8 @@ package scenes import ( + "time" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -15,6 +17,7 @@ type newProfile struct { parent tea.Model input textinput.Model title string + error *components.ErrorComponent } func NewNewProfile(root components.RootModel, parent tea.Model) tea.Model { @@ -45,7 +48,9 @@ func (m newProfile) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.parent, nil case KeyEnter: if _, err := m.root.GetGlobal().Profiles.AddProfile(m.input.Value()); err != nil { - panic(err) // TODO Handle Error + errorComponent, cmd := components.NewErrorComponent(err.Error(), time.Second*5) + m.error = errorComponent + return m, cmd } return m.parent, updateProfileListCmd @@ -56,6 +61,8 @@ func (m newProfile) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.WindowSizeMsg: m.root.SetSize(msg) + case components.ErrorComponentTimeoutMsg: + m.error = nil } return m, nil @@ -63,5 +70,10 @@ func (m newProfile) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m newProfile) View() string { inputView := lipgloss.NewStyle().Padding(1, 2).Render(m.input.View()) + + if m.error != nil { + return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.title, (*m.error).View(), inputView) + } + return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.title, inputView) } diff --git a/tea/scenes/profile.go b/tea/scenes/profile.go index a389b70..ecde78e 100644 --- a/tea/scenes/profile.go +++ b/tea/scenes/profile.go @@ -4,6 +4,7 @@ import ( "fmt" "time" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -20,6 +21,7 @@ type profile struct { parent tea.Model profile *cli.Profile hadRenamed bool + error *components.ErrorComponent } func NewProfile(root components.RootModel, parent tea.Model, profileData *cli.Profile) tea.Model { @@ -34,7 +36,9 @@ func NewProfile(root components.RootModel, parent tea.Model, profileData *cli.Pr ItemTitle: "Select", Activate: func(msg tea.Msg, currentModel profile) (tea.Model, tea.Cmd) { if err := root.SetCurrentProfile(profileData); err != nil { - panic(err) // TODO Handle Error + errorComponent, cmd := components.NewErrorComponent(err.Error(), time.Second*5) + currentModel.error = errorComponent + return currentModel, cmd } return currentModel.parent, nil @@ -55,7 +59,9 @@ func NewProfile(root components.RootModel, parent tea.Model, profileData *cli.Pr ItemTitle: "Delete", Activate: func(msg tea.Msg, currentModel profile) (tea.Model, tea.Cmd) { if err := root.GetGlobal().Profiles.DeleteProfile(profileData.Name); err != nil { - panic(err) // TODO Handle Error + errorComponent, cmd := components.NewErrorComponent(err.Error(), time.Second*5) + currentModel.error = errorComponent + return currentModel, cmd } return currentModel.parent, updateProfileListCmd @@ -73,6 +79,18 @@ func NewProfile(root components.RootModel, parent tea.Model, profileData *cli.Pr model.list.StatusMessageLifetime = time.Second * 3 model.list.DisableQuitKeybindings() + model.list.AdditionalShortHelpKeys = func() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithHelp("q", "back")), + } + } + + model.list.AdditionalFullHelpKeys = func() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithHelp("q", "back")), + } + } + return model } @@ -124,11 +142,20 @@ func (m profile) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case updateProfileNames: m.hadRenamed = true m.list.Title = fmt.Sprintf("Profile: %s", m.profile.Name) + case components.ErrorComponentTimeoutMsg: + m.error = nil } return m, nil } func (m profile) View() string { + if m.error != nil { + err := (*m.error).View() + m.list.SetSize(m.list.Width(), m.root.Size().Height-m.root.Height()-lipgloss.Height(err)) + return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), err, m.list.View()) + } + + m.list.SetSize(m.list.Width(), m.root.Size().Height-m.root.Height()) return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.list.View()) } diff --git a/tea/scenes/profiles.go b/tea/scenes/profiles.go index 94a39b6..f99108b 100644 --- a/tea/scenes/profiles.go +++ b/tea/scenes/profiles.go @@ -31,6 +31,7 @@ func NewProfiles(root components.RootModel, parent tea.Model) tea.Model { l.AdditionalShortHelpKeys = func() []key.Binding { return []key.Binding{ + key.NewBinding(key.WithHelp("q", "back")), key.NewBinding(key.WithHelp("n", "new profile")), } } diff --git a/tea/scenes/rename_profile.go b/tea/scenes/rename_profile.go index 63b65da..85547da 100644 --- a/tea/scenes/rename_profile.go +++ b/tea/scenes/rename_profile.go @@ -2,6 +2,7 @@ package scenes import ( "fmt" + "time" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" @@ -19,6 +20,7 @@ type renameProfile struct { input textinput.Model title string oldName string + error *components.ErrorComponent } func NewRenameProfile(root components.RootModel, parent tea.Model, profileData *cli.Profile) tea.Model { @@ -51,7 +53,9 @@ func (m renameProfile) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.parent, nil case KeyEnter: if err := m.root.GetGlobal().Profiles.RenameProfile(m.oldName, m.input.Value()); err != nil { - panic(err) // TODO Handle Error + errorComponent, cmd := components.NewErrorComponent(err.Error(), time.Second*5) + m.error = errorComponent + return m, cmd } return m.parent, updateProfileNamesCmd @@ -62,6 +66,8 @@ func (m renameProfile) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.WindowSizeMsg: m.root.SetSize(msg) + case components.ErrorComponentTimeoutMsg: + m.error = nil } return m, nil @@ -69,5 +75,10 @@ func (m renameProfile) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m renameProfile) View() string { inputView := lipgloss.NewStyle().Padding(1, 2).Render(m.input.View()) + + if m.error != nil { + return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.title, (*m.error).View(), inputView) + } + return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.title, inputView) } diff --git a/tea/scenes/select_mod_version.go b/tea/scenes/select_mod_version.go index f4e65b9..347354b 100644 --- a/tea/scenes/select_mod_version.go +++ b/tea/scenes/select_mod_version.go @@ -3,7 +3,9 @@ package scenes import ( "context" "fmt" + "time" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" @@ -20,6 +22,8 @@ type selectModVersionList struct { list list.Model parent tea.Model items chan []list.Item + err chan string + error *components.ErrorComponent } func NewModVersionList(root components.RootModel, parent tea.Model, mod utils.Mod) tea.Model { @@ -33,11 +37,24 @@ func NewModVersionList(root components.RootModel, parent tea.Model, mod utils.Mo l.KeyMap.Quit.SetHelp("q", "back") l.DisableQuitKeybindings() + l.AdditionalShortHelpKeys = func() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithHelp("q", "back")), + } + } + + l.AdditionalFullHelpKeys = func() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithHelp("q", "back")), + } + } + m := &selectModVersionList{ root: root, list: l, parent: parent, items: make(chan []list.Item), + err: make(chan string), } go func() { @@ -53,7 +70,8 @@ func NewModVersionList(root components.RootModel, parent tea.Model, mod utils.Mo }) if err != nil { - panic(err) // TODO Handle Error + m.err <- err.Error() + return } if len(versions.Mod.Versions) == 0 { @@ -71,7 +89,9 @@ func NewModVersionList(root components.RootModel, parent tea.Model, mod utils.Mo version := allVersions[currentOffset+currentI] err := root.GetCurrentProfile().AddMod(mod.Reference, version.Version) if err != nil { - panic(err) // TODO Handle Error + errorComponent, cmd := components.NewErrorComponent(err.Error(), time.Second*5) + currentModel.error = errorComponent + return currentModel, cmd } return currentModel.parent, nil }, @@ -137,6 +157,10 @@ func (m selectModVersionList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.list.StopSpinner() cmd := m.list.SetItems(items) return m, 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) @@ -147,5 +171,12 @@ func (m selectModVersionList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m selectModVersionList) View() string { + if m.error != nil { + err := (*m.error).View() + m.list.SetSize(m.list.Width(), m.root.Size().Height-m.root.Height()-lipgloss.Height(err)) + return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), err, m.list.View()) + } + + m.list.SetSize(m.list.Width(), m.root.Size().Height-m.root.Height()) return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.list.View()) } diff --git a/utils/io.go b/utils/io.go index 6fc228c..78d4c88 100644 --- a/utils/io.go +++ b/utils/io.go @@ -14,7 +14,34 @@ import ( "github.com/spf13/viper" ) -func DownloadOrCache(cacheKey string, hash string, url string) (r io.ReaderAt, size int64, err error) { +type Progresser struct { + io.Reader + total int64 + running int64 + updates chan GenericUpdate +} + +func (pt *Progresser) Read(p []byte) (int, error) { + n, err := pt.Reader.Read(p) + pt.running += int64(n) + + if err == nil { + if pt.updates != nil { + select { + case pt.updates <- GenericUpdate{Progress: float64(pt.running) / float64(pt.total)}: + default: + } + } + } + + return n, errors.Wrap(err, "failed to read") +} + +type GenericUpdate struct { + Progress float64 +} + +func DownloadOrCache(cacheKey string, hash string, url string, updates chan GenericUpdate) (r io.ReaderAt, size int64, err error) { downloadCache := path.Join(viper.GetString("cache-dir"), "downloadCache") if err := os.MkdirAll(downloadCache, 0777); err != nil { if !os.IsExist(err) { @@ -74,7 +101,13 @@ func DownloadOrCache(cacheKey string, hash string, url string) (r io.ReaderAt, s return nil, 0, fmt.Errorf("bad status: %s on url: %s", resp.Status, url) } - _, err = io.Copy(out, resp.Body) + progresser := &Progresser{ + Reader: resp.Body, + total: resp.ContentLength, + updates: updates, + } + + _, err = io.Copy(out, progresser) if err != nil { return nil, 0, errors.Wrap(err, "failed writing file to disk") } @@ -84,6 +117,13 @@ func DownloadOrCache(cacheKey string, hash string, url string) (r io.ReaderAt, s return nil, 0, errors.Wrap(err, "failed to open file: "+location) } + if updates != nil { + select { + case updates <- GenericUpdate{Progress: 1}: + default: + } + } + return f, resp.ContentLength, nil } @@ -96,7 +136,7 @@ func SHA256Data(f io.Reader) (string, error) { return hex.EncodeToString(h.Sum(nil)), nil } -func ExtractMod(f io.ReaderAt, size int64, location string) error { +func ExtractMod(f io.ReaderAt, size int64, location string, updates chan GenericUpdate) error { if err := os.MkdirAll(location, 0777); err != nil { if !os.IsExist(err) { return errors.Wrap(err, "failed to create mod directory: "+location) @@ -116,7 +156,7 @@ func ExtractMod(f io.ReaderAt, size int64, location string) error { return errors.Wrap(err, "failed to read file as zip") } - for _, file := range reader.File { + for i, file := range reader.File { if !file.FileInfo().IsDir() { outFileLocation := path.Join(location, file.Name) @@ -124,21 +164,45 @@ func ExtractMod(f io.ReaderAt, size int64, location string) error { return errors.Wrap(err, "failed to create mod directory: "+location) } - outFile, err := os.OpenFile(outFileLocation, os.O_CREATE|os.O_RDWR, 0644) - if err != nil { - return errors.Wrap(err, "failed to write to file: "+location) + if err := writeZipFile(outFileLocation, file); err != nil { + return err } + } - inFile, err := file.Open() - if err != nil { - return errors.Wrap(err, "failed to process mod zip") + if updates != nil { + select { + case updates <- GenericUpdate{Progress: float64(i) / float64(len(reader.File)-1)}: + default: } + } + } - if _, err := io.Copy(outFile, inFile); err != nil { - return errors.Wrap(err, "failed to write to file: "+location) - } + if updates != nil { + select { + case updates <- GenericUpdate{Progress: 1}: + default: } } return nil } + +func writeZipFile(outFileLocation string, file *zip.File) error { + outFile, err := os.OpenFile(outFileLocation, os.O_CREATE|os.O_RDWR, 0644) + if err != nil { + return errors.Wrap(err, "failed to write to file: "+outFileLocation) + } + + defer outFile.Close() + + inFile, err := file.Open() + if err != nil { + return errors.Wrap(err, "failed to process mod zip") + } + + if _, err := io.Copy(outFile, inFile); err != nil { + return errors.Wrap(err, "failed to write to file: "+outFileLocation) + } + + return nil +}