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 @@
+[](https://goreportcard.com/report/github.com/darkhz/rclone-tui)
+
+[](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)
+ })
+ }
+ }
+}