From 9a9dda96ce7afadeee99e6f463f875b0f734707b Mon Sep 17 00:00:00 2001 From: darkhz Date: Mon, 14 Nov 2022 12:39:18 +0530 Subject: [PATCH] rclone-tui: Initial implementation --- .goreleaser.yml | 54 ++ LICENSE | 22 + README.md | 124 ++++ cmd/config.go | 89 +++ cmd/flags.go | 126 ++++ go.mod | 24 + go.sum | 119 ++++ main.go | 29 + rclone/client.go | 284 +++++++++ rclone/config.go | 218 +++++++ rclone/dashboard.go | 173 ++++++ rclone/jobqueue.go | 381 ++++++++++++ rclone/login.go | 45 ++ rclone/operations/about.go | 37 ++ rclone/operations/explorer.go | 233 ++++++++ rclone/operations/list.go | 118 ++++ rclone/operations/mounts.go | 216 +++++++ rclone/operations/utils.go | 29 + rclone/version.go | 30 + ui/buttons.go | 136 +++++ ui/config.go | 943 ++++++++++++++++++++++++++++++ ui/dashboard.go | 184 ++++++ ui/explore.go | 1037 +++++++++++++++++++++++++++++++++ ui/formwidget.go | 532 +++++++++++++++++ ui/jobmonitor.go | 264 +++++++++ ui/login.go | 70 +++ ui/modal.go | 170 ++++++ ui/mounts.go | 549 +++++++++++++++++ ui/status.go | 345 +++++++++++ ui/suspend.go | 17 + ui/suspend_windows.go | 10 + ui/tabs.go | 136 +++++ ui/ui.go | 133 +++++ ui/utils.go | 212 +++++++ ui/views.go | 215 +++++++ 35 files changed, 7304 insertions(+) create mode 100644 .goreleaser.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmd/config.go create mode 100644 cmd/flags.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 rclone/client.go create mode 100644 rclone/config.go create mode 100644 rclone/dashboard.go create mode 100644 rclone/jobqueue.go create mode 100644 rclone/login.go create mode 100644 rclone/operations/about.go create mode 100644 rclone/operations/explorer.go create mode 100644 rclone/operations/list.go create mode 100644 rclone/operations/mounts.go create mode 100644 rclone/operations/utils.go create mode 100644 rclone/version.go create mode 100644 ui/buttons.go create mode 100644 ui/config.go create mode 100644 ui/dashboard.go create mode 100644 ui/explore.go create mode 100644 ui/formwidget.go create mode 100644 ui/jobmonitor.go create mode 100644 ui/login.go create mode 100644 ui/modal.go create mode 100644 ui/mounts.go create mode 100644 ui/status.go create mode 100644 ui/suspend.go create mode 100644 ui/suspend_windows.go create mode 100644 ui/tabs.go create mode 100644 ui/ui.go create mode 100644 ui/utils.go create mode 100644 ui/views.go diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..e61c46e --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,54 @@ +project_name: rclone-tui + +builds: + - env: + - CGO_ENABLED=0 + - GO111MODULE=on + - GOPROXY=https://proxy.golang.org + + goos: + - linux + - darwin + - windows + + goarch: + - arm + - 386 + - arm64 + - amd64 + + goarm: + - 5 + - 6 + - 7 + + ignore: + - goos: windows + goarch: arm64 + + - goos: windows + goarch: arm + +archives: + - replacements: + darwin: Darwin + linux: Linux + windows: Windows + 386: i386 + amd64: x86_64 + + format_overrides: + - goos: windows + format: zip + + files: + - LICENSE + +checksum: + name_template: 'checksums.txt' + +snapshot: + name_template: "{{ .Tag }}-next" + +changelog: + skip: true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4cab150 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2022 darkhz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e9dde02 --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +[![Go Report Card](https://goreportcard.com/badge/github.com/darkhz/rclone-tui)](https://goreportcard.com/report/github.com/darkhz/rclone-tui) + +[![youtube](https://img.youtube.com/vi/Jmm55Jh5Nhc/1.jpg)](https://youtube.com/watch?v=Jmm55Jh5Nhc) + +# rclone-tui +rclone-tui is a cross-platform manager for rclone. It aims to be on-par with the web GUI (--rc-web-gui) as well as provide more improvements and enhancements over its general functionality. + +Click on the above thumbnail to watch the demo video. +## Features +- Monitor rclone stats via the dashboard +- Create, update, view and delete configurations +- Explore remotes and perform various operations +- Mount and unmount remotes +- View file transfer and progress information + +## Installation +You can download the binaries present in the **Releases** page.

