technically "functional", but you be the judge

This commit is contained in:
Vilsol 2022-06-04 01:17:02 +03:00
parent 688b8ca175
commit bdcbb0b677
24 changed files with 800 additions and 107 deletions

View file

@ -9,7 +9,6 @@ import (
"github.com/Masterminds/semver/v3" "github.com/Masterminds/semver/v3"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/satisfactorymodding/ficsit-cli/ficsit" "github.com/satisfactorymodding/ficsit-cli/ficsit"
"github.com/satisfactorymodding/ficsit-cli/utils"
"github.com/spf13/viper" "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") 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{ instance := &resolvingInstance{
Resolver: d, Resolver: d,
InputLock: lockFile, InputLock: lockFile,
ToResolve: utils.CopyMap(constraints), ToResolve: copied,
OutputLock: make(LockFile), OutputLock: make(LockFile),
SMLVersions: smlVersionsDB, SMLVersions: smlVersionsDB,
GameVersion: gameVersion, GameVersion: gameVersion,
@ -50,7 +54,7 @@ type resolvingInstance struct {
InputLock *LockFile InputLock *LockFile
ToResolve map[string]string ToResolve map[string][]string
OutputLock LockFile OutputLock LockFile
@ -69,11 +73,12 @@ func (r *resolvingInstance) Step() error {
if id != "SML" { if id != "SML" {
converted = append(converted, ficsit.ModVersionConstraint{ converted = append(converted, ficsit.ModVersionConstraint{
ModIdOrReference: id, ModIdOrReference: id,
Version: constraint, Version: constraint[0],
}) })
} else { } else {
smlVersionConstraint, _ := semver.NewConstraint(constraint)
if existingSML, ok := r.OutputLock[id]; ok { if existingSML, ok := r.OutputLock[id]; ok {
for _, cs := range constraint {
smlVersionConstraint, _ := semver.NewConstraint(cs)
if !smlVersionConstraint.Check(semver.MustParse(existingSML.Version)) { if !smlVersionConstraint.Check(semver.MustParse(existingSML.Version)) {
return errors.Errorf("mod %s version %s does not match constraint %s", return errors.Errorf("mod %s version %s does not match constraint %s",
id, id,
@ -82,6 +87,7 @@ func (r *resolvingInstance) Step() error {
) )
} }
} }
}
var chosenSMLVersion *semver.Version var chosenSMLVersion *semver.Version
for _, version := range r.SMLVersions.SmlVersions.Sml_versions { for _, version := range r.SMLVersions.SmlVersions.Sml_versions {
@ -90,7 +96,18 @@ func (r *resolvingInstance) Step() error {
} }
currentVersion := semver.MustParse(version.Version) 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) { if chosenSMLVersion == nil || currentVersion.GreaterThan(chosenSMLVersion) {
chosenSMLVersion = currentVersion chosenSMLVersion = currentVersion
} }
@ -98,7 +115,7 @@ func (r *resolvingInstance) Step() error {
} }
if chosenSMLVersion == nil { 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{ 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 // TODO Cache
dependencies, err := ficsit.ResolveModDependencies(context.TODO(), r.Resolver.apiClient, converted) dependencies, err := ficsit.ResolveModDependencies(context.TODO(), r.Resolver.apiClient, converted)
@ -147,7 +164,27 @@ func (r *resolvingInstance) Step() error {
// Pick latest version // Pick latest version
// TODO Clone and branch // 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 _, ok := r.OutputLock[mod.Mod_reference]; ok {
if r.OutputLock[mod.Mod_reference].Version != selectedVersion.Version { if r.OutputLock[mod.Mod_reference].Version != selectedVersion.Version {
@ -181,9 +218,11 @@ 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) constraint, _ := semver.NewConstraint(dependency.Constraint)
resolvingConstraint, _ := semver.NewConstraint(resolving)
for _, cs := range resolving {
resolvingConstraint, _ := semver.NewConstraint(cs)
intersects, _ := constraint.Intersects(resolvingConstraint) intersects, _ := constraint.Intersects(resolvingConstraint)
if !intersects { if !intersects {
return errors.Errorf("mod %s constraint %s does not intersect with %s", return errors.Errorf("mod %s constraint %s does not intersect with %s",
@ -193,15 +232,18 @@ func (r *resolvingInstance) Step() error {
) )
} }
} }
}
if dependency.Optional { if dependency.Optional {
continue continue
} }
r.ToResolve[dependency.ModReference] = dependency.Constraint nextResolve[dependency.ModReference] = append(nextResolve[dependency.ModReference], dependency.Constraint)
} }
} }
r.ToResolve = nextResolve
for _, constraint := range converted { for _, constraint := range converted {
if _, ok := r.OutputLock[constraint.ModIdOrReference]; !ok { if _, ok := r.OutputLock[constraint.ModIdOrReference]; !ok {
return errors.New("failed resolving dependency: " + constraint.ModIdOrReference) 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 { func (r *resolvingInstance) LockStep(viewed map[string]bool) error {
added := false added := false
if r.InputLock != nil { if r.InputLock != nil {
for modReference, version := range r.ToResolve { for modReference, constraints := range r.ToResolve {
if _, ok := viewed[modReference]; ok { if _, ok := viewed[modReference]; ok {
continue continue
} }
@ -229,15 +271,25 @@ func (r *resolvingInstance) LockStep(viewed map[string]bool) error {
viewed[modReference] = true viewed[modReference] = true
if locked, ok := (*r.InputLock)[modReference]; ok { if locked, ok := (*r.InputLock)[modReference]; ok {
constraint, _ := semver.NewConstraint(version) passes := true
if constraint.Check(semver.MustParse(locked.Version)) {
for _, cs := range constraints {
constraint, _ := semver.NewConstraint(cs)
if !constraint.Check(semver.MustParse(locked.Version)) {
passes = false
break
}
}
if passes {
delete(r.ToResolve, modReference) delete(r.ToResolve, modReference)
r.OutputLock[modReference] = locked r.OutputLock[modReference] = locked
for k, v := range locked.Dependencies { for k, v := range locked.Dependencies {
if alreadyResolving, ok := r.ToResolve[k]; ok { if alreadyResolving, ok := r.ToResolve[k]; ok {
cs1, _ := semver.NewConstraint(v) newConstraint, _ := semver.NewConstraint(v)
cs2, _ := semver.NewConstraint(alreadyResolving) for _, resolvingConstraint := range alreadyResolving {
intersects, _ := cs1.Intersects(cs2) cs2, _ := semver.NewConstraint(resolvingConstraint)
intersects, _ := newConstraint.Intersects(cs2)
if !intersects { if !intersects {
return errors.Errorf("mod %s constraint %s does not intersect with %s", return errors.Errorf("mod %s constraint %s does not intersect with %s",
k, k,
@ -245,6 +297,10 @@ func (r *resolvingInstance) LockStep(viewed map[string]bool) error {
alreadyResolving, alreadyResolving,
) )
} }
}
r.ToResolve[k] = append(r.ToResolve[k], v)
continue continue
} }
@ -260,7 +316,7 @@ func (r *resolvingInstance) LockStep(viewed map[string]bool) error {
continue continue
} }
r.ToResolve[k] = v r.ToResolve[k] = append(r.ToResolve[k], v)
added = true added = true
} }
} }

View file

@ -6,6 +6,8 @@ import (
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"regexp"
"strings"
"github.com/satisfactorymodding/ficsit-cli/utils" "github.com/satisfactorymodding/ficsit-cli/utils"
@ -224,30 +226,84 @@ func (i *Installation) Validate(ctx *GlobalContext) error {
return nil return nil
} }
func (i *Installation) Install(ctx *GlobalContext) error { var (
if err := i.Validate(ctx); err != nil { lockFileCleaner = regexp.MustCompile(`[^a-zA-Z\d]]`)
return errors.Wrap(err, "failed to validate installation") 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) platform, err := i.GetPlatform(ctx)
if err != nil { 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 var lockFile *LockFile
lockFileJSON, err := os.ReadFile(lockfilePath) lockFileJSON, err := os.ReadFile(lockfilePath)
if err != nil { if err != nil {
if !os.IsNotExist(err) { if !os.IsNotExist(err) {
return errors.Wrap(err, "failed reading lockfile") return nil, errors.Wrap(err, "failed reading lockfile")
} }
} else { } else {
if err := json.Unmarshal(lockFileJSON, &lockFile); err != nil { 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) resolver := NewDependencyResolver(ctx.APIClient)
gameVersion, err := i.GetGameVersion(ctx) gameVersion, err := i.GetGameVersion(ctx)
@ -266,28 +322,79 @@ func (i *Installation) Install(ctx *GlobalContext) error {
return errors.Wrap(err, "failed creating Mods directory") return errors.Wrap(err, "failed creating Mods directory")
} }
dir, err := os.ReadDir(modsDirectory)
if err != nil {
return errors.Wrap(err, "failed to read mods directory")
}
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")
}
}
}
}
completed := 0
for modReference, version := range lockfile { for modReference, version := range lockfile {
// Only install if a link is provided, otherwise assume mod is already installed // Only install if a link is provided, otherwise assume mod is already installed
if version.Link != "" { if version.Link != "" {
reader, size, err := utils.DownloadOrCache(modReference+"_"+version.Version+".zip", version.Hash, version.Link) downloading := true
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 { if err != nil {
return errors.Wrap(err, "failed to download "+modReference+" from: "+version.Link) return errors.Wrap(err, "failed to download "+modReference+" from: "+version.Link)
} }
if err := utils.ExtractMod(reader, size, path.Join(modsDirectory, modReference)); err != nil { downloading = false
if err := utils.ExtractMod(reader, size, path.Join(modsDirectory, modReference), genericUpdates); err != nil {
return errors.Wrap(err, "could not extract "+modReference) return errors.Wrap(err, "could not extract "+modReference)
} }
}
close(genericUpdates)
} }
marshaledLockfile, err := json.MarshalIndent(lockfile, "", " ") completed++
if err != nil {
return errors.Wrap(err, "failed to serialize lockfile json")
} }
if err := os.WriteFile(lockfilePath, marshaledLockfile, 0777); err != nil { if err := i.WriteLockFile(ctx, lockfile); err != nil {
return errors.Wrap(err, "failed writing lockfile") return err
} }
return nil return nil

View file

@ -34,7 +34,7 @@ func TestAddInstallation(t *testing.T) {
testza.AssertNoError(t, err) testza.AssertNoError(t, err)
testza.AssertNotNil(t, installation) testza.AssertNotNil(t, installation)
err = installation.Install(ctx) err = installation.Install(ctx, nil)
testza.AssertNoError(t, err) testza.AssertNoError(t, err)
} }
} }

View file

@ -10,14 +10,14 @@ type Platform struct {
var platforms = []Platform{ var platforms = []Platform{
{ {
VersionPath: path.Join("Engine", "Binaries", "Linux", "UE4Server-Linux-Shipping.version"), 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"), 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"), VersionPath: path.Join("Engine", "Binaries", "Win64", "FactoryGame-Win64-Shipping.version"),
LockfilePath: path.Join("FactoryGame", "Mods", "mods-lock.json"), LockfilePath: path.Join("FactoryGame", "Mods"),
}, },
} }

View file

@ -95,10 +95,8 @@ func init() {
switch runtime.GOOS { switch runtime.GOOS {
case "windows": case "windows":
baseLocalDir = os.Getenv("APPDATA") baseLocalDir = os.Getenv("APPDATA")
break
case "linux": case "linux":
baseLocalDir = path.Join(os.Getenv("HOME"), ".local", "share") baseLocalDir = path.Join(os.Getenv("HOME"), ".local", "share")
break
default: default:
panic("unsupported platform: " + runtime.GOOS) panic("unsupported platform: " + runtime.GOOS)
} }

3
go.mod
View file

@ -5,7 +5,7 @@ go 1.18
require ( require (
github.com/JohannesKaufmann/html-to-markdown v1.3.3 github.com/JohannesKaufmann/html-to-markdown v1.3.3
github.com/Khan/genqlient v0.4.0 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/Masterminds/semver/v3 v3.1.1
github.com/PuerkitoBio/goquery v1.8.0 github.com/PuerkitoBio/goquery v1.8.0
github.com/charmbracelet/bubbles v0.10.3 github.com/charmbracelet/bubbles v0.10.3
@ -30,6 +30,7 @@ require (
github.com/atomicgo/cursor v0.0.1 // indirect github.com/atomicgo/cursor v0.0.1 // indirect
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymerick/douceur v0.2.0 // 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/containerd/console v1.0.3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.4.0 // indirect github.com/dlclark/regexp2 v1.4.0 // indirect

5
go.sum
View file

@ -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.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.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/2oUqKc6bF2c=
github.com/MarvinJWendt/testza v0.3.5/go.mod h1:ExbTpWmA1z2E9HSskvrNcwApoX4F9bID692s10nuHRY= 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.2 h1:Vbw9GkSB5erJI2BPnBL9SVGV9myE+XmUSFahBGUhW2Q=
github.com/MarvinJWendt/testza v0.4.1/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE= 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.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= 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/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 h1:wu15ykPdB7X6chxugG/NNfDUbyyrCLV9XBalj5wdu3g=
github.com/charmbracelet/glamour v0.5.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5UyZBbQsMQN0XIqc= 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/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.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM=
github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8=

48
tea/components/error.go Normal file
View file

@ -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{}

207
tea/scenes/apply.go Normal file
View file

@ -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)
}

View file

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@ -20,6 +21,7 @@ type installation struct {
parent tea.Model parent tea.Model
installation *cli.Installation installation *cli.Installation
hadRenamed bool hadRenamed bool
error *components.ErrorComponent
} }
func NewInstallation(root components.RootModel, parent tea.Model, installationData *cli.Installation) tea.Model { 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", ItemTitle: "Select",
Activate: func(msg tea.Msg, currentModel installation) (tea.Model, tea.Cmd) { Activate: func(msg tea.Msg, currentModel installation) (tea.Model, tea.Cmd) {
if err := root.SetCurrentInstallation(installationData); err != nil { 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 return currentModel.parent, nil
@ -44,7 +48,9 @@ func NewInstallation(root components.RootModel, parent tea.Model, installationDa
ItemTitle: "Delete", ItemTitle: "Delete",
Activate: func(msg tea.Msg, currentModel installation) (tea.Model, tea.Cmd) { Activate: func(msg tea.Msg, currentModel installation) (tea.Model, tea.Cmd) {
if err := root.GetGlobal().Installations.DeleteInstallation(installationData.Path); err != nil { 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 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.StatusMessageLifetime = time.Second * 3
model.list.DisableQuitKeybindings() 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 return model
} }
@ -112,11 +130,20 @@ func (m installation) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case updateInstallationNames: case updateInstallationNames:
m.hadRenamed = true m.hadRenamed = true
m.list.Title = fmt.Sprintf("Installation: %s", m.installation.Path) m.list.Title = fmt.Sprintf("Installation: %s", m.installation.Path)
case components.ErrorComponentTimeoutMsg:
m.error = nil
} }
return m, nil return m, nil
} }
func (m installation) View() string { 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()) return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.list.View())
} }

View file

@ -31,6 +31,7 @@ func NewInstallations(root components.RootModel, parent tea.Model) tea.Model {
l.AdditionalShortHelpKeys = func() []key.Binding { l.AdditionalShortHelpKeys = func() []key.Binding {
return []key.Binding{ return []key.Binding{
key.NewBinding(key.WithHelp("q", "back")),
key.NewBinding(key.WithHelp("n", "new installation")), key.NewBinding(key.WithHelp("n", "new installation")),
} }
} }

View file

@ -16,6 +16,7 @@ var _ tea.Model = (*mainMenu)(nil)
type mainMenu struct { type mainMenu struct {
root components.RootModel root components.RootModel
list list.Model list list.Model
error *components.ErrorComponent
} }
func NewMainMenu(root components.RootModel) tea.Model { func NewMainMenu(root components.RootModel) tea.Model {
@ -48,8 +49,15 @@ func NewMainMenu(root components.RootModel) tea.Model {
utils.SimpleItem[mainMenu]{ utils.SimpleItem[mainMenu]{
ItemTitle: "Apply Changes", ItemTitle: "Apply Changes",
Activate: func(msg tea.Msg, currentModel mainMenu) (tea.Model, tea.Cmd) { Activate: func(msg tea.Msg, currentModel mainMenu) (tea.Model, tea.Cmd) {
// TODO Apply changes to all changed profiles if err := root.GetGlobal().Save(); err != nil {
return nil, 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]{ 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) { Activate: func(msg tea.Msg, currentModel mainMenu) (tea.Model, tea.Cmd) {
if err := root.GetGlobal().Save(); err != nil { if err := root.GetGlobal().Save(); err != nil {
log.Error().Err(err).Msg(ErrorFailedAddMod) 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 currentModel, cmd
} }
return nil, nil return nil, nil
@ -76,8 +85,6 @@ func NewMainMenu(root components.RootModel) tea.Model {
model.list.SetFilteringEnabled(false) model.list.SetFilteringEnabled(false)
model.list.Title = "Main Menu" model.list.Title = "Main Menu"
model.list.Styles = utils.ListStyles model.list.Styles = utils.ListStyles
model.list.SetSize(model.list.Width(), model.list.Height())
model.list.StatusMessageLifetime = time.Second * 3
model.list.DisableQuitKeybindings() model.list.DisableQuitKeybindings()
return model 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() top, right, bottom, left := lipgloss.NewStyle().Margin(2, 2).GetMargin()
m.list.SetSize(msg.Width-left-right, msg.Height-top-bottom) m.list.SetSize(msg.Width-left-right, msg.Height-top-bottom)
m.root.SetSize(msg) m.root.SetSize(msg)
case components.ErrorComponentTimeoutMsg:
m.error = nil
} }
return m, nil return m, nil
} }
func (m mainMenu) View() string { 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()) return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.list.View())
} }

View file

@ -3,6 +3,7 @@ package scenes
import ( import (
"time" "time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "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.StatusMessageLifetime = time.Second * 3
model.list.DisableQuitKeybindings() 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 return model
} }

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@ -30,9 +31,11 @@ type modInfo struct {
spinner spinner.Model spinner spinner.Model
parent tea.Model parent tea.Model
modData chan ficsit.GetModGetMod modData chan ficsit.GetModGetMod
modError chan string
ready bool ready bool
help help.Model help help.Model
keys modInfoKeyMap keys modInfoKeyMap
error *components.ErrorComponent
} }
type modInfoKeyMap struct { type modInfoKeyMap struct {
@ -65,6 +68,7 @@ func NewModInfo(root components.RootModel, parent tea.Model, mod utils.Mod) tea.
spinner: spinner.New(), spinner: spinner.New(),
parent: parent, parent: parent,
modData: make(chan ficsit.GetModGetMod), modData: make(chan ficsit.GetModGetMod),
modError: make(chan string),
ready: false, ready: false,
help: help.New(), help: help.New(),
keys: modInfoKeyMap{ 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) fullMod, err := ficsit.GetMod(context.TODO(), root.GetAPIClient(), mod.ID)
if err != nil { if err != nil {
panic(err) // TODO Handle Error model.modError <- err.Error()
return
} }
if fullMod == nil { if fullMod == nil {
panic("mod is nil") // TODO Handle Error model.modError <- "unknown error (mod is nil)"
return
} }
model.modData <- fullMod.GetMod model.modData <- fullMod.GetMod
@ -204,6 +210,10 @@ func (m modInfo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg) m.viewport, cmd = m.viewport.Update(msg)
return m, cmd return m, cmd
case err := <-m.modError:
errorComponent, cmd := components.NewErrorComponent(err, time.Second*5)
m.error = errorComponent
return m, cmd
default: default:
return m, utils.Ticker() return m, utils.Ticker()
} }
@ -213,6 +223,11 @@ func (m modInfo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (m modInfo) View() string { 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 { if m.viewport.Height == 0 {
spinnerView := lipgloss.NewStyle().Padding(0, 2, 1).Render(m.spinner.View() + " Loading...") spinnerView := lipgloss.NewStyle().Padding(0, 2, 1).Render(m.spinner.View() + " Loading...")
return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), spinnerView) return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), spinnerView)

View file

@ -1,6 +1,8 @@
package scenes package scenes
import ( import (
"time"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@ -16,6 +18,7 @@ type modSemver struct {
input textinput.Model input textinput.Model
title string title string
mod utils.Mod mod utils.Mod
error *components.ErrorComponent
} }
func NewModSemver(root components.RootModel, parent tea.Model, mod utils.Mod) tea.Model { 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: case KeyEnter:
err := m.root.GetCurrentProfile().AddMod(m.mod.Reference, m.input.Value()) err := m.root.GetCurrentProfile().AddMod(m.mod.Reference, m.input.Value())
if err != nil { 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 return m.parent, nil
default: default:
@ -59,6 +64,8 @@ func (m modSemver) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.root.SetSize(msg) m.root.SetSize(msg)
case components.ErrorComponentTimeoutMsg:
m.error = nil
} }
return m, nil return m, nil
@ -66,5 +73,10 @@ func (m modSemver) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m modSemver) View() string { func (m modSemver) View() string {
inputView := lipgloss.NewStyle().Padding(1, 2).Render(m.input.View()) 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) return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.title, inputView)
} }

View file

@ -3,6 +3,7 @@ package scenes
import ( import (
"time" "time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "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.StatusMessageLifetime = time.Second * 3
model.list.DisableQuitKeybindings() 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 return model
} }

View file

@ -3,6 +3,7 @@ package scenes
import ( import (
"context" "context"
"sort" "sort"
"time"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
@ -40,6 +41,9 @@ type modsList struct {
showSortOrderList bool showSortOrderList bool
sortOrderList list.Model sortOrderList list.Model
err chan string
error *components.ErrorComponent
} }
func NewMods(root components.RootModel, parent tea.Model) tea.Model { 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 { l.AdditionalShortHelpKeys = func() []key.Binding {
return []key.Binding{ return []key.Binding{
key.NewBinding(key.WithHelp("q", "back")),
key.NewBinding(key.WithHelp("s", "sort")), key.NewBinding(key.WithHelp("s", "sort")),
key.NewBinding(key.WithHelp("o", "order")), 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 { l.AdditionalFullHelpKeys = func() []key.Binding {
return []key.Binding{ return []key.Binding{
key.NewBinding(key.WithHelp("q", "back")),
key.NewBinding(key.WithHelp("s", "sort")), key.NewBinding(key.WithHelp("s", "sort")),
key.NewBinding(key.WithHelp("o", "order")), key.NewBinding(key.WithHelp("o", "order")),
} }
@ -145,6 +151,7 @@ func NewMods(root components.RootModel, parent tea.Model) tea.Model {
sortingOrder: sortOrderDesc, sortingOrder: sortOrderDesc,
sortFieldList: sortFieldList, sortFieldList: sortFieldList,
sortOrderList: sortOrderList, sortOrderList: sortOrderList,
err: make(chan string),
} }
go func() { go func() {
@ -160,7 +167,8 @@ func NewMods(root components.RootModel, parent tea.Model) tea.Model {
}) })
if err != nil { if err != nil {
panic(err) // TODO Handle Error m.err <- err.Error()
return
} }
if len(mods.GetMods.Mods) == 0 { if len(mods.GetMods.Mods) == 0 {
@ -273,6 +281,10 @@ func (m modsList) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.list.StopSpinner() m.list.StopSpinner()
cmd := m.list.SetItems(items) cmd := m.list.SetItems(items)
return m, cmd return m, cmd
case err := <-m.err:
errorComponent, cmd := components.NewErrorComponent(err, time.Second*5)
m.error = errorComponent
return m, cmd
default: default:
start := m.list.StartSpinner() start := m.list.StartSpinner()
return m, tea.Batch(utils.Ticker(), start) 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 { func (m modsList) View() string {
var bottom string var bottomList list.Model
if m.showSortFieldList { if m.showSortFieldList {
bottom = m.sortFieldList.View() bottomList = m.sortFieldList
} else if m.showSortOrderList { } else if m.showSortOrderList {
bottom = m.sortOrderList.View() bottomList = m.sortOrderList
} else { } 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 { func sortItems(items []list.Item, field string, direction sortOrder) []list.Item {

View file

@ -1,6 +1,8 @@
package scenes package scenes
import ( import (
"time"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@ -15,6 +17,7 @@ type newInstallation struct {
parent tea.Model parent tea.Model
input textinput.Model input textinput.Model
title string title string
error *components.ErrorComponent
} }
func NewNewInstallation(root components.RootModel, parent tea.Model) tea.Model { 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 Tab-completion for input field
// TODO Directory listing // TODO Directory listing
// TODO SSH/FTP/SFTP support
return model return model
} }
@ -48,7 +52,9 @@ func (m newInstallation) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.parent, nil return m.parent, nil
case KeyEnter: case KeyEnter:
if _, err := m.root.GetGlobal().Installations.AddInstallation(m.root.GetGlobal(), m.input.Value(), m.root.GetGlobal().Profiles.SelectedProfile); err != nil { 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 return m.parent, updateInstallationListCmd
@ -59,6 +65,8 @@ func (m newInstallation) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.root.SetSize(msg) m.root.SetSize(msg)
case components.ErrorComponentTimeoutMsg:
m.error = nil
} }
return m, nil return m, nil
@ -66,5 +74,10 @@ func (m newInstallation) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m newInstallation) View() string { func (m newInstallation) View() string {
inputView := lipgloss.NewStyle().Padding(1, 2).Render(m.input.View()) 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) return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.title, inputView)
} }

View file

@ -1,6 +1,8 @@
package scenes package scenes
import ( import (
"time"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@ -15,6 +17,7 @@ type newProfile struct {
parent tea.Model parent tea.Model
input textinput.Model input textinput.Model
title string title string
error *components.ErrorComponent
} }
func NewNewProfile(root components.RootModel, parent tea.Model) tea.Model { 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 return m.parent, nil
case KeyEnter: case KeyEnter:
if _, err := m.root.GetGlobal().Profiles.AddProfile(m.input.Value()); err != nil { 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 return m.parent, updateProfileListCmd
@ -56,6 +61,8 @@ func (m newProfile) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.root.SetSize(msg) m.root.SetSize(msg)
case components.ErrorComponentTimeoutMsg:
m.error = nil
} }
return m, nil return m, nil
@ -63,5 +70,10 @@ func (m newProfile) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m newProfile) View() string { func (m newProfile) View() string {
inputView := lipgloss.NewStyle().Padding(1, 2).Render(m.input.View()) 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) return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.title, inputView)
} }

View file

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@ -20,6 +21,7 @@ type profile struct {
parent tea.Model parent tea.Model
profile *cli.Profile profile *cli.Profile
hadRenamed bool hadRenamed bool
error *components.ErrorComponent
} }
func NewProfile(root components.RootModel, parent tea.Model, profileData *cli.Profile) tea.Model { 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", ItemTitle: "Select",
Activate: func(msg tea.Msg, currentModel profile) (tea.Model, tea.Cmd) { Activate: func(msg tea.Msg, currentModel profile) (tea.Model, tea.Cmd) {
if err := root.SetCurrentProfile(profileData); err != nil { 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 return currentModel.parent, nil
@ -55,7 +59,9 @@ func NewProfile(root components.RootModel, parent tea.Model, profileData *cli.Pr
ItemTitle: "Delete", ItemTitle: "Delete",
Activate: func(msg tea.Msg, currentModel profile) (tea.Model, tea.Cmd) { Activate: func(msg tea.Msg, currentModel profile) (tea.Model, tea.Cmd) {
if err := root.GetGlobal().Profiles.DeleteProfile(profileData.Name); err != nil { 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 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.StatusMessageLifetime = time.Second * 3
model.list.DisableQuitKeybindings() 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 return model
} }
@ -124,11 +142,20 @@ func (m profile) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case updateProfileNames: case updateProfileNames:
m.hadRenamed = true m.hadRenamed = true
m.list.Title = fmt.Sprintf("Profile: %s", m.profile.Name) m.list.Title = fmt.Sprintf("Profile: %s", m.profile.Name)
case components.ErrorComponentTimeoutMsg:
m.error = nil
} }
return m, nil return m, nil
} }
func (m profile) View() string { 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()) return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.list.View())
} }

View file

@ -31,6 +31,7 @@ func NewProfiles(root components.RootModel, parent tea.Model) tea.Model {
l.AdditionalShortHelpKeys = func() []key.Binding { l.AdditionalShortHelpKeys = func() []key.Binding {
return []key.Binding{ return []key.Binding{
key.NewBinding(key.WithHelp("q", "back")),
key.NewBinding(key.WithHelp("n", "new profile")), key.NewBinding(key.WithHelp("n", "new profile")),
} }
} }

View file

@ -2,6 +2,7 @@ package scenes
import ( import (
"fmt" "fmt"
"time"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@ -19,6 +20,7 @@ type renameProfile struct {
input textinput.Model input textinput.Model
title string title string
oldName string oldName string
error *components.ErrorComponent
} }
func NewRenameProfile(root components.RootModel, parent tea.Model, profileData *cli.Profile) tea.Model { 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 return m.parent, nil
case KeyEnter: case KeyEnter:
if err := m.root.GetGlobal().Profiles.RenameProfile(m.oldName, m.input.Value()); err != nil { 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 return m.parent, updateProfileNamesCmd
@ -62,6 +66,8 @@ func (m renameProfile) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.root.SetSize(msg) m.root.SetSize(msg)
case components.ErrorComponentTimeoutMsg:
m.error = nil
} }
return m, nil return m, nil
@ -69,5 +75,10 @@ func (m renameProfile) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m renameProfile) View() string { func (m renameProfile) View() string {
inputView := lipgloss.NewStyle().Padding(1, 2).Render(m.input.View()) 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) return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.title, inputView)
} }

View file

@ -3,7 +3,9 @@ package scenes
import ( import (
"context" "context"
"fmt" "fmt"
"time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@ -20,6 +22,8 @@ type selectModVersionList struct {
list list.Model list list.Model
parent tea.Model parent tea.Model
items chan []list.Item items chan []list.Item
err chan string
error *components.ErrorComponent
} }
func NewModVersionList(root components.RootModel, parent tea.Model, mod utils.Mod) tea.Model { 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.KeyMap.Quit.SetHelp("q", "back")
l.DisableQuitKeybindings() 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{ m := &selectModVersionList{
root: root, root: root,
list: l, list: l,
parent: parent, parent: parent,
items: make(chan []list.Item), items: make(chan []list.Item),
err: make(chan string),
} }
go func() { go func() {
@ -53,7 +70,8 @@ func NewModVersionList(root components.RootModel, parent tea.Model, mod utils.Mo
}) })
if err != nil { if err != nil {
panic(err) // TODO Handle Error m.err <- err.Error()
return
} }
if len(versions.Mod.Versions) == 0 { 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] version := allVersions[currentOffset+currentI]
err := root.GetCurrentProfile().AddMod(mod.Reference, version.Version) err := root.GetCurrentProfile().AddMod(mod.Reference, version.Version)
if err != nil { 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 return currentModel.parent, nil
}, },
@ -137,6 +157,10 @@ func (m selectModVersionList) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.list.StopSpinner() m.list.StopSpinner()
cmd := m.list.SetItems(items) cmd := m.list.SetItems(items)
return m, cmd return m, cmd
case err := <-m.err:
errorComponent, cmd := components.NewErrorComponent(err, time.Second*5)
m.error = errorComponent
return m, cmd
default: default:
start := m.list.StartSpinner() start := m.list.StartSpinner()
return m, tea.Batch(utils.Ticker(), start) 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 { 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()) return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.list.View())
} }

View file

@ -14,7 +14,34 @@ import (
"github.com/spf13/viper" "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") downloadCache := path.Join(viper.GetString("cache-dir"), "downloadCache")
if err := os.MkdirAll(downloadCache, 0777); err != nil { if err := os.MkdirAll(downloadCache, 0777); err != nil {
if !os.IsExist(err) { 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) 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 { if err != nil {
return nil, 0, errors.Wrap(err, "failed writing file to disk") 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) 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 return f, resp.ContentLength, nil
} }
@ -96,7 +136,7 @@ func SHA256Data(f io.Reader) (string, error) {
return hex.EncodeToString(h.Sum(nil)), nil 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 err := os.MkdirAll(location, 0777); err != nil {
if !os.IsExist(err) { if !os.IsExist(err) {
return errors.Wrap(err, "failed to create mod directory: "+location) 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") 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() { if !file.FileInfo().IsDir() {
outFileLocation := path.Join(location, file.Name) outFileLocation := path.Join(location, file.Name)
@ -124,20 +164,44 @@ func ExtractMod(f io.ReaderAt, size int64, location string) error {
return errors.Wrap(err, "failed to create mod directory: "+location) return errors.Wrap(err, "failed to create mod directory: "+location)
} }
if err := writeZipFile(outFileLocation, file); err != nil {
return err
}
}
if updates != nil {
select {
case updates <- GenericUpdate{Progress: float64(i) / float64(len(reader.File)-1)}:
default:
}
}
}
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) outFile, err := os.OpenFile(outFileLocation, os.O_CREATE|os.O_RDWR, 0644)
if err != nil { if err != nil {
return errors.Wrap(err, "failed to write to file: "+location) return errors.Wrap(err, "failed to write to file: "+outFileLocation)
} }
defer outFile.Close()
inFile, err := file.Open() inFile, err := file.Open()
if err != nil { if err != nil {
return errors.Wrap(err, "failed to process mod zip") return errors.Wrap(err, "failed to process mod zip")
} }
if _, err := io.Copy(outFile, inFile); err != nil { if _, err := io.Copy(outFile, inFile); err != nil {
return errors.Wrap(err, "failed to write to file: "+location) return errors.Wrap(err, "failed to write to file: "+outFileLocation)
}
}
} }
return nil return nil