+Alternatively, if the **go** compiler is present in your system, you can install it with the following command:
+`go install github.com/darkhz/rclone-tui@latest` + +## Usage +``` +rclone-tui [] + +Flags: +--page Load the specified page (one of dashboard, configuration, explorer, mounts). +--host Specify a rclone host to connect to. +--password Specify a login password. +--user Specify a login username. +``` + +## Keybindings + +### Application + +#### Global +|Operation |Keybinding | +|----------------------------|----------------------------| +|Open job manager |Ctrl+j| +|Show view switcher |Ctrl+n| +|Cancel currently loading job|Ctrl+x| +|Suspend |Ctrl+z| +|Quit |Ctrl+q| + +#### Configuration/Mounts only +|Operation |Keybinding | +|-------------------------------------|-------------------------------------------------| +|Select button |Enter | +|Move between buttons |Ctrl+Left/Right | +|Move between sections (wizard only) |Shift+Tab | +|Move between form items (wizard only)|Ctrl+Down/Up/Tab| +|Show form options |Ctrl+o | +|Toggle password display |Ctrl+p | + +### Configuration +#### Manager +|Operation |Keybinding | +|----------|------------| +|Create new|n| +|Update |u| +|Delete |d| +|Filter |/| + +#### Wizard +|Operation |Keybinding | +|--------------|----------------------------| +|Jump to option|Ctrl+f| +|Save |Ctrl+s| +|Cancel |Ctrl+c| + +### Explorer + +#### General +|Operation |Keybinding | +|----------------------------|----------------------------| +|Switch between panes |Tab | +|Show remotes |g | +|Filter entries within pane |/ | +|Sort entries within pane |, | +|Navigate between directories|Left/Right | +|Refresh a pane |Ctrl+r| +|Cancel fetching remotes |Ctrl+x| + +#### Item selection +|Operation |Keybinding | +|-----------------|-----------------| +|Select one item |Space | +|Inverse selection|a | +|Select all items |A | +|Clear selections |Escape| + +#### Operations +|Operation |Keybinding | +|-----------------------------|------------| +|Copy selected items |p| +|Move selected items |m| +|Delete selected items |d| +|Make directory |M| +|Generate public link for item|;| + +### Mounts + +#### Manager +|Operation |Keybinding | +|-----------|------------| +|Create new |n| +|Unmount |u| +|Unmount all|U| + +#### Wizard +|Operation |Keybinding | +|-----------------|----------------------------| +|Create mountpoint|Ctrl+s| +|Cancel |Ctrl+c| + +### Job Manager +|Operation |Keybinding | +|---------------------|----------------------------| +|Navigate between jobs|Down/Up | +|Cancel job |x | +|Cancel job group |Ctrl+x| + +## Additional Notes +- To control your local rclone instance, launch `rclone rcd --rc-no-auth` and use the output host and port to login. Optionally, you can include authentication credentials with `--rc-user` and `--rc-pass` and excluding the `--rc-no-auth` flag. diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..658aaa3 --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,89 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" +) + +var ( + configPath string + configProperties map[string]string +) + +// SetupConfig checks for the config directory, +// and creates one if it doesn't exist. +func SetupConfig() error { + var dotConfigExists bool + + homedir, err := os.UserHomeDir() + if err != nil { + return err + } + + configProperties = make(map[string]string) + configDirs := []string{".config/rclone-tui", ".rclone-tui"} + + for i, dir := range configDirs { + fullpath := filepath.Join(homedir, dir) + configDirs[i] = fullpath + + if _, err := os.Stat(fullpath); err == nil { + configPath = fullpath + return nil + } + + if i == 0 { + if _, err := os.Stat( + filepath.Clean(filepath.Dir(fullpath)), + ); err == nil { + dotConfigExists = true + } + } + } + + if configPath == "" { + if dotConfigExists { + err := os.Mkdir(configDirs[0], 0700) + if err != nil { + return fmt.Errorf("Cannot create %s", configDirs[0]) + } + + configPath = configDirs[0] + } else { + err := os.Mkdir(configDirs[1], 0700) + if err != nil { + return fmt.Errorf("Cannot create %s", configDirs[1]) + } + + configPath = configDirs[1] + } + } + + return nil +} + +// ConfigPath returns the absolute path for the given configType. +func ConfigPath(configType string) (string, error) { + confPath := filepath.Join(configPath, configType) + + if _, err := os.Stat(confPath); err != nil { + fd, err := os.Create(confPath) + fd.Close() + if err != nil { + return "", fmt.Errorf("Cannot create "+configType+" file at %s", confPath) + } + } + + return confPath, nil +} + +// GetConfigProperty returns the value for the given property. +func GetConfigProperty(property string) string { + return configProperties[property] +} + +// AddConfigProperty adds a property and its value to the properties store. +func AddConfigProperty(property, value string) { + configProperties[property] = value +} diff --git a/cmd/flags.go b/cmd/flags.go new file mode 100644 index 0000000..bdbabf2 --- /dev/null +++ b/cmd/flags.go @@ -0,0 +1,126 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/darkhz/rclone-tui/rclone" + "github.com/jnovack/flag" +) + +// CmdOptions stores command-line options. +type CmdOptions struct { + Page string + Host, User, Pass string +} + +var cmdOptions CmdOptions + +// ParseFlags parses the command-line flags. +func ParseFlags() error { + configFile, err := ConfigPath("config") + if err != nil { + return err + } + + fs := flag.NewFlagSetWithEnvPrefix("rclone-tui", "RCLONETUI", flag.ExitOnError) + + fs.StringVar( + &cmdOptions.Page, + "page", + "", + "Load the specified page (one of dashboard, configuration, explorer, mounts).", + ) + fs.StringVar( + &cmdOptions.Host, + "host", + "", + "Specify a rclone host to connect to.", + ) + fs.StringVar( + &cmdOptions.User, + "user", + "", + "Specify a login username.", + ) + fs.StringVar( + &cmdOptions.Pass, + "password", + "", + "Specify a login password.", + ) + + fs.Usage = func() { + fmt.Fprintf( + flag.CommandLine.Output(), + "rclone-tui []\n\nConfig file is %s\n\nFlags:\n", + configFile, + ) + + fs.VisitAll(func(f *flag.Flag) { + s := fmt.Sprintf(" --%s", f.Name) + + if len(s) <= 4 { + s += "\t" + } else { + s += "\n \t" + } + + s += strings.ReplaceAll(f.Usage, "\n", "\n \t") + + fmt.Fprint(flag.CommandLine.Output(), s, "\n\n") + }) + } + + fs.ParseFile(configFile) + fs.Parse(os.Args[1:]) + + cmdLogin() + cmdPage() + + return nil +} + +func cmdLogin() { + if cmdOptions.Host == "" { + if cmdOptions.User != "" || cmdOptions.Pass != "" { + fmt.Println("Error: Specify a host") + goto Exit + } + + return + } + + if userInfo, err := rclone.Login(cmdOptions.Host, cmdOptions.User, cmdOptions.Pass); err != nil { + fmt.Printf("Error: %s\n", err.Error()) + } else { + AddConfigProperty("userInfo", userInfo) + return + } + +Exit: + os.Exit(0) +} + +func cmdPage() { + if cmdOptions.Page == "" { + return + } + + for _, page := range []string{ + "Dashboard", + "Configuration", + "Explorer", + "Mounts", + } { + if strings.Title(cmdOptions.Page) == page { + AddConfigProperty("page", page) + return + } + } + + fmt.Printf("Error: %s: No such page\n", cmdOptions.Page) + + os.Exit(0) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..47b22f8 --- /dev/null +++ b/go.mod @@ -0,0 +1,24 @@ +module github.com/darkhz/rclone-tui + +go 1.19 + +require ( + code.cloudfoundry.org/bytefmt v0.0.0-20211005130812-5bb3c17173e5 + github.com/darkhz/tview v0.0.0-20221214094756-6eabb0482877 + github.com/darkhz/tvxwidgets v0.0.0-20221214053829-6bf5ddd06058 + github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1 + github.com/iancoleman/strcase v0.2.0 + github.com/jnovack/flag v1.16.0 + github.com/mitchellh/mapstructure v1.5.0 + golang.org/x/sync v0.1.0 +) + +require ( + github.com/gdamore/encoding v1.0.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/rivo/uniseg v0.4.2 // indirect + golang.org/x/sys v0.0.0-20210423082822-04245dca01da // indirect + golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d // indirect + golang.org/x/text v0.3.7 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..cad0e90 --- /dev/null +++ b/go.sum @@ -0,0 +1,119 @@ +code.cloudfoundry.org/bytefmt v0.0.0-20211005130812-5bb3c17173e5 h1:tM5+dn2C9xZw1RzgI6WTQW1rGqdUimKB3RFbyu4h6Hc= +code.cloudfoundry.org/bytefmt v0.0.0-20211005130812-5bb3c17173e5/go.mod h1:v4VVB6oBMz/c9fRY6vZrwr5xKRWOH5NPDjQZlPk0Gbs= +github.com/darkhz/tview v0.0.0-20221214094756-6eabb0482877 h1:SXpU2C/Q0ttdCVRi6Ez7cw/sFL6oaB67QlYhsJCOSn4= +github.com/darkhz/tview v0.0.0-20221214094756-6eabb0482877/go.mod h1:MXOa8TFn7Yi9UHfFAhpRqGzQQF977sgz7YrDbv0LyeY= +github.com/darkhz/tvxwidgets v0.0.0-20221214053829-6bf5ddd06058 h1:t4HGKvS8CqZ1CzdWfoxhsqq/k+3PlQw0Lykl9SxAPiU= +github.com/darkhz/tvxwidgets v0.0.0-20221214053829-6bf5ddd06058/go.mod h1:VPZTGZUhJaMwSwATmFwPxpOsrDz1XDtRviFq7EZVghg= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1 h1:QqwPZCwh/k1uYqq6uXSb9TRDhTkfQbO80v8zhnIe5zM= +github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1/go.mod h1:Az6Jt+M5idSED2YPGtwnfJV0kXohgdCBPmHGSYc1r04= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/iancoleman/strcase v0.2.0 h1:05I4QRnGpI0m37iZQRuskXh+w77mr6Z41lwQzuHLwW0= +github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= +github.com/jnovack/flag v1.16.0 h1:gJC3JVofq/hNGlNfki4NlIWLOiDkaeLNUOCzznCablU= +github.com/jnovack/flag v1.16.0/go.mod h1:8g1MmrEr03yquMjIe6CYeXUiIsZ46ssYt+o3X7uEjcg= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.16.0 h1:6gjqkI8iiRHMvdccRJM8rVKjCWk6ZIm6FTm3ddIe4/c= +github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.2 h1:YwD0ulJSJytLpiaWua0sBDusfsCZohxjxzVTYjwxfV8= +github.com/rivo/uniseg v0.4.2/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781 h1:DzZ89McO9/gWPsQXS/FVKAlG02ZjaQ6AlZRBimEYOd0= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da h1:b3NXsE2LusjYGGjL5bxEVZZORm/YEFFrWFjR8eFrw/c= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d h1:SZxvLBoTP5yHO3Frd4z4vrF+DBX9vMVanchswa69toE= +golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/main.go b/main.go new file mode 100644 index 0000000..78fac97 --- /dev/null +++ b/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "fmt" + "os" + + "github.com/darkhz/rclone-tui/cmd" + "github.com/darkhz/rclone-tui/ui" +) + +func errMessage(err error) { + fmt.Fprintf(os.Stderr, "Error: %s\n", err.Error()) +} + +func main() { + err := cmd.SetupConfig() + if err != nil { + errMessage(err) + return + } + + err = cmd.ParseFlags() + if err != nil { + errMessage(err) + return + } + + ui.SetupUI() +} diff --git a/rclone/client.go b/rclone/client.go new file mode 100644 index 0000000..7d36be2 --- /dev/null +++ b/rclone/client.go @@ -0,0 +1,284 @@ +package rclone + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "sync" + "time" +) + +// Client stores host/client information and its credentials. +type Client struct { + Host string + URI *url.URL + + client *http.Client + user, pass string +} + +// Response stores the response obtained after a client request. +type Response struct { + Body io.ReadCloser +} + +const userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36" + +var ( + currentHost string + + sessionLock sync.Mutex + session map[string]*Client + + clientCtx context.Context + clientCancel context.CancelFunc +) + +// SendRequest sends a request to the rclone host and returns a response. +func (c *Client) SendRequest(command map[string]interface{}, endpoint string, ctx ...context.Context) (Response, error) { + if ctx == nil { + ctx = append(ctx, clientContext(false)) + } + + commandBytes, err := json.Marshal(&command) + if err != nil { + return Response{}, err + } + + req, err := http.NewRequestWithContext( + ctx[0], http.MethodPost, + c.Host+endpoint, bytes.NewReader(commandBytes), + ) + if err != nil { + return Response{}, err + } + + req.SetBasicAuth(c.user, c.pass) + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Content-Type", "application/json") + + res, err := c.client.Do(req) + if err != nil { + return Response{}, err + } + + if res.StatusCode == 401 { + return Response{}, fmt.Errorf("Unauthorized") + } + + return Response{res.Body}, nil +} + +// Hostname returns the client's hostname. +func (c *Client) Hostname() string { + return c.URI.Scheme + "://" + c.URI.Host +} + +// Decode unmarshals the json response into the provided data. +func (r *Response) Decode(v interface{}) error { + defer r.Body.Close() + + return json.NewDecoder(r.Body).Decode(v) +} + +// SetupClient sets up the client. +func SetupClient(host, user, pass string) error { + sessionLock.Lock() + defer sessionLock.Unlock() + + var client *Client + + u, err := url.Parse(host) + if err != nil { + return err + } + + if c, err := GetClient(host, struct{}{}); err == nil { + client = c + + goto LoadClient + } + + u.User = url.UserPassword(user, pass) + + client = &Client{ + URI: u, + client: &http.Client{ + Timeout: 10 * time.Second, + }, + + user: user, + pass: pass, + } + +LoadClient: + host = u.Scheme + "://" + if user != "" { + host += user + ":" + pass + "@" + } + host += u.Hostname() + if u.Port() != "" { + host += ":" + u.Port() + } + + client.Host = host + + if err := testClient(client); err != nil { + return err + } + + currentHost = client.Host + + if session == nil { + session = make(map[string]*Client) + } + + session[currentHost] = client + + return err +} + +// SetSession sets the current session. +func SetSession(host string) { + currentHost = host +} + +// GetSessions gets the running sessions. +func GetSessions() []string { + sessionLock.Lock() + defer sessionLock.Unlock() + + var uris []string + + for s := range session { + uris = append(uris, s) + } + + return uris +} + +// GetClient gets the client which corresponds to the provided host. +func GetClient(host string, nolock ...struct{}) (*Client, error) { + if nolock == nil { + sessionLock.Lock() + defer sessionLock.Unlock() + } + + client, ok := session[host] + if !ok { + return nil, fmt.Errorf("No client found") + } + + return client, nil +} + +// GetCurrentClient gets the client associated with the current host. +func GetCurrentClient() (*Client, error) { + return GetClient(currentHost) +} + +// DialServer checks whether the host is reachable. +func DialServer() error { + var address string + + client, err := GetCurrentClient() + if err != nil { + return err + } + + address = client.URI.Hostname() + ":" + if addrURI := client.URI; addrURI.Port() == "" { + address += addrURI.Scheme + } else { + address += addrURI.Port() + } + + _, err = net.DialTimeout("tcp", address, 1*time.Second) + + return err +} + +// SendCommand sends a command to the rclone host and returns a response. +// This is a blocking call. +func SendCommand(command map[string]interface{}, endpoint string, ctx ...context.Context) (Response, error) { + client, err := GetCurrentClient() + if err != nil { + return Response{}, err + } + + return client.SendRequest(command, endpoint, ctx...) +} + +// SendCommandAsync asynchronously sends a command to rclone and returns the job information +// for the running command. +func SendCommandAsync( + jobType, jobDesc string, + command map[string]interface{}, endpoint string, + noqueue ...struct{}, +) (*Job, error) { + var jobID map[string]interface{} + + command["_async"] = true + + response, err := SendCommand(command, endpoint) + if err != nil { + return nil, err + } + + err = response.Decode(&jobID) + if err != nil { + return nil, err + } + + if jobError, ok := jobID["error"]; ok { + if respErr, ok := jobError.(string); ok { + return nil, fmt.Errorf(respErr) + } + } + + id, ok := jobID["jobid"].(float64) + if !ok { + return nil, fmt.Errorf("Cannot get job ID from rclone") + } + + job := NewJob(jobType, jobDesc, int64(id)) + if noqueue != nil { + return job, nil + } + + return AddJobToQueue(job), nil +} + +// GetClientContext returns the client context. +func GetClientContext() context.Context { + return clientContext(false) +} + +// CancelClientContext cancels the client context. +func CancelClientContext() { + clientContext(true) +} + +// testClient tests the client's credentials and connectivity to the rclone host. +func testClient(client *Client) error { + _, err := client.SendRequest(map[string]interface{}{}, "/rc/noopauth") + return err +} + +// clientContext either returns the client context, or renews the context. +func clientContext(cancel bool) context.Context { + if cancel && clientCancel != nil { + clientCancel() + } + + if clientCtx == nil || cancel { + clientCtx, clientCancel = context.WithCancel(context.Background()) + } + + return clientCtx +} diff --git a/rclone/config.go b/rclone/config.go new file mode 100644 index 0000000..dc8c371 --- /dev/null +++ b/rclone/config.go @@ -0,0 +1,218 @@ +package rclone + +import ( + "fmt" + "strings" + + "github.com/mitchellh/mapstructure" +) + +// ConfigProviders stores the list of configuration providers. +type ConfigProviders struct { + Providers []Provider `json:"providers"` +} + +// Provider stores the provider information. +type Provider struct { + Name string `json:"Name"` + Description string `json:"Description"` + Prefix string `json:"Prefix"` + Options []ProviderOption `json:"Options"` +} + +// ProviderOption stores the provider's option data. +type ProviderOption struct { + Name string `json:"Name"` + Help string `json:"Help"` + Provider string `json:"Provider"` + ShortOpt string `json:"ShortOpt"` + Hide int `json:"Hide"` + Required bool `json:"Required"` + IsPassword bool `json:"IsPassword"` + NoPrefix bool `json:"NoPrefix"` + Advanced bool `json:"Advanced"` + Exclusive bool `json:"Exclusive"` + DefaultStr string `json:"DefaultStr"` + ValueStr string `json:"ValueStr"` + Type string `json:"Type"` + Examples []struct { + Value string `json:"Value"` + Help string `json:"Help"` + Provider string `json:"Provider"` + } `json:"Examples"` +} + +var ( + store ConfigProviders + currentSettings map[string]map[string]interface{} +) + +// CacheConfigProviders fetches the provider list and stores it. +func CacheConfigProviders() error { + response, err := SendCommand(map[string]interface{}{}, "/config/providers") + if err != nil { + return err + } + + return response.Decode(&store) +} + +// GetConfigProviders returns the list of providers. +func GetConfigProviders() (ConfigProviders, error) { + var err error + + if store.Providers != nil { + goto CachedProvider + } + + err = CacheConfigProviders() + if err != nil { + return ConfigProviders{}, err + } + +CachedProvider: + return store, nil +} + +// GetConfigSettings returns the list of configured remotes. +func GetConfigSettings() (map[string]map[string]interface{}, error) { + var settings map[string]map[string]interface{} + + job, err := SendCommandAsync("UI:Configuration", "Getting configuration", map[string]interface{}{}, "/config/dump") + if err != nil { + return nil, err + } + + jobInfo, err := GetJobReply(job) + if err != nil { + return nil, err + } + + err = mapstructure.Decode(jobInfo.Output, &settings) + + if settings != nil { + currentSettings = settings + } + + return settings, err +} + +// GetCurrentSettings returns the cached list of remotes. +func GetCurrentSettings() map[string]map[string]interface{} { + settings := make(map[string]map[string]interface{}) + + for name, setting := range currentSettings { + if settings[name] == nil { + settings[name] = make(map[string]interface{}) + } + + settings[name] = setting + } + + return settings +} + +// GetProviderByDesc matches and returns a provider by its description. +func GetProviderByDesc(confDesc string) (Provider, error) { + providers, err := GetConfigProviders() + if err != nil { + return Provider{}, err + } + + for _, p := range providers.Providers { + if strings.Index(p.Description, confDesc) != -1 { + return p, nil + } + } + + return Provider{}, fmt.Errorf("Could not find provider") +} + +// GetProviderByType matches and returns a provider by its type. +func GetProviderByType(confType string) (Provider, error) { + providers, err := GetConfigProviders() + if err != nil { + return Provider{}, err + } + + for _, p := range providers.Providers { + if p.Prefix == confType { + return p, nil + } + } + + return Provider{}, fmt.Errorf("Could not find provider") +} + +// GetProviderDesc returns the providers description. +func GetProviderDesc(confType string) string { + var confDesc string + + providers, err := GetConfigProviders() + if err != nil { + return "" + } + + for _, provider := range providers.Providers { + if provider.Prefix == confType { + confDesc = provider.Description + break + } + } + + return confDesc +} + +// SaveConfig saves the configuration. +func SaveConfig(data map[string]interface{}, create, interactiveConfig bool) error { + mode := "create" + if !create { + mode = "update" + } + + command := make(map[string]interface{}) + name := data["name"] + + delete(data, "name") + delete(data, "configuration") + if !create { + delete(data, "type") + } else { + command["type"] = data["type"] + } + + command["name"] = name + command["parameters"] = data + command["opt"] = map[string]interface{}{ + "nonInteractive": !interactiveConfig, + } + + job, err := SendCommandAsync( + "UI:Configuration", "Save '"+name.(string)+"'", command, "/config/"+mode, + ) + if err != nil { + return err + } + + _, err = GetJobReply(job) + + return err +} + +// DeleteConfig deletes the configuration. +func DeleteConfig(name string) error { + command := map[string]interface{}{ + "name": name, + } + + job, err := SendCommandAsync( + "UI:Configuration", "Delete '"+name+"'", command, "/config/delete", + ) + if err != nil { + return err + } + + _, err = GetJobReply(job) + + return err +} diff --git a/rclone/dashboard.go b/rclone/dashboard.go new file mode 100644 index 0000000..ea4d78a --- /dev/null +++ b/rclone/dashboard.go @@ -0,0 +1,173 @@ +package rclone + +import ( + "sync" + "time" +) + +// DashboardInfo stores the dashboard information. +type DashboardInfo struct { + Connected bool + Bandwidth string + Stats *DashboardStats + + User string + Version string +} + +// DashboardStats stores the rclone stats. +type DashboardStats struct { + Bytes int64 `json:"bytes"` + Checks int64 `json:"checks"` + DeletedDirs int64 `json:"deletedDirs"` + Deletes int64 `json:"deletes"` + ElapsedTime float64 `json:"elapsedTime"` + Errors int64 `json:"errors"` + Eta int64 `json:"eta"` + FatalError bool `json:"fatalError"` + Renames int64 `json:"renames"` + RetryError bool `json:"retryError"` + Speed float64 `json:"speed"` + TotalBytes int64 `json:"totalBytes"` + TotalChecks int64 `json:"totalChecks"` + TotalTransfers int64 `json:"totalTransfers"` + TransferTime int64 `json:"transferTime"` + Transferring []TransferStat `json:"transferring"` + Transfers int64 `json:"transfers"` +} + +var ( + dashExit chan struct{} + dashCheck, dashConnected chan bool + + dashLock sync.Mutex +) + +// StartDashboard starts polling for rclone stats. +func StartDashboard() (chan DashboardInfo, chan struct{}) { + dashInfo := make(chan DashboardInfo) + exit := make(chan struct{}, 1) + + dashExit = exit + + go updateDashboard(dashInfo, exit) + + return dashInfo, exit +} + +// StopDashboard stops polling for rclone stats. +func StopDashboard() { + if dashExit == nil { + return + } + + select { + case dashExit <- struct{}{}: + + default: + } +} + +// PollConnection polls the connectivity status of the host. +func PollConnection(updateMonitor bool) chan bool { + dashLock.Lock() + defer dashLock.Unlock() + + if dashConnected != nil && dashCheck != nil { + goto ConnectionChannel + } + + dashCheck = make(chan bool) + dashConnected = make(chan bool) + + go func() { + for { + connected := DialServer() == nil + + select { + case dashConnected <- connected: + + default: + } + + select { + case dashCheck <- connected: + + default: + } + + time.Sleep(1 * time.Second) + } + }() + +ConnectionChannel: + if updateMonitor { + return dashCheck + } + + return dashConnected +} + +// updateDashboard updates the rclone stats. +func updateDashboard(dashInfo chan DashboardInfo, exit chan struct{}) { + var connected bool + + data := []interface{}{ + nil, + new(DashboardStats), + map[string]interface{}{}, + } + + for { + select { + case <-exit: + return + + case connected = <-PollConnection(true): + } + + if connected { + version, err := GetVersion(false) + if err == nil { + data[0] = version.Version + " (" + version.Arch + ")" + } + + for i, endpoint := range []string{ + "/core/stats", + "/core/bwlimit", + } { + response, err := SendCommand(map[string]interface{}{}, endpoint) + if err != nil { + continue + } + + err = response.Decode(&data[i+1]) + if err != nil { + continue + } + } + } + + info := DashboardInfo{ + Connected: connected, + } + + if version, ok := data[0].(string); ok { + info.Version = version + } + if stats, ok := data[1].(*DashboardStats); ok { + info.Stats = stats + } + if bandwidth, ok := data[2].(map[string]interface{}); ok { + if bw, ok := bandwidth["rate"].(string); ok { + info.Bandwidth = bw + } + } + + select { + case dashInfo <- info: + + default: + } + } +} diff --git a/rclone/jobqueue.go b/rclone/jobqueue.go new file mode 100644 index 0000000..dc772d3 --- /dev/null +++ b/rclone/jobqueue.go @@ -0,0 +1,381 @@ +package rclone + +import ( + "context" + "fmt" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +// Job stores the rclone job information. +type Job struct { + ID int64 + Group string + Context context.Context + + Type string + Description string + Updates chan JobInfo + Cancel context.CancelFunc + + RefreshItems interface{} +} + +// JobInfo stores the rclone running job stats. +type JobInfo struct { + Duration float64 `json:"duration"` + EndTime time.Time `json:"endTime"` + Error string `json:"error"` + Finished bool `json:"finished"` + Group string `json:"group"` + ID int64 `json:"id"` + Output map[string]interface{} `json:"output"` + StartTime time.Time `json:"startTime"` + Success bool `json:"success"` + Transfers struct { + Stats []TransferStat `json:"transferring"` + } + + JobCount int64 + Type, Description string + CurrentTransfer TransferStat + + RefreshItems interface{} +} + +// TransferStat stores the file transfer stats. +type TransferStat struct { + Name string `json:"name"` + Size int64 `json:"size"` + Bytes int64 `json:"bytes,omitempty"` + Eta int64 `json:"eta,omitempty"` + Group string `json:"group,omitempty"` + Percentage int64 `json:"percentage,omitempty"` + Speed float64 `json:"speed,omitempty"` + SpeedAvg float64 `json:"speedAvg,omitempty"` +} + +var ( + jobQueue sync.Map + + jobLock sync.Mutex + jobTotal int64 + JobInfoChan chan JobInfo +) + +// NewJob returns a job with the provided type, description, and optional group. +func NewJob(jobType, jobDesc string, jobId int64, jobGroup ...string) *Job { + var group string + + if jobGroup != nil { + group = jobGroup[0] + } + + jobChan := make(chan JobInfo, 10) + context, cancel := context.WithCancel(context.Background()) + + job := Job{ + ID: jobId, + Group: group, + Context: context, + + Type: jobType, + Updates: jobChan, + Description: jobDesc, + Cancel: cancel, + } + + return &job +} + +// AddJobToQueue adds a job to the queue and if nomonitor is not set, will automatically +// start to monitor the job. +func AddJobToQueue(job *Job, nomonitor ...struct{}) *Job { + jobMap, ok := jobQueue.Load(job.Type) + if !ok { + jobMap = make(map[int64]*Job) + } + + jobMap.(map[int64]*Job)[job.ID] = job + + jobQueue.Store(job.Type, jobMap) + + modifyJobCount(job.Type, true) + + if nomonitor == nil { + go MonitorJob(job) + } + + return job +} + +// GetJobReply polls the job status and returns its output information +// when the job has finished. +func GetJobReply(job *Job) (JobInfo, error) { + var info JobInfo + var err error + + t := time.NewTicker(1 * time.Second) + defer t.Stop() + +MonitorStatus: + for { + select { + case info = <-job.Updates: + switch { + case info.Error != "": + err = fmt.Errorf(info.Error) + fallthrough + + case info.Finished: + break MonitorStatus + } + + case <-t.C: + } + } + + return info, err +} + +// GetJobQueue returns the job queue. +func GetJobQueue() *sync.Map { + return &jobQueue +} + +// GetNewJobID generates a new job ID. +func GetNewJobID(jobType string) int64 { + _, jobId := getJobTypeInfo(jobType) + if jobId == -1 { + return 0 + } + + return jobId + 1 +} + +// GetLatestJob gets the latest job associated with the provided job type. +func GetLatestJob(jobType string) (*Job, error) { + err := fmt.Errorf("Cannot get job information for " + jobType) + + jobMap, jobId := getJobTypeInfo(jobType) + if jobMap == nil { + return nil, err + } + + return jobMap[jobId], nil +} + +// JobInfoStatus returns the job stats update channel. +func JobInfoStatus() chan JobInfo { + jobLock.Lock() + defer jobLock.Unlock() + + if JobInfoChan == nil { + JobInfoChan = make(chan JobInfo, 100) + } + + return JobInfoChan +} + +// MonitorJob monitors the provided job, and if nostop is not set, it will +// automatically stop monitoring the job. +// +//gocyclo:ignore +func MonitorJob(job *Job, nostop ...struct{}) { + var jobInfo JobInfo + + t := time.NewTicker(1 * time.Second) + defer t.Stop() + + if job.Group == "" { + job.Group = "job/" + strconv.FormatInt(job.ID, 10) + } + + command := map[string]interface{}{ + "jobid": job.ID, + "group": job.Group, + } + + for { + for _, endpoint := range []string{ + "/job/status", + "/core/stats", + } { + response, err := SendCommand(command, endpoint) + if err != nil { + jobInfo.Error = err.Error() + goto SendInfo + } + + if strings.Contains(endpoint, "job") { + err = response.Decode(&jobInfo) + if err != nil { + jobInfo.Error = err.Error() + goto SendInfo + } + + continue + } + + err = response.Decode(&jobInfo.Transfers) + if err != nil { + jobInfo.Error = err.Error() + } + } + + jobInfo.Type = job.Type + jobInfo.JobCount = jobCount() + jobInfo.Description = job.Description + + for _, transfer := range jobInfo.Transfers.Stats { + if transfer.Group == job.Group { + jobInfo.CurrentTransfer = transfer + break + } + } + + jobInfo.Transfers.Stats = nil + + SendInfo: + select { + case job.Updates <- jobInfo: + select { + case JobInfoStatus() <- jobInfo: + + default: + } + + if jobInfo.Error != "" || jobInfo.Finished == true { + if nostop == nil { + StopJob(job, jobInfo.Error) + } + + return + } + + default: + } + + select { + case <-t.C: + + case <-job.Context.Done(): + jobInfo.Error = job.Description + " cancelled" + goto SendInfo + } + } +} + +// StopJob stops the provided job. +func StopJob(job *Job, errors string, force ...struct{}) { + command := map[string]interface{}{ + "jobid": job.ID, + } + + if force != nil { + jobQueue.Delete(job.Type) + goto JobFinished + } + + SendCommandAsync(job.Type, job.Description, command, "/job/stop", struct{}{}) + + if jobMap, ok := jobQueue.Load(job.Type); ok { + delete(jobMap.(map[int64]*Job), job.ID) + + if len(jobMap.(map[int64]*Job)) == 0 { + jobQueue.Delete(job.Type) + } else { + jobQueue.Store(job.Type, jobMap) + } + } + +JobFinished: + jobFinished := JobInfo{ + ID: job.ID, + Type: job.Type, + Description: job.Description, + JobCount: modifyJobCount(job.Type, false), + + Error: errors, + Finished: true, + + RefreshItems: job.RefreshItems, + } + + select { + case JobInfoStatus() <- jobFinished: + + default: + } +} + +// StopJobGroup stops all jobs associated with the group. +func StopJobGroup(job *Job) { + GetJobQueue().Range(func(key, value any) bool { + jobType := key.(string) + if jobType != job.Type { + return true + } + + jobMap := value.(map[int64]*Job) + + for _, j := range jobMap { + j.Cancel() + } + + return false + }) +} + +// getJobTypeInfo returns the information and an ID for the provided job type. +func getJobTypeInfo(jobType string) (map[int64]*Job, int64) { + var jobIds []int64 + + jobMap, ok := jobQueue.Load(jobType) + if !ok { + return nil, -1 + } + + for id := range jobMap.(map[int64]*Job) { + jobIds = append(jobIds, id) + } + sort.Slice(jobIds, func(i, j int) bool { + return jobIds[i] < jobIds[j] + }) + + if len(jobIds) == 0 { + return nil, -1 + } + + return jobMap.(map[int64]*Job), jobIds[len(jobIds)-1] +} + +// modifyJobCount modified the total job count. +func modifyJobCount(jobType string, inc bool) int64 { + jobLock.Lock() + defer jobLock.Unlock() + + if strings.HasPrefix(jobType, "UI:") || + strings.HasPrefix(jobType, "_") { + return -1 + } + + if inc { + jobTotal++ + } else { + jobTotal-- + } + + return jobTotal +} + +// jobCount returns the job count. +func jobCount() int64 { + jobLock.Lock() + defer jobLock.Unlock() + + return jobTotal +} diff --git a/rclone/login.go b/rclone/login.go new file mode 100644 index 0000000..d4141c5 --- /dev/null +++ b/rclone/login.go @@ -0,0 +1,45 @@ +package rclone + +import ( + "fmt" + + "golang.org/x/sync/semaphore" +) + +var loginLock *semaphore.Weighted + +// Login connects to the provided host with the username and password. +func Login(host, user, pass string) (string, error) { + if loginLock == nil { + loginLock = semaphore.NewWeighted(1) + } + + if !loginLock.TryAcquire(1) { + return "", fmt.Errorf("Attempting to log in") + } + defer loginLock.Release(1) + + CancelClientContext() + + err := SetupClient(host, user, pass) + if err != nil { + return "", err + } + + _, err = GetVersion(true) + if err != nil { + return "", err + } + + client, err := GetCurrentClient() + if err != nil { + return "", err + } + + userInfo := client.URI.Host + if user := client.URI.User; user != nil && user.Username() != "" { + userInfo = user.Username() + "@" + userInfo + } + + return userInfo, err +} diff --git a/rclone/operations/about.go b/rclone/operations/about.go new file mode 100644 index 0000000..e8d425a --- /dev/null +++ b/rclone/operations/about.go @@ -0,0 +1,37 @@ +package rclone + +import ( + "context" + + "github.com/darkhz/rclone-tui/rclone" +) + +// About stores the storage information for a remote. +type About struct { + Total int64 `json:"total"` + Used int64 `json:"used"` + Trashed int64 `json:"trashed"` + Other int64 `json:"other"` + Free int64 `json:"free"` +} + +// AboutFS returns the storage information for a remote. +func AboutFS(ctx context.Context, fs string) (About, error) { + var about About + + command := map[string]interface{}{ + "fs": fs, + } + + response, err := rclone.SendCommand(command, "/operations/about", ctx) + if err != nil { + return About{}, err + } + + err = response.Decode(&about) + if err != nil { + return About{}, err + } + + return about, nil +} diff --git a/rclone/operations/explorer.go b/rclone/operations/explorer.go new file mode 100644 index 0000000..43f457b --- /dev/null +++ b/rclone/operations/explorer.go @@ -0,0 +1,233 @@ +package rclone + +import ( + "context" + "fmt" + "path/filepath" + "strconv" + + "code.cloudfoundry.org/bytefmt" + "github.com/darkhz/rclone-tui/rclone" +) + +// Mkdir creates a directory within the remote. +func Mkdir(id, fs, remote, name string) error { + command := map[string]interface{}{ + "fs": fs, + "remote": filepath.Join(remote, name), + } + + job, err := rclone.SendCommandAsync("UI:Explorer:"+id, "Creating directory "+name, command, "/operations/mkdir") + if err != nil { + return err + } + + _, err = rclone.GetJobReply(job) + + return err +} + +// PublicLink returns a public link for the provided item. +func PublicLink(id, fs, remote string, item ListItem) (string, error) { + command := map[string]interface{}{ + "fs": fs, + "remote": filepath.Join(remote, item.Name), + } + + job, err := rclone.SendCommandAsync("UI:Explorer:"+id, "Generating public link", command, "/operations/publiclink") + if err != nil { + return "", err + } + + jobInfo, err := rclone.GetJobReply(job) + if err != nil { + return "", err + } + + url, ok := jobInfo.Output["url"] + if !ok || ok && url == nil { + return "", fmt.Errorf("Public link could not be generated") + } + + return url.(string), nil +} + +// Copy copies a list of items to the destination remote and path. +func Copy(items []ListItem, dstFs, dstRemote string) { + BatchOperation( + "Copy", "Copying", dstFs, dstRemote, + []string{"/sync/copy", "/operations/copyfile"}, items, + ) +} + +// Move moves a list of items to the destination remote and path. +func Move(items []ListItem, dstFs, dstRemote string) { + BatchOperation( + "Move", "Moving", dstFs, dstRemote, + []string{"/sync/move", "/operations/movefile"}, items, + ) +} + +// Delete deletes a list of items from the remote. +func Delete(items []ListItem) { + BatchOperation( + "Delete", "Deleting", "", "", + []string{"/operations/purge", "/operations/deletefile"}, items, + ) +} + +// BatchOperation starts a batch job on a list of items. +// +//gocyclo:ignore +func BatchOperation( + name, desc, dstFs, dstRemote string, endpoints []string, items []ListItem, +) { + if items == nil { + return + } + + id := rclone.GetNewJobID(name) + + job := rclone.NewJob( + name, desc, id, + name+"/"+strconv.FormatInt(id, 10), + ) + + rclone.AddJobToQueue(job, struct{}{}) + + go func(mainJob *rclone.Job) { + var jobErr string + + for i, item := range items { + var endpoint string + var description string + + select { + case <-mainJob.Context.Done(): + break + + default: + } + + if item.IsDir { + endpoint = endpoints[0] + } else { + endpoint = endpoints[1] + } + + command := batchCommand(name, dstFs, dstRemote, item) + command["_group"] = mainJob.Group + + description += "(" + strconv.Itoa(i+1) + "/" + strconv.Itoa(len(items)) + ") " + description += desc + " " + filepath.Base(item.Path) + if desc != "Deleting" { + description += " -> " + dstFs + dstRemote + } + + job, err := rclone.SendCommandAsync( + "_"+name, description, + command, endpoint, struct{}{}, + ) + if err != nil { + jobErr = err.Error() + break + } + + job.Group = mainJob.Group + job.Context = mainJob.Context + job.Cancel = mainJob.Cancel + + go rclone.MonitorJob(job, struct{}{}) + + jobInfo, err := rclone.GetJobReply(job) + if err != nil || jobInfo.Error != "" { + break + } + + refreshItems := []ListItem{} + + if name == "Delete" || name == "Move" { + item.RefreshAddItem = false + refreshItems = append(refreshItems, item) + } + + if name == "Copy" || name == "Move" { + item.FS = dstFs + item.Path = filepath.Join(dstRemote, item.Name) + item.RefreshAddItem = true + + if item.Size == -1 { + listItem, err := stat(job.Context, item.FS, item.Path) + if err != nil { + continue + } + + item.Size = listItem.Size + if listItem.Size > 0 { + item.ISize = bytefmt.ByteSize(uint64(item.Size)) + } + } + + refreshItems = append(refreshItems, item) + } + + job.RefreshItems = refreshItems + + rclone.StopJob(job, jobInfo.Error) + } + + rclone.StopJob(mainJob, jobErr, struct{}{}) + }(job) +} + +// stat returns the information for the item. +func stat(ctx context.Context, fs, remote string) (ListItem, error) { + var listItem ListItem + + item := struct { + Item ListItem `json:"item"` + }{} + + command := map[string]interface{}{ + "fs": fs, + "remote": remote, + } + + response, err := rclone.SendCommand(command, "/operations/stat", ctx) + if err == nil { + err = response.Decode(&item) + listItem = item.Item + } + + return listItem, err +} + +// batchCommand returns a command in an rclone-parseable format. +func batchCommand(operation, dstFs, dstRemote string, item ListItem) map[string]interface{} { + var command map[string]interface{} + + switch operation { + case "Copy", "Move": + if item.IsDir { + command = map[string]interface{}{ + "srcFs": item.Path, + "dstFs": dstFs + filepath.Join(dstRemote, item.Name), + } + } else { + command = map[string]interface{}{ + "srcFs": item.FS, + "srcRemote": item.Path, + "dstFs": dstFs, + "dstRemote": filepath.Join(dstRemote, item.Name), + } + } + + case "Delete": + command = map[string]interface{}{ + "fs": item.FS, + "remote": item.Path, + } + } + + return command +} diff --git a/rclone/operations/list.go b/rclone/operations/list.go new file mode 100644 index 0000000..201e645 --- /dev/null +++ b/rclone/operations/list.go @@ -0,0 +1,118 @@ +package rclone + +import ( + "context" + "path/filepath" + "strings" + "time" + + "code.cloudfoundry.org/bytefmt" + "github.com/darkhz/rclone-tui/rclone" + "github.com/mitchellh/mapstructure" +) + +// List stores a list of directory entries. +type List struct { + Items []ListItem `mapstructure:"list"` + + Path string +} + +// ListItem stores information about a directory entry. +type ListItem struct { + ID string `mapstructure:"ID"` + IsDir bool `mapstructure:"IsDir"` + MimeType string `mapstructure:"MimeType"` + ModTime string `mapstructure:"ModTime"` + Name string `mapstructure:"Name"` + Path string `mapstructure:"Path"` + Size int64 `mapstructure:"Size"` + + FS string + ISize string + ModifiedTime string + ModifiedTimeUnix int64 + + About bool + RefreshAddItem bool +} + +// ListFS returns a list of directory entries from the provided remote and path. +func ListFS(id, fstype, path string) (List, error) { + var list List + var fs, desc string + + if strings.Contains(fstype, ":") { + fs = fstype + if path != "" { + path = filepath.Clean(path) + } + } else { + fs = fstype + } + + desc = fs + path + + command := map[string]interface{}{ + "fs": fs, + "remote": path, + } + + job, err := rclone.SendCommandAsync("UI:Explorer:"+id, "Listing "+desc, command, "/operations/list") + if err != nil { + return List{}, err + } + + jobInfo, err := rclone.GetJobReply(job) + if err != nil { + return List{}, err + } + + err = mapstructure.Decode(jobInfo.Output, &list) + if err != nil { + return List{}, err + } + + for j, item := range list.Items { + modtime, _ := time.Parse(time.RFC3339, item.ModTime) + + list.Items[j].ModTime = "" + list.Items[j].ModifiedTimeUnix = modtime.Unix() + list.Items[j].ModifiedTime = modtime.Format("Mon 01/02 15:04") + + size := "Unknown" + if item.Size >= 0 { + size = bytefmt.ByteSize(uint64(item.Size)) + } + + list.Items[j].ISize = size + + list.Items[j].FS = fs + } + + return list, err +} + +// ListRemotes lists the configured remotes. +func ListRemotes(ctx context.Context) ([]string, error) { + return GetDataSlice(ctx, "/config/listremotes", "remotes") +} + +// GetListPath returns the joined path with the provided directory, or +// if cdback is true, returns the path's directory. +func GetListPath(path, dir string, cdback bool) (string, string) { + if filepath.Dir(path) == "." && cdback { + return "", "" + } + + path = filepath.Clean(path) + + if !cdback { + path = filepath.Join(path, dir) + } else { + path = filepath.Dir(path) + dir = filepath.Base(path) + } + + return path, dir +} diff --git a/rclone/operations/mounts.go b/rclone/operations/mounts.go new file mode 100644 index 0000000..aa754d7 --- /dev/null +++ b/rclone/operations/mounts.go @@ -0,0 +1,216 @@ +package rclone + +import ( + "context" + "strings" + "time" + + "github.com/darkhz/rclone-tui/rclone" +) + +// Mounts stores the list of mountpoints. +type Mounts struct { + MountPoints []MountPoint `json:"mountPoints"` +} + +// MountPoint stores information about a mountpoint. +type MountPoint struct { + Fs string `json:"Fs"` + MountPoint string `json:"MountPoint"` + MountedOn time.Time `json:"MountedOn"` +} + +// MountHelp stores information about a mount option. +type MountHelp struct { + Name, Help, OptionType, ValueType string + Windows, OSX, Other bool + + Options []string +} + +var mountOptionHelp = []MountHelp{ + {"FS", "A remote path to be mounted", "main:required", "string", true, true, true, nil}, + {"MountPoint", "Valid path on the local machine", "main:required", "string", true, true, true, nil}, + {"MountType", "One of the values (mount, cmount, mount2) specifies the mount implementation to use", "main:optional", "string", true, true, true, nil}, + {"AllowNonEmpty", "Allow mounting over a non-empty directory", "mountOpt", "bool", false, true, true, nil}, + {"AllowOther", "Allow access to other users", "mountOpt", "bool", false, true, true, nil}, + {"AllowRoot", "Allow access to root user", "mountOpt", "bool", false, true, true, nil}, + {"AsyncRead", "Use asynchronous reads", "mountOpt", "bool", false, true, true, nil}, + {"AttrTimeout", "Time for which file/directory attributes are cached", "mountOpt", "Duration", true, true, true, nil}, + {"CacheMaxAge", "Max age of objects in the cache", "vfsOpt", "Duration", true, true, true, nil}, + {"CacheMaxSize", "Max total size of objects in the cache", "vfsOpt", "int", true, true, true, nil}, + {"CacheMode", "Cache mode off|minimal|writes|full", "vfsOpt", "int", true, true, true, nil}, + {"CachePollInterval", "Interval to poll the cache for stale objects", "vfsOpt", "Duration", true, true, true, nil}, + {"CaseInsensitive", "If a file name not found, find a case insensitive match", "vfsOpt", "bool", true, true, true, nil}, + {"ChunkSize", "Read the source objects in chunks", "vfsOpt", "int", true, true, true, nil}, + {"ChunkSizeLimit", "If greater than --vfs-read-chunk-size, double the chunk size after each chunk read, until the limit is reached ('off' is unlimited)", "vfsOpt", "int", true, true, true, nil}, + {"Daemon", "Run mount in background and exit parent process (as background output is suppressed, use --log-file with --log-format=pid,... to monitor)", "mountOpt", "bool", false, true, true, nil}, + {"DaemonTimeout", "Time limit for rclone to respond to kernel", "mountOpt", "Duration", false, true, true, nil}, + {"DaemonWait", "Time to wait for ready mount from daemon (maximum time on Linux, constant sleep time on OSX/BSD)", "mountOpt", "Duration", false, true, true, nil}, + {"DebugFUSE", "Debug the FUSE internals", "mountOpt", "bool", true, true, true, nil}, + {"DefaultPermissions", "Makes kernel enforce access control based on the file mode", "mountOpt", "bool", false, true, true, nil}, + {"DeviceName", "Set the device name - default is remote:path", "mountOpt", "string", true, true, true, nil}, + {"DirCacheTime", "Time to cache directory entries for", "vfsOpt", "Duration", true, true, true, nil}, + {"DirPerms", "Directory permissions", "vfsOpt", "int", true, true, true, nil}, + {"DiskSpaceTotalSize", "Specify the total space of disk", "vfsOpt", "int", true, true, true, nil}, + {"ExtraFlags", "Flags or arguments to be passed direct to libfuse/WinFsp (repeat if required). Each mount option must be separated by a space.", "mountOpt", "StringArray", true, true, true, nil}, + {"ExtraOptions", "Option for libfuse/WinFsp (repeat if required). Each mount option must be separated by a space.", "mountOpt", "StringArray", true, true, true, nil}, + {"FastFingerprint", "Use fast (less accurate) fingerprints for change detection", "vfsOpt", "bool", true, true, true, nil}, + {"FilePerms", "File permissions", "vfsOpt", "int", true, true, true, nil}, + {"MaxReadAhead", "The number of bytes that can be prefetched for sequential reads", "mountOpt", "int", false, true, true, nil}, + {"NetworkMode", "Mount as remote network drive, instead of fixed disk drive", "mountOpt", "bool", true, false, false, nil}, + {"NoAppleDouble", "Ignore Apple Double (._) and .DS_Store files", "mountOpt", "bool", false, true, false, nil}, + {"NoAppleXattr", "Ignore all \"com.apple.*\" extended attributes", "mountOpt", "bool", false, true, false, nil}, + {"NoChecksum", "Don't compare checksums on up/download", "vfsOpt", "bool", true, true, true, nil}, + {"NoModTime", "Don't read/write the modification time (can speed things up)", "vfsOpt", "bool", true, true, true, nil}, + {"NoSeek", "Don't allow seeking in files", "vfsOpt", "bool", true, true, true, nil}, + {"PollInterval", "Time to wait between polling for changes, must be smaller than dir-cache-time and only on supported remotes (set 0 to disable)", "vfsOpt", "Duration", true, true, true, nil}, + {"ReadAhead", "Extra read ahead over --buffer-size when using cache-mode full", "vfsOpt", "int", true, true, true, nil}, + {"ReadOnly", "Only allow read-only access", "vfsOpt", "bool", true, true, true, nil}, + {"ReadWait", "Time to wait for in-sequence read before seeking", "vfsOpt", "Duration", true, true, true, nil}, + {"UsedIsSize", "Use the `rclone size` algorithm for Used size", "vfsOpt", "bool", true, true, true, nil}, + {"VolumeName", "Set the volume name", "mountOpt", "string", true, true, false, nil}, + {"WriteBack", "Time to writeback files after last use when using cache", "vfsOpt", "Duration", true, true, true, nil}, + {"WriteWait", "Time to wait for in-sequence write before giving error", "vfsOpt", "Duration", true, true, true, nil}, + {"WritebackCache", "Makes kernel buffer writes before sending them to rclone (without this, writethrough caching is used)", "mountOpt", "bool", false, true, true, nil}, +} + +// CreateMount creates a mountpoint. +func CreateMount(mountData map[string]interface{}) error { + parseMountExtras(mountData) + + job, err := rclone.SendCommandAsync("UI:Mounts", "Mounting remote", mountData, "/mount/mount") + if err != nil { + return err + } + + _, err = rclone.GetJobReply(job) + + return err +} + +// Unmount unmounts the provided mountpoint. +func Unmount(mountpoint string) error { + command := map[string]interface{}{ + "mountPoint": mountpoint, + } + + job, err := rclone.SendCommandAsync("UI:Mounts", "Unmounting mountpoint", command, "/mount/unmount") + if err != nil { + return err + } + + _, err = rclone.GetJobReply(job) + + return err +} + +// UnmountAll unmounts all mountpoints. +func UnmountAll() error { + job, err := rclone.SendCommandAsync( + "UI:Mounts", "Unmounting all mountpoints", + map[string]interface{}{}, "/mount/unmountall", + ) + if err != nil { + return err + } + + _, err = rclone.GetJobReply(job) + + return err +} + +// ListMountTypes lists the mount types. +func ListMountTypes(ctx context.Context) ([]string, error) { + return GetDataSlice(ctx, "/mount/types", "mountTypes") +} + +// GetMountPoints returns a list of mountpoints. +func GetMountPoints() ([]MountPoint, error) { + var mounts Mounts + + response, err := rclone.SendCommand(map[string]interface{}{}, "/mount/listmounts") + if err != nil { + return nil, err + } + + err = response.Decode(&mounts) + + return mounts.MountPoints, err +} + +// GetMountHelp returns the information for a mount option. +func GetMountHelp(name string) MountHelp { + var mountHelp MountHelp + + for _, mh := range mountOptionHelp { + if mh.Name == name { + mountHelp = mh + break + } + } + + return mountHelp +} + +// GetMountOptions returns a list of all mount options. +func GetMountOptions() ([]MountHelp, error) { + var opts []MountHelp + + remotes, err := ListRemotes(rclone.GetClientContext()) + if err != nil { + return nil, err + } + + mountTypes, err := ListMountTypes(rclone.GetClientContext()) + if err != nil { + return nil, err + } + + version, _ := rclone.GetVersion(false) + + for _, opt := range mountOptionHelp { + switch opt.Name { + case "FS": + opt.Options = remotes + + case "MountType": + opt.Options = mountTypes + } + + windowsOnly := version.Os == "windows" && opt.Windows + osxOnly := (version.Os == "darwin" || version.Os == "ios") && opt.OSX + + if windowsOnly || osxOnly || (!windowsOnly && !osxOnly) && opt.Other { + opts = append(opts, opt) + } + } + + mountOptionHelp = opts + + return mountOptionHelp, nil +} + +// parseMountExtras parses the 'ExtraFlags' and 'ExtraOptions' mount options. +func parseMountExtras(mountData map[string]interface{}) { + mountMap, ok := mountData["mountOpt"] + if !ok { + return + } + + mountOpts, ok := mountMap.(map[string]interface{}) + if !ok { + return + } + + for _, mountFlag := range []string{ + "ExtraFlags", + "ExtraOptions", + } { + if value, ok := mountOpts[mountFlag]; ok && value != nil { + if opts, ok := value.(string); ok { + mountOpts[mountFlag] = strings.Split(opts, " ") + } + } + } +} diff --git a/rclone/operations/utils.go b/rclone/operations/utils.go new file mode 100644 index 0000000..ec1766b --- /dev/null +++ b/rclone/operations/utils.go @@ -0,0 +1,29 @@ +package rclone + +import ( + "context" + + "github.com/darkhz/rclone-tui/rclone" +) + +// GetDataSlice runs a command and returns its output as a slice. +func GetDataSlice(ctx context.Context, endpoint, key string) ([]string, error) { + var data []string + var dataMap map[string]interface{} + + res, err := rclone.SendCommand(map[string]interface{}{}, endpoint, ctx) + if err != nil { + return nil, err + } + + err = res.Decode(&dataMap) + if err != nil { + return nil, err + } + + for _, remote := range dataMap[key].([]interface{}) { + data = append(data, remote.(string)) + } + + return data, nil +} diff --git a/rclone/version.go b/rclone/version.go new file mode 100644 index 0000000..6239553 --- /dev/null +++ b/rclone/version.go @@ -0,0 +1,30 @@ +package rclone + +type Version struct { + Arch string `json:"arch"` + GoTags string `json:"goTags"` + GoVersion string `json:"goVersion"` + IsBeta bool `json:"isBeta"` + IsGit bool `json:"isGit"` + Linking string `json:"linking"` + Os string `json:"os"` + Version string `json:"version"` +} + +var version Version + +// GetVersion returns the version. +func GetVersion(force bool) (Version, error) { + if version != (Version{}) && !force { + return version, nil + } + + res, err := SendCommand(map[string]interface{}{}, "/core/version") + if err != nil { + return Version{}, err + } + + err = res.Decode(&version) + + return version, err +} diff --git a/ui/buttons.go b/ui/buttons.go new file mode 100644 index 0000000..a50144e --- /dev/null +++ b/ui/buttons.go @@ -0,0 +1,136 @@ +package ui + +import ( + "fmt" + + "github.com/darkhz/tview" + "github.com/gdamore/tcell/v2" +) + +// Button stores a button's label and the function to be +// executed when it is selected. +type Button struct { + label string + selectedFunc func() +} + +// ButtonDisplay stores the layout for the currently configured buttons. +type ButtonDisplay struct { + Layout *tview.TextView + + currentButtons []Button + lastFocus tview.Primitive + + arrangeFunc func(label string) bool +} + +var buttonDisplay ButtonDisplay + +// ButtonView returns a display area to show buttons. +func ButtonView() *tview.TextView { + if buttonDisplay.Layout != nil { + goto ShowButtonLayout + } + + buttonDisplay.Layout = tview.NewTextView() + buttonDisplay.Layout.SetRegions(true) + buttonDisplay.Layout.SetDynamicColors(true) + buttonDisplay.Layout.SetTextAlign(tview.AlignRight) + buttonDisplay.Layout.SetBackgroundColor(tcell.ColorDefault) + buttonDisplay.Layout.SetBlurFunc(func() { + buttonDisplay.Layout.Highlight("") + }) + buttonDisplay.Layout.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyLeft, tcell.KeyRight: + if event.Modifiers() == tcell.ModCtrl { + switchButtons(event.Key() == tcell.KeyLeft) + goto Event + } + + case tcell.KeyEnter: + var selected string + + highlights := buttonDisplay.Layout.GetHighlights() + if highlights != nil { + selected = highlights[0] + } + + for _, button := range buttonDisplay.currentButtons { + if button.label == selected { + button.selectedFunc() + buttonDisplay.Layout.Highlight("") + + goto Event + } + } + } + + if buttonDisplay.lastFocus != nil { + App.SetFocus(buttonDisplay.lastFocus) + buttonDisplay.Layout.Highlight("") + } + + Event: + return event + }) + +ShowButtonLayout: + return buttonDisplay.Layout +} + +// UpdateButtonView updates the button display area. If addCondition is provided, +// each button label is checked against addCondition before displaying it. +func UpdateButtonView(buttons []Button, addCondition ...func(label string) bool) { + var buttonText string + + if buttons == nil { + return + } + + buttonDisplay.currentButtons = buttons + + ButtonView().Clear() + + for _, button := range buttons { + if addCondition != nil { + buttonDisplay.arrangeFunc = addCondition[0] + } + + if buttonDisplay.arrangeFunc != nil && !buttonDisplay.arrangeFunc(button.label) { + continue + } + + buttonText += fmt.Sprintf("[\"%s\"][blue::b][%s[][-:-:-][\"\"] ", button.label, button.label) + } + + ButtonView().SetText(buttonText) +} + +// switchButtons cycles between the button selection. +func switchButtons(reverse bool) { + SwitchTabView(reverse, buttonDisplay.Layout) +} + +// focusButtonView brings the button display area into focus. +func focusButtonView() { + buttonDisplay.lastFocus = App.GetFocus() + + App.SetFocus(ButtonView()) + + switchButtons(false) +} + +// buttonEventHandler handles key events for the button display area. +func buttonEventHandler(event *tcell.EventKey) bool { + if event.Modifiers() != tcell.ModCtrl { + return false + } + + switch event.Key() { + case tcell.KeyLeft, tcell.KeyRight: + focusButtonView() + } + + return true +} diff --git a/ui/config.go b/ui/config.go new file mode 100644 index 0000000..4036386 --- /dev/null +++ b/ui/config.go @@ -0,0 +1,943 @@ +package ui + +import ( + "fmt" + "sort" + "strings" + + "github.com/darkhz/rclone-tui/rclone" + "github.com/darkhz/tview" + "github.com/gdamore/tcell/v2" +) + +// ConfigUI stores a layout for the configuration page. +type ConfigUI struct { + formUI *FormUI + + checkedName string + formLoaded bool + WizardUpdating bool + options []rclone.ProviderOption +} + +const ( + configWizardTabs = `[aqua::b]["setup"]Setup[""][-:-:-] [white::b]------[-:-:-] [aqua::b]["basic"]Basic[""][-:-:-] ` + configWizardAdvancedTab = `[white::b]------[-:-:-] [aqua::b]["advanced"]Advanced[""][-:-:-]` +) + +var configuration ConfigUI + +// Name returns the page's name. +func (c *ConfigUI) Name() string { + return "Configuration" +} + +// Focused returns the currently focused view. +func (c *ConfigUI) Focused() string { + return c.Name() +} + +// Init initializes the page. +func (c *ConfigUI) Init() bool { + c.formUI.ManagerPages.SwitchToPage("manager") + go c.listConfigSettings() + + return true +} + +// Exit exits the page. +func (c *ConfigUI) Exit(page string) bool { + if page, _ := c.formUI.ManagerPages.GetFrontPage(); page == "wizard" { + c.wizardCancel() + + return false + } + + c.formUI.WizardHelp.Clear() + c.formUI.ManagerTable.Clear() + + c.clearWizardData() + + return true +} + +// Layout returns this page's layout. +func (c *ConfigUI) Layout() tview.Primitive { + c.formUI = NewFormUI("setup", "basic", "advanced") + c.formUI.ManagerTable.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyCtrlR: + go c.listConfigSettings() + } + + switch event.Rune() { + case 'n': + c.managerCreateNew() + + case 'u': + c.managerUpdate() + + case 'd': + c.managerDelete() + + case '/': + c.managerFilter() + } + + return event + }) + c.formUI.WizardPages.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Rune() == 's' && event.Modifiers() == tcell.ModAlt { + go c.saveConfig(true) + goto Event + } + + switch event.Key() { + case tcell.KeyCtrlF: + c.wizardJump() + + case tcell.KeyCtrlS: + go c.saveConfig(false) + + case tcell.KeyCtrlC: + c.wizardCancel() + + case tcell.KeyCtrlH: + App.SetFocus(c.formUI.WizardHelp) + } + + Event: + return event + }) + c.formUI.ManagerPages.SetChangedFunc(func() { + page, _ := c.formUI.ManagerPages.GetFrontPage() + + switch page { + case "manager": + SetViewTitle("Configuration") + + case "wizard": + SetViewTitle("Configuration Wizard") + } + + c.updateButtons(page) + }) + + c.formUI.ManagerButtons = []Button{ + {"Create New", c.managerCreateNew}, + {"Update", c.managerUpdate}, + {"Delete", c.managerDelete}, + } + + c.formUI.WizardButtons = []Button{ + {"Next", c.wizardNext}, + {"Previous", c.wizardPrevious}, + {"Filter", c.wizardJump}, + {"Save", c.wizardSave}, + {"Cancel", c.wizardCancel}, + } + + return c.formUI.Flex +} + +// listConfigSettings lists the rclone configurations. +func (c *ConfigUI) listConfigSettings(filterSetting ...map[string]map[string]interface{}) { + var err error + var settingKeys []string + var settings map[string]map[string]interface{} + + if filterSetting != nil { + settings = filterSetting[0] + goto LoadConfigTable + } + + if !c.formUI.managerLock.TryAcquire(1) { + return + } + defer c.formUI.managerLock.Release(1) + + StartLoading("Loading configuration settings...") + defer StopLoading() + + settings, err = rclone.GetConfigSettings() + +LoadConfigTable: + for name := range settings { + settingKeys = append(settingKeys, name) + } + sort.Strings(settingKeys) + + App.QueueUpdateDraw(func() { + c.formUI.ManagerTable.Clear() + + for col, header := range []string{ + "Name", + "Type", + } { + c.formUI.ManagerTable.SetCell(0, col, tview.NewTableCell("[::bu]"+header). + SetExpansion(1). + SetSelectable(false). + SetAlign(tview.AlignCenter). + SetBackgroundColor(tcell.ColorPurple), + ) + } + + if err != nil { + ErrorMessage("Configuration", err) + return + } + + bgColor := tcell.ColorSlateGray + row := c.formUI.ManagerTable.GetRowCount() - 1 + + for _, key := range settingKeys { + var configDesc string + setting := settings[key] + + configType := setting["type"].(string) + configDesc = rclone.GetProviderDesc(configType) + + if configType == "s3" { + descFields := strings.Fields(configDesc) + for i, field := range descFields { + if field == "including" { + descFields = descFields[:i] + break + } + } + + configDesc = strings.Join(descFields, " ") + } + + row++ + + c.formUI.ManagerTable.SetCell(row, 0, tview.NewTableCell(tview.Escape(key)). + SetAlign(tview.AlignCenter). + SetBackgroundColor(bgColor), + ) + c.formUI.ManagerTable.SetCell(row, 1, tview.NewTableCell(configDesc). + SetMaxWidth(10). + SetAlign(tview.AlignCenter). + SetBackgroundColor(bgColor), + ) + } + + c.updateButtons("manager") + + if filterSetting == nil { + App.SetFocus(c.formUI.ManagerTable) + } + }) +} + +// managerCreateNew shows the configuration wizard. +func (c *ConfigUI) managerCreateNew() { + c.setWizardUpdating(false) + + go c.setupWizard(true) +} + +// managerUpdate updates the selected configuration. +func (c *ConfigUI) managerUpdate() { + row, _ := c.formUI.ManagerTable.GetSelection() + if row <= 0 { + return + } + + name := c.formUI.ManagerTable.GetCell(row, 0).Text + config := c.formUI.ManagerTable.GetCell(row, 1).Text + + c.setWizardData("name", name) + c.setWizardData("configuration", config) + + c.setWizardUpdating(true) + + go c.setupWizard(false) +} + +// managerFilter filters through the configuration and displays the result. +func (c *ConfigUI) managerFilter() { + input := OpenStatusInput("Filter config:") + input.SetChangedFunc(func(text string) { + filterSetting := rclone.GetCurrentSettings() + + for name := range filterSetting { + if strings.Index(name, text) == -1 { + delete(filterSetting, name) + } + } + + go c.listConfigSettings(filterSetting) + }) + input.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyEscape: + CloseStatusInput() + App.SetFocus(c.formUI.ManagerTable) + + if c.formUI.ManagerTable.GetRowCount() <= 1 { + go c.listConfigSettings(rclone.GetCurrentSettings()) + } + } + + return event + }) + + App.SetFocus(input) +} + +// managerDelete deletes the selected configuration. +func (c *ConfigUI) managerDelete() { + currentRow, _ := c.formUI.ManagerTable.GetSelection() + if currentRow <= 0 { + return + } + + configName := c.formUI.ManagerTable.GetCell(currentRow, 0).Text + + go func(row int, name string) { + if !ConfirmInput("Delete configuration '" + name + "' (y/n)?") { + return + } + + if !c.formUI.managerLock.TryAcquire(1) { + InfoMessage("Deletion in progress...", false) + return + } + defer c.formUI.managerLock.Release(1) + + StartLoading("Deleting configuration '" + name + "'") + + err := rclone.DeleteConfig(name) + if err != nil { + ErrorMessage("Configuration", err, struct{}{}) + } + + StopLoading("Deleted configuration '" + name + "'") + + App.QueueUpdateDraw(func() { + c.formUI.ManagerTable.RemoveRow(row) + c.formUI.ManagerTable.Select(row, 0) + + c.updateButtons("manager") + }) + }(currentRow, configName) +} + +// wizardJump jumps to the selected option in the form. +func (c *ConfigUI) wizardJump() { + var row int + var filterOptions []rclone.ProviderOption + + if len(c.options) == 0 { + return + } + + modal := NewModal( + "filter_options", "Jump to option", true, false, 20, 60, + ) + + filterInput, filterTable := modal.Input, modal.Table + + updateTable := func(row int, option rclone.ProviderOption) bool { + if !MatchProvider(option.Provider, c.getWizardData("provider")) { + return false + } + + displayOption := "[aqua::b]" + option.Name + if option.Advanced { + displayOption += "[purple::b] (Advanced)" + } else { + displayOption += "[pink::b] (Basic)" + } + + filterTable.SetCell(row, 0, tview.NewTableCell(displayOption). + SetReference(option). + SetAttributes(tcell.AttrBold). + SetSelectedStyle(tcell.Style{}.Reverse(true)). + SetClickedFunc(func() bool { + filterInput.InputHandler()( + tcell.NewEventKey(tcell.KeyEnter, ' ', tcell.ModNone), nil, + ) + + return true + }), + ) + + return true + } + + selectForm := func() { + var formType string + + row, _ := filterTable.GetSelection() + option, ok := filterTable.GetCell(row, 0). + GetReference().(rclone.ProviderOption) + if !ok { + return + } + + if option.Advanced { + formType = "advanced" + } else { + formType = "basic" + } + + SelectTab(formType) + + form := c.formUI.WizardForms[formType] + form.SetFocus(form.GetFormItemIndex(option.Name)) + } + + filterOptions = append(filterOptions, c.options...) + sort.Slice(filterOptions, func(i, j int) bool { + return !(!filterOptions[i].Advanced != !filterOptions[j].Advanced) + }) + + for _, option := range filterOptions { + if updateTable(row, option) { + row++ + } + } + + filterInput.SetLabel("Filter: ") + + filterInput.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyDown, tcell.KeyUp: + filterTable.InputHandler()(event, nil) + + case tcell.KeyEnter: + selectForm() + fallthrough + + case tcell.KeyEscape: + modal.Exit() + } + + return event + }) + filterInput.SetChangedFunc(func(text string) { + var row int + + filterTable.Clear() + + for _, option := range filterOptions { + if strings.Index(option.Name, text) == -1 { + continue + } + + if updateTable(row, option) { + row++ + } + } + + filterTable.ScrollToBeginning() + filterTable.Select(0, 0) + }) + + modal.Show() +} + +// wizardNext goes to the next page of the form. +func (c *ConfigUI) wizardNext() { + SwitchTabView(false) + App.SetFocus(c.formUI.WizardPages) +} + +// wizardPrevious goes to the previous page of the form. +func (c *ConfigUI) wizardPrevious() { + SwitchTabView(true) + App.SetFocus(c.formUI.WizardPages) +} + +// wizardSave asks whether to automatically authorize the configuration remote, +// and saves the configuration. +func (c *ConfigUI) wizardSave() { + go c.saveConfig(false, struct{}{}) +} + +// saveConfig saves the configuration. +func (c *ConfigUI) saveConfig(interactiveConfig bool, confirm ...struct{}) { + if !c.formUI.wizardLock.TryAcquire(1) { + InfoMessage("Saving in progress...", false) + return + } + defer c.formUI.wizardLock.Release(1) + + if confirm != nil { + interactiveConfig = ConfirmInput("Authorize interactively? (y/n)") + } + + StartLoading("Saving configuration...") + + err := rclone.SaveConfig( + parseDataMap(c.formUI.WizardData), !c.getWizardUpdating(), interactiveConfig, + ) + if err != nil { + ErrorMessage("Configuration", err, struct{}{}) + return + } + + StopLoading("Configuration saved") + + c.wizardExit(false) +} + +// wizardCancel asks for confirmation before exiting the wizard. +func (c *ConfigUI) wizardCancel() { + c.wizardExit(true) +} + +// wizardExit exits the configuration wizard. +func (c *ConfigUI) wizardExit(confirm bool) { + go func() { + if confirm && !ConfirmInput("Cancel configuration editing (y/n)?") { + return + } + + App.QueueUpdateDraw(func() { + c.formUI.ManagerPages.SwitchToPage("manager") + }) + + c.clearWizardData() + c.listConfigSettings() + }() +} + +// setupWizard sets up the configuration wizard. +func (c *ConfigUI) setupWizard(newConfig bool, providerUpdate ...struct{}) { + var err error + var provider rclone.Provider + var config map[string]map[string]interface{} + + if !c.formUI.wizardLock.TryAcquire(1) { + return + } + defer c.formUI.wizardLock.Release(1) + + StartLoading("Creating forms...") + defer StopLoading() + + providers, err := rclone.GetConfigProviders() + if err != nil { + ErrorMessage("Configuration", err, struct{}{}) + return + } + + if !newConfig { + c.setFormLoaded(false) + + config, err = rclone.GetConfigSettings() + if err != nil { + ErrorMessage("Configuration", err, struct{}{}) + return + } + + if config := c.getWizardData("configuration"); config != "" { + provider, err = rclone.GetProviderByDesc(config) + if err != nil { + ErrorMessage("Configuration", err, struct{}{}) + return + } + + c.setWizardData("type", provider.Prefix) + } else { + provider, err = rclone.GetProviderByType(c.getWizardData("type")) + if err != nil { + ErrorMessage("Configuration", err, struct{}{}) + return + } + } + } + + App.QueueUpdateDraw(func() { + c.formUI.ManagerPages.SwitchToPage("wizard") + + c.createSetupForm(providers) + + if providerUpdate == nil { + SetupTabs( + configWizardTabs, tview.AlignCenter, c.wizardFormHandler, + func(tab string) { + c.formUI.WizardPages.SwitchToPage(tab) + }, + ) + } + + if !newConfig { + err := c.createBasicAdvancedForm(config, provider, providerUpdate...) + if err != nil { + ErrorMessage("Configuration", err, struct{}{}) + return + } + + SelectTab("basic") + c.setFormLoaded(true) + } else { + SelectTab("setup") + } + }) +} + +// wizardFormHandler decides when to move to another page within +// the form. This is used in the tab display handler. +func (c *ConfigUI) wizardFormHandler(reverse bool) bool { + var formError bool + + if reverse { + return true + } + + page, item := c.formUI.WizardPages.GetFrontPage() + + form, ok := item.(*tview.Form) + if !ok { + ErrorMessage( + "Configuration", + fmt.Errorf("Could not parse %s form", page), + ) + + return false + } + + defer App.SetFocus(form) + + for i := 0; i < form.GetFormItemCount(); i++ { + var options []rclone.ProviderOption + + formItem := form.GetFormItem(i).(*FormWidget) + + options = append(options, c.options...) + if page == "setup" { + options = append(options, []rclone.ProviderOption{ + {Name: "name", Required: true}, + {Name: "type", Required: true}, + }...) + } + if options == nil { + ErrorMessage( + "Configuration", + fmt.Errorf("Could not get provider options"), + ) + + return false + } + + for _, option := range options { + if option.Name == strings.ToLower(formItem.GetLabel()) { + if option.Required && c.checkWizardDataEmpty(option.Name) != nil { + formError = true + formItem.EnableMarker() + } + } + } + } + + if formError { + ErrorMessage( + "Configuration", + fmt.Errorf("Fill the required fields"), + ) + + return false + } + + if page == "setup" { + go c.setupWizard(false) + if !c.getFormLoaded() { + return false + } + } + + return true +} + +// createSetupForm creates the initial basic form. +func (c *ConfigUI) createSetupForm(providers rclone.ConfigProviders) { + optionData := map[string]string{} + + for _, provider := range providers.Providers { + optionData[provider.Prefix] = provider.Description + } + + setupForm := c.formUI.WizardForms["setup"].Clear(false) + + setupForm.AddFormItem( + GetFormInputField( + "Name", !c.getWizardUpdating(), false, + c.setWizardData, c.updateHelp, + c.getWizardData("name"), + ).SetRequired(), + ) + + setupForm.AddFormItem( + GetFormList( + "Type", optionData, !c.getWizardUpdating(), true, + c.setWizardData, c.updateHelp, nil, + c.getWizardData("type"), + ).SetRequired(), + ) + + App.SetFocus(setupForm) +} + +// createBasicAdvancedForm creates basic and advanced forms based on the options +// provided in the setup(basic) form. +// +//gocyclo:ignore +func (c *ConfigUI) createBasicAdvancedForm( + config map[string]map[string]interface{}, + provider rclone.Provider, + providerUpdate ...struct{}, +) error { + if err := c.checkWizardDataEmpty("name", "type"); err != nil { + return err + } + + name := c.getWizardData("name") + + setting := config[name] + if providerUpdate == nil { + if c.getWizardUpdating() { + for settingKey, settingValue := range setting { + c.setWizardData(settingKey, settingValue) + } + } else { + if setting != nil && name != c.checkedName { + return fmt.Errorf("'" + name + "' already exists") + } else { + c.checkedName = name + } + } + } + + basicForm := c.formUI.WizardForms["basic"].Clear(false) + advancedForm := c.formUI.WizardForms["advanced"] + if providerUpdate == nil { + advancedForm.Clear(false) + } + + c.options = provider.Options + + for _, option := range provider.Options { + var formItem *FormWidget + + if providerUpdate != nil && option.Advanced { + continue + } + + if !MatchProvider(option.Provider, c.getWizardData("provider")) { + continue + } + + switch { + case option.Type == "bool": + formItem = GetFormCheckBox( + option.Name, + c.setWizardData, c.updateHelp, + c.getWizardData(option.Name), + ) + + case option.Examples != nil: + optionData := map[string]string{} + + for _, example := range option.Examples { + if !MatchProvider(example.Provider, c.getWizardData("provider")) { + continue + } + + if example.Value == "" { + continue + } + + optionData[example.Value] = example.Help + } + + formItem = GetFormList( + option.Name, optionData, true, option.Exclusive, + c.setWizardData, c.updateHelp, func(label string) { + if label == "provider" { + go c.setupWizard(false, struct{}{}) + } + }, c.getWizardData(option.Name), + ) + + default: + formItem = GetFormInputField( + option.Name, true, option.IsPassword, + c.setWizardData, c.updateHelp, + c.getWizardData(option.Name), + ) + } + + if option.Required { + formItem.SetRequired() + } + + if formItem != nil { + if option.Advanced { + advancedForm.AddFormItem(formItem) + } else { + basicForm.AddFormItem(formItem) + } + } + } + + if providerUpdate == nil && advancedForm.GetFormItemCount() > 0 { + SetTabs(configWizardTabs + configWizardAdvancedTab) + } + + App.SetFocus(basicForm) + + return nil +} + +// updateButtons updates the buttons according to the page/form displayed. +// +//gocyclo:ignore +func (c *ConfigUI) updateButtons(page string) { + if pg, _ := c.formUI.ManagerPages.GetFrontPage(); pg != page { + return + } + + switch page { + case "manager": + UpdateButtonView(c.formUI.ManagerButtons, func(label string) bool { + if c.formUI.ManagerTable.GetRowCount() == 0 { + return false + } + + if c.formUI.ManagerTable.GetRowCount() <= 1 && label != "Create New" { + return false + } + + return true + }) + + case "wizard": + UpdateButtonView(c.formUI.WizardButtons, func(label string) bool { + page, _ := c.formUI.WizardPages.GetFrontPage() + + switch page { + case "setup": + if label == "Previous" || label == "Save" || label == "Filter" { + return false + } + + case "basic": + if !HasTab("advanced") && label == "Next" { + return false + } + + case "advanced": + if label == "Next" { + return false + } + } + + return true + }) + } +} + +// updateHelp updates the help information for each item within the form. +func (c *ConfigUI) updateHelp(field string) { + c.formUI.WizardHelp.Clear() + + options := []rclone.ProviderOption{ + {Name: "Name", Help: "Enter the name for this configuration."}, + {Name: "Type", Help: "Choose a provider type from the list."}, + } + + for _, option := range append(options, c.options...) { + if option.Name == field && MatchProvider(option.Provider, c.getWizardData("provider")) { + field = "[::bu]" + field + "[-:-:-]" + if option.Provider != "" { + field += tview.Escape(" [" + option.Provider + "]") + } + if option.Required { + field += " (Required)" + } + + c.formUI.WizardHelp.SetText(field + "\n" + option.Help) + + break + } + } + + c.formUI.WizardHelp.ScrollToBeginning() +} + +// getWizardData returns the stored form data. +func (c *ConfigUI) getWizardData(key string) string { + c.formUI.dataLock.RLock() + defer c.formUI.dataLock.RUnlock() + + return modifyDataMap(c.formUI.WizardData, key, nil, false) +} + +// setWizardData stores a form item's name(key) and its value. +func (c *ConfigUI) setWizardData(key string, value interface{}) { + c.formUI.dataLock.Lock() + defer c.formUI.dataLock.Unlock() + + modifyDataMap(c.formUI.WizardData, strings.ToLower(key), value, true) +} + +// clearWizardData clears the form data. +func (c *ConfigUI) clearWizardData() { + c.formUI.dataLock.Lock() + defer c.formUI.dataLock.Unlock() + + c.options = nil + c.formUI.WizardData = make(map[string]interface{}) + + for _, form := range c.formUI.WizardForms { + form.Clear(true) + } +} + +// checkWizardDataEmpty checks whether each specified field in +// the form's data is empty. +func (c *ConfigUI) checkWizardDataEmpty(fields ...string) error { + for _, field := range fields { + if c.getWizardData(field) == "" { + return fmt.Errorf("Fill the %s field", field) + } + } + + return nil +} + +// getWizardUpdating returns whether the form is being updated(true) or created(false). +func (c *ConfigUI) getWizardUpdating() bool { + c.formUI.dataLock.Lock() + defer c.formUI.dataLock.Unlock() + + return c.WizardUpdating +} + +// setWizardUpdating sets the form updation status. +func (c *ConfigUI) setWizardUpdating(updating bool) { + c.formUI.dataLock.Lock() + defer c.formUI.dataLock.Unlock() + + c.WizardUpdating = updating +} + +// getFormLoaded returns whether the form has loaded. +func (c *ConfigUI) getFormLoaded() bool { + c.formUI.dataLock.Lock() + defer c.formUI.dataLock.Unlock() + + return c.formLoaded +} + +// setFormLoaded sets the form loaded status +func (c *ConfigUI) setFormLoaded(loaded bool) { + c.formUI.dataLock.Lock() + defer c.formUI.dataLock.Unlock() + + c.formLoaded = loaded +} diff --git a/ui/dashboard.go b/ui/dashboard.go new file mode 100644 index 0000000..1a8500d --- /dev/null +++ b/ui/dashboard.go @@ -0,0 +1,184 @@ +package ui + +import ( + "strconv" + "time" + + "code.cloudfoundry.org/bytefmt" + "github.com/darkhz/rclone-tui/rclone" + "github.com/darkhz/tview" + "github.com/darkhz/tvxwidgets" + "github.com/gdamore/tcell/v2" +) + +// DashboardUI stores a layout to display dashboard information. +type DashboardUI struct { + Table *tview.Table + Plot *tvxwidgets.Plot + + transfer [][]float64 +} + +var dashboard DashboardUI + +// Name returns the page's name. +func (d *DashboardUI) Name() string { + return "Dashboard" +} + +// Focused returns the currently focused view. +func (d *DashboardUI) Focused() string { + return d.Name() +} + +// Init initializes the page. +func (d *DashboardUI) Init() bool { + go d.populateDashboard(d.Table) + + return true +} + +// Exit exits the page. +func (d *DashboardUI) Exit(page string) bool { + rclone.StopDashboard() + + return true +} + +// Layout returns this page's layout. +func (d *DashboardUI) Layout() tview.Primitive { + d.Table = tview.NewTable() + d.Table.SetBackgroundColor(tcell.ColorDefault) + + d.Plot = tvxwidgets.NewPlot() + d.Plot.SetDrawAxes(false) + d.Plot.SetLineColor([]tcell.Color{ + tcell.ColorBlue, + tcell.ColorGreen, + }) + d.Plot.SetMarker(tvxwidgets.PlotMarkerBraille) + d.Plot.SetBackgroundColor(tcell.ColorDefault) + + d.transfer = [][]float64{{}, {}} + + plotInfo := tview.NewTextView() + plotInfo.SetDynamicColors(true) + plotInfo.SetTextAlign(tview.AlignRight) + plotInfo.SetText("[blue::b]...[-:-:-] Speed [green::b]...[-:-:-] Average Speed") + plotInfo.SetBackgroundColor(tcell.ColorDefault) + + return tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(d.Table, 0, 1, false). + AddItem(plotInfo, 1, 0, false). + AddItem(d.Plot, 0, 1, false) +} + +// populateDashboard collects rclone stats and displays them. +func (d *DashboardUI) populateDashboard(t *tview.Table) { + var gotInfo bool + + dashInfo, exit := rclone.StartDashboard() + + StartLoading("Loading stats") + + for { + select { + case <-exit: + return + + case info := <-dashInfo: + if !gotInfo && info.Connected { + StopLoading() + gotInfo = true + } + + d.setDashboardInfo(info) + } + } +} + +// setDashboardInfo sets the dashboard information. +func (d *DashboardUI) setDashboardInfo(info rclone.DashboardInfo) { + connectStatus := "[green::b]Connected" + if !info.Connected { + connectStatus = "[red::b]Not Connected" + } + + client, err := rclone.GetCurrentClient() + if err != nil { + return + } + + layout := []struct { + Title string + Info string + Header bool + }{ + {"Overview", "", true}, + {"Status", connectStatus, false}, + {"Current URL", client.Hostname(), false}, + {"Bandwidth Control", info.Bandwidth, false}, + {"Version", info.Version, false}, + {}, + {"Global Stats", "", true}, + {"Running time", ReadableString(time.Duration(info.Stats.ElapsedTime) * time.Second), false}, + {"Average Speed", bytefmt.ByteSize(uint64(info.Stats.Speed)) + "/s", false}, + {"Transferred Bytes", bytefmt.ByteSize(uint64(info.Stats.Bytes)), false}, + {"Checks", strconv.FormatInt(info.Stats.Checks, 10), false}, + {"Deletes", strconv.FormatInt(info.Stats.Deletes, 10), false}, + {"Transfers", strconv.FormatInt(info.Stats.Transfers, 10), false}, + {"Errors", strconv.FormatInt(info.Stats.Errors, 10), false}, + } + + App.QueueUpdateDraw(func() { + dashboard.Table.Clear() + + for i, stat := range layout { + if !info.Connected && i > 1 { + return + } + + if info.Stats.Transferring != nil { + for _, transfer := range info.Stats.Transferring { + for i, speed := range []float64{ + transfer.Speed, + transfer.SpeedAvg, + } { + if speed > 0 { + d.transfer[i] = append(d.transfer[i], speed) + } + } + } + + transferNorm := [][]float64{{}, {}} + + for i, data := range d.transfer { + if len(data) > 100 { + d.transfer[i] = d.transfer[i][len(d.transfer[i])-2:] + } + + transferNorm[i] = Normalize(data...) + } + + d.Plot.SetData(transferNorm) + } + + title := stat.Title + if title == "" { + continue + } + if stat.Header { + title = "[::bu]" + title + } else { + title = "[aqua::b]" + title + } + + dashboard.Table.SetCell(i, 0, tview.NewTableCell(title). + SetExpansion(1), + ) + + dashboard.Table.SetCell(i, 1, tview.NewTableCell("[::b]"+stat.Info)) + } + }) +} diff --git a/ui/explore.go b/ui/explore.go new file mode 100644 index 0000000..1853443 --- /dev/null +++ b/ui/explore.go @@ -0,0 +1,1037 @@ +package ui + +import ( + "context" + "fmt" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "code.cloudfoundry.org/bytefmt" + "github.com/darkhz/rclone-tui/rclone" + rcfns "github.com/darkhz/rclone-tui/rclone/operations" + "github.com/darkhz/tview" + "github.com/darkhz/tvxwidgets" + "github.com/gdamore/tcell/v2" + "golang.org/x/sync/semaphore" +) + +// ExplorerUI stores the layout for the explorer page. +type ExplorerUI struct { + Panes []*Pane + Flex *tview.Flex + + currentPane int + dataLock sync.Mutex + + selections map[rcfns.ListItem]struct{} + selectionLock sync.Mutex + + init bool + numPanes int + refreshPanes chan rclone.JobInfo +} + +// Pane stores the layout for a single explorer pane. +type Pane struct { + ID, FS, Path string + + Title *tview.TextView + View *tview.Table + + Flex *tview.Flex + Modal *Modal + + Status *tview.Pages + Input *tview.InputField + AboutText *tview.TextView + SortListText *tview.TextView + SpinnerText *tview.TextView + Spinner *tvxwidgets.Spinner + SpinnerCancel chan struct{} + + Lock *semaphore.Weighted + + list rcfns.List + savedPaths map[string]string + + sortMode string + sortAsc bool + + aboutCtx context.Context + remoteCtx context.Context + aboutCancel context.CancelFunc + remoteCancel context.CancelFunc + + filtered, isloading bool + refreshChan chan rclone.JobInfo +} + +var explorer ExplorerUI + +// Name returns the page's name. +func (e *ExplorerUI) Name() string { + return "Explorer" +} + +// Focused returns the currently focused view. +func (e *ExplorerUI) Focused() string { + return e.Name() + ":" + e.getPane().ID +} + +// Init initializes the page. +func (e *ExplorerUI) Init() bool { + if e.init { + return true + } + + e.init = true + go e.getPane().List() + + return e.init +} + +// Exit exits the page. +func (e *ExplorerUI) Exit(page string) bool { + e.getPane().remoteCancel() + + return true +} + +// Layout returns this page's layout. +func (e *ExplorerUI) Layout() tview.Primitive { + e.refreshPanes = make(chan rclone.JobInfo, 10) + e.selections = make(map[rcfns.ListItem]struct{}) + + e.Flex = tview.NewFlex() + e.Flex.SetDirection(tview.FlexColumn) + e.Flex.SetFocusFunc(func() { + App.SetFocus(e.getPane().View) + }) + + if e.numPanes == 0 { + e.numPanes = 2 + } + + go e.detectPaneRefresh() + + for i := 0; i < e.numPanes; i++ { + title := tview.NewTextView() + title.SetDynamicColors(true) + title.SetTextAlign(tview.AlignCenter) + title.SetText("Press 'g' to select remote") + title.SetBackgroundColor(tcell.ColorDefault) + + view := tview.NewTable() + view.SetBorder(true) + view.SetSelectorWrap(true) + view.SetFocusBorder(false) + view.SetSelectable(true, false) + view.SetBackgroundColor(tcell.ColorDefault) + view.SetFocusFunc(func() { + view.SetBorderColor(tcell.ColorBlue) + }) + view.SetBlurFunc(func() { + view.SetBorderColor(tcell.ColorWhite) + }) + view.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyCtrlR: + go e.getPane().List() + + case tcell.KeyCtrlX: + e.getPane().remoteCancel() + + case tcell.KeyEscape: + go e.reloadPanes(!e.getPane().filtered) + + case tcell.KeyTab: + e.switchPanes(event.Key() == tcell.KeyBacktab) + + case tcell.KeyLeft, tcell.KeyRight: + e.getPane().ChangeDir(event.Key() == tcell.KeyLeft) + } + + switch event.Rune() { + case 'g': + go e.getPane().ShowRemotes() + + case '/': + e.getPane().Filter() + + case ',': + e.getPane().Sort() + + case 'p', 'm', 'd', 'M', ';', 'i': + go e.getPane().Operation(event.Rune()) + + case ' ', 'a', 'A': + e.getPane().Select(event.Rune() == 'A', event.Rune() == 'a') + } + + return event + }) + + input := tview.NewInputField() + input.SetLabelColor(tcell.ColorWhite) + input.SetBackgroundColor(tcell.ColorDefault) + input.SetFieldBackgroundColor(tcell.ColorDefault) + + spinner := tvxwidgets.NewSpinner() + spinner.SetCustomStyle(nil) + spinner.SetBackgroundColor(tcell.ColorDefault) + + spinnerText := tview.NewTextView() + spinnerText.SetDynamicColors(true) + spinnerText.SetBackgroundColor(tcell.ColorDefault) + + aboutText := tview.NewTextView() + aboutText.SetDynamicColors(true) + aboutText.SetBackgroundColor(tcell.ColorDefault) + + sortListText := tview.NewTextView() + sortListText.SetRegions(true) + sortListText.SetDynamicColors(true) + sortListText.SetBackgroundColor(tcell.ColorDefault) + + spinnerCancel := make(chan struct{}) + + spinnerFlex := tview.NewFlex(). + SetDirection(tview.FlexColumn). + AddItem(spinner, 1, 0, false). + AddItem(nil, 1, 0, false). + AddItem(spinnerText, 0, 1, false) + + status := tview.NewPages() + status.AddPage("about", aboutText, true, false) + status.AddPage("input", input, true, false) + status.AddPage("sort", sortListText, true, false) + status.AddPage("load", spinnerFlex, true, false) + status.SetBackgroundColor(tcell.ColorDefault) + + layout := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(title, 1, 0, false). + AddItem(view, 0, 1, true). + AddItem(status, 1, 0, false) + layout.SetBackgroundColor(tcell.ColorDefault) + + lock := semaphore.NewWeighted(1) + + pane := Pane{ + ID: strconv.Itoa(i), + + Title: title, + View: view, + Flex: layout, + + Status: status, + Input: input, + AboutText: aboutText, + SortListText: sortListText, + Spinner: spinner, + SpinnerText: spinnerText, + SpinnerCancel: spinnerCancel, + + Lock: lock, + + sortMode: "name", + sortAsc: true, + + savedPaths: make(map[string]string), + refreshChan: make(chan rclone.JobInfo, 10), + } + + e.Panes = append(e.Panes, &pane) + + e.Flex.AddItem(pane.Flex, 0, 1, true) + + go pane.watchItem() + } + + return e.Flex +} + +// List lists the directory items for the current remote and path. +func (p *Pane) List(item ...rcfns.ListItem) { + if !p.Lock.TryAcquire(1) { + return + } + defer p.Lock.Release(1) + + var listItem rcfns.ListItem + + if item != nil { + listItem = item[0] + goto StartListing + } + + if p.FS == "" { + p.ShowRemotes() + return + } + + listItem.FS = p.FS + listItem.Path = p.Path + +StartListing: + App.QueueUpdateDraw(func() { + p.View.SetSelectable(false, false) + }) + defer App.QueueUpdateDraw(func() { + p.View.SetSelectable(true, false) + + if page, _ := MainPage.GetFrontPage(); page == "explorer" { + App.SetFocus(explorer.getPane().View) + } + }) + + go p.startLoading("Listing " + listItem.FS + listItem.Path) + defer p.stopLoading() + + list, err := rcfns.ListFS(p.ID, listItem.FS, listItem.Path) + if err != nil { + ErrorMessage("Explorer", err) + return + } + sortList(list.Items, p.sortAsc, p.sortMode) + + p.list = list + p.FS = listItem.FS + p.Path = listItem.Path + + p.filtered = false + + if listItem.About { + go p.setAbout() + } + + App.QueueUpdateDraw(func() { + p.viewList(list) + }) +} + +// ChangeDir changes the current directory. +func (p *Pane) ChangeDir(cdback bool) { + var path, dir string + var listItem rcfns.ListItem + + if !cdback { + _, list, err := p.getSelection() + if err != nil { + return + } + + if !list.IsDir { + return + } + + path = list.Path + dir = filepath.Base(list.Name) + + _, dir = rcfns.GetListPath(path, dir, cdback) + list.Name = filepath.Base(dir) + + listItem = list + } else { + path, _ = rcfns.GetListPath(p.Path, "", cdback) + listItem.Path = path + } + + listItem.FS = p.FS + + go p.List(listItem) +} + +// ShowRemotes displays a modal with a list of available remotes. +func (p *Pane) ShowRemotes() { + InfoMessage("Getting remotes...", true) + defer InfoMessage("", false) + + if p.remoteCtx != nil { + p.remoteCancel() + } + + p.remoteCtx, p.remoteCancel = context.WithCancel(context.Background()) + + remotes, err := rcfns.ListRemotes(p.remoteCtx) + if err != nil { + ErrorMessage("Explorer", err) + return + } + + remotes = append(remotes, "local") + + p.Modal = NewModal("show_remotes", "Select remote", true, false, len(remotes)+6, 60) + remoteInput, remoteTable := p.Modal.Input, p.Modal.Table + + updateTable := func(text ...string) { + var row int + + remoteTable.Clear() + + for _, remote := range remotes { + if text != nil && strings.Index(remote, text[0]) == -1 { + continue + } + + remoteTable.SetCell(row, 0, tview.NewTableCell(tview.Escape(remote)). + SetExpansion(1). + SetReference(remote). + SetAlign(tview.AlignCenter), + ) + + row++ + } + } + + remoteTable.SetSelectorWrap(false) + + remoteInput.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyDown, tcell.KeyUp: + remoteTable.InputHandler()(event, nil) + + case tcell.KeyEscape: + p.Modal.Exit() + + case tcell.KeyEnter: + var fs, path string + + row, _ := remoteTable.GetSelection() + remote, ok := remoteTable.GetCell(row, 0). + GetReference().(string) + if !ok { + goto Event + } + + p.savedPaths[p.FS] = p.Path + + if remote == "local" { + fs = "/" + } else { + fs = remote + ":" + } + + path = p.savedPaths[fs] + p.Modal.Exit() + + p.remoteCtx, p.remoteCancel = context.WithCancel(context.Background()) + + go func() { + if job, err := rclone.GetLatestJob("UI:" + explorer.Focused()); err == nil { + if strings.Contains(job.Description, "Listing") { + job.Cancel() + + p.Lock.Acquire(p.remoteCtx, 1) + p.Lock.Release(1) + } + } + + go p.List(rcfns.ListItem{FS: fs, Path: path, About: true}) + }() + } + + Event: + return event + }) + remoteInput.SetChangedFunc(func(text string) { + updateTable(text) + }) + + updateTable() + + App.QueueUpdateDraw(func() { + p.Modal.Show() + }) +} + +// Operation executes an operation according to the key pressed. +func (p *Pane) Operation(key rune) { + switch key { + case 'p': + rcfns.Copy(explorer.getSelectionsList(), p.FS, p.Path) + + case 'm': + rcfns.Move(explorer.getSelectionsList(), p.FS, p.Path) + + case 'd': + list := explorer.getSelectionsList() + if len(list) == 0 { + return + } + + if !ConfirmInput("Delete selected files? (y/n)") { + return + } + + rcfns.Delete(explorer.getSelectionsList()) + + case 'M': + dirName := SetInput("Create directory:", struct{}{}) + fullPath := p.FS + filepath.Join(p.Path, dirName) + + go p.startLoading("Creating " + fullPath) + defer p.stopLoading() + + if err := rcfns.Mkdir(p.ID, p.FS, p.Path, dirName); err != nil { + ErrorMessage("Explorer", err) + } + + go p.List() + + return + + case ';': + _, item, err := p.getSelection() + if err != nil { + return + } + + go p.startLoading("Loading public link for " + item.Name) + defer p.stopLoading() + + publiclink, err := rcfns.PublicLink(p.ID, p.FS, p.Path, item) + if err != nil { + ErrorMessage("Explorer", err, struct{}{}) + return + } + + modal := NewModal("public_link", "Public Link", false, true, 10, len(publiclink)+10) + + modal.TextView.SetText(publiclink) + modal.TextView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyEscape: + modal.Exit() + } + + return event + }) + + modal.Show() + + return + + default: + return + } + + go explorer.reloadPanes(true) +} + +// Select selects multiple items within the current directory. The selected +// items can then be used within an operation, for example copying files. +func (p *Pane) Select(all, inverse bool) { + if !p.Lock.TryAcquire(1) { + return + } + defer p.Lock.Release(1) + + row, listItem, err := p.getSelection() + if err != nil { + return + } + + if all { + for _, item := range p.list.Items { + p.itemSelected(item, true) + } + + p.viewList(p.list) + + p.View.Select(row, 0) + + return + } + + if inverse { + for row, item := range p.list.Items { + p.viewList(rcfns.List{ + Items: []rcfns.ListItem{item}, + }, row) + } + + p.View.Select(row, 0) + + return + } + + p.viewList(rcfns.List{ + Items: []rcfns.ListItem{listItem}, + }, row) +} + +// Filter filters the items within the current directory. +func (p *Pane) Filter() { + if !p.Lock.TryAcquire(1) { + return + } + defer p.Lock.Release(1) + + p.Status.SwitchToPage("input") + + p.Input.SetText("") + p.Input.SetLabel("[::b]Filter: ") + p.Input.SetChangedFunc(func(text string) { + var items []rcfns.ListItem + + for _, item := range p.list.Items { + if strings.Index( + strings.ToLower(item.Name), + strings.ToLower(text), + ) != -1 { + items = append(items, item) + } + } + + p.viewList(rcfns.List{Items: items}) + }) + p.Input.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyEscape: + if p.isloading { + p.Status.SwitchToPage("load") + } else { + p.Status.SwitchToPage("about") + } + + p.filtered = true + + App.SetFocus(p.Flex) + } + + return event + }) + + App.SetFocus(p.Input) +} + +// Sort sorts the current directory list. +func (p *Pane) Sort() { + if !p.Lock.TryAcquire(1) { + return + } + defer p.Lock.Release(1) + + var lastRune rune + + arrange := func() string { + var sortMethod string + + if p.sortAsc { + sortMethod = " asc " + } else { + sortMethod = " desc " + } + + return "[::b]Sort by[-:-:-]" + sortMethod + } + + text := `["name"](n)ame[""] ["size"](s)ize[""] ["modified"](m)odified[""]` + + p.Status.SwitchToPage("sort") + + p.SortListText.SetText(arrange() + text) + p.SortListText.Highlight(p.sortMode) + p.SortListText.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyEscape: + if p.isloading { + p.Status.SwitchToPage("load") + } else { + p.Status.SwitchToPage("about") + } + + App.SetFocus(p.Flex) + + case tcell.KeyRune: + for _, region := range p.SortListText.GetRegionIDs() { + if rune(region[0]) != event.Rune() { + continue + } + + p.sortMode = region + + if lastRune == event.Rune() { + p.sortAsc = !p.sortAsc + } else { + p.sortAsc = true + } + lastRune = event.Rune() + + p.SortListText.SetText(arrange() + text) + p.SortListText.Highlight(region) + + sortList(p.list.Items, p.sortAsc, p.sortMode) + p.viewList(p.list) + + return nil + } + } + + return event + }) + + App.SetFocus(p.SortListText) +} + +// viewList displays the current directory listing in the pane. +func (p *Pane) viewList(list rcfns.List, selectRow ...int) { + if selectRow != nil { + goto ShowList + } + + p.View.Clear() + + if title := p.FS + p.Path; title != "" { + p.Title.SetText("[::bu]" + tview.Escape(title)) + p.Title.ScrollToEnd() + } + +ShowList: + for row, item := range list.Items { + var itemColor, infoColor tcell.Color + + if selectRow != nil { + row = selectRow[0] + } + + name := item.Name + if item.IsDir { + name += "/" + } + + if ok := p.itemSelected(item, false, selectRow...); ok { + itemColor = tcell.ColorOrange + infoColor = tcell.ColorOrange + } else { + if item.IsDir { + itemColor = tcell.ColorBlue + } else { + itemColor = tcell.ColorWhite + } + + infoColor = tcell.ColorGrey + } + + p.View.SetCell(row, 0, tview.NewTableCell(item.ISize). + SetReference(item). + SetTextColor(infoColor). + SetBackgroundColor(tcell.ColorDefault). + SetSelectedStyle(tcell.Style{}. + Bold(true), + ), + ) + p.View.SetCell(row, 1, tview.NewTableCell(item.ModifiedTime). + SetReference(item). + SetTextColor(infoColor). + SetBackgroundColor(tcell.ColorDefault). + SetSelectedStyle(tcell.Style{}. + Bold(true), + ), + ) + p.View.SetCell(row, 2, tview.NewTableCell(tview.Escape(name)). + SetExpansion(1). + SetTextColor(itemColor). + SetAttributes(tcell.AttrBold). + SetBackgroundColor(tcell.ColorDefault). + SetSelectedStyle(tcell.Style{}. + Foreground(itemColor). + Background(tcell.Color16), + ), + ) + + if selectRow != nil { + if rowCount := p.View.GetRowCount(); row >= rowCount-1 { + row = rowCount - 1 + } else { + row++ + } + + p.View.Select(row, 0) + + return + } + } + + p.View.ScrollToBeginning() + + p.View.Select(0, 0) +} + +// watchItem watches for whether items in the current directory +// have been added or removed. +func (p *Pane) watchItem() { + for info := range p.refreshChan { + if !info.Finished || info.Error != "" { + continue + } + + items, ok := info.RefreshItems.([]rcfns.ListItem) + if !ok { + continue + } + + func() { + p.Lock.Acquire(context.Background(), 1) + defer p.Lock.Release(1) + + for _, item := range items { + var exist bool + + itemPath := filepath.Dir(item.Path) + if itemPath == "." { + itemPath = "" + } + + if p.FS+p.Path != item.FS+itemPath { + continue + } + + for i, listItem := range p.list.Items { + if listItem.ID == item.ID || listItem.Name == item.Name { + if item.RefreshAddItem { + p.list.Items[i].Size = item.Size + p.list.Items[i].ISize = item.ISize + + exist = true + } else { + list := p.list.Items + list = list[:i+copy(list[i:], list[i+1:])] + + p.list.Items = list + } + + break + } + } + + if !exist && item.RefreshAddItem { + p.list.Items = append(p.list.Items, item) + sortList(p.list.Items, p.sortAsc, p.sortMode) + } + + App.QueueUpdateDraw(func() { + p.viewList(p.list) + }) + } + }() + } +} + +// getSelection returns the current directory item selection. +func (p *Pane) getSelection() (int, rcfns.ListItem, error) { + row, _ := p.View.GetSelection() + + cell := p.View.GetCell(row, 0) + list, ok := cell.GetReference().(rcfns.ListItem) + if !ok { + return -1, rcfns.ListItem{}, fmt.Errorf("Cannot select list item") + } + + return row, list, nil +} + +// itemSelected returns whether an item is selected. If modify is set, it will +// modify the item's selected status. +func (p *Pane) itemSelected(key rcfns.ListItem, forceSelect bool, modify ...int) bool { + explorer.selectionLock.Lock() + defer explorer.selectionLock.Unlock() + + _, ok := explorer.selections[key] + + if modify != nil || forceSelect { + if forceSelect || (!ok && modify != nil) { + explorer.selections[key] = struct{}{} + } else if ok && modify != nil { + delete(explorer.selections, key) + } + + ok = !ok + } + + return ok +} + +// setAbout sets the space information for a remote. +func (p *Pane) setAbout() { + var aboutText string + + if p.aboutCtx != nil { + p.aboutCancel() + } + + p.aboutCtx, p.aboutCancel = context.WithCancel(context.Background()) + + about, err := rcfns.AboutFS(p.aboutCtx, p.FS) + if err != nil { + return + } + + if about.Total <= 0 { + return + } + + aboutText += bytefmt.ByteSize(uint64(about.Used)) + "/" + + bytefmt.ByteSize(uint64(about.Total)) + " used, " + + bytefmt.ByteSize(uint64(about.Free)) + " free" + + App.QueueUpdateDraw(func() { + p.AboutText.SetText(" " + aboutText) + }) +} + +// startLoading starts the loading spinner for a pane. +func (p *Pane) startLoading(message string) { + t := time.NewTicker(100 * time.Millisecond) + defer t.Stop() + + App.QueueUpdateDraw(func() { + if pg, _ := p.Status.GetFrontPage(); pg != "input" { + p.Status.SwitchToPage("load") + } + + p.SpinnerText.SetText("[yellow::b]" + message) + p.Spinner.SetStyle(tvxwidgets.SpinnerDotsCircling) + + p.isloading = true + }) + + for { + select { + case <-t.C: + App.QueueUpdateDraw(func() { + p.Spinner.Pulse() + }) + + case <-p.SpinnerCancel: + App.QueueUpdateDraw(func() { + if pg, _ := p.Status.GetFrontPage(); pg != "input" { + p.Status.SwitchToPage("about") + } + + p.SpinnerText.SetText("") + p.Spinner.SetCustomStyle(nil) + + p.isloading = false + }) + + return + } + } +} + +// stopLoading stops the loading spinner for a pane. +func (p *Pane) stopLoading() { + p.SpinnerCancel <- struct{}{} +} + +// getSelectionsList returns the list of all the selected items across +// all panes. +func (e *ExplorerUI) getSelectionsList() []rcfns.ListItem { + e.selectionLock.Lock() + defer e.selectionLock.Unlock() + + var list []rcfns.ListItem + + for selection := range e.selections { + list = append(list, selection) + } + + return list +} + +// getPane returns the currently focused pane. +func (e *ExplorerUI) getPane() *Pane { + return explorer.Panes[explorer.currentPane] +} + +// reloadPanes clears all selections across all panes. +func (e *ExplorerUI) reloadPanes(clearSelections bool) { + if clearSelections { + e.selectionLock.Lock() + e.selections = make(map[rcfns.ListItem]struct{}) + e.selectionLock.Unlock() + } + + e.dataLock.Lock() + defer e.dataLock.Unlock() + + for _, p := range e.Panes { + if !p.Lock.TryAcquire(1) { + continue + } + defer p.Lock.Release(1) + + if p.FS == "" { + continue + } + + App.QueueUpdateDraw(func() { + p.viewList(p.list) + }) + + p.filtered = false + } +} + +// switchPanes cycles between the panes. +func (e *ExplorerUI) switchPanes(reverse bool) { + e.dataLock.Lock() + defer e.dataLock.Unlock() + + currentPane := e.currentPane + + prevpane := e.Panes[currentPane] + prevpath := prevpane.FS + prevpane.Path + + if reverse { + currentPane-- + } else { + currentPane++ + } + + if currentPane >= len(e.Panes) { + currentPane = 0 + } else if currentPane < 0 { + currentPane = len(e.Panes) - 1 + } + + currentpane := e.Panes[currentPane] + currentpath := currentpane.FS + currentpane.Path + + if prevpath == currentpath { + go App.QueueUpdateDraw(func() { + row, _, _ := currentpane.getSelection() + + currentpane.viewList(currentpane.list) + currentpane.View.Select(row, 0) + + }) + } + + App.SetFocus(currentpane.View) + + e.currentPane = currentPane +} + +// detectPaneRefresh gets a signal and sends it to all the panes +// so as to enable refreshing their current content. +func (e *ExplorerUI) detectPaneRefresh() { + for jobInfo := range e.refreshPanes { + e.dataLock.Lock() + + for _, pane := range e.Panes { + select { + case pane.refreshChan <- jobInfo: + + default: + } + } + + e.dataLock.Unlock() + } +} diff --git a/ui/formwidget.go b/ui/formwidget.go new file mode 100644 index 0000000..e12ce72 --- /dev/null +++ b/ui/formwidget.go @@ -0,0 +1,532 @@ +package ui + +import ( + "fmt" + "sort" + "strings" + "sync" + + "github.com/darkhz/tview" + "github.com/gdamore/tcell/v2" + "golang.org/x/sync/semaphore" +) + +// FormUI stores the layout for a form page. +type FormUI struct { + ManagerTable *tview.Table + ManagerPages *tview.Pages + ManagerButtons []Button + + WizardPages *tview.Pages + WizardButtons []Button + WizardData map[string]interface{} + WizardForms map[string]*tview.Form + WizardHelp *tview.TextView + + Flex *tview.Flex + + dataLock sync.RWMutex + wizardLock *semaphore.Weighted + managerLock *semaphore.Weighted +} + +// FormWidget stores the layout for a form item. +type FormWidget struct { + tview.Primitive + + Item tview.FormItem + + Label string + Marker *tview.TextView + + listOptions *tview.TextView +} + +// NewFormUI returns a form page. A form page typically consists of two sub-pages, +// the former to display data and the latter to configure data, like a wizard. +func NewFormUI(pages ...string) *FormUI { + var formUI FormUI + + formUI.ManagerTable = tview.NewTable() + formUI.ManagerTable.SetFixed(1, 1) + formUI.ManagerTable.SetExpandSpace(true) + formUI.ManagerTable.SetSelectable(true, false) + formUI.ManagerTable.SetBackgroundColor(tcell.ColorDefault) + + formUI.WizardPages = tview.NewPages() + formUI.WizardPages.SetBackgroundColor(tcell.ColorDefault) + formUI.WizardPages.SetChangedFunc(func() { + UpdateButtonView(formUI.WizardButtons) + }) + + formUI.WizardData = make(map[string]interface{}) + formUI.WizardForms = make(map[string]*tview.Form) + + for _, page := range pages { + form := NewForm() + + formUI.WizardForms[page] = form + formUI.WizardPages.AddPage(page, form, true, false) + } + + formUI.WizardHelp = tview.NewTextView() + formUI.WizardHelp.SetBorder(true) + formUI.WizardHelp.SetDynamicColors(true) + formUI.WizardHelp.SetBackgroundColor(tcell.ColorDefault) + formUI.WizardHelp.SetFocusFunc(func() { + formUI.WizardHelp.SetBorderColor(tcell.ColorBlue) + }) + formUI.WizardHelp.SetBlurFunc(func() { + formUI.WizardHelp.SetBorderColor(tcell.ColorWhite) + }) + formUI.WizardHelp.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyEscape, tcell.KeyCtrlH: + _, item := formUI.WizardPages.GetFrontPage() + App.SetFocus(item) + } + + return event + }) + + configWizardFlex := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(TabView(), 1, 0, false). + AddItem(formUI.WizardPages, 0, 1, true). + AddItem(formUI.WizardHelp, 6, 0, false) + + formUI.ManagerPages = tview.NewPages() + formUI.ManagerPages.AddPage("manager", formUI.ManagerTable, true, false) + formUI.ManagerPages.AddPage("wizard", configWizardFlex, true, false) + formUI.ManagerPages.SetBackgroundColor(tcell.ColorDefault) + formUI.ManagerPages.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + buttonEventHandler(event) + + if page, _ := formUI.ManagerPages.GetFrontPage(); page == "wizard" { + tabEventHandler(event) + } + + return event + }) + + formUI.wizardLock = semaphore.NewWeighted(1) + formUI.managerLock = semaphore.NewWeighted(1) + + formUI.Flex = tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(formUI.ManagerPages, 0, 1, true). + AddItem(ButtonView(), 1, 0, false) + formUI.Flex.SetBackgroundColor(tcell.ColorDefault) + + return &formUI +} + +// NewForm returns a form. A form is a display area for various form items. +func NewForm() *tview.Form { + form := tview.NewForm() + form.SetBackgroundColor(tcell.ColorDefault) + form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Modifiers() != tcell.ModCtrl { + goto Event + } + + switch event.Key() { + case tcell.KeyUp: + form.InputHandler()( + tcell.NewEventKey(tcell.KeyBacktab, ' ', tcell.ModNone), nil, + ) + + case tcell.KeyDown: + form.InputHandler()( + tcell.NewEventKey(tcell.KeyTab, ' ', tcell.ModNone), nil, + ) + } + + Event: + return event + }) + + return form +} + +// NewFormWidget returns a form item, which can be added to a form. +func NewFormWidget(label string, primitive tview.FormItem, passwordButton ...*tview.Button) *FormWidget { + labelView := tview.NewTextView() + labelView.SetDynamicColors(true) + labelView.SetText("[white::b]" + label + ":") + labelView.SetBackgroundColor(tcell.ColorDefault) + + marker := tview.NewTextView() + marker.SetDynamicColors(true) + marker.SetBackgroundColor(tcell.ColorDefault) + + flex := tview.NewFlex() + flex.AddItem(labelView, 0, 1, false) + flex.AddItem(primitive, 0, 2, true) + flex.AddItem(nil, 1, 0, false) + flex.SetBackgroundColor(tcell.ColorDefault) + + if passwordButton != nil { + flex.AddItem(passwordButton[0], 10, 0, false) + flex.AddItem(nil, 1, 0, false) + } else { + flex.AddItem(nil, 10, 0, false) + flex.AddItem(nil, 1, 0, false) + } + flex.AddItem(marker, 1, 0, false) + + return &FormWidget{ + Primitive: flex, + Label: label, + Marker: marker, + Item: primitive, + } +} + +// GetFormCheckBox returns a checkbox. +func GetFormCheckBox( + label string, + setData func(name string, data interface{}), doFunc func(label string), + value ...string, +) *FormWidget { + var f *FormWidget + + checkBox := tview.NewCheckbox() + checkBox.SetBackgroundColor(tcell.ColorDefault) + + if value != nil { + checkBox.SetChecked(value[0] == "true") + } + + checkBox.SetChangedFunc(func(checked bool) { + f.DisableMarker() + setData(f.GetLabel(), checked) + }) + checkBox.SetFocusFunc(func() { + doFunc(f.GetLabel()) + }) + + f = NewFormWidget(label, checkBox) + + return f +} + +// GetFormInputField returns an input field. +func GetFormInputField( + label string, editable, password bool, + setData func(name string, data interface{}), doFunc func(label string), + value ...string, +) *FormWidget { + var f *FormWidget + var b []*tview.Button + var button *tview.Button + + inputField := tview.NewInputField() + inputField.SetEnableFocus(true) + inputField.SetBackgroundColor(tcell.ColorDefault) + + if value != nil { + inputField.SetText(value[0]) + } + if password { + inputField.SetMaskCharacter('*') + + button = tview.NewButton("[::bu]Show") + button.SetBackgroundColor(tcell.ColorDefault) + button.SetSelectedFunc(func() { + label := button.GetLabel() + if strings.Index(label, "Show") != -1 { + button.SetLabel("[::bu]Hide") + inputField.SetMaskCharacter(0) + } else { + button.SetLabel("[::bu]Show") + inputField.SetMaskCharacter('*') + } + }) + } + + inputField.SetChangedFunc(func(text string) { + if !editable { + return + } + + setData(f.GetLabel(), text) + }) + inputField.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyTab, tcell.KeyEnter: + f.DisableMarker() + return event + + case tcell.KeyCtrlP: + if button != nil { + button.InputHandler()( + tcell.NewEventKey(tcell.KeyEnter, ' ', tcell.ModNone), nil, + ) + } + + default: + if editable { + goto Event + } + + return nil + } + + Event: + return event + }) + inputField.SetFocusFunc(func() { + doFunc(f.GetLabel()) + }) + + if button != nil { + b = append(b, button) + } + + f = NewFormWidget(label, inputField, b...) + + return f +} + +// GetFormList returns a queryable list with options. +func GetFormList( + label string, + optionData map[string]string, + editable, exclusive bool, + setData func(name string, data interface{}), + doFunc func(label string), + changedFunc func(option string), + value ...string, +) *FormWidget { + var f *FormWidget + + input := tview.NewInputField() + input.SetEnableFocus(true) + input.SetBackgroundColor(tcell.ColorDefault) + + modal := GetList( + "list_options", "Select "+label, label, optionData, + func(text string) { + input.SetText(text) + }, changedFunc, + ) + + if value != nil { + input.SetText(value[0]) + } + + input.SetChangedFunc(func(text string) { + if !editable { + return + } + + setData(f.GetLabel(), text) + }) + input.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if !editable && event.Key() != tcell.KeyTab { + return nil + } + + switch event.Key() { + case tcell.KeyDown, tcell.KeyUp: + if event.Modifiers() == tcell.ModCtrl { + goto Event + } + + fallthrough + + case tcell.KeyCtrlO: + modal.Show() + + case tcell.KeyRune: + if exclusive { + modal.Show() + return nil + } + + case tcell.KeyEnter: + if input.GetText() == "" { + modal.Show() + f.DisableMarker() + + return nil + } + + case tcell.KeyTab: + f.DisableMarker() + } + + Event: + return event + }) + input.SetFocusFunc(func() { + doFunc(f.GetLabel()) + }) + + button := tview.NewButton("[::bu]Options") + button.SetBackgroundColor(tcell.ColorDefault) + button.SetSelectedFunc(func() { + if editable { + modal.Show() + } + }) + + f = NewFormWidget(label, input, button) + + return f +} + +// GetList returns a list. +func GetList( + name, title, label string, + optionData map[string]string, + exitFunc func(text string), + changedFunc func(option string), +) *Modal { + var options, highlight string + + modal := NewModal( + name, title, true, true, len(optionData)+11, 60, + ) + + listInput, list := modal.Input, modal.TextView + + updateList := func(text string) { + list.Clear() + + options, highlight = "", "" + + sortedKeys := []string{} + for key := range optionData { + sortedKeys = append(sortedKeys, key) + } + sort.Strings(sortedKeys) + + for _, key := range sortedKeys { + if strings.Index( + key, text, + ) == -1 && + strings.Index( + strings.ToLower(optionData[key]), + strings.ToLower(text), + ) == -1 { + continue + } + + if highlight == "" { + highlight = key + } + + optFormat := "[\"%s\"][::bu]%s[-:-:-][\"\"]\n%s\n" + if optionData[key] != "" { + optFormat += "\n" + } + + options += fmt.Sprintf( + optFormat, + key, key, optionData[key], + ) + } + + list.SetText(options) + + list.Highlight(highlight) + list.ScrollToHighlight() + } + + updateList("") + + listInput.SetChangedFunc(func(text string) { + updateList(text) + }) + listInput.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyDown, tcell.KeyUp: + SwitchTabView(event.Key() == tcell.KeyUp, list) + + case tcell.KeyEnter: + highlight := list.GetHighlights() + if highlight != nil { + exitFunc(highlight[0]) + } + + if changedFunc != nil { + changedFunc(strings.ToLower(label)) + } + + fallthrough + + case tcell.KeyEscape: + listInput.SetText("") + modal.Exit() + } + + return event + }) + + list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + listInput.InputHandler()(event, nil) + + return event + }) + + return modal +} + +// SetRequired marks the form item as required to be filled. +func (f *FormWidget) SetRequired() *FormWidget { + f.Marker.SetText("[orange::bu]*[-:-:-]") + + return f +} + +// EnableMarker enables the marker for the form item. +func (f *FormWidget) EnableMarker() *FormWidget { + f.Marker.SetBackgroundColor(tcell.ColorRed) + + return f +} + +// DisableMarker disables the marker for the form item. +func (f *FormWidget) DisableMarker() *FormWidget { + f.Marker.SetBackgroundColor(tcell.ColorDefault) + + return f +} + +// GetLabel returns the label for the form item. +func (f *FormWidget) GetLabel() string { + return f.Label +} + +// SetFormAttributes sets the form attributes for the form item. +func (f *FormWidget) SetFormAttributes(labelWidth int, labelColor, bgColor, fieldTextColor, fieldBgColor tcell.Color) tview.FormItem { + formItem := f.GetFormItem() + formItem.SetFormAttributes(labelWidth, labelColor, bgColor, fieldTextColor, fieldBgColor) + + return f +} + +// GetFieldWidth returns the field width of the form item. +func (f *FormWidget) GetFieldWidth() int { + _, _, w, _ := f.GetFormItem().GetRect() + + return w +} + +func (f *FormWidget) GetFieldHeight() int { + return 1 +} + +// SetFinishedFunc sets the handler for when the form item is not focused. +func (f *FormWidget) SetFinishedFunc(handler func(key tcell.Key)) tview.FormItem { + f.GetFormItem().SetFinishedFunc(handler) + + return f +} + +// GetFormItem returns the form item. +func (f *FormWidget) GetFormItem() tview.FormItem { + return f.Item +} diff --git a/ui/jobmonitor.go b/ui/jobmonitor.go new file mode 100644 index 0000000..41755f2 --- /dev/null +++ b/ui/jobmonitor.go @@ -0,0 +1,264 @@ +package ui + +import ( + "fmt" + "strconv" + "strings" + "sync" + "time" + + "code.cloudfoundry.org/bytefmt" + "github.com/darkhz/rclone-tui/rclone" + "github.com/darkhz/tview" + "github.com/gdamore/tcell/v2" +) + +// JobUI stores a layout to display running job information. +type JobUI struct { + View *tview.TreeView + + Lock sync.Mutex + + isOpen bool + prevPage string +} + +var ( + jobUI JobUI + + jobIndicator = make(chan rclone.JobInfo, 10) +) + +// JobMonitor monitors currently running jobs and displays them. +func JobMonitor() { + for jobInfo := range rclone.JobInfoStatus() { + select { + case jobIndicator <- jobInfo: + + default: + } + + select { + case explorer.refreshPanes <- jobInfo: + + default: + } + + if jobInfo.Error != "" { + ErrorMessage("Job Monitor", fmt.Errorf(jobInfo.Error)) + } + + App.QueueUpdateDraw(func() { + modifyJobNode(jobInfo) + }) + } +} + +// jobManager returns a display area for currently running jobs. +func jobManager() *tview.TreeView { + if jobUI.View != nil { + goto Layout + } + + jobUI.View = tview.NewTreeView() + jobUI.View.SetGraphics(false) + jobUI.View.SetBackgroundColor(tcell.ColorDefault) + jobUI.View.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyEscape: + closeJobManager() + + case tcell.KeyCtrlX: + node := jobUI.View.GetCurrentNode() + if job, ok := node.GetReference().(*rclone.Job); ok { + rclone.StopJobGroup(job) + } + } + + switch event.Rune() { + case 'x': + node := jobUI.View.GetCurrentNode() + if job, ok := node.GetReference().(*rclone.Job); ok { + job.Cancel() + } + } + + return event + }) + +Layout: + return jobUI.View +} + +// modifyJobNode modifies the displayed job information for the given job. +func modifyJobNode(jobInfo rclone.JobInfo) { + if jobUI.View == nil { + return + } + + if !isOpen() && !jobInfo.Finished { + return + } + + if strings.HasPrefix(jobInfo.Type, "UI:") { + return + } + + rootNode := jobUI.View.GetRoot() + + for i, jobTypeNode := range rootNode.GetChildren() { + for _, jobNode := range jobTypeNode.GetChildren() { + job := jobNode.GetReference().(*rclone.Job) + + if jobInfo.Group != "" { + typeID := strings.Split(jobInfo.Group, "/") + if len(typeID) < 2 { + continue + } + + if typeID[0] != job.Type || + typeID[1] != strconv.FormatInt(job.ID, 10) { + continue + } + } else if job.ID != jobInfo.ID { + continue + } + + if jobInfo.Finished && jobInfo.Group == "" { + jobTypeNode.RemoveChild(jobNode) + + if len(jobTypeNode.GetChildren()) == 0 { + rootNode.RemoveChild(jobTypeNode) + rootNode.RemoveChild(rootNode.GetChildren()[i]) + } + + continue + } + + desc := "[::b]" + jobInfo.Description + + jobNode.SetText(desc) + updateJobNodeDetails(jobNode, jobInfo) + } + } +} + +// updateJobNodeDetails updates the information within the job node. +func updateJobNodeDetails(node *tview.TreeNode, jobInfo rclone.JobInfo) { + if strings.Contains(jobInfo.Type, "Delete") { + return + } + + states := []string{ + "Percentage: ", + "ETA: ", + } + + transferStats := jobInfo.CurrentTransfer + if transferStats.Percentage == 0 && transferStats.Bytes > 0 { + states[0] = "Transferred: " + states[0] += bytefmt.ByteSize(uint64(transferStats.Bytes)) + } else { + states[0] += strconv.FormatInt(transferStats.Percentage, 10) + "%" + } + + if transferStats.Speed > 0 { + states[0] += " (" + bytefmt.ByteSize(uint64(transferStats.Speed)) + "/s)" + } + + if transferStats.Eta == 0 { + states[1] += "Unspecified" + } else { + states[1] += ReadableString(time.Duration(float64(transferStats.Eta)) * time.Second) + } + + if len(node.GetChildren()) == 0 { + for i := 0; i < len(states); i++ { + node.AddChild(tview.NewTreeNode("")) + } + } + + for j, node := range node.GetChildren() { + node.SetText(states[j]) + } +} + +// openJobManager displays the job manager. +func openJobManager() { + var rootNode *tview.TreeNode + + if jobUI.View != nil { + rootNode = jobUI.View.GetRoot() + rootNode.ClearChildren() + + goto SwitchToView + } + + rootNode = tview.NewTreeNode("[::bu]Job Manager") + rootNode.SetSelectable(false) + +SwitchToView: + rootNode.AddChild( + tview.NewTreeNode("").SetSelectable(false), + ) + + rclone.GetJobQueue().Range(func(key, value interface{}) bool { + jobType := key.(string) + if strings.HasPrefix(jobType, "UI:") { + return true + } + + jobMap := value.(map[int64]*rclone.Job) + + jobTypeNode := tview.NewTreeNode("[::b]- [::bu]" + jobType) + jobTypeNode.SetSelectable(false) + jobTypeNode.SetColor(tcell.ColorPurple) + + for _, job := range jobMap { + jobNode := tview.NewTreeNode("[::b]" + job.Description) + jobNode.SetReference(job) + jobNode.SetColor(tcell.ColorGreen) + + jobTypeNode.AddChild(jobNode) + } + + rootNode.AddChild(jobTypeNode).AddChild( + tview.NewTreeNode("").SetSelectable(false), + ) + + return true + }) + + jobUI.prevPage, _ = MainPage.GetFrontPage() + MainPage.AddAndSwitchToPage("job_view", jobManager(), true) + + jobUI.View.SetRoot(rootNode) + if rootChildren := rootNode.GetChildren(); len(rootChildren) > 0 { + jobUI.View.SetCurrentNode(rootChildren[len(rootChildren)-1]) + } + + setOpen(true) +} + +// closeJobManager closes the job manager. +func closeJobManager() { + MainPage.SwitchToPage(jobUI.prevPage) + + setOpen(false) +} + +// isOpen checks whether the job manager is displayed. +func isOpen() bool { + jobUI.Lock.Lock() + defer jobUI.Lock.Unlock() + + return jobUI.isOpen +} + +// setOpen sets the job manager display status. +func setOpen(open bool) { + jobUI.Lock.Lock() + defer jobUI.Lock.Unlock() + + jobUI.isOpen = open +} diff --git a/ui/login.go b/ui/login.go new file mode 100644 index 0000000..a40b448 --- /dev/null +++ b/ui/login.go @@ -0,0 +1,70 @@ +package ui + +import ( + "github.com/darkhz/rclone-tui/rclone" + "github.com/darkhz/tview" + "github.com/gdamore/tcell/v2" +) + +// LoginUI stores the authentication parameters. +type LoginUI struct { + params map[string]string +} + +var login LoginUI + +// LoginScreen displays a login screen to enter authentication information. +func LoginScreen() { + var modal *Modal + + setData := func(name string, data interface{}) { + if login.params == nil { + login.params = make(map[string]string) + } + + login.params[name] = data.(string) + } + + form := NewForm() + form.SetButtonsAlign(tview.AlignCenter) + form.AddFormItem( + GetFormInputField("Host", true, false, setData, func(label string) {}), + ) + form.AddFormItem( + GetFormInputField("User", true, false, setData, func(label string) {}), + ) + form.AddFormItem( + GetFormInputField("Password", true, true, setData, func(label string) {}), + ) + form.AddButton("Login", func() { + if login.params == nil { + return + } + + go func(host, user, pass string) { + StartLoading("Logging in") + + userInfo, err := rclone.Login(host, user, pass) + if err != nil { + ErrorMessage("Login", err, struct{}{}) + return + } + + StopLoading("Logged in") + + App.QueueUpdateDraw(func() { + modal.Exit() + MainPage.RemovePage("login") + + SetViewHostname(userInfo) + InitViewByName("Dashboard") + }) + }(login.params["Host"], login.params["User"], login.params["Password"]) + }) + + SetViewTitle("Login") + MainPage.AddAndSwitchToPage("login", tview.NewBox().SetBackgroundColor(tcell.ColorDefault), true) + + modal = NewCustomModal("login_form", form, form.GetFormItemCount()+8, 100) + modal.Show() +} diff --git a/ui/modal.go b/ui/modal.go new file mode 100644 index 0000000..3b5a246 --- /dev/null +++ b/ui/modal.go @@ -0,0 +1,170 @@ +package ui + +import ( + "github.com/darkhz/tview" + "github.com/gdamore/tcell/v2" +) + +// Modal stores a layout to display a floating modal. +type Modal struct { + Name string + + Flex *tview.Flex + Table *tview.Table + TextView *tview.TextView + Input *tview.InputField + + y *tview.Flex + x *tview.Flex + + height, width int + + open bool +} + +var currentModal *Modal + +// NewModal returns a modal with an optional inputfield or textview. +func NewModal(name, title string, withInput, withTextView bool, height, width int) *Modal { + var modalTable *tview.Table + var modalTextView *tview.TextView + + modalTitle := tview.NewTextView() + modalTitle.SetDynamicColors(true) + modalTitle.SetText("[::bu]" + title) + modalTitle.SetTextAlign(tview.AlignCenter) + modalTitle.SetBackgroundColor(tcell.ColorBlack) + + if withTextView { + modalTextView = tview.NewTextView() + modalTextView.SetWrap(true) + modalTextView.SetRegions(true) + modalTextView.SetWordWrap(true) + modalTextView.SetDynamicColors(true) + modalTextView.SetBackgroundColor(tcell.ColorBlack) + } else { + modalTable = tview.NewTable() + modalTable.SetSelectorWrap(true) + modalTable.SetSelectable(true, false) + modalTable.SetBackgroundColor(tcell.ColorBlack) + } + + modalInput := tview.NewInputField() + modalInput.SetBackgroundColor(tcell.ColorBlack) + + flex := tview.NewFlex() + flex.SetBorder(true) + flex.SetDirection(tview.FlexRow) + + box := tview.NewBox() + box.SetBackgroundColor(tcell.ColorBlack) + + flex.AddItem(modalTitle, 1, 0, false) + flex.AddItem(box, 1, 0, false) + if withInput { + flex.AddItem(modalInput, 1, 0, true) + } + flex.AddItem(box, 1, 0, false) + if withTextView { + flex.AddItem(modalTextView, 0, 1, !withInput) + } else { + flex.AddItem(modalTable, 0, 1, !withInput) + } + flex.SetBackgroundColor(tcell.ColorBlack) + + return &Modal{ + Name: name, + Flex: flex, + Input: modalInput, + Table: modalTable, + TextView: modalTextView, + + height: height, + width: width, + } +} + +// NewCustomModal returns a modal which includes the given item. +func NewCustomModal(name string, item tview.Primitive, height, width int) *Modal { + flex := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(item, 0, 1, true) + flex.SetBorder(true) + flex.SetBackgroundColor(tcell.ColorDefault) + + return &Modal{ + Name: name, + Flex: flex, + + height: height, + width: width, + } +} + +// Show shows the modal. +func (m *Modal) Show() { + if currentModal != nil { + return + } + + prevPage, _ := MainPage.GetFrontPage() + + m.open = true + + m.y = tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(nil, 0, 0, false). + AddItem(m.Flex, m.height, 0, true) + + m.x = tview.NewFlex(). + SetDirection(tview.FlexColumn). + AddItem(nil, 0, 0, false). + AddItem(m.y, m.width, 0, true) + + MainPage.AddAndSwitchToPage(m.Name, m.x, true).ShowPage(prevPage) + App.SetFocus(m.Flex) + + currentModal = m + resizeModal() +} + +// Exit exits the modal. +func (m *Modal) Exit() { + MainPage.RemovePage(m.Name) + + currentModal = nil +} + +// resizeModal resizes the modal according to the current screen dimensions. +func resizeModal() { + if currentModal == nil { + return + } + + if !currentModal.open { + return + } + + _, _, pageWidth, pageHeight := MainPage.GetInnerRect() + + height := currentModal.height + if height >= pageHeight { + height = pageHeight + } + + width := currentModal.width + if width >= pageWidth { + width = pageWidth + } + + x := (pageWidth - currentModal.width) / 2 + y := (pageHeight - currentModal.height) / 2 + + currentModal.y.ResizeItem(currentModal.Flex, height, 0) + currentModal.y.ResizeItem(nil, y, 0) + + currentModal.x.ResizeItem(currentModal.y, width, 0) + currentModal.x.ResizeItem(nil, x, 0) + + go App.Draw() +} diff --git a/ui/mounts.go b/ui/mounts.go new file mode 100644 index 0000000..67ddd90 --- /dev/null +++ b/ui/mounts.go @@ -0,0 +1,549 @@ +package ui + +import ( + "fmt" + "sort" + "strings" + + rcfns "github.com/darkhz/rclone-tui/rclone/operations" + "github.com/darkhz/tview" + "github.com/gdamore/tcell/v2" + "github.com/iancoleman/strcase" +) + +// MountsUI stores a layout for the mounts page. +type MountsUI struct { + formUI *FormUI +} + +const ( + mountWizardTabs = `[aqua::b]["main"]Setup[""][-:-:-] [white::b]------[-:-:-] [aqua::b]["mountOpt"]Mount Options[""][-:-:-] [white::b]------[-:-:-] [aqua::b]["vfsOpt"]VFS Options[""][-:-:-]` +) + +var mounts MountsUI + +// Name returns the page's name. +func (m *MountsUI) Name() string { + return "Mounts" +} + +// Focused returns the currently focused view. +func (m *MountsUI) Focused() string { + return m.Name() +} + +// Init initializes the page. +func (m *MountsUI) Init() bool { + m.formUI.ManagerPages.SwitchToPage("manager") + go m.listMounts() + + return true +} + +// Exit exits the page. +func (m *MountsUI) Exit(page string) bool { + if pg, _ := m.formUI.ManagerPages.GetFrontPage(); pg == "wizard" { + m.wizardExit(true, page) + return false + } + + m.formUI.WizardHelp.Clear() + m.formUI.ManagerTable.Clear() + + m.clearWizardData() + + return true +} + +// Layout returns this page's layout. +func (m *MountsUI) Layout() tview.Primitive { + m.formUI = NewFormUI("main", "mountOpt", "vfsOpt") + m.formUI.ManagerTable.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyCtrlR: + go m.listMounts() + } + + switch event.Rune() { + case 'n': + m.managerNewMount() + + case 'u': + m.managerUnmount() + + case 'U': + m.managerUnmountAll() + } + + return event + }) + m.formUI.WizardPages.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyCtrlS: + m.wizardCreateMount() + + case tcell.KeyCtrlC: + m.wizardCancel() + + case tcell.KeyCtrlH: + App.SetFocus(m.formUI.WizardHelp) + } + + return event + }) + m.formUI.ManagerPages.SetChangedFunc(func() { + page, _ := m.formUI.ManagerPages.GetFrontPage() + + switch page { + case "manager": + SetViewTitle("Mounts") + + case "wizard": + SetViewTitle("Mount Wizard") + } + + m.updateButtons(page) + }) + + m.formUI.ManagerButtons = []Button{ + {"New Mount", m.managerNewMount}, + {"Unmount", m.managerUnmount}, + {"Unmount All", m.managerUnmountAll}, + } + + m.formUI.WizardButtons = []Button{ + {"Next", m.wizardNext}, + {"Previous", m.wizardPrevious}, + {"Create Mount", m.wizardCreateMount}, + {"Cancel", m.wizardCancel}, + } + + strcase.ConfigureAcronym("Fs", "FS") + + return m.formUI.Flex +} + +// listMounts lists the current mountpoints. +func (m *MountsUI) listMounts(filterSetting ...rcfns.MountPoint) { + var err error + var mountPoints []rcfns.MountPoint + + if filterSetting != nil { + mountPoints = filterSetting + goto LoadMountsTable + } + + if !m.formUI.managerLock.TryAcquire(1) { + return + } + defer m.formUI.managerLock.Release(1) + + StartLoading("Loading mountpoints") + defer StopLoading() + + mountPoints, err = rcfns.GetMountPoints() + +LoadMountsTable: + sort.Slice(mountPoints, func(i, j int) bool { + return mountPoints[i].Fs < mountPoints[j].Fs + }) + + App.QueueUpdateDraw(func() { + m.formUI.ManagerTable.Clear() + + for col, header := range []string{ + "FS", + "Mount Point", + "Mounted On", + } { + m.formUI.ManagerTable.SetCell(0, col, tview.NewTableCell("[::bu]"+header). + SetExpansion(1). + SetSelectable(false). + SetAlign(tview.AlignCenter). + SetBackgroundColor(tcell.ColorPurple), + ) + } + + if err != nil { + ErrorMessage("Mounts", err) + return + } + + bgColor := tcell.ColorSlateGray + row := m.formUI.ManagerTable.GetRowCount() - 1 + + for _, point := range mountPoints { + row++ + + m.formUI.ManagerTable.SetCell(row, 0, tview.NewTableCell(tview.Escape(point.Fs)). + SetAlign(tview.AlignCenter). + SetBackgroundColor(bgColor), + ) + m.formUI.ManagerTable.SetCell(row, 1, tview.NewTableCell(point.MountPoint). + SetMaxWidth(10). + SetAlign(tview.AlignCenter). + SetBackgroundColor(bgColor), + ) + m.formUI.ManagerTable.SetCell(row, 2, tview.NewTableCell(point.MountedOn.Format("Mon 01/02 15:04")). + SetAlign(tview.AlignCenter). + SetBackgroundColor(bgColor), + ) + } + + m.updateButtons("manager") + + if filterSetting == nil { + App.SetFocus(m.formUI.ManagerTable) + } + }) +} + +// managerNewMount displays the mount wizard. +func (m *MountsUI) managerNewMount() { + go m.setupWizard() +} + +// managerUnmount unmounts the selected mountpoint. +func (m *MountsUI) managerUnmount() { + selectedRow, _ := m.formUI.ManagerTable.GetSelection() + if selectedRow <= 0 { + return + } + + mountedOn := m.formUI.ManagerTable.GetCell(selectedRow, 1).Text + + go func(row int, mountpoint string) { + if !m.formUI.wizardLock.TryAcquire(1) { + return + } + defer m.formUI.wizardLock.Release(1) + + if !ConfirmInput("Unmount " + mountpoint + "? (y/n)") { + return + } + + StartLoading("Unmounting" + mountpoint) + + if err := rcfns.Unmount(mountpoint); err != nil { + ErrorMessage("Mounts", err, struct{}{}) + return + } + + StopLoading("Unmounted" + mountpoint) + + App.QueueUpdateDraw(func() { + m.formUI.ManagerTable.RemoveRow(row) + m.formUI.ManagerTable.Select(row, 0) + + m.updateButtons("manager") + }) + }(selectedRow, mountedOn) +} + +// managerUnmountAll unmounts all the mountpoints. +func (m *MountsUI) managerUnmountAll() { + go func() { + if !m.formUI.wizardLock.TryAcquire(1) { + return + } + defer m.formUI.wizardLock.Release(1) + + if !ConfirmInput("Unmount all mountpoints? (y/n)") { + return + } + + StartLoading("Unmounting all mountpoints") + + if err := rcfns.UnmountAll(); err != nil { + ErrorMessage("Mounts", err, struct{}{}) + return + } + + StopLoading("Unmounted all mountpoints") + + App.QueueUpdateDraw(func() { + for row := 1; row < m.formUI.ManagerTable.GetRowCount(); row++ { + m.formUI.ManagerTable.RemoveRow(row) + } + + m.updateButtons("manager") + }) + }() +} + +// wizardNext moves to the next page of the form. +func (m *MountsUI) wizardNext() { + SwitchTabView(false) + App.SetFocus(m.formUI.WizardPages) +} + +// wizardPrevious moves to the previous page of the form. +func (m *MountsUI) wizardPrevious() { + SwitchTabView(true) + App.SetFocus(m.formUI.WizardPages) +} + +// wizardCreateMount creates a mountpoint. +func (m *MountsUI) wizardCreateMount() { + go func() { + if !m.formUI.wizardLock.TryAcquire(1) { + return + } + defer m.formUI.wizardLock.Release(1) + + var fs, mountPoint string + + if f, ok := m.formUI.WizardData["fs"].(string); ok && f != "" { + fs = f + } else { + ErrorMessage("Mounts", fmt.Errorf("FS is not specified")) + return + } + + if m, ok := m.formUI.WizardData["mountPoint"].(string); ok && m != "" { + mountPoint = m + } else { + ErrorMessage("Mounts", fmt.Errorf("Mount Point is not specified")) + return + } + + loadText := fs + " on " + mountPoint + + StartLoading("Mounting " + loadText) + + if err := rcfns.CreateMount(parseDataMap(m.formUI.WizardData)); err != nil { + ErrorMessage("Mounts", err, struct{}{}) + return + } + + StopLoading("Mounted " + loadText) + + m.wizardExit(false) + }() +} + +// wizardCancel asks for confirmation before exiting the wizard. +func (m *MountsUI) wizardCancel() { + m.wizardExit(true) +} + +// wizardExit exits the wizard. +func (m *MountsUI) wizardExit(confirm bool, page ...string) { + go func() { + if confirm && !ConfirmInput("Cancel configuration editing (y/n)?") { + return + } + + m.clearWizardData() + + if page != nil { + App.QueueUpdateDraw(func() { + InitViewByName(page[0]) + }) + + return + } + + App.QueueUpdateDraw(func() { + m.formUI.ManagerPages.SwitchToPage("manager") + }) + + m.listMounts() + }() +} + +// setupWizard sets up the mounts wizard. +func (m *MountsUI) setupWizard() { + StartLoading("Loading mountpoints") + + mountHelp, err := rcfns.GetMountOptions() + if err != nil { + ErrorMessage("Mounts", err, struct{}{}) + return + } + + StopLoading() + + for _, opt := range mountHelp { + var page string + var formItem *FormWidget + + if strings.HasPrefix(opt.OptionType, "main") { + page = "main" + } else { + page = opt.OptionType + } + + name := strings.Title(strcase.ToDelimited(opt.Name, ' ')) + + switch { + case opt.ValueType == "bool": + formItem = GetFormCheckBox( + name, + m.setWizardData, m.updateHelp, + m.getWizardData(name), + ) + + case opt.Options != nil: + optionData := map[string]string{} + + for _, o := range opt.Options { + optionData[o] = "" + } + + formItem = GetFormList( + name, optionData, true, opt.Name == "FS", + m.setWizardData, m.updateHelp, nil, + m.getWizardData(name), + ) + + default: + formItem = GetFormInputField( + name, true, false, + m.setWizardData, m.updateHelp, + m.getWizardData(name), + ) + } + + if strings.HasSuffix(opt.OptionType, "required") { + formItem.SetRequired() + } + + if formItem != nil { + App.QueueUpdateDraw(func() { + m.formUI.WizardForms[page].AddFormItem(formItem) + }) + } + } + + App.QueueUpdateDraw(func() { + SetupTabs( + mountWizardTabs, tview.AlignCenter, + func(reverse bool) bool { + return true + }, + func(tab string) { + m.formUI.WizardPages.SwitchToPage(tab) + }, + ) + + m.formUI.ManagerPages.SwitchToPage("wizard") + m.formUI.WizardPages.SwitchToPage("main") + + App.SetFocus(m.formUI.WizardPages) + }) +} + +// updateButtons updates the buttons according to the page/form displayed. +func (m *MountsUI) updateButtons(page string) { + if pg, _ := m.formUI.ManagerPages.GetFrontPage(); pg != page { + return + } + + switch page { + case "manager": + UpdateButtonView(m.formUI.ManagerButtons, func(label string) bool { + if m.formUI.ManagerTable.GetRowCount() == 0 { + return false + } + + if m.formUI.ManagerTable.GetRowCount() <= 1 && label != "New Mount" { + return false + } + + return true + }) + + case "wizard": + UpdateButtonView(m.formUI.WizardButtons, func(label string) bool { + page, _ := m.formUI.WizardPages.GetFrontPage() + + switch page { + case "main": + if label == "Previous" { + return false + } + + case "vfsOpt": + if label == "Next" { + return false + } + } + + return true + }) + } +} + +// updateHelp updates the help information for each item within the form. +func (m *MountsUI) updateHelp(field string) { + mh := rcfns.GetMountHelp(strcase.ToCamel(field)) + name := strings.Title(strcase.ToDelimited(mh.Name, ' ')) + + text := "[::bu]" + name + "[-:-:-]" + if strings.HasSuffix(mh.OptionType, "required") { + text += " (Required)" + } + + text += "\n" + mh.Help + + m.formUI.WizardHelp.SetText(text) + m.formUI.WizardHelp.ScrollToBeginning() +} + +// getWizardData returns the stored form data. +func (m *MountsUI) getWizardData(key string) string { + m.formUI.dataLock.RLock() + defer m.formUI.dataLock.RUnlock() + + dataKey, dataMap := m.getDataMap(key) + + return modifyDataMap(dataMap, dataKey, nil, false) +} + +// setWizardData stores a form item's name(key) and its value. +func (m *MountsUI) setWizardData(key string, value interface{}) { + m.formUI.dataLock.Lock() + defer m.formUI.dataLock.Unlock() + + dataKey, dataMap := m.getDataMap(key) + + modifyDataMap(dataMap, dataKey, value, true) +} + +// clearWizardData clears the form data. +func (m *MountsUI) clearWizardData() { + m.formUI.dataLock.Lock() + defer m.formUI.dataLock.Unlock() + + m.formUI.WizardData = make(map[string]interface{}) + + for _, form := range m.formUI.WizardForms { + form.Clear(true) + } +} + +// getDataMap returns either the nested data for mount or vfs options, or +// it returns the wizard map itself for other(main) options. +func (m *MountsUI) getDataMap(key string) (string, map[string]interface{}) { + dataMap := m.formUI.WizardData + + if page, _ := m.formUI.WizardPages.GetFrontPage(); page == "mountOpt" || page == "vfsOpt" { + key = strcase.ToCamel(key) + + v, ok := m.formUI.WizardData[page] + if !ok || ok && v == nil { + dataMap[page] = make(map[string]interface{}) + } + + dataMap = dataMap[page].(map[string]interface{}) + } else if page == "main" { + if strings.ToLower(key) == "fs" { + key = "fs" + } else { + key = strcase.ToLowerCamel(key) + } + } + + return key, dataMap +} diff --git a/ui/status.go b/ui/status.go new file mode 100644 index 0000000..437f762 --- /dev/null +++ b/ui/status.go @@ -0,0 +1,345 @@ +package ui + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/darkhz/tview" + "github.com/darkhz/tvxwidgets" + "github.com/gdamore/tcell/v2" +) + +// Status stores the layout for a status bar. +type Status struct { + Pages *tview.Pages + + Input *tview.InputField + Message *tview.TextView + + LoadText *tview.TextView + LoadSpinner *tvxwidgets.Spinner + LoadStatus bool + + msgchan chan message + loadchan chan message + messageLock sync.RWMutex +} + +// message stores the status message. +type message struct { + text string + persist bool +} + +var ( + status Status + + sctx context.Context + scancel context.CancelFunc +) + +// statusBar sets up the statusbar. +func statusBar() *tview.Pages { + status.Pages = tview.NewPages() + status.Pages.SetBackgroundColor(tcell.ColorDefault) + + status.Input = tview.NewInputField() + status.Input.SetLabelColor(tcell.ColorWhite) + status.Input.SetBackgroundColor(tcell.ColorDefault) + status.Input.SetFieldBackgroundColor(tcell.ColorDefault) + + status.Message = tview.NewTextView() + status.Message.SetDynamicColors(true) + status.Message.SetBackgroundColor(tcell.ColorDefault) + + status.LoadSpinner = tvxwidgets.NewSpinner() + status.LoadSpinner.SetStyle(tvxwidgets.SpinnerDotsCircling) + status.LoadSpinner.SetBackgroundColor(tcell.ColorDefault) + + status.LoadText = tview.NewTextView() + status.LoadText.SetDynamicColors(true) + status.LoadText.SetBackgroundColor(tcell.ColorDefault) + + loadingFlex := tview.NewFlex(). + SetDirection(tview.FlexColumn). + AddItem(status.LoadSpinner, 1, 0, false). + AddItem(nil, 1, 0, false). + AddItem(status.LoadText, 0, 1, false) + + status.Pages.AddPage("input", status.Input, true, true) + status.Pages.AddPage("loading", loadingFlex, true, false) + status.Pages.AddPage("messages", status.Message, true, false) + + status.Pages.SwitchToPage("messages") + + status.msgchan = make(chan message, 10) + status.loadchan = make(chan message, 10) + sctx, scancel = context.WithCancel(context.Background()) + + go startStatus() + go startLoad() + + return status.Pages +} + +// StopStatus stops the message event loop. +func stopStatus() { + scancel() + close(status.msgchan) +} + +// SetInput sets the inputfield label and returns the input text. +func SetInput(label string, multichar ...struct{}) string { + entered := make(chan bool, 1) + + go func(ch chan bool) { + send := func(state bool) { + ch <- state + + _, item := MainPage.GetFrontPage() + App.SetFocus(item) + + CloseStatusInput() + } + + App.QueueUpdateDraw(func() { + input := OpenStatusInput(label) + input.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyEscape, tcell.KeyEnter: + send(event.Key() == tcell.KeyEnter) + } + + return event + }) + if multichar != nil { + input.SetAcceptanceFunc(nil) + } else { + input.SetAcceptanceFunc(tview.InputFieldMaxLength(1)) + } + + App.SetFocus(input) + }) + }(entered) + + hasEntered := <-entered + if !hasEntered { + return "" + } + + return status.Input.GetText() +} + +// InfoMessage sends an info message to the status bar. +func InfoMessage(text string, persist bool) { + if text != "" { + text = "[white::b][I[] " + text + } + + select { + case status.msgchan <- message{text, persist}: + return + + default: + } +} + +// ErrorMessage sends an error message to the status bar. +func ErrorMessage(page string, err error, stopLoading ...struct{}) { + if stopLoading != nil { + StopLoading() + } + + if errors.Is(err, context.Canceled) { + err = fmt.Errorf("Operation cancelled") + } + + select { + case status.msgchan <- message{ + "[red::b][E[] " + page + ": " + err.Error(), false, + }: + return + + default: + } +} + +// StartLoading starts the loading spinner. +func StartLoading(text string) { + select { + case status.loadchan <- message{text, true}: + + default: + } +} + +// StopLoading stops the loading spinner. +func StopLoading(text ...string) { + var infoText string + + if text != nil { + infoText = text[0] + } + + select { + case status.loadchan <- message{infoText, false}: + + default: + } +} + +// OpenStatusInput opens the input field within the status bar. +func OpenStatusInput(label string) *tview.InputField { + status.Input.SetText("") + status.Input.SetLabel("[::b]" + label + " ") + + status.Pages.SwitchToPage("input") + + return status.Input +} + +// CloseStatusInput closes the input field within the status bar. +func CloseStatusInput() { + var isLoading bool + + status.Input.SetChangedFunc(nil) + status.Input.SetInputCapture(nil) + status.Input.SetAcceptanceFunc(nil) + + status.messageLock.Lock() + isLoading = status.LoadStatus + status.messageLock.Unlock() + + if isLoading { + status.Pages.SwitchToPage("loading") + return + } + + status.Pages.SwitchToPage("messages") +} + +// ConfirmInput returns whether the user has pressed 'y' to confirm. +func ConfirmInput(label string) bool { + reply := SetInput(label) + + return reply == "y" +} + +// startLoad starts the spinner event loop. +func startLoad() { + var spin bool + + t := time.NewTicker(100 * time.Millisecond) + defer t.Stop() + + for { + select { + case load := <-status.loadchan: + spin = load.persist + + status.messageLock.Lock() + status.LoadStatus = load.persist + status.messageLock.Unlock() + + App.QueueUpdateDraw(func() { + if load.persist { + switchStatusPage("loading") + status.LoadText.SetText("[yellow::b]" + load.text) + } else { + status.LoadSpinner.Reset() + switchStatusPage("messages") + + if load.text != "" { + InfoMessage(load.text, false) + } + } + }) + + case <-t.C: + if !spin { + continue + } + + App.QueueUpdateDraw(func() { + status.LoadSpinner.Pulse() + }) + } + } +} + +// startStatus starts the message event loop. +func startStatus() { + var text string + var cleared, loadPage bool + + t := time.NewTicker(2 * time.Second) + defer t.Stop() + + for { + select { + case <-sctx.Done(): + return + + case msg, ok := <-status.msgchan: + if !ok { + return + } + + t.Reset(2 * time.Second) + + cleared = false + + if msg.persist { + text = msg.text + } + + if !msg.persist && text != "" { + text = "" + } + + App.QueueUpdateDraw(func() { + if page, _ := status.Pages.GetFrontPage(); page == "loading" { + loadPage = true + switchStatusPage("messages") + } + + status.Message.SetText(msg.text) + }) + + case <-t.C: + App.QueueUpdateDraw(func() { + var isLoading bool + + if loadPage { + loadPage = false + + status.messageLock.RLock() + isLoading = status.LoadStatus + status.messageLock.RUnlock() + + if isLoading { + switchStatusPage("loading") + } + } + + if !cleared { + cleared = true + status.Message.SetText(text) + } + }) + } + } +} + +// switchStatusPage switches the status page only if the status bar +// input field is not in focus. +func switchStatusPage(page string) { + if p, _ := status.Pages.GetFrontPage(); p == "input" { + return + } + + status.Pages.SwitchToPage(page) +} diff --git a/ui/suspend.go b/ui/suspend.go new file mode 100644 index 0000000..2cc25f3 --- /dev/null +++ b/ui/suspend.go @@ -0,0 +1,17 @@ +//go:build !windows +// +build !windows + +package ui + +import ( + "syscall" + + "github.com/gdamore/tcell/v2" +) + +// SuspendApp suspends the application. +func SuspendApp(t tcell.Screen) { + t.Suspend() + syscall.Kill(syscall.Getpid(), syscall.SIGSTOP) + t.Resume() +} diff --git a/ui/suspend_windows.go b/ui/suspend_windows.go new file mode 100644 index 0000000..faf261a --- /dev/null +++ b/ui/suspend_windows.go @@ -0,0 +1,10 @@ +//go:build windows +// +build windows + +package ui + +import "github.com/gdamore/tcell/v2" + +// SuspendApp is disabled in Windows. +func SuspendApp(t tcell.Screen) { +} diff --git a/ui/tabs.go b/ui/tabs.go new file mode 100644 index 0000000..096461e --- /dev/null +++ b/ui/tabs.go @@ -0,0 +1,136 @@ +package ui + +import ( + "strings" + + "github.com/darkhz/tview" + "github.com/gdamore/tcell/v2" +) + +// TabDisplay stores a layout to display tabs. +type TabDisplay struct { + View *tview.TextView + + switcherFunc func(reverse bool) bool +} + +var tabDisplay TabDisplay + +// SetupTabs sets up the tabs within the display area. +func SetupTabs(tabs string, alignTabView int, switchFunc func(reverse bool) bool, changedFunc func(tab string)) { + if tabDisplay.View != nil { + goto FinishSwitcherSetup + } + + tabDisplay.View = tview.NewTextView() + tabDisplay.View.SetRegions(true) + tabDisplay.View.SetDynamicColors(true) + tabDisplay.View.SetTextAlign(alignTabView) + tabDisplay.View.SetBackgroundColor(tcell.ColorDefault) + tabDisplay.View.SetMouseCapture(func(action tview.MouseAction, event *tcell.EventMouse) (tview.MouseAction, *tcell.EventMouse) { + return action, nil + }) + +FinishSwitcherSetup: + tabDisplay.View.SetHighlightedFunc(func(added, removed, remaining []string) { + if added == nil { + return + } + + changedFunc(added[0]) + }) + + tabDisplay.switcherFunc = switchFunc + SetTabs(tabs) +} + +// TabView returns the tab display area. +func TabView() *tview.TextView { + if tabDisplay.View == nil { + SetupTabs( + "", tview.AlignCenter, + func(reverse bool) bool { return false }, + func(tab string) {}, + ) + } + + return tabDisplay.View +} + +// SetTabs sets the provided tabs. +func SetTabs(tabs string) { + tabDisplay.View.SetText(tabs) + + regions := tabDisplay.View.GetRegionIDs() + if regions != nil { + tabDisplay.View.Highlight(regions[0]) + } +} + +// SelectTab selects a tab. +func SelectTab(tab string) { + tabDisplay.View.Highlight(tab) +} + +// SwitchTabView cycles through the tabs. +func SwitchTabView(reverse bool, view ...*tview.TextView) string { + var currentView int + + if view == nil && tabDisplay.switcherFunc != nil && !tabDisplay.switcherFunc(reverse) { + return "" + } + + textView := tabDisplay.View + if view != nil { + textView = view[0] + } + + regions := textView.GetRegionIDs() + if len(regions) == 0 { + return "" + } + + if highlights := textView.GetHighlights(); highlights != nil { + for i, region := range regions { + if region == highlights[0] { + currentView = i + } + } + + if reverse { + currentView-- + } else { + currentView++ + } + } + + if currentView >= len(regions) { + currentView = 0 + } else if currentView < 0 { + currentView = len(regions) - 1 + } + + textView.Highlight(regions[currentView]) + textView.ScrollToHighlight() + + return regions[currentView] +} + +// HasTab checks if a tab exists. +func HasTab(tab string) bool { + return strings.Index(tabDisplay.View.GetText(false), tab) != -1 +} + +// tabEventHandler handles key events for the tab display area. +func tabEventHandler(event *tcell.EventKey) bool { + if event.Modifiers() != tcell.ModShift { + return false + } + + switch event.Key() { + case tcell.KeyLeft, tcell.KeyRight: + SwitchTabView(event.Key() == tcell.KeyLeft) + } + + return true +} diff --git a/ui/ui.go b/ui/ui.go new file mode 100644 index 0000000..5f2700f --- /dev/null +++ b/ui/ui.go @@ -0,0 +1,133 @@ +package ui + +import ( + "strings" + + "github.com/darkhz/rclone-tui/cmd" + "github.com/darkhz/rclone-tui/rclone" + "github.com/darkhz/tview" + "github.com/gdamore/tcell/v2" +) + +var ( + App *tview.Application + + MainPage *tview.Pages + UIPages *tview.Pages + + width, height int + appSuspend bool +) + +// SetupUI sets up the user interface. +func SetupUI() { + App = tview.NewApplication() + UIPages = tview.NewPages() + MainPage = tview.NewPages() + + flex := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(ViewTitle(), 1, 0, false). + AddItem(nil, 1, 0, false). + AddItem(MainPage, 0, 1, true) + flex.SetBackgroundColor(tcell.ColorDefault) + + UIPages.AddPage("main", flex, true, true) + UIPages.SetBackgroundColor(tcell.ColorDefault) + + uiLayout := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(UIPages, 0, 1, true). + AddItem(statusBar(), 1, 0, false) + uiLayout.SetBackgroundColor(tcell.ColorDefault) + + AddViews() + if page := cmd.GetConfigProperty("page"); page != "" { + InitViewByName(strings.Title(page)) + } else { + InitViewByName("Dashboard") + } + + go JobMonitor() + + App.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyCtrlC: + MainPage.InputHandler()(event, nil) + return nil + + case tcell.KeyCtrlJ: + openJobManager() + + case tcell.KeyCtrlN: + ShowViews() + + case tcell.KeyCtrlX: + if isOpen() { + goto Event + } + + rclone.CancelClientContext() + + if currentView != nil { + page := currentView.Focused() + if job, err := rclone.GetLatestJob("UI:" + page); err == nil { + job.Cancel() + } + } + + case tcell.KeyCtrlZ: + appSuspend = true + + case tcell.KeyCtrlQ: + go StopUI() + } + + Event: + return event + }) + + App.SetBeforeDrawFunc(func(t tcell.Screen) bool { + suspendUI(t) + + return false + }) + + App.SetAfterDrawFunc(func(screen tcell.Screen) { + w, h := screen.Size() + + if w == width && h == height { + return + } + + width, height = w, h + + resizeModal() + }) + + if err := App.SetRoot(uiLayout, true).SetFocus(flex).Run(); err != nil { + panic(err) + } +} + +// StopUI asks for confirmation before stopping the user interface. +func StopUI() { + if !ConfirmInput("Quit (y/n)?") { + return + } + + App.QueueUpdateDraw(func() { + App.Stop() + }) +} + +// suspendUI suspends the application. +func suspendUI(t tcell.Screen) { + if !appSuspend { + return + } + + SuspendApp(t) + + appSuspend = false +} diff --git a/ui/utils.go b/ui/utils.go new file mode 100644 index 0000000..edbe2b0 --- /dev/null +++ b/ui/utils.go @@ -0,0 +1,212 @@ +package ui + +import ( + "math" + "sort" + "strconv" + "strings" + "time" + + rcfns "github.com/darkhz/rclone-tui/rclone/operations" +) + +// MatchProvider returns true if provider matches the providerConfig string. +// Taken from: https://github.com/rclone/rclone/blob/master/fs/backend_config.go#L542 +func MatchProvider(providerConfig, provider string) bool { + if providerConfig == "" || provider == "" { + return true + } + negate := false + if strings.HasPrefix(providerConfig, "!") { + providerConfig = providerConfig[1:] + negate = true + } + providers := strings.Split(providerConfig, ",") + matched := false + for _, p := range providers { + if p == provider { + matched = true + break + } + } + if negate { + return !matched + } + return matched +} + +// ReadableString parses d into a human-readable duration. +// Taken from: https://github.com/rclone/rclone/blob/master/fs/parseduration.go#L132 +func ReadableString(d time.Duration) string { + switch d { + case 0: + return "0s" + } + + readableString := "" + + // Check for minus durations. + if d < 0 { + readableString += "-" + } + + duration := time.Duration(math.Abs(float64(d))) + + // Convert duration. + seconds := int64(duration.Seconds()) % 60 + minutes := int64(duration.Minutes()) % 60 + hours := int64(duration.Hours()) % 24 + days := int64(duration/(24*time.Hour)) % 365 % 7 + + // Edge case between 364 and 365 days. + // We need to calculate weeks from what is left from years + leftYearDays := int64(duration/(24*time.Hour)) % 365 + weeks := leftYearDays / 7 + if leftYearDays >= 364 && leftYearDays < 365 { + weeks = 52 + } + + years := int64(duration/(24*time.Hour)) / 365 + milliseconds := int64(duration/time.Millisecond) - + (seconds * 1000) - (minutes * 60000) - (hours * 3600000) - + (days * 86400000) - (weeks * 604800000) - (years * 31536000000) + + // Create a map of the converted duration time. + durationMap := map[string]int64{ + "ms": milliseconds, + "s": seconds, + "m": minutes, + "h": hours, + "d": days, + "w": weeks, + "y": years, + } + + // Construct duration string. + for _, u := range [...]string{"y", "w", "d", "h", "m", "s", "ms"} { + v := durationMap[u] + strval := strconv.FormatInt(v, 10) + if v == 0 { + continue + } + readableString += strval + u + " " + } + + return readableString +} + +// Normalize returns a set of numbers on the interval [0,1] for a given set of inputs. +// Adapted from: https://github.com/blend/go-sdk/blob/master/mathutil/normalize.go#L13 +func Normalize(values ...float64) []float64 { + var max float64 + + if len(values) == 0 { + return values + } + + for _, v := range values { + if v > max { + max = v + } + } + + output := make([]float64, len(values)) + for x, v := range values { + output[x] = RoundDown(v/max, 0.0001) + } + return output +} + +// RoundDown rounds down to a given roundTo value. +// Taken from: https://github.com/blend/go-sdk/blob/master/mathutil/round.go#L18 +func RoundDown(value, roundTo float64) float64 { + d1 := math.Floor(value / roundTo) + return d1 * roundTo +} + +// parseDataMap parses the user-input form data into an rclone parseable format. +func parseDataMap(origData map[string]interface{}) map[string]interface{} { + data := make(map[string]interface{}) + + for key, value := range origData { + if submap, ok := value.(map[string]interface{}); ok { + data[key] = parseDataMap(submap) + continue + } + + if b, ok := value.(bool); ok { + data[key] = b + continue + } + + v, ok := value.(string) + if !ok { + continue + } + + if i, err := strconv.ParseInt(v, 10, 64); err == nil { + data[key] = i + } else if f, err := strconv.ParseFloat(v, 64); err == nil { + data[key] = f + } else { + data[key] = value + } + } + + return data +} + +// sortList sorts a list of directory entries according to the order and mode. +func sortList(listItems []rcfns.ListItem, asc bool, mode string) { + sort.Slice(listItems, func(i, j int) bool { + var a, b int + + if listItems[i].IsDir != listItems[j].IsDir { + return listItems[i].IsDir + } + + if asc { + a, b = i, j + } else { + a, b = j, i + } + + switch mode { + case "size": + return listItems[a].Size < listItems[b].Size + + case "modified": + return listItems[a].ModifiedTimeUnix < listItems[b].ModifiedTimeUnix + } + + return listItems[a].Name < listItems[b].Name + }) +} + +// modifyDataMap either returns the string representation of the data if set it false, or +// it sets the data within the map if set is true. +func modifyDataMap(datamap map[string]interface{}, key string, value interface{}, set bool) string { + if set { + if datamap == nil { + datamap = make(map[string]interface{}) + } + + datamap[key] = value + + return "" + } + + if datamap == nil { + return "" + } + + switch data := datamap[key].(type) { + case bool: + return strconv.FormatBool(data) + + case string: + return data + } + + return "" +} diff --git a/ui/views.go b/ui/views.go new file mode 100644 index 0000000..0329b9a --- /dev/null +++ b/ui/views.go @@ -0,0 +1,215 @@ +package ui + +import ( + "strconv" + "strings" + + "github.com/darkhz/rclone-tui/cmd" + "github.com/darkhz/rclone-tui/rclone" + "github.com/darkhz/tview" + "github.com/gdamore/tcell/v2" +) + +// View describes the layout for a view page. +type View interface { + Init() bool + + Exit(page string) bool + + Name() string + + Focused() string + + Layout() tview.Primitive +} + +// ViewBar stores a layout for the title bar. +type ViewBar struct { + Title *tview.TextView + + Flex *tview.Flex + ConnIndicator *tview.TextView + JobIndicator *tview.TextView +} + +var ( + viewBar ViewBar + + currentView View + views = []View{&dashboard, &configuration, &explorer, &mounts} +) + +// ViewTitle returns the title bar. +func ViewTitle() tview.Primitive { + viewBar.Title = tview.NewTextView() + viewBar.Title.SetDynamicColors(true) + viewBar.Title.SetBackgroundColor(tcell.ColorDefault) + + viewBar.ConnIndicator = tview.NewTextView() + viewBar.ConnIndicator.SetDynamicColors(true) + viewBar.ConnIndicator.SetText("Disconnected") + viewBar.ConnIndicator.SetTextAlign(tview.AlignCenter) + viewBar.ConnIndicator.SetBackgroundColor(tcell.ColorDefault) + + viewBar.JobIndicator = tview.NewTextView() + viewBar.JobIndicator.SetDynamicColors(true) + viewBar.JobIndicator.SetTextAlign(tview.AlignCenter) + viewBar.JobIndicator.SetBackgroundColor(tcell.ColorDefault) + + go updateIndicators() + + viewBar.Flex = tview.NewFlex(). + SetDirection(tview.FlexColumn). + AddItem(viewBar.Title, 0, 1, false). + AddItem(viewBar.JobIndicator, 10, 0, false). + AddItem(viewBar.ConnIndicator, 20, 0, false) + + return viewBar.Flex +} + +// AddViews registers the views and their layouts. +func AddViews() { + for _, view := range views { + MainPage.AddPage(view.Name(), view.Layout(), true, false) + } +} + +// ShowViews shows a modal to select a view. +func ShowViews() { + if page, _ := MainPage.GetFrontPage(); page == "show_view" { + return + } + + modal := NewModal("show_view", "Select page", false, false, len(views)+6, 60) + + modal.Table.SetSelectorWrap(false) + modal.Table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyEnter: + row, _ := modal.Table.GetSelection() + view := modal.Table.GetCell(row, 0).GetReference().(View) + + SetView(view) + + fallthrough + + case tcell.KeyEscape: + modal.Exit() + + } + + return event + }) + + for row, view := range views { + modal.Table.SetCell(row, 0, tview.NewTableCell(strings.Title(view.Name())). + SetExpansion(1). + SetReference(view). + SetAlign(tview.AlignCenter), + ) + } + + modal.Table.Select(0, 0) + + modal.Show() +} + +// SetView brings the provided view into focus. +func SetView(view View, noexit ...struct{}) { + if len(rclone.GetSessions()) == 0 { + LoginScreen() + return + } + + if userInfo := cmd.GetConfigProperty("userInfo"); userInfo != "" { + SetViewHostname(userInfo) + cmd.AddConfigProperty("userInfo", "") + } + + if noexit == nil && !currentView.Exit(view.Name()) { + return + } + + if !view.Init() { + return + } + + currentView = view + + MainPage.SwitchToPage(view.Name()) + SetViewTitle(view.Name()) + + StopLoading() +} + +// SetViewTitle sets the title for the current view. +func SetViewTitle(title string, appendToCurrent ...struct{}) { + var currentTitle string + + title = strings.Title(title) + if appendToCurrent != nil { + currentTitle = viewBar.Title.GetText(true) + " " + title + } else { + currentTitle = "Rclone " + title + } + + viewBar.Title.SetText("[::bu]" + currentTitle + "") +} + +// SetViewHostname sets the user and host information within the title bar. +func SetViewHostname(userInfo string) { + viewBar.ConnIndicator.SetText("[::b]" + userInfo) + viewBar.Flex.ResizeItem(viewBar.ConnIndicator, len(userInfo)+2, 0) +} + +// InitViewByName searches for a view by name and sets it. +func InitViewByName(name string) { + for _, view := range views { + if view.Name() == name { + SetView(view, struct{}{}) + return + } + } +} + +// updateIndicators updates the connectivity/job count indicators. +func updateIndicators() { + for { + select { + case info := <-jobIndicator: + App.QueueUpdateDraw(func() { + var text string + var color tcell.Color + + jobCount := info.JobCount + if jobCount < 0 { + return + } + if jobCount == 0 { + text = "" + color = tcell.ColorDefault + } else { + text = strconv.FormatInt(jobCount, 10) + color = tcell.ColorYellow + } + + viewBar.JobIndicator.SetText("[::b]" + text) + viewBar.JobIndicator.SetBackgroundColor(color) + viewBar.JobIndicator.SetTextColor(tcell.Color16) + }) + + case connected := <-rclone.PollConnection(false): + App.QueueUpdateDraw(func() { + var color tcell.Color + + if connected { + color = tcell.ColorGreen + } else { + color = tcell.ColorRed + } + + viewBar.ConnIndicator.SetBackgroundColor(color) + }) + } + } +}