VA Log Configuration Commands, etc...

* 🪵 Implemented an Improved Global logger solution
* 🥈 Removed duplicate APIClient inits
* 🐛 Corrected an issue with the payload for spconfig import
* 🚤 Significantly improved the Speed of Parsing log files with sail va parse
* 🎢 Improved error handling for all VA commands
* 💻 Added a VA List Command, along with Get and Set commands for VA Log Config
This commit is contained in:
luke-hagar-sp
2023-05-16 09:01:34 -05:00
parent 10d8ac3be5
commit c4c15361d6
2655 changed files with 685 additions and 863023 deletions

2
.gitignore vendored
View File

@@ -9,7 +9,7 @@ spconfig-exports
# CLI binary
sailpoint-cli
vendor
data
.DS_Store
assets/demo_create.json

View File

@@ -21,7 +21,7 @@ const (
func NewConnCmd() *cobra.Command {
conn := &cobra.Command{
Use: "connectors",
Short: "manage connectors",
Short: "Manage connectors",
Aliases: []string{"conn"},
Run: func(command *cobra.Command, args []string) {
_, _ = fmt.Fprintf(command.OutOrStdout(), command.UsageString())

View File

@@ -1,6 +1,7 @@
package environment
import (
"github.com/charmbracelet/log"
"github.com/sailpoint-oss/sailpoint-cli/internal/config"
"github.com/sailpoint-oss/sailpoint-cli/internal/terminal"
"github.com/sailpoint-oss/sailpoint-cli/internal/tui"
@@ -46,20 +47,20 @@ func NewEnvironmentCommand() *cobra.Command {
if foundEnv, exists := environments[env]; exists && !overwrite && config.GetTenantUrl() != "" && config.GetBaseUrl() != "" {
if show {
config.Log.Warn("You are about to Print out the Environment", "env", env)
log.Warn("You are about to Print out the Environment", "env", env)
res := terminal.InputPrompt("Press Enter to continue")
if res == "" {
util.PrettyPrint(foundEnv)
}
} else if erase {
config.Log.Warn("You are about to Erase the Environment", "env", env)
log.Warn("You are about to Erase the Environment", "env", env)
res := terminal.InputPrompt("Press Enter to continue")
if res == "" {
viper.Set("environments."+config.GetActiveEnvironment(), config.Environment{})
}
} else {
config.Log.Info("Environment changed", "env", env)
log.Info("Environment changed", "env", env)
}
} else {
@@ -72,7 +73,7 @@ func NewEnvironmentCommand() *cobra.Command {
}
} else {
config.Log.Warn("No Environment Provided")
log.Warn("No Environment Provided")
}
return nil

View File

@@ -8,6 +8,7 @@ import (
"os"
"strings"
"github.com/charmbracelet/log"
v3 "github.com/sailpoint-oss/golang-sdk/v3"
"github.com/sailpoint-oss/sailpoint-cli/internal/config"
"github.com/sailpoint-oss/sailpoint-cli/internal/templates"
@@ -57,13 +58,13 @@ func newTemplateCmd() *cobra.Command {
return fmt.Errorf("no template specified")
}
config.Log.Info("Selected Template", "template", template)
log.Info("Selected Template", "template", template)
matches := types.Filter(reportTemplates, func(st templates.ReportTemplate) bool { return st.Name == template })
if len(matches) < 1 {
return fmt.Errorf("no template matches for %s", template)
} else if len(matches) > 1 {
config.Log.Warn("multiple template matches, the first match will be used", "template", template)
log.Warn("multiple template matches, the first match will be used", "template", template)
}
selectedTemplate = matches[0]
varCount := len(selectedTemplate.Variables)

View File

@@ -4,6 +4,7 @@ package search
import (
"fmt"
"github.com/charmbracelet/log"
"github.com/sailpoint-oss/sailpoint-cli/internal/config"
"github.com/sailpoint-oss/sailpoint-cli/internal/search"
"github.com/spf13/cobra"
@@ -42,7 +43,7 @@ func newQueryCmd() *cobra.Command {
return err
}
config.Log.Info("Performing Search", "Query", searchQuery, "Indices", indices)
log.Info("Performing Search", "Query", searchQuery, "Indices", indices)
formattedResponse, err := search.PerformSearch(*apiClient, searchObj)
if err != nil {

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"strings"
"github.com/charmbracelet/log"
"github.com/sailpoint-oss/sailpoint-cli/internal/config"
"github.com/sailpoint-oss/sailpoint-cli/internal/search"
"github.com/sailpoint-oss/sailpoint-cli/internal/templates"
@@ -55,13 +56,13 @@ func newTemplateCmd() *cobra.Command {
return fmt.Errorf("no template specified")
}
config.Log.Info("Selected Template", "template", template)
log.Info("Selected Template", "template", template)
matches := types.Filter(searchTemplates, func(st templates.SearchTemplate) bool { return st.Name == template })
if len(matches) < 1 {
return fmt.Errorf("no template matches for %s", template)
} else if len(matches) > 1 {
config.Log.Warn("multiple template matches, the first match will be used", "template", template)
log.Warn("multiple template matches, the first match will be used", "template", template)
}
selectedTemplate = matches[0]
varCount := len(selectedTemplate.Variables)
@@ -77,7 +78,7 @@ func newTemplateCmd() *cobra.Command {
}
}
config.Log.Info("Performing Search", "Query", selectedTemplate.SearchQuery.Query.GetQuery(), "Indicies", selectedTemplate.SearchQuery.Indices)
log.Info("Performing Search", "Query", selectedTemplate.SearchQuery.Query.GetQuery(), "Indicies", selectedTemplate.SearchQuery.Indices)
formattedResponse, err := search.PerformSearch(*apiClient, selectedTemplate.SearchQuery)
if err != nil {

View File

@@ -3,6 +3,7 @@ package set
import (
"strings"
"github.com/charmbracelet/log"
"github.com/sailpoint-oss/sailpoint-cli/internal/config"
"github.com/sailpoint-oss/sailpoint-cli/internal/tui"
"github.com/spf13/cobra"
@@ -46,15 +47,15 @@ func newAuthCommand() *cobra.Command {
case "pat":
config.SetAuthType("pat")
config.Log.Info("Authentication method set to PAT")
log.Info("Authentication method set to PAT")
case "oauth":
config.SetAuthType("oauth")
config.Log.Info("Authentication method set to OAuth")
log.Info("Authentication method set to OAuth")
default:
config.Log.Error("Invalid Selection")
log.Error("Invalid Selection")
}
return nil

View File

@@ -3,7 +3,7 @@ package set
import (
"strings"
"github.com/sailpoint-oss/sailpoint-cli/internal/config"
"github.com/charmbracelet/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
@@ -20,18 +20,18 @@ func newDebugCommand() *cobra.Command {
switch strings.ToLower(args[0]) {
case "enable":
viper.Set("debug", true)
config.Log.Info("Debug Enabled")
log.Info("Debug Enabled")
case "true":
viper.Set("debug", true)
config.Log.Info("Debug Enabled")
log.Info("Debug Enabled")
case "disable":
viper.Set("debug", false)
config.Log.Info("Debug Disabled")
log.Info("Debug Disabled")
case "false":
viper.Set("debug", false)
config.Log.Info("Debug Disabled")
log.Info("Debug Disabled")
default:
config.Log.Error("Invalid Selection")
log.Error("Invalid Selection")
}
return nil

View File

@@ -1,6 +1,7 @@
package set
import (
"github.com/charmbracelet/log"
"github.com/sailpoint-oss/sailpoint-cli/internal/config"
"github.com/spf13/cobra"
)
@@ -17,7 +18,7 @@ func newExportTemplateCommand() *cobra.Command {
filePath := args[0]
if filePath == "" {
config.Log.Error("File Path Cannot Be Blank")
log.Error("File Path Cannot Be Blank")
}
config.SetCustomExportTemplatePath(filePath)

View File

@@ -1,6 +1,7 @@
package set
import (
"github.com/charmbracelet/log"
"github.com/sailpoint-oss/sailpoint-cli/internal/config"
"github.com/spf13/cobra"
)
@@ -17,7 +18,7 @@ func newSearchTemplateCommand() *cobra.Command {
filePath := args[0]
if filePath == "" {
config.Log.Error("File Path Cannot Be Blank")
log.Error("File Path Cannot Be Blank")
}
config.SetCustomSearchTemplatePath(filePath)

View File

@@ -2,6 +2,7 @@
package spconfig
import (
"github.com/charmbracelet/log"
"github.com/sailpoint-oss/sailpoint-cli/internal/config"
"github.com/sailpoint-oss/sailpoint-cli/internal/spconfig"
"github.com/spf13/cobra"
@@ -20,30 +21,35 @@ func newDownloadCmd() *cobra.Command {
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
apiClient, err := config.InitAPIClient()
if err != nil {
return err
}
if len(importIDs) > 0 {
for i := 0; i < len(importIDs); i++ {
jobId := importIDs[i]
config.Log.Info("Checking Import Job", "JobID", jobId)
err := spconfig.DownloadImport(jobId, "spconfig-import-"+jobId+".json", folderPath)
log.Info("Checking Import Job", "JobID", jobId)
err := spconfig.DownloadImport(*apiClient, jobId, "spconfig-import-"+jobId+".json", folderPath)
if err != nil {
return err
}
}
} else {
config.Log.Info("No Import Job IDs provided")
log.Info("No Import Job IDs provided")
}
if len(exportIDs) > 0 {
for i := 0; i < len(exportIDs); i++ {
jobId := exportIDs[i]
config.Log.Info("Checking Export Job", "JobID", jobId)
err := spconfig.DownloadExport(jobId, "spconfig-export-"+jobId+".json", folderPath)
log.Info("Checking Export Job", "JobID", jobId)
err := spconfig.DownloadExport(*apiClient, jobId, "spconfig-export-"+jobId+".json", folderPath)
if err != nil {
return err
}
}
} else {
config.Log.Info("No Export Job IDs provided")
log.Info("No Export Job IDs provided")
}
return nil

View File

@@ -4,6 +4,7 @@ package spconfig
import (
"context"
"github.com/charmbracelet/log"
sailpointbetasdk "github.com/sailpoint-oss/golang-sdk/beta"
"github.com/sailpoint-oss/sailpoint-cli/internal/config"
"github.com/sailpoint-oss/sailpoint-cli/internal/spconfig"
@@ -44,8 +45,8 @@ func newExportCmd() *cobra.Command {
spconfig.PrintJob(*job)
if wait {
config.Log.Warn("Waiting for export task to complete")
spconfig.DownloadExport(job.JobId, "spconfig-export-"+job.JobId+".json", folderPath)
log.Warn("Waiting for export task to complete")
spconfig.DownloadExport(*apiClient, job.JobId, "spconfig-export-"+job.JobId+".json", folderPath)
}
return nil

View File

@@ -3,10 +3,9 @@ package spconfig
import (
"context"
"encoding/json"
"os"
sailpointbetasdk "github.com/sailpoint-oss/golang-sdk/beta"
"github.com/charmbracelet/log"
"github.com/sailpoint-oss/sailpoint-cli/internal/config"
"github.com/sailpoint-oss/sailpoint-cli/internal/spconfig"
"github.com/spf13/cobra"
@@ -16,7 +15,7 @@ func newImportCommand() *cobra.Command {
var filePath string
var folderPath string
var wait bool
var payload *sailpointbetasdk.ImportOptions
cmd := &cobra.Command{
Use: "import",
Short: "Start an Import job in IdentityNow",
@@ -37,16 +36,9 @@ func newImportCommand() *cobra.Command {
}
defer file.Close()
err = json.NewDecoder(file).Decode(&payload)
if err != nil {
return err
}
ctx := context.TODO()
payload = sailpointbetasdk.NewImportOptions()
job, _, err := apiClient.Beta.SPConfigApi.ImportSpConfig(ctx).Data(args[0]).Options(*payload).Execute()
job, _, err := apiClient.Beta.SPConfigApi.ImportSpConfig(ctx).Data(file).Execute()
if err != nil {
return err
}
@@ -54,8 +46,8 @@ func newImportCommand() *cobra.Command {
spconfig.PrintJob(*job)
if wait {
config.Log.Warn("Waiting for import task to complete")
spconfig.DownloadImport(job.JobId, "spconfig-import-"+job.JobId+".json", folderPath)
log.Warn("Waiting for import task to complete")
spconfig.DownloadImport(*apiClient, job.JobId, "spconfig-import-"+job.JobId+".json", folderPath)
}
return nil
@@ -63,7 +55,7 @@ func newImportCommand() *cobra.Command {
}
cmd.Flags().StringVarP(&filePath, "filePath", "f", "", "the path to the file containing the import payload")
cmd.Flags().StringVarP(&folderPath, "folderPath", "p", "spconfig-imports", "folder path to save the search results in. If the directory doesn't exist, then it will be automatically created. (default is the current working directory)")
cmd.Flags().StringVarP(&folderPath, "folderPath", "p", "spconfig-imports", "folder path to save the import results in. If the directory doesn't exist, then it will be automatically created. (default is the current working directory)")
cmd.Flags().BoolVarP(&wait, "wait", "w", false, "wait for the import job to finish, and download the results")
cmd.MarkFlagRequired("filepath")

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"strings"
"github.com/charmbracelet/log"
"github.com/sailpoint-oss/sailpoint-cli/internal/config"
"github.com/sailpoint-oss/sailpoint-cli/internal/spconfig"
"github.com/sailpoint-oss/sailpoint-cli/internal/templates"
@@ -52,13 +53,13 @@ func newTemplateCmd() *cobra.Command {
return fmt.Errorf("no template specified")
}
config.Log.Info("Template Selected", "Template", template)
log.Info("Template Selected", "Template", template)
matches := types.Filter(exportTemplates, func(st templates.ExportTemplate) bool { return st.Name == template })
if len(matches) < 1 {
return fmt.Errorf("no template matches for %s", template)
} else if len(matches) > 1 {
config.Log.Warn("Multiple template matches", "Template", template)
log.Warn("Multiple template matches", "Template", template)
}
selectedTemplate = matches[0]
varCount := len(selectedTemplate.Variables)
@@ -82,8 +83,8 @@ func newTemplateCmd() *cobra.Command {
spconfig.PrintJob(*job)
if wait {
config.Log.Info("Checking Export Job", "JobID", job.JobId)
spconfig.DownloadExport(job.JobId, "spconfig-export-"+template+"-"+job.JobId+".json", folderPath)
log.Info("Checking Export Job", "JobID", job.JobId)
spconfig.DownloadExport(*apiClient, job.JobId, "spconfig-export-"+template+"-"+job.JobId+".json", folderPath)
}
return nil

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"github.com/charmbracelet/log"
sailpointsdk "github.com/sailpoint-oss/golang-sdk/v3"
"github.com/sailpoint-oss/sailpoint-cli/internal/config"
"github.com/sailpoint-oss/sailpoint-cli/internal/sdk"
@@ -63,7 +64,7 @@ func newCreateCmd() *cobra.Command {
return sdk.HandleSDKError(resp, err)
}
config.Log.Info("Transform created successfully")
log.Info("Transform created successfully")
cmd.Print(*transformObj.Id)

View File

@@ -9,6 +9,7 @@ import (
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/log"
"github.com/sailpoint-oss/sailpoint-cli/internal/config"
"github.com/sailpoint-oss/sailpoint-cli/internal/transform"
tuitable "github.com/sailpoint-oss/sailpoint-cli/internal/tui/table"
@@ -25,8 +26,13 @@ func newDeleteCmd() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
var id []string
apiClient, err := config.InitAPIClient()
if err != nil {
return err
}
if len(args) < 1 {
transforms, err := transform.GetTransforms()
transforms, err := transform.GetTransforms(*apiClient)
if err != nil {
return err
}
@@ -84,17 +90,12 @@ func newDeleteCmd() *cobra.Command {
transformID := id[i]
apiClient, err := config.InitAPIClient()
if err != nil {
return err
}
_, err = apiClient.V3.TransformsApi.DeleteTransform(context.TODO(), transformID).Execute()
if err != nil {
return err
}
config.Log.Info("Transform successfully deleted", "TransformID", transformID)
log.Info("Transform successfully deleted", "TransformID", transformID)
}
return nil

View File

@@ -7,6 +7,7 @@ import (
"path/filepath"
"strings"
"github.com/charmbracelet/log"
"github.com/sailpoint-oss/sailpoint-cli/internal/config"
"github.com/sailpoint-oss/sailpoint-cli/internal/transform"
"github.com/spf13/cobra"
@@ -23,12 +24,17 @@ func newDownloadCmd() *cobra.Command {
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
transforms, err := transform.GetTransforms()
apiClient, err := config.InitAPIClient()
if err != nil {
return err
}
err = transform.ListTransforms()
transforms, err := transform.GetTransforms(*apiClient)
if err != nil {
return err
}
err = transform.ListTransforms(transforms)
if err != nil {
return err
}
@@ -60,7 +66,7 @@ func newDownloadCmd() *cobra.Command {
}
}
config.Log.Info("Transforms downloaded successfully", "path", destination)
log.Info("Transforms downloaded successfully", "path", destination)
return nil
},

View File

@@ -2,6 +2,7 @@
package transform
import (
"github.com/sailpoint-oss/sailpoint-cli/internal/config"
"github.com/sailpoint-oss/sailpoint-cli/internal/transform"
"github.com/spf13/cobra"
)
@@ -16,11 +17,20 @@ func newListCmd() *cobra.Command {
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
err := transform.ListTransforms()
apiClient, err := config.InitAPIClient()
if err != nil {
return err
}
transforms, err := transform.GetTransforms(*apiClient)
if err != nil {
return err
}
err = transform.ListTransforms(transforms)
if err != nil {
return err
}
return nil
},
}

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"github.com/charmbracelet/log"
sailpointsdk "github.com/sailpoint-oss/golang-sdk/v3"
"github.com/sailpoint-oss/sailpoint-cli/internal/config"
"github.com/sailpoint-oss/sailpoint-cli/internal/sdk"
@@ -50,7 +51,7 @@ func newUpdateCmd() *cobra.Command {
id := data["id"].(string)
delete(data, "id") // ID can't be present in the update payload
config.Log.Info("Updating Transaform", "transformID", id)
log.Info("Updating Transaform", "transformID", id)
transform := sailpointsdk.NewTransform(data["name"].(string), data["type"].(string), data["attributes"].(map[string]interface{}))

View File

@@ -1,17 +1,18 @@
package va
import (
"fmt"
"os"
"path"
"sync"
"time"
conf "github.com/sailpoint-oss/sailpoint-cli/internal/config"
"github.com/sailpoint-oss/sailpoint-cli/internal/terminal"
"github.com/charmbracelet/log"
"github.com/sailpoint-oss/sailpoint-cli/internal/va"
"github.com/spf13/cobra"
"github.com/vbauerster/mpb/v8"
)
func newCollectCmd() *cobra.Command {
var credentials []string
var output string
var logs bool
var config bool
@@ -19,10 +20,9 @@ func newCollectCmd() *cobra.Command {
Use: "collect",
Short: "Collect Configuration or Log Files from a SailPoint Virtual Appliance",
Long: "\nCollect Configuration or Log Files from a SailPoint Virtual Appliance\n\n",
Example: "sail va collect 10.10.10.10, 10.10.10.11 (-l only collect log files) (-c only collect config files) (-o /path/to/save/files)\n\nLog Files:\n/home/sailpoint/log/ccg.log\n/home/sailpoint/log/charon.log\n/home/sailpoint/stuntlog.txt\n\nConfig Files:\n/home/sailpoint/proxy.yaml\n/etc/systemd/network/static.network\n/etc/resolv.conf\n",
Example: "sail va collect 10.10.10.25, 10.10.10.26 -p S@ilp0int -p S@ilp0int \n\nLog Files:\n/home/sailpoint/log/ccg.log\n/home/sailpoint/log/charon.log\n/home/sailpoint/stuntlog.txt\n\nConfig Files:\n/home/sailpoint/proxy.yaml\n/etc/systemd/network/static.network\n/etc/resolv.conf\n",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var credentials []string
if output == "" {
output, _ = os.Getwd()
@@ -36,24 +36,27 @@ func newCollectCmd() *cobra.Command {
files = []string{"/home/sailpoint/log/ccg.log", "/home/sailpoint/log/charon.log", "/home/sailpoint/stuntlog.txt", "/home/sailpoint/proxy.yaml", "/etc/systemd/network/static.network", "/etc/resolv.conf"}
}
for credential := 0; credential < len(args); credential++ {
password, _ := terminal.PromptPassword(fmt.Sprintf("Enter Password for %v:", args[credential]))
credentials = append(credentials, password)
var wg sync.WaitGroup
p := mpb.New(mpb.WithWidth(60),
mpb.PopCompletedMode(),
mpb.WithRefreshRate(180*time.Millisecond),
mpb.WithWaitGroup(&wg))
for i, endpoint := range args {
wg.Add(1)
go func(endpoint, password string) {
defer wg.Done()
outputFolder := output
err := va.CollectVAFiles(endpoint, password, outputFolder, files, p)
if err != nil {
log.Error("Error collecting files for", "VA", endpoint, "err", err)
}
}(endpoint, credentials[i])
}
p.Wait()
for host := 0; host < len(args); host++ {
endpoint := args[host]
password := credentials[host]
outputFolder := path.Join(output, endpoint)
err := va.CollectVAFiles(endpoint, password, outputFolder, files)
if err != nil {
return err
}
}
conf.Log.Info("All Operations Complete")
log.Info("All Operations Complete")
return nil
},
}
@@ -61,6 +64,8 @@ func newCollectCmd() *cobra.Command {
cmd.Flags().StringVarP(&output, "Output", "o", "", "The path to save the log files")
cmd.Flags().BoolVarP(&logs, "logs", "l", false, "Retrieve log files")
cmd.Flags().BoolVarP(&config, "config", "c", false, "Retrieve config files")
cmd.Flags().StringArrayVarP(&credentials, "Passwords", "p", []string{}, "You can enter the Passwords for the servers in the same order that the servers are listed as arguments")
cmd.MarkFlagsMutuallyExclusive("config", "logs")
return cmd

40
cmd/va/list.go Normal file
View File

@@ -0,0 +1,40 @@
package va
import (
"context"
sailpoint "github.com/sailpoint-oss/golang-sdk"
sailpointbetasdk "github.com/sailpoint-oss/golang-sdk/beta"
"github.com/sailpoint-oss/sailpoint-cli/internal/config"
"github.com/sailpoint-oss/sailpoint-cli/internal/sdk"
"github.com/sailpoint-oss/sailpoint-cli/internal/util"
"github.com/spf13/cobra"
)
func newListCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List the Clusters and Virtual Appliances configured in IdentityNow",
Long: "\nList the Clusters and Virtual Appliances configured in IdentityNow\n\n",
Example: "sail va list",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
apiClient, err := config.InitAPIClient()
if err != nil {
return err
}
clusters, resp, err := sailpoint.PaginateWithDefaults[sailpointbetasdk.ManagedCluster](apiClient.Beta.ManagedClustersApi.GetManagedClusters(context.TODO()))
if err != nil {
return sdk.HandleSDKError(resp, err)
}
cmd.Println(util.PrettyPrint(clusters))
return nil
},
}
return cmd
}

51
cmd/va/logConfig/get.go Normal file
View File

@@ -0,0 +1,51 @@
package logConfig
import (
"context"
"github.com/sailpoint-oss/golang-sdk/beta"
"github.com/sailpoint-oss/sailpoint-cli/internal/config"
"github.com/sailpoint-oss/sailpoint-cli/internal/sdk"
"github.com/sailpoint-oss/sailpoint-cli/internal/util"
"github.com/spf13/cobra"
)
func newGetCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "get",
Short: "Return a Virtual Appliances log configuration",
Long: "\nReturn a Virtual Appliances log configuration\n\n",
Example: "sail va log get",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var output []beta.ClientLogConfiguration
apiClient, err := config.InitAPIClient()
if err != nil {
return err
}
for i := 0; i < len(args); i++ {
clusterId := args[i]
configuration, resp, err := apiClient.Beta.ManagedClustersApi.GetClientLogConfiguration(context.TODO(), clusterId).Execute()
if err != nil {
return sdk.HandleSDKError(resp, err)
}
if configuration != nil {
output = append(output, *configuration)
}
}
cmd.Println(util.PrettyPrint(output))
return nil
},
}
return cmd
}

View File

@@ -0,0 +1,27 @@
// Copyright (c) 2021, SailPoint Technologies, Inc. All rights reserved.
package logConfig
import (
"fmt"
"github.com/spf13/cobra"
)
func NewLogCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "log",
Short: "Interact with a SailPoint Virtual Appliances log configuration",
Long: "\nInteract with SailPoint Virtual Appliances log configuration\n\n",
Aliases: []string{"l"},
Run: func(cmd *cobra.Command, args []string) {
_, _ = fmt.Fprint(cmd.OutOrStdout(), cmd.UsageString())
},
}
cmd.AddCommand(
newGetCmd(),
newSetCmd(),
)
return cmd
}

84
cmd/va/logConfig/set.go Normal file
View File

@@ -0,0 +1,84 @@
package logConfig
import (
"context"
"strings"
"github.com/charmbracelet/log"
"github.com/sailpoint-oss/golang-sdk/beta"
"github.com/sailpoint-oss/sailpoint-cli/internal/config"
"github.com/sailpoint-oss/sailpoint-cli/internal/sdk"
"github.com/sailpoint-oss/sailpoint-cli/internal/util"
"github.com/spf13/cobra"
)
func newSetCmd() *cobra.Command {
var level string
var durationInMinutes int32
var connectors []string
var expiration string
cmd := &cobra.Command{
Use: "set",
Short: "Set a Virtual Appliances log configuration",
Long: "\nSet a Virtual Appliances log configuration\n\nA list of Connectors can be found here:\nhttps://community.sailpoint.com/t5/IdentityNow-Articles/Enabling-Connector-Logging-in-IdentityNow/ta-p/188107\n\n",
Example: "sail va log set",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
rootLevel := beta.StandardLevel(level)
if rootLevel.IsValid() == false {
log.Fatal("logLevel provided is invalid", "level", level)
}
if durationInMinutes < 5 || durationInMinutes > 1440 {
log.Fatal("durationInMinutes provided is invalid", "durationInMinutes", durationInMinutes)
}
var output []beta.ClientLogConfiguration
apiClient, err := config.InitAPIClient()
if err != nil {
return err
}
logLevels := make(map[string]beta.StandardLevel)
for j := 0; j < len(connectors); j++ {
connector := connectors[j]
parts := strings.Split(connector, "=")
conLevel := beta.StandardLevel(parts[1])
if conLevel.IsValid() {
logLevels[parts[0]] = conLevel
} else {
log.Warn("Log Level Invalid", "Connector", parts[0], "LogLevel", parts[1])
}
}
logConfig := beta.NewClientLogConfiguration(durationInMinutes, rootLevel)
logConfig.LogLevels = &logLevels
for i := 0; i < len(args); i++ {
clusterId := args[i]
configuration, resp, err := apiClient.Beta.ManagedClustersApi.PutClientLogConfiguration(context.TODO(), clusterId).ClientLogConfiguration(*logConfig).Execute()
if err != nil {
return sdk.HandleSDKError(resp, err)
}
output = append(output, *configuration)
}
cmd.Println(util.PrettyPrint(output))
return nil
},
}
cmd.Flags().StringVarP(&level, "rootLogLevel", "r", "", "Root Log Level for the log configuration")
cmd.Flags().Int32VarP(&durationInMinutes, "durationInMinutes", "d", 30, "Duration in minutes for the log configuration.\nProvided value must be above 5 and below 1440")
cmd.Flags().StringVarP(&expiration, "expiration", "e", "", "Expiration string value for the log configuration. Example: 2020-12-15T19:13:36.079Z")
cmd.Flags().StringArrayVarP(&connectors, "connector", "c", []string{}, "Connectors and Log Level to configure. Example:\n-c sailpoint.connector.ADLDAPConnector=TRACE\n--connector sailpoint.connector.ADLDAPConnector=TRACE")
cmd.MarkFlagsMutuallyExclusive("expiration", "durationInMinutes")
return cmd
}

View File

@@ -6,14 +6,13 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"os"
"path"
"strings"
"sync"
"time"
"github.com/fatih/color"
"github.com/charmbracelet/log"
"github.com/spf13/cobra"
"github.com/vbauerster/mpb/v8"
"github.com/vbauerster/mpb/v8/decor"
@@ -61,6 +60,11 @@ type CCG struct {
SCIMCommon string `json:"SCIM Common"`
}
var cache = make(map[string]*os.File)
var cacheLock sync.Mutex
const numWorkers = 8
func saveCanalLine(bytes []byte, dir string) {
line := CANAL{}
@@ -72,36 +76,50 @@ func saveCanalLine(bytes []byte, dir string) {
line.Time = lineArray[2]
line.HostName = lineArray[3]
line.Service = strings.ReplaceAll(lineArray[4], ":", "")
line.Message = strings.ReplaceAll(strings.Join(lineArray[5:], ""), "\n", "")
line.Message = strings.ReplaceAll(strings.Join(lineArray[5:], " "), "\n", "")
if line.Month != "" && line.Day != "" && line.Time != "" && line.HostName != "" && line.Service != "" && line.Message != "" && line.HostName != "at" {
folder := "/Standard/"
if line.HostName != "at" && line.Month != "" && line.Day != "" && line.Time != "" && line.HostName != "" && line.Service != "" && line.Message != "" {
folder := "Standard"
if strings.Contains(line.Message, "Error") || strings.Contains(line.Message, "WARNING") {
folder = "/Errors/"
folder = "Errors"
}
filename := dir + line.HostName + "/" + line.Month + "-" + line.Day + folder + "/Canal.log"
filename := path.Join(dir, line.HostName, line.Month+"-"+line.Day, folder, "Canal.log")
tempdir, _ := path.Split(filename)
if _, err := os.Stat(tempdir); errors.Is(err, os.ErrNotExist) {
err := os.MkdirAll(tempdir, 0700)
if err != nil {
log.Println(err)
cacheLock.Lock()
defer cacheLock.Unlock()
f, exists := cache[filename]
if !exists {
if _, err := os.Stat(tempdir); errors.Is(err, os.ErrNotExist) {
err := os.MkdirAll(tempdir, 0700)
if err != nil {
log.Error(err)
}
}
f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
panic(err)
}
cache[filename] = f
}
f, openErr := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if openErr != nil {
panic(openErr)
}
fileWriter := bufio.NewWriter(f)
_, writeErr := fileWriter.WriteString(string(bytes))
if writeErr != nil {
panic(writeErr)
}
fileWriter.Flush()
f.Close()
}
}
}
func closeFiles() {
cacheLock.Lock()
defer cacheLock.Unlock()
for _, f := range cache {
f.Close()
}
}
func saveCCGLine(line CCG, dir string, isErr bool) error {
@@ -111,18 +129,30 @@ func saveCCGLine(line CCG, dir string, isErr bool) error {
}
filename := path.Join(dir, line.Org, folder, line.Timestamp.Format("2006-01-02"), strings.ReplaceAll(line.Logger_name, ".", "-"), "log.json")
jsonBytes, _ := json.MarshalIndent(line, "", " ")
err := CreateFolder(filename)
if err != nil {
return err
}
f, openErr := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if openErr != nil {
return openErr
cacheLock.Lock()
defer cacheLock.Unlock()
f, exists := cache[filename]
if !exists {
tempdir, _ := path.Split(filename)
if _, err := os.Stat(tempdir); errors.Is(err, os.ErrNotExist) {
err := os.MkdirAll(tempdir, 0700)
if err != nil {
log.Error(err)
}
}
f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
cache[filename] = f
}
if _, writeErr := f.Write(jsonBytes); writeErr != nil {
return writeErr
}
f.Close()
return nil
}
@@ -153,9 +183,6 @@ func ErrorCheck(token []byte) bool {
}
func ParseCCGFile(p *mpb.Progress, filepath string, everything bool) error {
var lineCount int
var processCount int
file, err := os.Open(filepath)
if err != nil {
return err
@@ -187,10 +214,26 @@ func ParseCCGFile(p *mpb.Progress, filepath string, everything bool) error {
bufReader := bufio.NewReader(proxyReader)
type task struct {
line CCG
token []byte
dir string
}
taskChan := make(chan task)
var wg sync.WaitGroup
wg.Add(numWorkers)
for i := 0; i < numWorkers; i++ {
go func() {
defer wg.Done()
for t := range taskChan {
saveCCGLine(t.line, t.dir, ErrorCheck(t.token))
}
}()
}
for {
lineCount++
token, err := bufReader.ReadBytes('\n')
if err != nil {
break
@@ -198,33 +241,24 @@ func ParseCCGFile(p *mpb.Progress, filepath string, everything bool) error {
if ErrorCheck(token) || everything {
var line CCG
unErr := json.Unmarshal(token, &line)
if unErr == nil {
if line.Org != "" {
processCount++
wg.Add(1)
go func() {
defer wg.Done()
if ErrorCheck(token) {
saveCCGLine(line, dir, true)
} else {
saveCCGLine(line, dir, false)
}
}()
if unErr == nil && line.Org != "" {
taskChan <- task{
line: line,
token: token,
dir: dir,
}
}
}
}
}
close(taskChan)
wg.Wait()
bar.SetTotal(-1, true)
return nil
}
func ParseCanalFile(p *mpb.Progress, filepath string, everything bool) error {
var lineCount int
file, err := os.Open(filepath)
if err != nil {
return err
@@ -256,22 +290,36 @@ func ParseCanalFile(p *mpb.Progress, filepath string, everything bool) error {
bufReader := bufio.NewReader(proxyReader)
type task struct {
token []byte
dir string
}
taskChan := make(chan task)
var wg sync.WaitGroup
wg.Add(numWorkers)
for i := 0; i < numWorkers; i++ {
go func() {
defer wg.Done()
for t := range taskChan {
saveCanalLine(t.token, t.dir)
}
}()
}
for {
lineCount++
token, err := bufReader.ReadBytes('\n')
if err != nil {
break
} else {
wg.Add(1)
go func() {
defer wg.Done()
saveCanalLine(token, dir)
}()
taskChan <- task{
token: token,
dir: dir,
}
}
}
close(taskChan)
wg.Wait()
bar.SetTotal(-1, true)
@@ -298,8 +346,9 @@ func newParseCmd() *cobra.Command {
mpb.WithRefreshRate(180*time.Millisecond),
)
log.Info("Parsing Log Files", "files", args)
log.SetOutput(p)
color.Blue("Parsing Files %s\n", args)
for i := 0; i < len(args); i++ {
wg.Add(1)
@@ -310,7 +359,7 @@ func newParseCmd() *cobra.Command {
defer wg.Done()
err := ParseCCGFile(p, filepath, everything)
if err != nil {
log.Panicf("Issue Parsing log file: %v", filepath)
log.Error("Issue Parsing log file", "file", filepath, "error", err)
}
}()
} else if canal {
@@ -318,7 +367,7 @@ func newParseCmd() *cobra.Command {
defer wg.Done()
err := ParseCanalFile(p, filepath, everything)
if err != nil {
log.Panicf("Issue Parsing log file: %v", filepath)
log.Error("Issue Parsing log file", "file", filepath, "error", err)
}
}()
}

View File

@@ -3,6 +3,8 @@ package va
import (
"errors"
"fmt"
"sync"
"time"
"os"
"path"
@@ -11,6 +13,7 @@ import (
"github.com/sailpoint-oss/sailpoint-cli/internal/terminal"
"github.com/sailpoint-oss/sailpoint-cli/internal/va"
"github.com/spf13/cobra"
"github.com/vbauerster/mpb/v8"
)
func NewTroubleshootCmd() *cobra.Command {
@@ -54,7 +57,13 @@ func NewTroubleshootCmd() *cobra.Command {
color.Green("Troubleshooting Complete")
color.Blue("Collecting stuntlog")
err := va.CollectVAFiles(endpoint, password, outputDir, []string{"/home/sailpoint/stuntlog.txt"})
var wg sync.WaitGroup
p := mpb.New(mpb.WithWidth(60),
mpb.PopCompletedMode(),
mpb.WithRefreshRate(180*time.Millisecond),
mpb.WithWaitGroup(&wg))
err := va.CollectVAFiles(endpoint, password, outputDir, []string{"/home/sailpoint/stuntlog.txt"}, p)
if err != nil {
return err
}

View File

@@ -3,44 +3,54 @@ package va
import (
"fmt"
"github.com/fatih/color"
"github.com/charmbracelet/log"
"github.com/sailpoint-oss/sailpoint-cli/internal/terminal"
"github.com/sailpoint-oss/sailpoint-cli/internal/va"
"github.com/spf13/cobra"
)
func updateAndRebootVA(endpoint, password string) {
log.Info("Attempting to Update", "VA", endpoint)
update, updateErr := va.RunVACmd(endpoint, password, UpdateCommand)
if updateErr != nil {
log.Error("Problem Updating", "VA", endpoint, "err", updateErr, "resp", update)
} else {
log.Info("Virtual Appliance Updating", "VA", endpoint)
reboot, rebootErr := va.RunVACmd(endpoint, password, RebootCommand)
if rebootErr != nil && rebootErr.Error() != "wait: remote command exited without exit status or exit signal" {
log.Error("Problem Rebooting", "VA", endpoint, "err", rebootErr, "resp", reboot)
} else {
log.Info("Virtual Appliance Rebooting", "VA", endpoint)
}
}
fmt.Println()
}
func newUpdateCmd() *cobra.Command {
var credentials []string
cmd := &cobra.Command{
Use: "update",
Short: "Perform Update Operations on a SailPoint Virtual Appliance",
Long: "\nPerform Update Operations on a SailPoint Virtual Appliance\n\n",
Example: "sail va update 10.10.10.10 10.10.10.11",
Example: "sail va update 10.10.10.10 10.10.10.11 -c S@ilp0int -c S@ilp0int",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var credentials []string
for credential := 0; credential < len(args); credential++ {
password, _ := terminal.PromptPassword(fmt.Sprintf("Enter Password for %v:", args[credential]))
credentials = append(credentials, password)
}
for i := 0; i < len(args); i++ {
endpoint := args[i]
fmt.Printf("Starting update for %v\n", endpoint)
for i, endpoint := range args {
password := credentials[i]
_, updateErr := va.RunVACmd(endpoint, password, UpdateCommand)
if updateErr != nil {
return updateErr
} else {
color.Green("Initiating update check and install (%v)", endpoint)
}
reboot, rebootErr := va.RunVACmd(endpoint, password, RebootCommand)
if rebootErr != nil {
color.Green("Rebooting Virtual Appliance (%v)", endpoint)
} else {
color.Red(reboot)
if password == "" {
password, _ = terminal.PromptPassword("Enter Password for " + endpoint + ":")
}
updateAndRebootVA(endpoint, password)
}
return nil
},
}
cmd.Flags().StringArrayVarP(&credentials, "Passwords", "p", []string{}, "You can enter the Passwords for the servers in the same order that the servers are listed as arguments")
return cmd
}

View File

@@ -4,6 +4,7 @@ package va
import (
"fmt"
"github.com/sailpoint-oss/sailpoint-cli/cmd/va/logConfig"
"github.com/spf13/cobra"
)
@@ -21,8 +22,10 @@ func NewVACmd() *cobra.Command {
cmd.AddCommand(
newCollectCmd(),
// newTroubleshootCmd(),
newUpdateCmd(),
newListCmd(),
newParseCmd(),
newUpdateCmd(),
logConfig.NewLogCmd(),
)
return cmd

48
go.mod
View File

@@ -5,26 +5,26 @@ go 1.19
require (
github.com/charmbracelet/bubbles v0.15.0
github.com/charmbracelet/bubbletea v0.23.2
github.com/charmbracelet/lipgloss v0.6.0
github.com/charmbracelet/log v0.1.1
github.com/fatih/color v1.14.1
github.com/gocarina/gocsv v0.0.0-20230223115542-dc4ee9de5fe2
github.com/charmbracelet/lipgloss v0.7.1
github.com/charmbracelet/log v0.2.1
github.com/fatih/color v1.15.0
github.com/gocarina/gocsv v0.0.0-20230406101422-6445c2b15027
github.com/golang/mock v1.6.0
github.com/kr/pretty v0.3.1
github.com/logrusorgru/aurora v2.0.3+incompatible
github.com/mitchellh/mapstructure v1.5.0
github.com/olekukonko/tablewriter v0.0.5
github.com/pkg/sftp v1.13.5
github.com/sailpoint-oss/golang-sdk v1.0.1
github.com/sailpoint-oss/golang-sdk v1.0.4
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
github.com/spf13/cobra v1.6.1
github.com/spf13/cobra v1.7.0
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.15.0
github.com/vbauerster/mpb/v8 v8.2.0
golang.org/x/crypto v0.6.0
golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2
golang.org/x/oauth2 v0.5.0
golang.org/x/term v0.5.0
github.com/vbauerster/mpb/v8 v8.4.0
golang.org/x/crypto v0.9.0
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53
golang.org/x/oauth2 v0.8.0
golang.org/x/term v0.8.0
gopkg.in/alessio/shellescape.v1 v1.0.0-20170105083845-52074bc9df61
gopkg.in/square/go-jose.v2 v2.6.0
gopkg.in/yaml.v2 v2.4.0
@@ -40,11 +40,11 @@ require (
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect
github.com/alessio/shellescape v1.4.1 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52 v1.2.1 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/containerd/console v1.0.3 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kr/fs v0.1.0 // indirect
@@ -52,27 +52,27 @@ require (
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/mattn/go-isatty v0.0.18 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.14.0 // indirect
github.com/pelletier/go-toml/v2 v2.0.6 // indirect
github.com/muesli/termenv v0.15.1 // indirect
github.com/pelletier/go-toml/v2 v2.0.7 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/rogpeppe/go-internal v1.9.0 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/sahilm/fuzzy v0.1.0 // indirect
github.com/spf13/afero v1.9.4 // indirect
github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/afero v1.9.5 // indirect
github.com/spf13/cast v1.5.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/subosito/gotenv v1.4.2 // indirect
golang.org/x/net v0.7.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.5.0 // indirect
golang.org/x/text v0.7.0 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/text v0.9.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.28.1 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

99
go.sum
View File

@@ -47,8 +47,9 @@ github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPp
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
github.com/aymanbagabas/go-osc52 v1.2.1 h1:q2sWUyDcozPLcLabEMd+a+7Ea2DitxZVN9hTxab9L4E=
github.com/aymanbagabas/go-osc52 v1.2.1/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/charmbracelet/bubbles v0.15.0 h1:c5vZ3woHV5W2b8YZI1q7v4ZNQaPetfHuoHzx+56Z6TI=
github.com/charmbracelet/bubbles v0.15.0/go.mod h1:Y7gSFbBzlMpUDR/XM9MhZI374Q+1p1kluf1uLl8iK74=
@@ -56,10 +57,11 @@ github.com/charmbracelet/bubbletea v0.23.1/go.mod h1:JAfGK/3/pPKHTnAS8JIE2u9f61B
github.com/charmbracelet/bubbletea v0.23.2 h1:vuUJ9HJ7b/COy4I30e8xDVQ+VRDUEFykIjryPfgsdps=
github.com/charmbracelet/bubbletea v0.23.2/go.mod h1:FaP3WUivcTM0xOKNmhciz60M6I+weYLF76mr1JyI7sM=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v0.6.0 h1:1StyZB9vBSOyuZxQUcUwGr17JmojPNm87inij9N3wJY=
github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk=
github.com/charmbracelet/log v0.1.1 h1:XBqTzrgT+7BenzFkb4DP3rt6VKLtx/zpgNMfJMAdbCc=
github.com/charmbracelet/log v0.1.1/go.mod h1:nbG+jJlMwbPM8v2wFyPG6qCr9flLQ1XTS+fhzUWd6JE=
github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E=
github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c=
github.com/charmbracelet/log v0.2.1 h1:1z7jpkk4yKyjwlmKmKMM5qnEDSpV32E7XtWhuv0mTZE=
github.com/charmbracelet/log v0.2.1/go.mod h1:GwFfjewhcVDWLrpAbY5A0Hin9YOlEn40eWT4PNaxFT4=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@@ -80,9 +82,10 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w=
github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg=
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE=
github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
@@ -90,8 +93,8 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/gocarina/gocsv v0.0.0-20230223115542-dc4ee9de5fe2 h1:7YY24YwanM5Fvt1/6LCZzWcjH5pL9w2MlUj/8a3GVUk=
github.com/gocarina/gocsv v0.0.0-20230223115542-dc4ee9de5fe2/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI=
github.com/gocarina/gocsv v0.0.0-20230406101422-6445c2b15027 h1:LCGzZb4kMUUjMUzLxxqSJBwo9szUO0tK8cOxnEOT4Jc=
github.com/gocarina/gocsv v0.0.0-20230406101422-6445c2b15027/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -120,8 +123,8 @@ github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QD
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -165,7 +168,6 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
@@ -192,8 +194,9 @@ github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxec
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
@@ -205,8 +208,8 @@ github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh
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/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a h1:jlDOeO5TU0pYlbc/y6PFguab5IjANI0Knrpg3u/ton4=
github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
@@ -214,12 +217,14 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc=
github.com/muesli/termenv v0.14.0 h1:8x9NFfOe8lmIWK4pgy3IfVEy47f+ppe3tUqdPZG2Uy0=
github.com/muesli/termenv v0.14.0/go.mod h1:kG/pF1E7fh949Xhe156crRUrHNyK221IuGO7Ez60Uc8=
github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs=
github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU=
github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml/v2 v2.0.7 h1:muncTPStnKRos5dpVKULv2FVd4bMOhNePj9CjgDb8Us=
github.com/pelletier/go-toml/v2 v2.0.7/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
@@ -233,21 +238,26 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/sailpoint-oss/golang-sdk v1.0.1 h1:JFqoW5K7/RkwzqhEnh0bJfcDc5CDAwrf9TxXnYqK/Ho=
github.com/sailpoint-oss/golang-sdk v1.0.1/go.mod h1:aMxZu5ieAI89duSsOy/R7+dXwk5EbF6CAEvXt8ivMYw=
github.com/sailpoint-oss/golang-sdk v1.0.3 h1:uJzKJ2+gOdVY7sbpjpvm4O4c3woAjBSx9ASMYi3bh8E=
github.com/sailpoint-oss/golang-sdk v1.0.3/go.mod h1:k8tO4zw0wmivf5NjrPE2tF+ToCr6AJUV9BJnyGW4/rA=
github.com/sailpoint-oss/golang-sdk v1.0.4 h1:NLdExj3bb7NWKhHnzReP0ZiSUNR+Nlqd58HjHJtue+I=
github.com/sailpoint-oss/golang-sdk v1.0.4/go.mod h1:k8tO4zw0wmivf5NjrPE2tF+ToCr6AJUV9BJnyGW4/rA=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/spf13/afero v1.9.4 h1:Sd43wM1IWz/s1aVXdOBkjJvuP8UdyqioeE4AmM0QsBs=
github.com/spf13/afero v1.9.4/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y=
github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM=
github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ=
github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w=
github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU=
github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA=
github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY=
github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA=
github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
@@ -263,12 +273,12 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8=
github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
github.com/vbauerster/mpb/v8 v8.2.0 h1:zaH0DaIcUoOeItZ/Yy567ZhaPUC3GMhUyHollQDgZvs=
github.com/vbauerster/mpb/v8 v8.2.0/go.mod h1:HEVcHNizbUIg0l4Qwhw0BDvg50zo3CMiWkbz1WUEQ94=
github.com/vbauerster/mpb/v8 v8.4.0 h1:Jq2iNA7T6SydpMVOwaT+2OBWlXS9Th8KEvBqeu5eeTo=
github.com/vbauerster/mpb/v8 v8.4.0/go.mod h1:vjp3hSTuCtR+x98/+2vW3eZ8XzxvGoP8CPseHMhiPyc=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -286,10 +296,10 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U
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/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -300,8 +310,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2 h1:Jvc7gsqn21cJHCmAWx0LiimpP18LZmUxkT5Mp7EZ1mI=
golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 h1:5llv2sWeaMSnA3w2kS57ouQQ4pudlXrR0dCgw51QK9o=
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -359,8 +369,8 @@ golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -370,8 +380,8 @@ golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.5.0 h1:HuArIo48skDwlrvM3sEdHXElYslAMsf3KwRkkW4MC4s=
golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I=
golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8=
golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -428,12 +438,13 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -442,8 +453,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -590,8 +601,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
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=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/alessio/shellescape.v1 v1.0.0-20170105083845-52074bc9df61 h1:8ajkpB4hXVftY5ko905id+dOnmorcS2CHNxxHLLDcFM=
gopkg.in/alessio/shellescape.v1 v1.0.0-20170105083845-52074bc9df61/go.mod h1:IfMagxm39Ys4ybJrDb7W3Ob8RwxftP0Yy+or/NVz1O8=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -14,15 +14,6 @@ import (
"gopkg.in/square/go-jose.v2/jwt"
)
var Log log.Logger
func init() {
Log = log.New()
if GetDebug() {
Log.SetLevel(log.DebugLevel)
}
}
var ErrAccessTokenExpired = fmt.Errorf("accesstoken is expired")
const (
@@ -149,6 +140,10 @@ func InitConfig() error {
}
}
if GetDebug() {
log.SetLevel(log.DebugLevel)
}
return nil
}
@@ -162,12 +157,20 @@ func InitAPIClient() (*sailpoint.APIClient, error) {
token, err := GetAuthToken()
if err != nil {
Log.Debug("unable to retrieve accesstoken: %s ", err)
log.Debug("unable to retrieve accesstoken: %s ", err)
}
configuration := sailpoint.NewConfiguration(sailpoint.ClientConfiguration{Token: token, BaseURL: GetBaseUrl()})
apiClient = sailpoint.NewAPIClient(configuration)
if !GetDebug() {
if GetDebug() {
logger := log.NewWithOptions(os.Stdout, log.Options{
ReportTimestamp: true,
Level: log.DebugLevel,
})
debugLogger := logger.StandardLog(log.StandardLogOptions{ForceLevel: log.DebugLevel})
apiClient.V3.GetConfig().HTTPClient.Logger = debugLogger
apiClient.Beta.GetConfig().HTTPClient.Logger = debugLogger
} else {
var DevNull types.DevNull
apiClient.V3.GetConfig().HTTPClient.Logger = DevNull
apiClient.Beta.GetConfig().HTTPClient.Logger = DevNull
@@ -187,10 +190,10 @@ func CheckToken(tokenString string) error {
token.UnsafeClaimsWithoutVerification(&claims)
if claims["user_name"] == nil {
Log.Warn("It looks like the token you are using is missing a user context, this will cause many of the CLI commands to fail.")
log.Warn("It looks like the token you are using is missing a user context, this will cause many of the CLI commands to fail.")
}
Log.Debug("Token Debug Info", "user_name", claims["user_name"], "org", claims["org"], "pod", claims["pod"])
log.Debug("Token Debug Info", "user_name", claims["user_name"], "org", claims["org"], "pod", claims["pod"])
return nil
}
@@ -300,7 +303,7 @@ func SaveConfig() error {
if _, err := os.Stat(filepath.Join(home, configFolder)); os.IsNotExist(err) {
err = os.Mkdir(filepath.Join(home, configFolder), 0777)
if err != nil {
Log.Warn("failed to create %s folder for config. %v", configFolder, err)
log.Warn("failed to create %s folder for config. %v", configFolder, err)
}
}
@@ -327,35 +330,35 @@ func Validate() error {
case "pat":
if GetBaseUrl() == "" {
Log.Error("configured environment is missing BaseURL")
log.Error("configured environment is missing BaseURL")
errors++
}
if GetPatClientID() == "" {
Log.Error("configured environment is missing PAT ClientID")
log.Error("configured environment is missing PAT ClientID")
errors++
}
if GetPatClientSecret() == "" {
Log.Error("configured environment is missing PAT ClientSecret")
log.Error("configured environment is missing PAT ClientSecret")
errors++
}
case "oauth":
if GetBaseUrl() == "" {
Log.Error("configured environment is missing BaseURL")
log.Error("configured environment is missing BaseURL")
errors++
}
if GetTenantUrl() == "" {
Log.Error("configured environment is missing TenantURL")
log.Error("configured environment is missing TenantURL")
errors++
}
default:
Log.Error("invalid authtype '%s' configured", authType)
log.Error("invalid authtype '%s' configured", authType)
errors++
}

View File

@@ -2,9 +2,12 @@ package sdk
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"github.com/charmbracelet/log"
)
type Message struct {
@@ -25,13 +28,13 @@ func HandleSDKError(resp *http.Response, sdkErr error) error {
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
fmt.Println(err)
log.Error(err)
}
var formattedBody SDKResp
err = json.Unmarshal(body, &formattedBody)
if err != nil {
fmt.Println(err)
log.Error(err)
}
outputErr := fmt.Sprintf("%s\ndate: %s\nslpt-request-id: %s\nmsgs:\n", sdkErr, resp.Header["Date"][0], resp.Header["Slpt-Request-Id"][0])
@@ -40,7 +43,10 @@ func HandleSDKError(resp *http.Response, sdkErr error) error {
for _, v := range formattedBody.Messages {
outputErr = outputErr + fmt.Sprintf("%s\n", v.Text)
}
} else {
}
return fmt.Errorf(outputErr)
return errors.New(outputErr)
}

View File

@@ -6,10 +6,10 @@ import (
"os"
"path"
"github.com/charmbracelet/log"
"github.com/mitchellh/mapstructure"
sailpoint "github.com/sailpoint-oss/golang-sdk"
sailpointsdk "github.com/sailpoint-oss/golang-sdk/v3"
"github.com/sailpoint-oss/sailpoint-cli/internal/config"
"github.com/sailpoint-oss/sailpoint-cli/internal/output"
)
@@ -62,7 +62,7 @@ func PerformSearch(apiClient sailpoint.APIClient, search sailpointsdk.Search) (S
fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r)
}
config.Log.Info("Search complete")
log.Info("Search complete")
for i := 0; i < len(resp); i++ {
entry := resp[i]
@@ -174,7 +174,7 @@ func SaveResults[T any](formattedResponse []T, fileName string, filePath string,
case "json":
fileName = fileName + ".json"
savePath := path.Join(filePath, fileName)
config.Log.Info("Saving Results", "file", savePath)
log.Info("Saving Results", "file", savePath)
err := output.SaveJSONFile(formattedResponse, fileName, filePath)
if err != nil {
return err
@@ -182,7 +182,7 @@ func SaveResults[T any](formattedResponse []T, fileName string, filePath string,
case "csv":
fileName = fileName + ".csv"
savePath := path.Join(filePath, fileName)
config.Log.Info("Saving Results", "file", savePath)
log.Info("Saving Results", "file", savePath)
err := output.SaveCSVFile(formattedResponse, fileName, filePath)
if err != nil {
return err

View File

@@ -6,9 +6,10 @@ import (
"path"
"time"
"github.com/charmbracelet/log"
"github.com/fatih/color"
sailpoint "github.com/sailpoint-oss/golang-sdk"
sailpointbetasdk "github.com/sailpoint-oss/golang-sdk/beta"
"github.com/sailpoint-oss/sailpoint-cli/internal/config"
"github.com/sailpoint-oss/sailpoint-cli/internal/output"
)
@@ -16,12 +17,7 @@ func PrintJob(job sailpointbetasdk.SpConfigJob) {
fmt.Printf("Job Type: %s\nJob ID: %s\nStatus: %s\nExpired: %s\nCreated: %s\nModified: %s\nCompleted: %s\n", job.Type, job.JobId, job.Status, job.GetExpiration(), job.GetCreated(), job.GetModified(), job.GetCompleted())
}
func DownloadExport(jobId string, fileName string, folderPath string) error {
apiClient, err := config.InitAPIClient()
if err != nil {
return err
}
func DownloadExport(apiClient sailpoint.APIClient, jobId string, fileName string, folderPath string) error {
for {
response, _, err := apiClient.Beta.SPConfigApi.ExportSpConfigJobStatus(context.TODO(), jobId).Execute()
@@ -34,12 +30,12 @@ func DownloadExport(jobId string, fileName string, folderPath string) error {
} else {
switch response.Status {
case "COMPLETE":
config.Log.Info("Job Complete")
log.Info("Job Complete")
exportData, _, err := apiClient.Beta.SPConfigApi.ExportSpConfigDownload(context.TODO(), jobId).Execute()
if err != nil {
return err
}
config.Log.Info("Saving export data", "filePath", path.Join(folderPath, fileName))
log.Info("Saving export data", "filePath", path.Join(folderPath, fileName))
err = output.SaveJSONFile(exportData, fileName, folderPath)
if err != nil {
return err
@@ -56,12 +52,7 @@ func DownloadExport(jobId string, fileName string, folderPath string) error {
return nil
}
func DownloadImport(jobId string, fileName string, folderPath string) error {
apiClient, err := config.InitAPIClient()
if err != nil {
return err
}
func DownloadImport(apiClient sailpoint.APIClient, jobId string, fileName string, folderPath string) error {
for {
response, _, err := apiClient.Beta.SPConfigApi.ImportSpConfigJobStatus(context.TODO(), jobId).Execute()

View File

@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"github.com/charmbracelet/log"
"github.com/fatih/color"
"github.com/sailpoint-oss/sailpoint-cli/internal/config"
"github.com/sailpoint-oss/sailpoint-cli/internal/tui"
@@ -37,7 +38,7 @@ func GetSearchTemplates() ([]SearchTemplate, error) {
file, err := os.OpenFile(templateFile, os.O_RDWR, 0777)
if err != nil {
config.Log.Debug("error opening file %s", templateFile)
log.Debug("error opening file %s", templateFile)
} else {
@@ -48,7 +49,7 @@ func GetSearchTemplates() ([]SearchTemplate, error) {
err = json.Unmarshal(raw, &templates)
if err != nil {
config.Log.Error("an error occured while parsing the file: %s", templateFile)
log.Error("an error occured while parsing the file: %s", templateFile)
return nil, err
}
@@ -103,7 +104,7 @@ func GetExportTemplates() ([]ExportTemplate, error) {
file, err := os.OpenFile(templateFile, os.O_RDWR, 0777)
if err != nil {
config.Log.Debug("error opening file %s", templateFile)
log.Debug("error opening file %s", templateFile)
} else {
@@ -114,7 +115,7 @@ func GetExportTemplates() ([]ExportTemplate, error) {
err = json.Unmarshal(raw, &templates)
if err != nil {
config.Log.Debug("an error occured while parsing the file: %s", templateFile)
log.Debug("an error occured while parsing the file: %s", templateFile)
return nil, err
}
@@ -124,7 +125,7 @@ func GetExportTemplates() ([]ExportTemplate, error) {
err = json.Unmarshal([]byte(builtInExportTemplates), &templates)
if err != nil {
config.Log.Error("an error occured while parsing the built in templates")
log.Error("an error occured while parsing the built in templates")
return nil, err
}
@@ -173,7 +174,7 @@ func GetReportTemplates() ([]ReportTemplate, error) {
file, err := os.OpenFile(templateFile, os.O_RDWR, 0777)
if err != nil {
config.Log.Debug("error opening file %s", templateFile)
log.Debug("error opening file %s", templateFile)
} else {
@@ -184,7 +185,7 @@ func GetReportTemplates() ([]ReportTemplate, error) {
err = json.Unmarshal(raw, &templates)
if err != nil {
config.Log.Error("an error occured while parsing the file: %s", templateFile)
log.Error("an error occured while parsing the file: %s", templateFile)
return nil, err
}

View File

@@ -8,18 +8,12 @@ import (
sailpoint "github.com/sailpoint-oss/golang-sdk"
sailpointsdk "github.com/sailpoint-oss/golang-sdk/v3"
transmodel "github.com/sailpoint-oss/sailpoint-cli/cmd/transform/model"
"github.com/sailpoint-oss/sailpoint-cli/internal/config"
"github.com/sailpoint-oss/sailpoint-cli/internal/sdk"
)
func GetTransforms() ([]sailpointsdk.Transform, error) {
func GetTransforms(apiClient sailpoint.APIClient) ([]sailpointsdk.Transform, error) {
var transforms []sailpointsdk.Transform
apiClient, err := config.InitAPIClient()
if err != nil {
return transforms, err
}
transforms, resp, err := sailpoint.PaginateWithDefaults[sailpointsdk.Transform](apiClient.V3.TransformsApi.ListTransforms(context.TODO()))
if err != nil {
return transforms, sdk.HandleSDKError(resp, err)
@@ -28,12 +22,7 @@ func GetTransforms() ([]sailpointsdk.Transform, error) {
return transforms, nil
}
func ListTransforms() error {
transforms, err := GetTransforms()
if err != nil {
return err
}
func ListTransforms(transforms []sailpointsdk.Transform) error {
table := tablewriter.NewWriter(os.Stdout)
table.SetHeader(transmodel.TransformColumns)

View File

@@ -39,8 +39,12 @@ func (d ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
fn := itemStyle.Render
if index == m.Index() {
fn = func(s string) string {
return selectedItemStyle.Render("> " + s)
fn = func(s ...string) string {
var fullString string
for _, v := range s {
fullString = fullString + v
}
return selectedItemStyle.Render("> " + fullString)
}
}

View File

@@ -5,10 +5,10 @@ import (
"fmt"
)
func PrettyPrint(v interface{}) {
func PrettyPrint(v interface{}) string {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
fmt.Println("error:", err)
}
fmt.Print(string(b))
return (string(b))
}

View File

@@ -6,14 +6,12 @@ import (
"errors"
"fmt"
"io"
"log"
"net"
"os"
"path"
"sync"
"time"
"github.com/fatih/color"
"github.com/charmbracelet/log"
"github.com/pkg/sftp"
"github.com/vbauerster/mpb/v8"
"github.com/vbauerster/mpb/v8/decor"
@@ -91,8 +89,9 @@ func RunVACmdLive(addr string, password string, cmd string) error {
return nil
}
func CollectVAFiles(endpoint string, password string, output string, files []string) error {
color.Blue("Starting File Collection for %s\n", endpoint)
func CollectVAFiles(endpoint string, password string, output string, files []string, p *mpb.Progress) error {
log.Info("Starting File Collection", "VA", endpoint)
config := &ssh.ClientConfig{
User: "sailpoint",
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
@@ -101,88 +100,88 @@ func CollectVAFiles(endpoint string, password string, output string, files []str
},
}
var wg sync.WaitGroup
// passed wg will be accounted at p.Wait() call
p := mpb.New(mpb.WithWidth(60),
mpb.PopCompletedMode(),
mpb.WithRefreshRate(180*time.Millisecond),
mpb.WithWaitGroup(&wg))
log.SetOutput(p)
outputFolder := path.Join(output, endpoint)
for i := 0; i < len(files); i++ {
filePath := files[i]
// Connect
client, err := ssh.Dial("tcp", net.JoinHostPort(endpoint, "22"), config)
if err != nil {
return fmt.Errorf("failed to dial: %v", err)
}
defer client.Close()
sftp, err := sftp.NewClient(client)
if err != nil {
return fmt.Errorf("failed to create SFTP client: %v", err)
}
defer sftp.Close()
var wg sync.WaitGroup
for _, filePath := range files {
wg.Add(1)
go func(filePath string) error {
// Connect
client, err := ssh.Dial("tcp", net.JoinHostPort(endpoint, "22"), config)
if err != nil {
fmt.Println(err)
}
sftp, err := sftp.NewClient(client)
if err != nil {
fmt.Println(err)
}
defer sftp.Close()
go func(filePath string) {
defer wg.Done()
_, base := path.Split(filePath)
outputFile := path.Join(outputFolder, base)
if _, err := os.Stat(outputFolder); errors.Is(err, os.ErrNotExist) {
err := os.MkdirAll(outputFolder, 0700)
if err != nil {
fmt.Println(err)
}
err := collectFile(sftp, filePath, outputFolder, endpoint, p)
if err != nil {
log.Warn("Error collecting file", "file", filePath, "VA", endpoint, "err", err)
}
remoteFileStats, statErr := sftp.Stat(filePath)
if statErr == nil {
name := fmt.Sprintf("%v - %v", endpoint, base)
bar := p.AddBar(remoteFileStats.Size(),
mpb.BarFillerClearOnComplete(),
mpb.PrependDecorators(
// simple name decorator
decor.Name(name, decor.WCSyncSpaceR),
decor.Name(":", decor.WCSyncSpaceR),
decor.OnComplete(decor.CountersKiloByte("% .2f / % .2f", decor.WCSyncSpaceR), "Complete"),
decor.TotalKiloByte("% .2f", decor.WCSyncSpaceR),
),
mpb.AppendDecorators(
decor.OnComplete(decor.EwmaSpeed(decor.UnitKB, "% .2f", 90, decor.WCSyncWidth), ""),
decor.OnComplete(decor.Percentage(decor.WC{W: 5}), ""),
),
)
// Open the source file
srcFile, err := sftp.Open(filePath)
if err != nil {
log.Println(err)
}
defer srcFile.Close()
// Create the destination file
dstFile, err := os.Create(outputFile)
if err != nil {
return err
}
defer dstFile.Close()
writer := io.Writer(dstFile)
// create proxy reader
proxyWriter := bar.ProxyWriter(writer)
defer proxyWriter.Close()
io.Copy(proxyWriter, srcFile)
}
return nil
}(filePath)
}
p.Wait()
wg.Wait()
return nil
}
func collectFile(sftp *sftp.Client, filePath, outputFolder, endpoint string, p *mpb.Progress) error {
_, base := path.Split(filePath)
outputFile := path.Join(outputFolder, base)
if _, err := os.Stat(outputFolder); errors.Is(err, os.ErrNotExist) {
err := os.MkdirAll(outputFolder, 0700)
if err != nil {
return fmt.Errorf("failed to create output folder: %v", err)
}
}
remoteFileStats, statErr := sftp.Stat(filePath)
if statErr != nil {
return fmt.Errorf("failed to stat remote file: %v", statErr)
}
name := fmt.Sprintf("%v - %v", endpoint, base)
bar := p.AddBar(remoteFileStats.Size(),
mpb.BarFillerClearOnComplete(),
mpb.PrependDecorators(
decor.Name(name, decor.WCSyncSpaceR),
decor.Name(":", decor.WCSyncSpaceR),
decor.OnComplete(decor.CountersKiloByte("% .2f / % .2f", decor.WCSyncSpaceR), "Complete"),
decor.TotalKiloByte("% .2f", decor.WCSyncSpaceR),
),
mpb.AppendDecorators(
decor.OnComplete(decor.CountersKibiByte("% .2f / % .2f", decor.WCSyncWidth), "Complete")),
)
srcFile, err := sftp.Open(filePath)
if err != nil {
return fmt.Errorf("failed to open remote file: %v", err)
}
defer srcFile.Close()
dstFile, err := os.Create(outputFile)
if err != nil {
return fmt.Errorf("failed to create local file: %v", err)
}
defer dstFile.Close()
writer := io.Writer(dstFile)
proxyWriter := bar.ProxyWriter(writer)
defer proxyWriter.Close()
_, err = io.Copy(proxyWriter, srcFile)
if err != nil {
return fmt.Errorf("failed to copy file contents: %v", err)
}
return nil
}

11
main.go
View File

@@ -2,9 +2,7 @@
package main
import (
"os"
"github.com/fatih/color"
"github.com/charmbracelet/log"
"github.com/sailpoint-oss/sailpoint-cli/cmd/root"
"github.com/sailpoint-oss/sailpoint-cli/internal/config"
"github.com/spf13/cobra"
@@ -26,13 +24,12 @@ func init() {
func main() {
err := rootCmd.Execute()
saveErr := config.SaveConfig()
if saveErr != nil {
color.Yellow("error saving config file", saveErr)
if saveErr := config.SaveConfig(); saveErr != nil {
log.Warn("Issue saving config file", "error", saveErr)
}
if err != nil {
os.Exit(1)
log.Fatal(err)
}
}

View File

@@ -1,3 +0,0 @@
.DS_Store
.*.sw?
/coverage.txt

View File

@@ -1,3 +0,0 @@
{
"settingsInheritedFrom": "VividCortex/whitesource-config@master"
}

View File

@@ -1,21 +0,0 @@
The MIT License
Copyright (c) 2013 VividCortex
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.

View File

@@ -1,145 +0,0 @@
# EWMA
[![GoDoc](https://godoc.org/github.com/VividCortex/ewma?status.svg)](https://godoc.org/github.com/VividCortex/ewma)
![build](https://github.com/VividCortex/ewma/workflows/build/badge.svg)
[![codecov](https://codecov.io/gh/VividCortex/ewma/branch/master/graph/badge.svg)](https://codecov.io/gh/VividCortex/ewma)
This repo provides Exponentially Weighted Moving Average algorithms, or EWMAs for short, [based on our
Quantifying Abnormal Behavior talk](https://vividcortex.com/blog/2013/07/23/a-fast-go-library-for-exponential-moving-averages/).
### Exponentially Weighted Moving Average
An exponentially weighted moving average is a way to continuously compute a type of
average for a series of numbers, as the numbers arrive. After a value in the series is
added to the average, its weight in the average decreases exponentially over time. This
biases the average towards more recent data. EWMAs are useful for several reasons, chiefly
their inexpensive computational and memory cost, as well as the fact that they represent
the recent central tendency of the series of values.
The EWMA algorithm requires a decay factor, alpha. The larger the alpha, the more the average
is biased towards recent history. The alpha must be between 0 and 1, and is typically
a fairly small number, such as 0.04. We will discuss the choice of alpha later.
The algorithm works thus, in pseudocode:
1. Multiply the next number in the series by alpha.
2. Multiply the current value of the average by 1 minus alpha.
3. Add the result of steps 1 and 2, and store it as the new current value of the average.
4. Repeat for each number in the series.
There are special-case behaviors for how to initialize the current value, and these vary
between implementations. One approach is to start with the first value in the series;
another is to average the first 10 or so values in the series using an arithmetic average,
and then begin the incremental updating of the average. Each method has pros and cons.
It may help to look at it pictorially. Suppose the series has five numbers, and we choose
alpha to be 0.50 for simplicity. Here's the series, with numbers in the neighborhood of 300.
![Data Series](https://user-images.githubusercontent.com/279875/28242350-463289a2-6977-11e7-88ca-fd778ccef1f0.png)
Now let's take the moving average of those numbers. First we set the average to the value
of the first number.
![EWMA Step 1](https://user-images.githubusercontent.com/279875/28242353-464c96bc-6977-11e7-9981-dc4e0789c7ba.png)
Next we multiply the next number by alpha, multiply the current value by 1-alpha, and add
them to generate a new value.
![EWMA Step 2](https://user-images.githubusercontent.com/279875/28242351-464abefa-6977-11e7-95d0-43900f29bef2.png)
This continues until we are done.
![EWMA Step N](https://user-images.githubusercontent.com/279875/28242352-464c58f0-6977-11e7-8cd0-e01e4efaac7f.png)
Notice how each of the values in the series decays by half each time a new value
is added, and the top of the bars in the lower portion of the image represents the
size of the moving average. It is a smoothed, or low-pass, average of the original
series.
For further reading, see [Exponentially weighted moving average](http://en.wikipedia.org/wiki/Moving_average#Exponential_moving_average) on wikipedia.
### Choosing Alpha
Consider a fixed-size sliding-window moving average (not an exponentially weighted moving average)
that averages over the previous N samples. What is the average age of each sample? It is N/2.
Now suppose that you wish to construct a EWMA whose samples have the same average age. The formula
to compute the alpha required for this is: alpha = 2/(N+1). Proof is in the book
"Production and Operations Analysis" by Steven Nahmias.
So, for example, if you have a time-series with samples once per second, and you want to get the
moving average over the previous minute, you should use an alpha of .032786885. This, by the way,
is the constant alpha used for this repository's SimpleEWMA.
### Implementations
This repository contains two implementations of the EWMA algorithm, with different properties.
The implementations all conform to the MovingAverage interface, and the constructor returns
that type.
Current implementations assume an implicit time interval of 1.0 between every sample added.
That is, the passage of time is treated as though it's the same as the arrival of samples.
If you need time-based decay when samples are not arriving precisely at set intervals, then
this package will not support your needs at present.
#### SimpleEWMA
A SimpleEWMA is designed for low CPU and memory consumption. It **will** have different behavior than the VariableEWMA
for multiple reasons. It has no warm-up period and it uses a constant
decay. These properties let it use less memory. It will also behave
differently when it's equal to zero, which is assumed to mean
uninitialized, so if a value is likely to actually become zero over time,
then any non-zero value will cause a sharp jump instead of a small change.
#### VariableEWMA
Unlike SimpleEWMA, this supports a custom age which must be stored, and thus uses more memory.
It also has a "warmup" time when you start adding values to it. It will report a value of 0.0
until you have added the required number of samples to it. It uses some memory to store the
number of samples added to it. As a result it uses a little over twice the memory of SimpleEWMA.
## Usage
### API Documentation
View the GoDoc generated documentation [here](http://godoc.org/github.com/VividCortex/ewma).
```go
package main
import "github.com/VividCortex/ewma"
func main() {
samples := [100]float64{
4599, 5711, 4746, 4621, 5037, 4218, 4925, 4281, 5207, 5203, 5594, 5149,
}
e := ewma.NewMovingAverage() //=> Returns a SimpleEWMA if called without params
a := ewma.NewMovingAverage(5) //=> returns a VariableEWMA with a decay of 2 / (5 + 1)
for _, f := range samples {
e.Add(f)
a.Add(f)
}
e.Value() //=> 13.577404704631077
a.Value() //=> 1.5806140565521463e-12
}
```
## Contributing
We only accept pull requests for minor fixes or improvements. This includes:
* Small bug fixes
* Typos
* Documentation or comments
Please open issues to discuss new features. Pull requests for new features will be rejected,
so we recommend forking the repository and making changes in your fork for your use case.
## License
This repository is Copyright (c) 2013 VividCortex, Inc. All rights reserved.
It is licensed under the MIT license. Please see the LICENSE file for applicable license terms.

View File

@@ -1,6 +0,0 @@
coverage:
status:
project:
default:
threshold: 15%
patch: off

View File

@@ -1,126 +0,0 @@
// Package ewma implements exponentially weighted moving averages.
package ewma
// Copyright (c) 2013 VividCortex, Inc. All rights reserved.
// Please see the LICENSE file for applicable license terms.
const (
// By default, we average over a one-minute period, which means the average
// age of the metrics in the period is 30 seconds.
AVG_METRIC_AGE float64 = 30.0
// The formula for computing the decay factor from the average age comes
// from "Production and Operations Analysis" by Steven Nahmias.
DECAY float64 = 2 / (float64(AVG_METRIC_AGE) + 1)
// For best results, the moving average should not be initialized to the
// samples it sees immediately. The book "Production and Operations
// Analysis" by Steven Nahmias suggests initializing the moving average to
// the mean of the first 10 samples. Until the VariableEwma has seen this
// many samples, it is not "ready" to be queried for the value of the
// moving average. This adds some memory cost.
WARMUP_SAMPLES uint8 = 10
)
// MovingAverage is the interface that computes a moving average over a time-
// series stream of numbers. The average may be over a window or exponentially
// decaying.
type MovingAverage interface {
Add(float64)
Value() float64
Set(float64)
}
// NewMovingAverage constructs a MovingAverage that computes an average with the
// desired characteristics in the moving window or exponential decay. If no
// age is given, it constructs a default exponentially weighted implementation
// that consumes minimal memory. The age is related to the decay factor alpha
// by the formula given for the DECAY constant. It signifies the average age
// of the samples as time goes to infinity.
func NewMovingAverage(age ...float64) MovingAverage {
if len(age) == 0 || age[0] == AVG_METRIC_AGE {
return new(SimpleEWMA)
}
return &VariableEWMA{
decay: 2 / (age[0] + 1),
}
}
// A SimpleEWMA represents the exponentially weighted moving average of a
// series of numbers. It WILL have different behavior than the VariableEWMA
// for multiple reasons. It has no warm-up period and it uses a constant
// decay. These properties let it use less memory. It will also behave
// differently when it's equal to zero, which is assumed to mean
// uninitialized, so if a value is likely to actually become zero over time,
// then any non-zero value will cause a sharp jump instead of a small change.
// However, note that this takes a long time, and the value may just
// decays to a stable value that's close to zero, but which won't be mistaken
// for uninitialized. See http://play.golang.org/p/litxBDr_RC for example.
type SimpleEWMA struct {
// The current value of the average. After adding with Add(), this is
// updated to reflect the average of all values seen thus far.
value float64
}
// Add adds a value to the series and updates the moving average.
func (e *SimpleEWMA) Add(value float64) {
if e.value == 0 { // this is a proxy for "uninitialized"
e.value = value
} else {
e.value = (value * DECAY) + (e.value * (1 - DECAY))
}
}
// Value returns the current value of the moving average.
func (e *SimpleEWMA) Value() float64 {
return e.value
}
// Set sets the EWMA's value.
func (e *SimpleEWMA) Set(value float64) {
e.value = value
}
// VariableEWMA represents the exponentially weighted moving average of a series of
// numbers. Unlike SimpleEWMA, it supports a custom age, and thus uses more memory.
type VariableEWMA struct {
// The multiplier factor by which the previous samples decay.
decay float64
// The current value of the average.
value float64
// The number of samples added to this instance.
count uint8
}
// Add adds a value to the series and updates the moving average.
func (e *VariableEWMA) Add(value float64) {
switch {
case e.count < WARMUP_SAMPLES:
e.count++
e.value += value
case e.count == WARMUP_SAMPLES:
e.count++
e.value = e.value / float64(WARMUP_SAMPLES)
e.value = (value * e.decay) + (e.value * (1 - e.decay))
default:
e.value = (value * e.decay) + (e.value * (1 - e.decay))
}
}
// Value returns the current value of the average, or 0.0 if the series hasn't
// warmed up yet.
func (e *VariableEWMA) Value() float64 {
if e.count <= WARMUP_SAMPLES {
return 0.0
}
return e.value
}
// Set sets the EWMA's value.
func (e *VariableEWMA) Set(value float64) {
e.value = value
if e.count <= WARMUP_SAMPLES {
e.count = WARMUP_SAMPLES + 1
}
}

View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2018 Andrew Carlson
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.

View File

@@ -1,30 +0,0 @@
Strip ANSI
==========
This Go package removes ANSI escape codes from strings.
Ideally, we would prevent these from appearing in any text we want to process.
However, sometimes this can't be helped, and we need to be able to deal with that noise.
This will use a regexp to remove those unwanted escape codes.
## Install
```sh
$ go get -u github.com/acarl005/stripansi
```
## Usage
```go
import (
"fmt"
"github.com/acarl005/stripansi"
)
func main() {
msg := "\x1b[38;5;140m foo\x1b[0m bar"
cleanMsg := stripansi.Strip(msg)
fmt.Println(cleanMsg) // " foo bar"
}
```

View File

@@ -1,13 +0,0 @@
package stripansi
import (
"regexp"
)
const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))"
var re = regexp.MustCompile(ansi)
func Strip(str string) string {
return re.ReplaceAllString(str, "")
}

View File

@@ -1,22 +0,0 @@
language: go
os:
- linux
- osx
- windows
go:
- go1.13.x
- go1.x
services:
- xvfb
before_install:
- export DISPLAY=:99.0
script:
- if [ "$TRAVIS_OS_NAME" = "linux" ]; then sudo apt-get install xsel; fi
- go test -v .
- if [ "$TRAVIS_OS_NAME" = "linux" ]; then sudo apt-get install xclip; fi
- go test -v .

View File

@@ -1,27 +0,0 @@
Copyright (c) 2013 Ato Araki. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of @atotto. nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -1,48 +0,0 @@
[![Build Status](https://travis-ci.org/atotto/clipboard.svg?branch=master)](https://travis-ci.org/atotto/clipboard)
[![GoDoc](https://godoc.org/github.com/atotto/clipboard?status.svg)](http://godoc.org/github.com/atotto/clipboard)
# Clipboard for Go
Provide copying and pasting to the Clipboard for Go.
Build:
$ go get github.com/atotto/clipboard
Platforms:
* OSX
* Windows 7 (probably work on other Windows)
* Linux, Unix (requires 'xclip' or 'xsel' command to be installed)
Document:
* http://godoc.org/github.com/atotto/clipboard
Notes:
* Text string only
* UTF-8 text encoding only (no conversion)
TODO:
* Clipboard watcher(?)
## Commands:
paste shell command:
$ go get github.com/atotto/clipboard/cmd/gopaste
$ # example:
$ gopaste > document.txt
copy shell command:
$ go get github.com/atotto/clipboard/cmd/gocopy
$ # example:
$ cat document.txt | gocopy

View File

@@ -1,20 +0,0 @@
// Copyright 2013 @atotto. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package clipboard read/write on clipboard
package clipboard
// ReadAll read string from clipboard
func ReadAll() (string, error) {
return readAll()
}
// WriteAll write string to clipboard
func WriteAll(text string) error {
return writeAll(text)
}
// Unsupported might be set true during clipboard init, to help callers decide
// whether or not to offer clipboard options.
var Unsupported bool

View File

@@ -1,52 +0,0 @@
// Copyright 2013 @atotto. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build darwin
package clipboard
import (
"os/exec"
)
var (
pasteCmdArgs = "pbpaste"
copyCmdArgs = "pbcopy"
)
func getPasteCommand() *exec.Cmd {
return exec.Command(pasteCmdArgs)
}
func getCopyCommand() *exec.Cmd {
return exec.Command(copyCmdArgs)
}
func readAll() (string, error) {
pasteCmd := getPasteCommand()
out, err := pasteCmd.Output()
if err != nil {
return "", err
}
return string(out), nil
}
func writeAll(text string) error {
copyCmd := getCopyCommand()
in, err := copyCmd.StdinPipe()
if err != nil {
return err
}
if err := copyCmd.Start(); err != nil {
return err
}
if _, err := in.Write([]byte(text)); err != nil {
return err
}
if err := in.Close(); err != nil {
return err
}
return copyCmd.Wait()
}

View File

@@ -1,42 +0,0 @@
// Copyright 2013 @atotto. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build plan9
package clipboard
import (
"os"
"io/ioutil"
)
func readAll() (string, error) {
f, err := os.Open("/dev/snarf")
if err != nil {
return "", err
}
defer f.Close()
str, err := ioutil.ReadAll(f)
if err != nil {
return "", err
}
return string(str), nil
}
func writeAll(text string) error {
f, err := os.OpenFile("/dev/snarf", os.O_WRONLY, 0666)
if err != nil {
return err
}
defer f.Close()
_, err = f.Write([]byte(text))
if err != nil {
return err
}
return nil
}

View File

@@ -1,149 +0,0 @@
// Copyright 2013 @atotto. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build freebsd linux netbsd openbsd solaris dragonfly
package clipboard
import (
"errors"
"os"
"os/exec"
)
const (
xsel = "xsel"
xclip = "xclip"
powershellExe = "powershell.exe"
clipExe = "clip.exe"
wlcopy = "wl-copy"
wlpaste = "wl-paste"
termuxClipboardGet = "termux-clipboard-get"
termuxClipboardSet = "termux-clipboard-set"
)
var (
Primary bool
trimDos bool
pasteCmdArgs []string
copyCmdArgs []string
xselPasteArgs = []string{xsel, "--output", "--clipboard"}
xselCopyArgs = []string{xsel, "--input", "--clipboard"}
xclipPasteArgs = []string{xclip, "-out", "-selection", "clipboard"}
xclipCopyArgs = []string{xclip, "-in", "-selection", "clipboard"}
powershellExePasteArgs = []string{powershellExe, "Get-Clipboard"}
clipExeCopyArgs = []string{clipExe}
wlpasteArgs = []string{wlpaste, "--no-newline"}
wlcopyArgs = []string{wlcopy}
termuxPasteArgs = []string{termuxClipboardGet}
termuxCopyArgs = []string{termuxClipboardSet}
missingCommands = errors.New("No clipboard utilities available. Please install xsel, xclip, wl-clipboard or Termux:API add-on for termux-clipboard-get/set.")
)
func init() {
if os.Getenv("WAYLAND_DISPLAY") != "" {
pasteCmdArgs = wlpasteArgs
copyCmdArgs = wlcopyArgs
if _, err := exec.LookPath(wlcopy); err == nil {
if _, err := exec.LookPath(wlpaste); err == nil {
return
}
}
}
pasteCmdArgs = xclipPasteArgs
copyCmdArgs = xclipCopyArgs
if _, err := exec.LookPath(xclip); err == nil {
return
}
pasteCmdArgs = xselPasteArgs
copyCmdArgs = xselCopyArgs
if _, err := exec.LookPath(xsel); err == nil {
return
}
pasteCmdArgs = termuxPasteArgs
copyCmdArgs = termuxCopyArgs
if _, err := exec.LookPath(termuxClipboardSet); err == nil {
if _, err := exec.LookPath(termuxClipboardGet); err == nil {
return
}
}
pasteCmdArgs = powershellExePasteArgs
copyCmdArgs = clipExeCopyArgs
trimDos = true
if _, err := exec.LookPath(clipExe); err == nil {
if _, err := exec.LookPath(powershellExe); err == nil {
return
}
}
Unsupported = true
}
func getPasteCommand() *exec.Cmd {
if Primary {
pasteCmdArgs = pasteCmdArgs[:1]
}
return exec.Command(pasteCmdArgs[0], pasteCmdArgs[1:]...)
}
func getCopyCommand() *exec.Cmd {
if Primary {
copyCmdArgs = copyCmdArgs[:1]
}
return exec.Command(copyCmdArgs[0], copyCmdArgs[1:]...)
}
func readAll() (string, error) {
if Unsupported {
return "", missingCommands
}
pasteCmd := getPasteCommand()
out, err := pasteCmd.Output()
if err != nil {
return "", err
}
result := string(out)
if trimDos && len(result) > 1 {
result = result[:len(result)-2]
}
return result, nil
}
func writeAll(text string) error {
if Unsupported {
return missingCommands
}
copyCmd := getCopyCommand()
in, err := copyCmd.StdinPipe()
if err != nil {
return err
}
if err := copyCmd.Start(); err != nil {
return err
}
if _, err := in.Write([]byte(text)); err != nil {
return err
}
if err := in.Close(); err != nil {
return err
}
return copyCmd.Wait()
}

View File

@@ -1,157 +0,0 @@
// Copyright 2013 @atotto. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build windows
package clipboard
import (
"runtime"
"syscall"
"time"
"unsafe"
)
const (
cfUnicodetext = 13
gmemMoveable = 0x0002
)
var (
user32 = syscall.MustLoadDLL("user32")
isClipboardFormatAvailable = user32.MustFindProc("IsClipboardFormatAvailable")
openClipboard = user32.MustFindProc("OpenClipboard")
closeClipboard = user32.MustFindProc("CloseClipboard")
emptyClipboard = user32.MustFindProc("EmptyClipboard")
getClipboardData = user32.MustFindProc("GetClipboardData")
setClipboardData = user32.MustFindProc("SetClipboardData")
kernel32 = syscall.NewLazyDLL("kernel32")
globalAlloc = kernel32.NewProc("GlobalAlloc")
globalFree = kernel32.NewProc("GlobalFree")
globalLock = kernel32.NewProc("GlobalLock")
globalUnlock = kernel32.NewProc("GlobalUnlock")
lstrcpy = kernel32.NewProc("lstrcpyW")
)
// waitOpenClipboard opens the clipboard, waiting for up to a second to do so.
func waitOpenClipboard() error {
started := time.Now()
limit := started.Add(time.Second)
var r uintptr
var err error
for time.Now().Before(limit) {
r, _, err = openClipboard.Call(0)
if r != 0 {
return nil
}
time.Sleep(time.Millisecond)
}
return err
}
func readAll() (string, error) {
// LockOSThread ensure that the whole method will keep executing on the same thread from begin to end (it actually locks the goroutine thread attribution).
// Otherwise if the goroutine switch thread during execution (which is a common practice), the OpenClipboard and CloseClipboard will happen on two different threads, and it will result in a clipboard deadlock.
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if formatAvailable, _, err := isClipboardFormatAvailable.Call(cfUnicodetext); formatAvailable == 0 {
return "", err
}
err := waitOpenClipboard()
if err != nil {
return "", err
}
h, _, err := getClipboardData.Call(cfUnicodetext)
if h == 0 {
_, _, _ = closeClipboard.Call()
return "", err
}
l, _, err := globalLock.Call(h)
if l == 0 {
_, _, _ = closeClipboard.Call()
return "", err
}
text := syscall.UTF16ToString((*[1 << 20]uint16)(unsafe.Pointer(l))[:])
r, _, err := globalUnlock.Call(h)
if r == 0 {
_, _, _ = closeClipboard.Call()
return "", err
}
closed, _, err := closeClipboard.Call()
if closed == 0 {
return "", err
}
return text, nil
}
func writeAll(text string) error {
// LockOSThread ensure that the whole method will keep executing on the same thread from begin to end (it actually locks the goroutine thread attribution).
// Otherwise if the goroutine switch thread during execution (which is a common practice), the OpenClipboard and CloseClipboard will happen on two different threads, and it will result in a clipboard deadlock.
runtime.LockOSThread()
defer runtime.UnlockOSThread()
err := waitOpenClipboard()
if err != nil {
return err
}
r, _, err := emptyClipboard.Call(0)
if r == 0 {
_, _, _ = closeClipboard.Call()
return err
}
data := syscall.StringToUTF16(text)
// "If the hMem parameter identifies a memory object, the object must have
// been allocated using the function with the GMEM_MOVEABLE flag."
h, _, err := globalAlloc.Call(gmemMoveable, uintptr(len(data)*int(unsafe.Sizeof(data[0]))))
if h == 0 {
_, _, _ = closeClipboard.Call()
return err
}
defer func() {
if h != 0 {
globalFree.Call(h)
}
}()
l, _, err := globalLock.Call(h)
if l == 0 {
_, _, _ = closeClipboard.Call()
return err
}
r, _, err = lstrcpy.Call(l, uintptr(unsafe.Pointer(&data[0])))
if r == 0 {
_, _, _ = closeClipboard.Call()
return err
}
r, _, err = globalUnlock.Call(h)
if r == 0 {
if err.(syscall.Errno) != 0 {
_, _, _ = closeClipboard.Call()
return err
}
}
r, _, err = setClipboardData.Call(cfUnicodetext, h)
if r == 0 {
_, _, _ = closeClipboard.Call()
return err
}
h = 0 // suppress deferred cleanup
closed, _, err := closeClipboard.Call()
if closed == 0 {
return err
}
return nil
}

View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2022 Ayman Bagabas
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.

View File

@@ -1,63 +0,0 @@
# go-osc52
<p>
<a href="https://github.com/aymanbagabas/go-osc52/releases"><img src="https://img.shields.io/github/release/aymanbagabas/go-osc52.svg" alt="Latest Release"></a>
<a href="https://pkg.go.dev/github.com/aymanbagabas/go-osc52?tab=doc"><img src="https://godoc.org/github.com/golang/gddo?status.svg" alt="GoDoc"></a>
</p>
A Go library to work with the [ANSI OSC52](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands) terminal sequence.
## Example
```go
str := "Hello World!"
osc52.Copy(str) // Copies str to system clipboard
osc52.CopyPrimary(str) // Copies str to primary clipboard (X11 only)
```
## SSH Example
You can use this over SSH using [gliderlabs/ssh](https://github.com/gliderlabs/ssh) for instance:
```go
envs := sshSession.Environ()
pty, _, _ := s.Pty()
envs = append(envs, "TERM="+pty.Term)
output := NewOutput(sshSession, envs)
// Copy text in your application
output.Copy("Hello awesome!")
```
If you're using tmux, you could pass the `TMUX` environment variable to help detect tmux:
```sh
ssh -o SendEnv=TMUX <host>
```
### Tmux users
If you're using tmux, make sure you set `set -g default-terminal` in your tmux
config, to a value that starts with `tmux-`. `tmux-256color` for instance. See
[this](https://github.com/tmux/tmux/wiki/FAQ#why-do-you-use-the-screen-terminal-description-inside-tmux)
for more details.
`go-osc52` will wrap the OSC52 sequence in a `tmux` escape sequence if tmux is
detected. If you're running tmux >= 3.3, OSC52 won't work and you'll need to set
the `set -g allow-passthrough on` in your tmux config.
```tmux
set -g allow-passthrough on
```
or set `set -g set-clipboard on` in your tmux config and use your outer terminal in your code instead:
```go
// Assuming this code is running in tmux >= 3.3 in kitty
seq := osc52.Sequence("Hello awesome!", "xterm-kitty", osc52.ClipboardC)
os.Stderr.WriteString(seq)
```
## Credits
* [vim-oscyank](https://github.com/ojroques/vim-oscyank) this is heavily inspired by vim-oscyank.

View File

@@ -1,219 +0,0 @@
// OSC52 is a terminal escape sequence that allows copying text to the clipboard.
//
// The sequence consists of the following:
//
// OSC 52 ; Pc ; Pd BEL
//
// Pc is the clipboard choice:
//
// c: clipboard
// p: primary
// q: secondary (not supported)
// s: select (not supported)
// 0-7: cut-buffers (not supported)
//
// Pd is the data to copy to the clipboard. This string should be encoded in
// base64 (RFC-4648).
//
// If Pd is "?", the terminal replies to the host with the current contents of
// the clipboard.
//
// If Pd is neither a base64 string nor "?", the terminal clears the clipboard.
//
// See https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands
// where Ps = 52 => Manipulate Selection Data.
package osc52
import (
"encoding/base64"
"fmt"
"io"
"os"
"strings"
)
// Clipboard is the clipboard buffer to use.
type Clipboard uint
const (
// SystemClipboard is the system clipboard buffer.
SystemClipboard Clipboard = iota
// PrimaryClipboard is the primary clipboard buffer (X11).
PrimaryClipboard
)
// String implements the fmt.Stringer interface for [Clipboard].
func (c Clipboard) String() string {
return []string{
"c", "p",
}[c]
}
// output is the default output for Copy which uses os.Stdout and os.Environ.
var output = NewOutput(os.Stdout, os.Environ())
// envs is a map of environment variables.
type envs map[string]string
// Get returns the value of the environment variable named by the key.
func (e envs) Get(key string) string {
v, ok := e[key]
if !ok {
return ""
}
return v
}
// Output is where the OSC52 string should be written.
type Output struct {
out io.Writer
envs envs
}
// NewOutput returns a new Output.
func NewOutput(out io.Writer, envs []string) *Output {
e := make(map[string]string, 0)
for _, env := range envs {
s := strings.Split(env, "=")
k := s[0]
v := strings.Join(s[1:], "=")
e[k] = v
}
o := &Output{
out: out,
envs: e,
}
return o
}
// DefaultOutput returns the default output for Copy.
func DefaultOutput() *Output {
return output
}
// Copy copies the OSC52 string to the output. This uses the system clipboard buffer.
func Copy(str string) {
output.Copy(str)
}
// Copy copies the OSC52 string to the output. This uses the system clipboard buffer.
func (o *Output) Copy(str string) {
o.CopyClipboard(str, SystemClipboard)
}
// CopyPrimary copies the OSC52 string to the output. This uses the primary clipboard buffer.
func CopyPrimary(str string) {
output.CopyPrimary(str)
}
// CopyPrimary copies the OSC52 string to the output. This uses the primary clipboard buffer.
func (o *Output) CopyPrimary(str string) {
o.CopyClipboard(str, PrimaryClipboard)
}
// CopyClipboard copies the OSC52 string to the output. This uses the passed clipboard buffer.
func CopyClipboard(str string, c Clipboard) {
output.CopyClipboard(str, c)
}
// CopyClipboard copies the OSC52 string to the output. This uses the passed clipboard buffer.
func (o *Output) CopyClipboard(str string, c Clipboard) {
o.osc52Write(str, c)
}
func (o *Output) osc52Write(str string, c Clipboard) {
var seq string
term := strings.ToLower(o.envs.Get("TERM"))
switch {
case o.envs.Get("TMUX") != "", strings.HasPrefix(term, "tmux"):
seq = Sequence(str, "tmux", c)
case strings.HasPrefix(term, "screen"):
seq = Sequence(str, "screen", c)
case strings.Contains(term, "kitty"):
// First, we flush the keyboard before copying, this is required for
// Kitty < 0.22.0.
o.out.Write([]byte(Clear(term, c)))
seq = Sequence(str, "kitty", c)
default:
seq = Sequence(str, term, c)
}
o.out.Write([]byte(seq))
}
func seqStart(term string, c Clipboard) string {
var seq strings.Builder
switch {
case strings.Contains(term, "tmux"):
// Write the start of a tmux escape sequence.
seq.WriteString("\x1bPtmux;\x1b")
case strings.Contains(term, "screen"):
// Write the start of a DCS sequence.
seq.WriteString("\x1bP")
}
// OSC52 sequence start.
seq.WriteString(fmt.Sprintf("\x1b]52;%s;", c))
return seq.String()
}
func seqEnd(term string) string {
var seq strings.Builder
// OSC52 sequence end.
seq.WriteString("\x07")
switch {
case strings.Contains(term, "tmux"):
// Terminate the tmux escape sequence.
seq.WriteString("\x1b\\")
case strings.Contains(term, "screen"):
// Write the end of a DCS sequence.
seq.WriteString("\x1b\x5c")
}
return seq.String()
}
// sequence returns the OSC52 sequence for the passed content.
// Beware that the string here is not base64 encoded.
func sequence(contents string, term string, c Clipboard) string {
var seq strings.Builder
term = strings.ToLower(term)
seq.WriteString(seqStart(term, c))
switch {
case strings.Contains(term, "screen"):
// Screen doesn't support OSC52 but will pass the contents of a DCS sequence to
// the outer terminal unchanged.
//
// Here, we split the encoded string into 76 bytes chunks and then join the
// chunks with <end-dsc><start-dsc> sequences. Finally, wrap the whole thing in
// <start-dsc><start-osc52><joined-chunks><end-osc52><end-dsc>.
s := strings.SplitN(contents, "", 76)
seq.WriteString(strings.Join(s, "\x1b\\\x1bP"))
default:
seq.WriteString(contents)
}
seq.WriteString(seqEnd(term))
return seq.String()
}
// Sequence returns the OSC52 sequence for the given string, terminal, and clipboard choice.
func Sequence(str string, term string, c Clipboard) string {
b64 := base64.StdEncoding.EncodeToString([]byte(str))
return sequence(b64, term, c)
}
// Contents returns the contents of the clipboard.
func Contents(term string, c Clipboard) string {
var seq strings.Builder
seq.WriteString(seqStart(term, c))
seq.WriteString("?")
seq.WriteString(seqEnd(term))
return seq.String()
}
// Clear returns the OSC52 sequence to clear the clipboard.
func Clear(term string, c Clipboard) string {
var seq strings.Builder
seq.WriteString(seqStart(term, c))
// Clear the clipboard
seq.WriteString("!")
seq.WriteString(seqEnd(term))
return seq.String()
}

View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2020 Charmbracelet, Inc
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.

View File

@@ -1,207 +0,0 @@
package cursor
import (
"context"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const defaultBlinkSpeed = time.Millisecond * 530
// initialBlinkMsg initializes cursor blinking.
type initialBlinkMsg struct{}
// BlinkMsg signals that the cursor should blink. It contains metadata that
// allows us to tell if the blink message is the one we're expecting.
type BlinkMsg struct {
id int
tag int
}
// blinkCanceled is sent when a blink operation is canceled.
type blinkCanceled struct{}
// blinkCtx manages cursor blinking.
type blinkCtx struct {
ctx context.Context
cancel context.CancelFunc
}
// Mode describes the behavior of the cursor.
type Mode int
// Available cursor modes.
const (
CursorBlink Mode = iota
CursorStatic
CursorHide
)
// String returns the cursor mode in a human-readable format. This method is
// provisional and for informational purposes only.
func (c Mode) String() string {
return [...]string{
"blink",
"static",
"hidden",
}[c]
}
// Model is the Bubble Tea model for this cursor element.
type Model struct {
BlinkSpeed time.Duration
// Style for styling the cursor block.
Style lipgloss.Style
// TextStyle is the style used for the cursor when it is hidden (when blinking).
// I.e. displaying normal text.
TextStyle lipgloss.Style
// char is the character under the cursor
char string
// The ID of this Model as it relates to other cursors
id int
// focus indicates whether the containing input is focused
focus bool
// Cursor Blink state.
Blink bool
// Used to manage cursor blink
blinkCtx *blinkCtx
// The ID of the blink message we're expecting to receive.
blinkTag int
// mode determines the behavior of the cursor
mode Mode
}
// New creates a new model with default settings.
func New() Model {
return Model{
BlinkSpeed: defaultBlinkSpeed,
Blink: true,
mode: CursorBlink,
blinkCtx: &blinkCtx{
ctx: context.Background(),
},
}
}
// Update updates the cursor.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case initialBlinkMsg:
// We accept all initialBlinkMsgs generated by the Blink command.
if m.mode != CursorBlink || !m.focus {
return m, nil
}
cmd := m.BlinkCmd()
return m, cmd
case BlinkMsg:
// We're choosy about whether to accept blinkMsgs so that our cursor
// only exactly when it should.
// Is this model blink-able?
if m.mode != CursorBlink || !m.focus {
return m, nil
}
// Were we expecting this blink message?
if msg.id != m.id || msg.tag != m.blinkTag {
return m, nil
}
var cmd tea.Cmd
if m.mode == CursorBlink {
m.Blink = !m.Blink
cmd = m.BlinkCmd()
}
return m, cmd
case blinkCanceled: // no-op
return m, nil
}
return m, nil
}
// Mode returns the model's cursor mode. For available cursor modes, see
// type Mode.
func (m Model) Mode() Mode {
return m.mode
}
// SetMode sets the model's cursor mode. This method returns a command.
//
// For available cursor modes, see type CursorMode.
func (m *Model) SetMode(mode Mode) tea.Cmd {
m.mode = mode
m.Blink = m.mode == CursorHide || !m.focus
if mode == CursorBlink {
return Blink
}
return nil
}
// BlinkCmd is an command used to manage cursor blinking.
func (m *Model) BlinkCmd() tea.Cmd {
if m.mode != CursorBlink {
return nil
}
if m.blinkCtx != nil && m.blinkCtx.cancel != nil {
m.blinkCtx.cancel()
}
ctx, cancel := context.WithTimeout(m.blinkCtx.ctx, m.BlinkSpeed)
m.blinkCtx.cancel = cancel
m.blinkTag++
return func() tea.Msg {
defer cancel()
<-ctx.Done()
if ctx.Err() == context.DeadlineExceeded {
return BlinkMsg{id: m.id, tag: m.blinkTag}
}
return blinkCanceled{}
}
}
// Blink is a command used to initialize cursor blinking.
func Blink() tea.Msg {
return initialBlinkMsg{}
}
// Focus focuses the cursor to allow it to blink if desired.
func (m *Model) Focus() tea.Cmd {
m.focus = true
m.Blink = m.mode == CursorHide // show the cursor unless we've explicitly hidden it
if m.mode == CursorBlink && m.focus {
return m.BlinkCmd()
}
return nil
}
// Blur blurs the cursor.
func (m *Model) Blur() {
m.focus = false
m.Blink = true
}
// SetChar sets the character under the cursor.
func (m *Model) SetChar(char string) {
m.char = char
}
// View displays the cursor.
func (m Model) View() string {
if m.Blink {
return m.TextStyle.Inline(true).Render(m.char)
}
return m.Style.Inline(true).Reverse(true).Render(m.char)
}

View File

@@ -1,233 +0,0 @@
package help
import (
"strings"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// KeyMap is a map of keybindings used to generate help. Since it's an
// interface it can be any type, though struct or a map[string][]key.Binding
// are likely candidates.
//
// Note that if a key is disabled (via key.Binding.SetEnabled) it will not be
// rendered in the help view, so in theory generated help should self-manage.
type KeyMap interface {
// ShortHelp returns a slice of bindings to be displayed in the short
// version of the help. The help bubble will render help in the order in
// which the help items are returned here.
ShortHelp() []key.Binding
// MoreHelp returns an extended group of help items, grouped by columns.
// The help bubble will render the help in the order in which the help
// items are returned here.
FullHelp() [][]key.Binding
}
// Styles is a set of available style definitions for the Help bubble.
type Styles struct {
Ellipsis lipgloss.Style
// Styling for the short help
ShortKey lipgloss.Style
ShortDesc lipgloss.Style
ShortSeparator lipgloss.Style
// Styling for the full help
FullKey lipgloss.Style
FullDesc lipgloss.Style
FullSeparator lipgloss.Style
}
// Model contains the state of the help view.
type Model struct {
Width int
ShowAll bool // if true, render the "full" help menu
ShortSeparator string
FullSeparator string
// The symbol we use in the short help when help items have been truncated
// due to width. Periods of ellipsis by default.
Ellipsis string
Styles Styles
}
// New creates a new help view with some useful defaults.
func New() Model {
keyStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
Light: "#909090",
Dark: "#626262",
})
descStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
Light: "#B2B2B2",
Dark: "#4A4A4A",
})
sepStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
Light: "#DDDADA",
Dark: "#3C3C3C",
})
return Model{
ShortSeparator: " • ",
FullSeparator: " ",
Ellipsis: "…",
Styles: Styles{
ShortKey: keyStyle,
ShortDesc: descStyle,
ShortSeparator: sepStyle,
Ellipsis: sepStyle.Copy(),
FullKey: keyStyle.Copy(),
FullDesc: descStyle.Copy(),
FullSeparator: sepStyle.Copy(),
},
}
}
// NewModel creates a new help view with some useful defaults.
//
// Deprecated: use [New] instead.
var NewModel = New
// Update helps satisfy the Bubble Tea Model interface. It's a no-op.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
return m, nil
}
// View renders the help view's current state.
func (m Model) View(k KeyMap) string {
if m.ShowAll {
return m.FullHelpView(k.FullHelp())
}
return m.ShortHelpView(k.ShortHelp())
}
// ShortHelpView renders a single line help view from a slice of keybindings.
// If the line is longer than the maximum width it will be gracefully
// truncated, showing only as many help items as possible.
func (m Model) ShortHelpView(bindings []key.Binding) string {
if len(bindings) == 0 {
return ""
}
var b strings.Builder
var totalWidth int
var separator = m.Styles.ShortSeparator.Inline(true).Render(m.ShortSeparator)
for i, kb := range bindings {
if !kb.Enabled() {
continue
}
var sep string
if totalWidth > 0 && i < len(bindings) {
sep = separator
}
str := sep +
m.Styles.ShortKey.Inline(true).Render(kb.Help().Key) + " " +
m.Styles.ShortDesc.Inline(true).Render(kb.Help().Desc)
w := lipgloss.Width(str)
// If adding this help item would go over the available width, stop
// drawing.
if m.Width > 0 && totalWidth+w > m.Width {
// Although if there's room for an ellipsis, print that.
tail := " " + m.Styles.Ellipsis.Inline(true).Render(m.Ellipsis)
tailWidth := lipgloss.Width(tail)
if totalWidth+tailWidth < m.Width {
b.WriteString(tail)
}
break
}
totalWidth += w
b.WriteString(str)
}
return b.String()
}
// FullHelpView renders help columns from a slice of key binding slices. Each
// top level slice entry renders into a column.
func (m Model) FullHelpView(groups [][]key.Binding) string {
if len(groups) == 0 {
return ""
}
// Linter note: at this time we don't think it's worth the additional
// code complexity involved in preallocating this slice.
//nolint:prealloc
var (
out []string
totalWidth int
sep = m.Styles.FullSeparator.Render(m.FullSeparator)
sepWidth = lipgloss.Width(sep)
)
// Iterate over groups to build columns
for i, group := range groups {
if group == nil || !shouldRenderColumn(group) {
continue
}
var (
keys []string
descriptions []string
)
// Separate keys and descriptions into different slices
for _, kb := range group {
if !kb.Enabled() {
continue
}
keys = append(keys, kb.Help().Key)
descriptions = append(descriptions, kb.Help().Desc)
}
col := lipgloss.JoinHorizontal(lipgloss.Top,
m.Styles.FullKey.Render(strings.Join(keys, "\n")),
m.Styles.FullKey.Render(" "),
m.Styles.FullDesc.Render(strings.Join(descriptions, "\n")),
)
// Column
totalWidth += lipgloss.Width(col)
if m.Width > 0 && totalWidth > m.Width {
break
}
out = append(out, col)
// Separator
if i < len(group)-1 {
totalWidth += sepWidth
if m.Width > 0 && totalWidth > m.Width {
break
}
}
out = append(out, sep)
}
return lipgloss.JoinHorizontal(lipgloss.Top, out...)
}
func shouldRenderColumn(b []key.Binding) (ok bool) {
for _, v := range b {
if v.Enabled() {
return true
}
}
return false
}

View File

@@ -1,142 +0,0 @@
// Package key provides some types and functions for generating user-definable
// keymappings useful in Bubble Tea components. There are a few different ways
// you can define a keymapping with this package. Here's one example:
//
// type KeyMap struct {
// Up key.Binding
// Down key.Binding
// }
//
// var DefaultKeyMap = KeyMap{
// Up: key.NewBinding(
// key.WithKeys("k", "up"), // actual keybindings
// key.WithHelp("↑/k", "move up"), // corresponding help text
// ),
// Down: key.NewBinding(
// key.WithKeys("j", "down"),
// key.WithHelp("↓/j", "move down"),
// ),
// }
//
// func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// switch msg := msg.(type) {
// case tea.KeyMsg:
// switch {
// case key.Matches(msg, DefaultKeyMap.Up):
// // The user pressed up
// case key.Matches(msg, DefaultKeyMap.Down):
// // The user pressed down
// }
// }
//
// // ...
// }
//
// The help information, which is not used in the example above, can be used
// to render help text for keystrokes in your views.
package key
import (
tea "github.com/charmbracelet/bubbletea"
)
// Binding describes a set of keybindings and, optionally, their associated
// help text.
type Binding struct {
keys []string
help Help
disabled bool
}
// BindingOpt is an initialization option for a keybinding. It's used as an
// argument to NewBinding.
type BindingOpt func(*Binding)
// NewBinding returns a new keybinding from a set of BindingOpt options.
func NewBinding(opts ...BindingOpt) Binding {
b := &Binding{}
for _, opt := range opts {
opt(b)
}
return *b
}
// WithKeys initializes a keybinding with the given keystrokes.
func WithKeys(keys ...string) BindingOpt {
return func(b *Binding) {
b.keys = keys
}
}
// WithHelp initializes a keybinding with the given help text.
func WithHelp(key, desc string) BindingOpt {
return func(b *Binding) {
b.help = Help{Key: key, Desc: desc}
}
}
// WithDisabled initializes a disabled keybinding.
func WithDisabled() BindingOpt {
return func(b *Binding) {
b.disabled = true
}
}
// SetKeys sets the keys for the keybinding.
func (b *Binding) SetKeys(keys ...string) {
b.keys = keys
}
// Keys returns the keys for the keybinding.
func (b Binding) Keys() []string {
return b.keys
}
// SetHelp sets the help text for the keybinding.
func (b *Binding) SetHelp(key, desc string) {
b.help = Help{Key: key, Desc: desc}
}
// Help returns the Help information for the keybinding.
func (b Binding) Help() Help {
return b.help
}
// Enabled returns whether or not the keybinding is enabled. Disabled
// keybindings won't be activated and won't show up in help. Keybindings are
// enabled by default.
func (b Binding) Enabled() bool {
return !b.disabled && b.keys != nil
}
// SetEnabled enables or disables the keybinding.
func (b *Binding) SetEnabled(v bool) {
b.disabled = !v
}
// Unbind removes the keys and help from this binding, effectively nullifying
// it. This is a step beyond disabling it, since applications can enable
// or disable key bindings based on application state.
func (b *Binding) Unbind() {
b.keys = nil
b.help = Help{}
}
// Help is help information for a given keybinding.
type Help struct {
Key string
Desc string
}
// Matches checks if the given KeyMsg matches the given bindings.
func Matches(k tea.KeyMsg, b ...Binding) bool {
keys := k.String()
for _, binding := range b {
for _, v := range binding.keys {
if keys == v && binding.Enabled() {
return true
}
}
}
return false
}

View File

@@ -1,227 +0,0 @@
package list
import (
"fmt"
"io"
"strings"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/reflow/truncate"
)
// DefaultItemStyles defines styling for a default list item.
// See DefaultItemView for when these come into play.
type DefaultItemStyles struct {
// The Normal state.
NormalTitle lipgloss.Style
NormalDesc lipgloss.Style
// The selected item state.
SelectedTitle lipgloss.Style
SelectedDesc lipgloss.Style
// The dimmed state, for when the filter input is initially activated.
DimmedTitle lipgloss.Style
DimmedDesc lipgloss.Style
// Charcters matching the current filter, if any.
FilterMatch lipgloss.Style
}
// NewDefaultItemStyles returns style definitions for a default item. See
// DefaultItemView for when these come into play.
func NewDefaultItemStyles() (s DefaultItemStyles) {
s.NormalTitle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"}).
Padding(0, 0, 0, 2)
s.NormalDesc = s.NormalTitle.Copy().
Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"})
s.SelectedTitle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(lipgloss.AdaptiveColor{Light: "#F793FF", Dark: "#AD58B4"}).
Foreground(lipgloss.AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"}).
Padding(0, 0, 0, 1)
s.SelectedDesc = s.SelectedTitle.Copy().
Foreground(lipgloss.AdaptiveColor{Light: "#F793FF", Dark: "#AD58B4"})
s.DimmedTitle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}).
Padding(0, 0, 0, 2)
s.DimmedDesc = s.DimmedTitle.Copy().
Foreground(lipgloss.AdaptiveColor{Light: "#C2B8C2", Dark: "#4D4D4D"})
s.FilterMatch = lipgloss.NewStyle().Underline(true)
return s
}
// DefaultItem describes an items designed to work with DefaultDelegate.
type DefaultItem interface {
Item
Title() string
Description() string
}
// DefaultDelegate is a standard delegate designed to work in lists. It's
// styled by DefaultItemStyles, which can be customized as you like.
//
// The description line can be hidden by setting Description to false, which
// renders the list as single-line-items. The spacing between items can be set
// with the SetSpacing method.
//
// Setting UpdateFunc is optional. If it's set it will be called when the
// ItemDelegate called, which is called when the list's Update function is
// invoked.
//
// Settings ShortHelpFunc and FullHelpFunc is optional. They can can be set to
// include items in the list's default short and full help menus.
type DefaultDelegate struct {
ShowDescription bool
Styles DefaultItemStyles
UpdateFunc func(tea.Msg, *Model) tea.Cmd
ShortHelpFunc func() []key.Binding
FullHelpFunc func() [][]key.Binding
height int
spacing int
}
// NewDefaultDelegate creates a new delegate with default styles.
func NewDefaultDelegate() DefaultDelegate {
return DefaultDelegate{
ShowDescription: true,
Styles: NewDefaultItemStyles(),
height: 2,
spacing: 1,
}
}
// SetHeight sets delegate's preferred height.
func (d *DefaultDelegate) SetHeight(i int) {
d.height = i
}
// Height returns the delegate's preferred height.
// This has effect only if ShowDescription is true,
// otherwise height is always 1.
func (d DefaultDelegate) Height() int {
if d.ShowDescription {
return d.height
}
return 1
}
// SetSpacing set the delegate's spacing.
func (d *DefaultDelegate) SetSpacing(i int) {
d.spacing = i
}
// Spacing returns the delegate's spacing.
func (d DefaultDelegate) Spacing() int {
return d.spacing
}
// Update checks whether the delegate's UpdateFunc is set and calls it.
func (d DefaultDelegate) Update(msg tea.Msg, m *Model) tea.Cmd {
if d.UpdateFunc == nil {
return nil
}
return d.UpdateFunc(msg, m)
}
// Render prints an item.
func (d DefaultDelegate) Render(w io.Writer, m Model, index int, item Item) {
var (
title, desc string
matchedRunes []int
s = &d.Styles
)
if i, ok := item.(DefaultItem); ok {
title = i.Title()
desc = i.Description()
} else {
return
}
if m.width <= 0 {
// short-circuit
return
}
// Prevent text from exceeding list width
textwidth := uint(m.width - s.NormalTitle.GetPaddingLeft() - s.NormalTitle.GetPaddingRight())
title = truncate.StringWithTail(title, textwidth, ellipsis)
if d.ShowDescription {
var lines []string
for i, line := range strings.Split(desc, "\n") {
if i >= d.height-1 {
break
}
lines = append(lines, truncate.StringWithTail(line, textwidth, ellipsis))
}
desc = strings.Join(lines, "\n")
}
// Conditions
var (
isSelected = index == m.Index()
emptyFilter = m.FilterState() == Filtering && m.FilterValue() == ""
isFiltered = m.FilterState() == Filtering || m.FilterState() == FilterApplied
)
if isFiltered && index < len(m.filteredItems) {
// Get indices of matched characters
matchedRunes = m.MatchesForItem(index)
}
if emptyFilter {
title = s.DimmedTitle.Render(title)
desc = s.DimmedDesc.Render(desc)
} else if isSelected && m.FilterState() != Filtering {
if isFiltered {
// Highlight matches
unmatched := s.SelectedTitle.Inline(true)
matched := unmatched.Copy().Inherit(s.FilterMatch)
title = lipgloss.StyleRunes(title, matchedRunes, matched, unmatched)
}
title = s.SelectedTitle.Render(title)
desc = s.SelectedDesc.Render(desc)
} else {
if isFiltered {
// Highlight matches
unmatched := s.NormalTitle.Inline(true)
matched := unmatched.Copy().Inherit(s.FilterMatch)
title = lipgloss.StyleRunes(title, matchedRunes, matched, unmatched)
}
title = s.NormalTitle.Render(title)
desc = s.NormalDesc.Render(desc)
}
if d.ShowDescription {
fmt.Fprintf(w, "%s\n%s", title, desc)
return
}
fmt.Fprintf(w, "%s", title)
}
// ShortHelp returns the delegate's short help.
func (d DefaultDelegate) ShortHelp() []key.Binding {
if d.ShortHelpFunc != nil {
return d.ShortHelpFunc()
}
return nil
}
// FullHelp returns the delegate's full help.
func (d DefaultDelegate) FullHelp() [][]key.Binding {
if d.FullHelpFunc != nil {
return d.FullHelpFunc()
}
return nil
}

View File

@@ -1,97 +0,0 @@
package list
import "github.com/charmbracelet/bubbles/key"
// KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which
// is used to render the menu menu.
type KeyMap struct {
// Keybindings used when browsing the list.
CursorUp key.Binding
CursorDown key.Binding
NextPage key.Binding
PrevPage key.Binding
GoToStart key.Binding
GoToEnd key.Binding
Filter key.Binding
ClearFilter key.Binding
// Keybindings used when setting a filter.
CancelWhileFiltering key.Binding
AcceptWhileFiltering key.Binding
// Help toggle keybindings.
ShowFullHelp key.Binding
CloseFullHelp key.Binding
// The quit keybinding. This won't be caught when filtering.
Quit key.Binding
// The quit-no-matter-what keybinding. This will be caught when filtering.
ForceQuit key.Binding
}
// DefaultKeyMap returns a default set of keybindings.
func DefaultKeyMap() KeyMap {
return KeyMap{
// Browsing.
CursorUp: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑/k", "up"),
),
CursorDown: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓/j", "down"),
),
PrevPage: key.NewBinding(
key.WithKeys("left", "h", "pgup", "b", "u"),
key.WithHelp("←/h/pgup", "prev page"),
),
NextPage: key.NewBinding(
key.WithKeys("right", "l", "pgdown", "f", "d"),
key.WithHelp("→/l/pgdn", "next page"),
),
GoToStart: key.NewBinding(
key.WithKeys("home", "g"),
key.WithHelp("g/home", "go to start"),
),
GoToEnd: key.NewBinding(
key.WithKeys("end", "G"),
key.WithHelp("G/end", "go to end"),
),
Filter: key.NewBinding(
key.WithKeys("/"),
key.WithHelp("/", "filter"),
),
ClearFilter: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "clear filter"),
),
// Filtering.
CancelWhileFiltering: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "cancel"),
),
AcceptWhileFiltering: key.NewBinding(
key.WithKeys("enter", "tab", "shift+tab", "ctrl+k", "up", "ctrl+j", "down"),
key.WithHelp("enter", "apply filter"),
),
// Toggle help.
ShowFullHelp: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "more"),
),
CloseFullHelp: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "close help"),
),
// Quitting.
Quit: key.NewBinding(
key.WithKeys("q", "esc"),
key.WithHelp("q", "quit"),
),
ForceQuit: key.NewBinding(key.WithKeys("ctrl+c")),
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,99 +0,0 @@
package list
import (
"github.com/charmbracelet/lipgloss"
)
const (
bullet = "•"
ellipsis = "…"
)
// Styles contains style definitions for this list component. By default, these
// values are generated by DefaultStyles.
type Styles struct {
TitleBar lipgloss.Style
Title lipgloss.Style
Spinner lipgloss.Style
FilterPrompt lipgloss.Style
FilterCursor lipgloss.Style
// Default styling for matched characters in a filter. This can be
// overridden by delegates.
DefaultFilterCharacterMatch lipgloss.Style
StatusBar lipgloss.Style
StatusEmpty lipgloss.Style
StatusBarActiveFilter lipgloss.Style
StatusBarFilterCount lipgloss.Style
NoItems lipgloss.Style
PaginationStyle lipgloss.Style
HelpStyle lipgloss.Style
// Styled characters.
ActivePaginationDot lipgloss.Style
InactivePaginationDot lipgloss.Style
ArabicPagination lipgloss.Style
DividerDot lipgloss.Style
}
// DefaultStyles returns a set of default style definitions for this list
// component.
func DefaultStyles() (s Styles) {
verySubduedColor := lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"}
subduedColor := lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"}
s.TitleBar = lipgloss.NewStyle().Padding(0, 0, 1, 2)
s.Title = lipgloss.NewStyle().
Background(lipgloss.Color("62")).
Foreground(lipgloss.Color("230")).
Padding(0, 1)
s.Spinner = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#8E8E8E", Dark: "#747373"})
s.FilterPrompt = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#04B575", Dark: "#ECFD65"})
s.FilterCursor = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"})
s.DefaultFilterCharacterMatch = lipgloss.NewStyle().Underline(true)
s.StatusBar = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}).
Padding(0, 0, 1, 2)
s.StatusEmpty = lipgloss.NewStyle().Foreground(subduedColor)
s.StatusBarActiveFilter = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"})
s.StatusBarFilterCount = lipgloss.NewStyle().Foreground(verySubduedColor)
s.NoItems = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#909090", Dark: "#626262"})
s.ArabicPagination = lipgloss.NewStyle().Foreground(subduedColor)
s.PaginationStyle = lipgloss.NewStyle().PaddingLeft(2) //nolint:gomnd
s.HelpStyle = lipgloss.NewStyle().Padding(1, 0, 0, 2)
s.ActivePaginationDot = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#847A85", Dark: "#979797"}).
SetString(bullet)
s.InactivePaginationDot = lipgloss.NewStyle().
Foreground(verySubduedColor).
SetString(bullet)
s.DividerDot = lipgloss.NewStyle().
Foreground(verySubduedColor).
SetString(" " + bullet + " ")
return s
}

View File

@@ -1,193 +0,0 @@
// Package paginator provides a Bubble Tea package for calulating pagination
// and rendering pagination info. Note that this package does not render actual
// pages: it's purely for handling keystrokes related to pagination, and
// rendering pagination status.
package paginator
import (
"fmt"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
)
// Type specifies the way we render pagination.
type Type int
// Pagination rendering options.
const (
Arabic Type = iota
Dots
)
// KeyMap is the key bindings for different actions within the paginator.
type KeyMap struct {
PrevPage key.Binding
NextPage key.Binding
}
// DefaultKeyMap is the default set of key bindings for navigating and acting
// upon the paginator.
var DefaultKeyMap = KeyMap{
PrevPage: key.NewBinding(key.WithKeys("pgup", "left", "h")),
NextPage: key.NewBinding(key.WithKeys("pgdown", "right", "l")),
}
// Model is the Bubble Tea model for this user interface.
type Model struct {
// Type configures how the pagination is rendered (Arabic, Dots).
Type Type
// Page is the current page number.
Page int
// PerPage is the number of items per page.
PerPage int
// TotalPages is the total number of pages.
TotalPages int
// ActiveDot is used to mark the current page under the Dots display type.
ActiveDot string
// InactiveDot is used to mark inactive pages under the Dots display type.
InactiveDot string
// ArabicFormat is the printf-style format to use for the Arabic display type.
ArabicFormat string
// KeyMap encodes the keybindings recognized by the widget.
KeyMap KeyMap
// Deprecated: customize [KeyMap] instead.
UsePgUpPgDownKeys bool
// Deprecated: customize [KeyMap] instead.
UseLeftRightKeys bool
// Deprecated: customize [KeyMap] instead.
UseUpDownKeys bool
// Deprecated: customize [KeyMap] instead.
UseHLKeys bool
// Deprecated: customize [KeyMap] instead.
UseJKKeys bool
}
// SetTotalPages is a helper function for calculating the total number of pages
// from a given number of items. It's use is optional since this pager can be
// used for other things beyond navigating sets. Note that it both returns the
// number of total pages and alters the model.
func (m *Model) SetTotalPages(items int) int {
if items < 1 {
return m.TotalPages
}
n := items / m.PerPage
if items%m.PerPage > 0 {
n++
}
m.TotalPages = n
return n
}
// ItemsOnPage is a helper function for returning the number of items on the
// current page given the total number of items passed as an argument.
func (m Model) ItemsOnPage(totalItems int) int {
if totalItems < 1 {
return 0
}
start, end := m.GetSliceBounds(totalItems)
return end - start
}
// GetSliceBounds is a helper function for paginating slices. Pass the length
// of the slice you're rendering and you'll receive the start and end bounds
// corresponding the to pagination. For example:
//
// bunchOfStuff := []stuff{...}
// start, end := model.GetSliceBounds(len(bunchOfStuff))
// sliceToRender := bunchOfStuff[start:end]
func (m *Model) GetSliceBounds(length int) (start int, end int) {
start = m.Page * m.PerPage
end = min(m.Page*m.PerPage+m.PerPage, length)
return start, end
}
// PrevPage is a number function for navigating one page backward. It will not
// page beyond the first page (i.e. page 0).
func (m *Model) PrevPage() {
if m.Page > 0 {
m.Page--
}
}
// NextPage is a helper function for navigating one page forward. It will not
// page beyond the last page (i.e. totalPages - 1).
func (m *Model) NextPage() {
if !m.OnLastPage() {
m.Page++
}
}
// OnLastPage returns whether or not we're on the last page.
func (m Model) OnLastPage() bool {
return m.Page == m.TotalPages-1
}
// New creates a new model with defaults.
func New() Model {
return Model{
Type: Arabic,
Page: 0,
PerPage: 1,
TotalPages: 1,
KeyMap: DefaultKeyMap,
ActiveDot: "•",
InactiveDot: "○",
ArabicFormat: "%d/%d",
}
}
// NewModel creates a new model with defaults.
//
// Deprecated: use [New] instead.
var NewModel = New
// Update is the Tea update function which binds keystrokes to pagination.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.KeyMap.NextPage):
m.NextPage()
case key.Matches(msg, m.KeyMap.PrevPage):
m.PrevPage()
}
}
return m, nil
}
// View renders the pagination to a string.
func (m Model) View() string {
switch m.Type {
case Dots:
return m.dotsView()
default:
return m.arabicView()
}
}
func (m Model) dotsView() string {
var s string
for i := 0; i < m.TotalPages; i++ {
if i == m.Page {
s += m.ActiveDot
continue
}
s += m.InactiveDot
}
return s
}
func (m Model) arabicView() string {
return fmt.Sprintf(m.ArabicFormat, m.Page+1, m.TotalPages)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

View File

@@ -1,102 +0,0 @@
// Package runeutil provides a utility function for use in Bubbles
// that can process Key messages containing runes.
package runeutil
import (
"unicode"
"unicode/utf8"
)
// Sanitizer is a helper for bubble widgets that want to process
// Runes from input key messages.
type Sanitizer interface {
// Sanitize removes control characters from runes in a KeyRunes
// message, and optionally replaces newline/carriage return/tabs by a
// specified character.
//
// The rune array is modified in-place if possible. In that case, the
// returned slice is the original slice shortened after the control
// characters have been removed/translated.
Sanitize(runes []rune) []rune
}
// NewSanitizer constructs a rune sanitizer.
func NewSanitizer(opts ...Option) Sanitizer {
s := sanitizer{
replaceNewLine: []rune("\n"),
replaceTab: []rune(" "),
}
for _, o := range opts {
s = o(s)
}
return &s
}
// Option is the type of an option that can be passed to Sanitize().
type Option func(sanitizer) sanitizer
// ReplaceTabs replaces tabs by the specified string.
func ReplaceTabs(tabRepl string) Option {
return func(s sanitizer) sanitizer {
s.replaceTab = []rune(tabRepl)
return s
}
}
// ReplaceNewlines replaces newline characters by the specified string.
func ReplaceNewlines(nlRepl string) Option {
return func(s sanitizer) sanitizer {
s.replaceNewLine = []rune(nlRepl)
return s
}
}
func (s *sanitizer) Sanitize(runes []rune) []rune {
// dstrunes are where we are storing the result.
dstrunes := runes[:0:len(runes)]
// copied indicates whether dstrunes is an alias of runes
// or a copy. We need a copy when dst moves past src.
// We use this as an optimization to avoid allocating
// a new rune slice in the common case where the output
// is smaller or equal to the input.
copied := false
for src := 0; src < len(runes); src++ {
r := runes[src]
switch {
case r == utf8.RuneError:
// skip
case r == '\r' || r == '\n':
if len(dstrunes)+len(s.replaceNewLine) > src && !copied {
dst := len(dstrunes)
dstrunes = make([]rune, dst, len(runes)+len(s.replaceNewLine))
copy(dstrunes, runes[:dst])
copied = true
}
dstrunes = append(dstrunes, s.replaceNewLine...)
case r == '\t':
if len(dstrunes)+len(s.replaceTab) > src && !copied {
dst := len(dstrunes)
dstrunes = make([]rune, dst, len(runes)+len(s.replaceTab))
copy(dstrunes, runes[:dst])
copied = true
}
dstrunes = append(dstrunes, s.replaceTab...)
case unicode.IsControl(r):
// Other control characters: skip.
default:
// Keep the character.
dstrunes = append(dstrunes, runes[src])
}
}
return dstrunes
}
type sanitizer struct {
replaceNewLine []rune
replaceTab []rune
}

View File

@@ -1,226 +0,0 @@
package spinner
import (
"sync"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// Internal ID management. Used during animating to ensure that frame messages
// are received only by spinner components that sent them.
var (
lastID int
idMtx sync.Mutex
)
// Return the next ID we should use on the Model.
func nextID() int {
idMtx.Lock()
defer idMtx.Unlock()
lastID++
return lastID
}
// Spinner is a set of frames used in animating the spinner.
type Spinner struct {
Frames []string
FPS time.Duration
}
// Some spinners to choose from. You could also make your own.
var (
Line = Spinner{
Frames: []string{"|", "/", "-", "\\"},
FPS: time.Second / 10, //nolint:gomnd
}
Dot = Spinner{
Frames: []string{"⣾ ", "⣽ ", "⣻ ", "⢿ ", "⡿ ", "⣟ ", "⣯ ", "⣷ "},
FPS: time.Second / 10, //nolint:gomnd
}
MiniDot = Spinner{
Frames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"},
FPS: time.Second / 12, //nolint:gomnd
}
Jump = Spinner{
Frames: []string{"⢄", "⢂", "⢁", "⡁", "⡈", "⡐", "⡠"},
FPS: time.Second / 10, //nolint:gomnd
}
Pulse = Spinner{
Frames: []string{"█", "▓", "▒", "░"},
FPS: time.Second / 8, //nolint:gomnd
}
Points = Spinner{
Frames: []string{"∙∙∙", "●∙∙", "∙●∙", "∙∙●"},
FPS: time.Second / 7, //nolint:gomnd
}
Globe = Spinner{
Frames: []string{"🌍", "🌎", "🌏"},
FPS: time.Second / 4, //nolint:gomnd
}
Moon = Spinner{
Frames: []string{"🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"},
FPS: time.Second / 8, //nolint:gomnd
}
Monkey = Spinner{
Frames: []string{"🙈", "🙉", "🙊"},
FPS: time.Second / 3, //nolint:gomnd
}
Meter = Spinner{
Frames: []string{
"▱▱▱",
"▰▱▱",
"▰▰▱",
"▰▰▰",
"▰▰▱",
"▰▱▱",
"▱▱▱",
},
FPS: time.Second / 7, //nolint:gomnd
}
Hamburger = Spinner{
Frames: []string{"☱", "☲", "☴", "☲"},
FPS: time.Second / 3, //nolint:gomnd
}
)
// Model contains the state for the spinner. Use New to create new models
// rather than using Model as a struct literal.
type Model struct {
// Spinner settings to use. See type Spinner.
Spinner Spinner
// Style sets the styling for the spinner. Most of the time you'll just
// want foreground and background coloring, and potentially some padding.
//
// For an introduction to styling with Lip Gloss see:
// https://github.com/charmbracelet/lipgloss
Style lipgloss.Style
frame int
id int
tag int
}
// ID returns the spinner's unique ID.
func (m Model) ID() int {
return m.id
}
// New returns a model with default values.
func New(opts ...Option) Model {
m := Model{
Spinner: Line,
id: nextID(),
}
for _, opt := range opts {
opt(&m)
}
return m
}
// NewModel returns a model with default values.
//
// Deprecated: use [New] instead.
var NewModel = New
// TickMsg indicates that the timer has ticked and we should render a frame.
type TickMsg struct {
Time time.Time
tag int
ID int
}
// Update is the Tea update function.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case TickMsg:
// If an ID is set, and the ID doesn't belong to this spinner, reject
// the message.
if msg.ID > 0 && msg.ID != m.id {
return m, nil
}
// If a tag is set, and it's not the one we expect, reject the message.
// This prevents the spinner from receiving too many messages and
// thus spinning too fast.
if msg.tag > 0 && msg.tag != m.tag {
return m, nil
}
m.frame++
if m.frame >= len(m.Spinner.Frames) {
m.frame = 0
}
m.tag++
return m, m.tick(m.id, m.tag)
default:
return m, nil
}
}
// View renders the model's view.
func (m Model) View() string {
if m.frame >= len(m.Spinner.Frames) {
return "(error)"
}
return m.Style.Render(m.Spinner.Frames[m.frame])
}
// Tick is the command used to advance the spinner one frame. Use this command
// to effectively start the spinner.
func (m Model) Tick() tea.Msg {
return TickMsg{
// The time at which the tick occurred.
Time: time.Now(),
// The ID of the spinner that this message belongs to. This can be
// helpful when routing messages, however bear in mind that spinners
// will ignore messages that don't contain ID by default.
ID: m.id,
tag: m.tag,
}
}
func (m Model) tick(id, tag int) tea.Cmd {
return tea.Tick(m.Spinner.FPS, func(t time.Time) tea.Msg {
return TickMsg{
Time: t,
ID: id,
tag: tag,
}
})
}
// Tick is the command used to advance the spinner one frame. Use this command
// to effectively start the spinner.
//
// Deprecated: Use [Model.Tick] instead.
func Tick() tea.Msg {
return TickMsg{Time: time.Now()}
}
// Option is used to set options in New. For example:
//
// spinner := New(WithSpinner(Dot))
type Option func(*Model)
// WithSpinner is an option to set the spinner.
func WithSpinner(spinner Spinner) Option {
return func(m *Model) {
m.Spinner = spinner
}
}
// WithStyle is an option to set the spinner style.
func WithStyle(style lipgloss.Style) Option {
return func(m *Model) {
m.Style = style
}
}

View File

@@ -1,425 +0,0 @@
package table
import (
"strings"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
)
// Model defines a state for the table widget.
type Model struct {
KeyMap KeyMap
cols []Column
rows []Row
cursor int
focus bool
styles Styles
viewport viewport.Model
start int
end int
}
// Row represents one line in the table.
type Row []string
// Column defines the table structure.
type Column struct {
Title string
Width int
}
// KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which
// is used to render the menu menu.
type KeyMap struct {
LineUp key.Binding
LineDown key.Binding
PageUp key.Binding
PageDown key.Binding
HalfPageUp key.Binding
HalfPageDown key.Binding
GotoTop key.Binding
GotoBottom key.Binding
}
// DefaultKeyMap returns a default set of keybindings.
func DefaultKeyMap() KeyMap {
const spacebar = " "
return KeyMap{
LineUp: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑/k", "up"),
),
LineDown: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓/j", "down"),
),
PageUp: key.NewBinding(
key.WithKeys("b", "pgup"),
key.WithHelp("b/pgup", "page up"),
),
PageDown: key.NewBinding(
key.WithKeys("f", "pgdown", spacebar),
key.WithHelp("f/pgdn", "page down"),
),
HalfPageUp: key.NewBinding(
key.WithKeys("u", "ctrl+u"),
key.WithHelp("u", "½ page up"),
),
HalfPageDown: key.NewBinding(
key.WithKeys("d", "ctrl+d"),
key.WithHelp("d", "½ page down"),
),
GotoTop: key.NewBinding(
key.WithKeys("home", "g"),
key.WithHelp("g/home", "go to start"),
),
GotoBottom: key.NewBinding(
key.WithKeys("end", "G"),
key.WithHelp("G/end", "go to end"),
),
}
}
// Styles contains style definitions for this list component. By default, these
// values are generated by DefaultStyles.
type Styles struct {
Header lipgloss.Style
Cell lipgloss.Style
Selected lipgloss.Style
}
// DefaultStyles returns a set of default style definitions for this table.
func DefaultStyles() Styles {
return Styles{
Selected: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")),
Header: lipgloss.NewStyle().Bold(true).Padding(0, 1),
Cell: lipgloss.NewStyle().Padding(0, 1),
}
}
// SetStyles sets the table styles.
func (m *Model) SetStyles(s Styles) {
m.styles = s
m.UpdateViewport()
}
// Option is used to set options in New. For example:
//
// table := New(WithColumns([]Column{{Title: "ID", Width: 10}}))
type Option func(*Model)
// New creates a new model for the table widget.
func New(opts ...Option) Model {
m := Model{
cursor: 0,
viewport: viewport.New(0, 20),
KeyMap: DefaultKeyMap(),
styles: DefaultStyles(),
}
for _, opt := range opts {
opt(&m)
}
m.UpdateViewport()
return m
}
// WithColumns sets the table columns (headers).
func WithColumns(cols []Column) Option {
return func(m *Model) {
m.cols = cols
}
}
// WithRows sets the table rows (data).
func WithRows(rows []Row) Option {
return func(m *Model) {
m.rows = rows
}
}
// WithHeight sets the height of the table.
func WithHeight(h int) Option {
return func(m *Model) {
m.viewport.Height = h
}
}
// WithWidth sets the width of the table.
func WithWidth(w int) Option {
return func(m *Model) {
m.viewport.Width = w
}
}
// WithFocused sets the focus state of the table.
func WithFocused(f bool) Option {
return func(m *Model) {
m.focus = f
}
}
// WithStyles sets the table styles.
func WithStyles(s Styles) Option {
return func(m *Model) {
m.styles = s
}
}
// WithKeyMap sets the key map.
func WithKeyMap(km KeyMap) Option {
return func(m *Model) {
m.KeyMap = km
}
}
// Update is the Bubble Tea update loop.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if !m.focus {
return m, nil
}
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.KeyMap.LineUp):
m.MoveUp(1)
case key.Matches(msg, m.KeyMap.LineDown):
m.MoveDown(1)
case key.Matches(msg, m.KeyMap.PageUp):
m.MoveUp(m.viewport.Height)
case key.Matches(msg, m.KeyMap.PageDown):
m.MoveDown(m.viewport.Height)
case key.Matches(msg, m.KeyMap.HalfPageUp):
m.MoveUp(m.viewport.Height / 2)
case key.Matches(msg, m.KeyMap.HalfPageDown):
m.MoveDown(m.viewport.Height / 2)
case key.Matches(msg, m.KeyMap.LineDown):
m.MoveDown(1)
case key.Matches(msg, m.KeyMap.GotoTop):
m.GotoTop()
case key.Matches(msg, m.KeyMap.GotoBottom):
m.GotoBottom()
}
}
return m, tea.Batch(cmds...)
}
// Focused returns the focus state of the table.
func (m Model) Focused() bool {
return m.focus
}
// Focus focusses the table, allowing the user to move around the rows and
// interact.
func (m *Model) Focus() {
m.focus = true
m.UpdateViewport()
}
// Blur blurs the table, preventing selection or movement.
func (m *Model) Blur() {
m.focus = false
m.UpdateViewport()
}
// View renders the component.
func (m Model) View() string {
return m.headersView() + "\n" + m.viewport.View()
}
// UpdateViewport updates the list content based on the previously defined
// columns and rows.
func (m *Model) UpdateViewport() {
renderedRows := make([]string, 0, len(m.rows))
// Render only rows from: m.cursor-m.viewport.Height to: m.cursor+m.viewport.Height
// Constant runtime, independent of number of rows in a table.
// Limits the number of renderedRows to a maximum of 2*m.viewport.Height
if m.cursor >= 0 {
m.start = clamp(m.cursor-m.viewport.Height, 0, m.cursor)
} else {
m.start = 0
}
m.end = clamp(m.cursor+m.viewport.Height, m.cursor, len(m.rows))
for i := m.start; i < m.end; i++ {
renderedRows = append(renderedRows, m.renderRow(i))
}
m.viewport.SetContent(
lipgloss.JoinVertical(lipgloss.Left, renderedRows...),
)
}
// SelectedRow returns the selected row.
// You can cast it to your own implementation.
func (m Model) SelectedRow() Row {
return m.rows[m.cursor]
}
// Rows returns the current rows.
func (m Model) Rows() []Row {
return m.rows
}
// SetRows set a new rows state.
func (m *Model) SetRows(r []Row) {
m.rows = r
m.UpdateViewport()
}
// SetColumns set a new columns state.
func (m *Model) SetColumns(c []Column) {
m.cols = c
m.UpdateViewport()
}
// SetWidth sets the width of the viewport of the table.
func (m *Model) SetWidth(w int) {
m.viewport.Width = w
m.UpdateViewport()
}
// SetHeight sets the height of the viewport of the table.
func (m *Model) SetHeight(h int) {
m.viewport.Height = h
m.UpdateViewport()
}
// Height returns the viewport height of the table.
func (m Model) Height() int {
return m.viewport.Height
}
// Width returns the viewport width of the table.
func (m Model) Width() int {
return m.viewport.Width
}
// Cursor returns the index of the selected row.
func (m Model) Cursor() int {
return m.cursor
}
// SetCursor sets the cursor position in the table.
func (m *Model) SetCursor(n int) {
m.cursor = clamp(n, 0, len(m.rows)-1)
m.UpdateViewport()
}
// MoveUp moves the selection up by any number of row.
// It can not go above the first row.
func (m *Model) MoveUp(n int) {
m.cursor = clamp(m.cursor-n, 0, len(m.rows)-1)
switch {
case m.start == 0:
m.viewport.SetYOffset(clamp(m.viewport.YOffset, 0, m.cursor))
case m.start < m.viewport.Height:
m.viewport.SetYOffset(clamp(m.viewport.YOffset+n, 0, m.cursor))
case m.viewport.YOffset >= 1:
m.viewport.YOffset = clamp(m.viewport.YOffset+n, 1, m.viewport.Height)
}
m.UpdateViewport()
}
// MoveDown moves the selection down by any number of row.
// It can not go below the last row.
func (m *Model) MoveDown(n int) {
m.cursor = clamp(m.cursor+n, 0, len(m.rows)-1)
m.UpdateViewport()
switch {
case m.end == len(m.rows):
m.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.viewport.Height))
case m.cursor > (m.end-m.start)/2:
m.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.cursor))
case m.viewport.YOffset > 1:
case m.cursor > m.viewport.YOffset+m.viewport.Height-1:
m.viewport.SetYOffset(clamp(m.viewport.YOffset+1, 0, 1))
}
}
// GotoTop moves the selection to the first row.
func (m *Model) GotoTop() {
m.MoveUp(m.cursor)
}
// GotoBottom moves the selection to the last row.
func (m *Model) GotoBottom() {
m.MoveDown(len(m.rows))
}
// FromValues create the table rows from a simple string. It uses `\n` by
// default for getting all the rows and the given separator for the fields on
// each row.
func (m *Model) FromValues(value, separator string) {
rows := []Row{}
for _, line := range strings.Split(value, "\n") {
r := Row{}
for _, field := range strings.Split(line, separator) {
r = append(r, field)
}
rows = append(rows, r)
}
m.SetRows(rows)
}
func (m Model) headersView() string {
var s = make([]string, 0, len(m.cols))
for _, col := range m.cols {
style := lipgloss.NewStyle().Width(col.Width).MaxWidth(col.Width).Inline(true)
renderedCell := style.Render(runewidth.Truncate(col.Title, col.Width, "…"))
s = append(s, m.styles.Header.Render(renderedCell))
}
return lipgloss.JoinHorizontal(lipgloss.Left, s...)
}
func (m *Model) renderRow(rowID int) string {
var s = make([]string, 0, len(m.cols))
for i, value := range m.rows[rowID] {
style := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width).Inline(true)
renderedCell := m.styles.Cell.Render(style.Render(runewidth.Truncate(value, m.cols[i].Width, "…")))
s = append(s, renderedCell)
}
row := lipgloss.JoinHorizontal(lipgloss.Left, s...)
if rowID == m.cursor {
return m.styles.Selected.Render(row)
}
return row
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func clamp(v, low, high int) int {
return min(max(v, low), high)
}

View File

@@ -1,721 +0,0 @@
package textinput
import (
"strings"
"time"
"unicode"
"github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/cursor"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/runeutil"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
rw "github.com/mattn/go-runewidth"
)
// Internal messages for clipboard operations.
type pasteMsg string
type pasteErrMsg struct{ error }
// EchoMode sets the input behavior of the text input field.
type EchoMode int
const (
// EchoNormal displays text as is. This is the default behavior.
EchoNormal EchoMode = iota
// EchoPassword displays the EchoCharacter mask instead of actual
// characters. This is commonly used for password fields.
EchoPassword
// EchoNone displays nothing as characters are entered. This is commonly
// seen for password fields on the command line.
EchoNone
// EchoOnEdit.
)
// ValidateFunc is a function that returns an error if the input is invalid.
type ValidateFunc func(string) error
// KeyMap is the key bindings for different actions within the textinput.
type KeyMap struct {
CharacterForward key.Binding
CharacterBackward key.Binding
WordForward key.Binding
WordBackward key.Binding
DeleteWordBackward key.Binding
DeleteWordForward key.Binding
DeleteAfterCursor key.Binding
DeleteBeforeCursor key.Binding
DeleteCharacterBackward key.Binding
DeleteCharacterForward key.Binding
LineStart key.Binding
LineEnd key.Binding
Paste key.Binding
}
// DefaultKeyMap is the default set of key bindings for navigating and acting
// upon the textinput.
var DefaultKeyMap = KeyMap{
CharacterForward: key.NewBinding(key.WithKeys("right", "ctrl+f")),
CharacterBackward: key.NewBinding(key.WithKeys("left", "ctrl+b")),
WordForward: key.NewBinding(key.WithKeys("alt+right", "alt+f")),
WordBackward: key.NewBinding(key.WithKeys("alt+left", "alt+b")),
DeleteWordBackward: key.NewBinding(key.WithKeys("alt+backspace", "ctrl+w")),
DeleteWordForward: key.NewBinding(key.WithKeys("alte+delete", "alt+d")),
DeleteAfterCursor: key.NewBinding(key.WithKeys("ctrl+k")),
DeleteBeforeCursor: key.NewBinding(key.WithKeys("ctrl+u")),
DeleteCharacterBackward: key.NewBinding(key.WithKeys("backspace", "ctrl+h")),
DeleteCharacterForward: key.NewBinding(key.WithKeys("delete", "ctrl+d")),
LineStart: key.NewBinding(key.WithKeys("home", "ctrl+a")),
LineEnd: key.NewBinding(key.WithKeys("end", "ctrl+e")),
Paste: key.NewBinding(key.WithKeys("ctrl+v")),
}
// Model is the Bubble Tea model for this text input element.
type Model struct {
Err error
// General settings.
Prompt string
Placeholder string
EchoMode EchoMode
EchoCharacter rune
Cursor cursor.Model
// Deprecated: use [cursor.BlinkSpeed] instead.
BlinkSpeed time.Duration
// Styles. These will be applied as inline styles.
//
// For an introduction to styling with Lip Gloss see:
// https://github.com/charmbracelet/lipgloss
PromptStyle lipgloss.Style
TextStyle lipgloss.Style
BackgroundStyle lipgloss.Style
PlaceholderStyle lipgloss.Style
CursorStyle lipgloss.Style
// CharLimit is the maximum amount of characters this input element will
// accept. If 0 or less, there's no limit.
CharLimit int
// Width is the maximum number of characters that can be displayed at once.
// It essentially treats the text field like a horizontally scrolling
// viewport. If 0 or less this setting is ignored.
Width int
// KeyMap encodes the keybindings recognized by the widget.
KeyMap KeyMap
// Underlying text value.
value []rune
// focus indicates whether user input focus should be on this input
// component. When false, ignore keyboard input and hide the cursor.
focus bool
// Cursor position.
pos int
// Used to emulate a viewport when width is set and the content is
// overflowing.
offset int
offsetRight int
// Validate is a function that checks whether or not the text within the
// input is valid. If it is not valid, the `Err` field will be set to the
// error returned by the function. If the function is not defined, all
// input is considered valid.
Validate ValidateFunc
// rune sanitizer for input.
rsan runeutil.Sanitizer
}
// New creates a new model with default settings.
func New() Model {
return Model{
Prompt: "> ",
EchoCharacter: '*',
CharLimit: 0,
PlaceholderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
Cursor: cursor.New(),
KeyMap: DefaultKeyMap,
value: nil,
focus: false,
pos: 0,
}
}
// NewModel creates a new model with default settings.
//
// Deprecated: Use [New] instead.
var NewModel = New
// SetValue sets the value of the text input.
func (m *Model) SetValue(s string) {
// Clean up any special characters in the input provided by the
// caller. This avoids bugs due to e.g. tab characters and whatnot.
runes := m.san().Sanitize([]rune(s))
m.setValueInternal(runes)
}
func (m *Model) setValueInternal(runes []rune) {
if m.Validate != nil {
if err := m.Validate(string(runes)); err != nil {
m.Err = err
return
}
}
empty := len(m.value) == 0
m.Err = nil
if m.CharLimit > 0 && len(runes) > m.CharLimit {
m.value = runes[:m.CharLimit]
} else {
m.value = runes
}
if (m.pos == 0 && empty) || m.pos > len(m.value) {
m.SetCursor(len(m.value))
}
m.handleOverflow()
}
// Value returns the value of the text input.
func (m Model) Value() string {
return string(m.value)
}
// Position returns the cursor position.
func (m Model) Position() int {
return m.pos
}
// SetCursor moves the cursor to the given position. If the position is
// out of bounds the cursor will be moved to the start or end accordingly.
func (m *Model) SetCursor(pos int) {
m.pos = clamp(pos, 0, len(m.value))
m.handleOverflow()
}
// CursorStart moves the cursor to the start of the input field.
func (m *Model) CursorStart() {
m.SetCursor(0)
}
// CursorEnd moves the cursor to the end of the input field.
func (m *Model) CursorEnd() {
m.SetCursor(len(m.value))
}
// Focused returns the focus state on the model.
func (m Model) Focused() bool {
return m.focus
}
// Focus sets the focus state on the model. When the model is in focus it can
// receive keyboard input and the cursor will be shown.
func (m *Model) Focus() tea.Cmd {
m.focus = true
return m.Cursor.Focus()
}
// Blur removes the focus state on the model. When the model is blurred it can
// not receive keyboard input and the cursor will be hidden.
func (m *Model) Blur() {
m.focus = false
m.Cursor.Blur()
}
// Reset sets the input to its default state with no input.
func (m *Model) Reset() {
m.value = nil
m.SetCursor(0)
}
// rsan initializes or retrieves the rune sanitizer.
func (m *Model) san() runeutil.Sanitizer {
if m.rsan == nil {
// Textinput has all its input on a single line so collapse
// newlines/tabs to single spaces.
m.rsan = runeutil.NewSanitizer(
runeutil.ReplaceTabs(" "), runeutil.ReplaceNewlines(" "))
}
return m.rsan
}
func (m *Model) insertRunesFromUserInput(v []rune) {
// Clean up any special characters in the input provided by the
// clipboard. This avoids bugs due to e.g. tab characters and
// whatnot.
paste := m.san().Sanitize(v)
var availSpace int
if m.CharLimit > 0 {
availSpace = m.CharLimit - len(m.value)
// If the char limit's been reached, cancel.
if availSpace <= 0 {
return
}
// If there's not enough space to paste the whole thing cut the pasted
// runes down so they'll fit.
if availSpace < len(paste) {
paste = paste[:len(paste)-availSpace]
}
}
// Stuff before and after the cursor
head := m.value[:m.pos]
tailSrc := m.value[m.pos:]
tail := make([]rune, len(tailSrc))
copy(tail, tailSrc)
oldPos := m.pos
// Insert pasted runes
for _, r := range paste {
head = append(head, r)
m.pos++
if m.CharLimit > 0 {
availSpace--
if availSpace <= 0 {
break
}
}
}
// Put it all back together
value := append(head, tail...)
m.setValueInternal(value)
if m.Err != nil {
m.pos = oldPos
}
}
// If a max width is defined, perform some logic to treat the visible area
// as a horizontally scrolling viewport.
func (m *Model) handleOverflow() {
if m.Width <= 0 || rw.StringWidth(string(m.value)) <= m.Width {
m.offset = 0
m.offsetRight = len(m.value)
return
}
// Correct right offset if we've deleted characters
m.offsetRight = min(m.offsetRight, len(m.value))
if m.pos < m.offset {
m.offset = m.pos
w := 0
i := 0
runes := m.value[m.offset:]
for i < len(runes) && w <= m.Width {
w += rw.RuneWidth(runes[i])
if w <= m.Width+1 {
i++
}
}
m.offsetRight = m.offset + i
} else if m.pos >= m.offsetRight {
m.offsetRight = m.pos
w := 0
runes := m.value[:m.offsetRight]
i := len(runes) - 1
for i > 0 && w < m.Width {
w += rw.RuneWidth(runes[i])
if w <= m.Width {
i--
}
}
m.offset = m.offsetRight - (len(runes) - 1 - i)
}
}
// deleteBeforeCursor deletes all text before the cursor.
func (m *Model) deleteBeforeCursor() {
m.value = m.value[m.pos:]
m.offset = 0
m.SetCursor(0)
}
// deleteAfterCursor deletes all text after the cursor. If input is masked
// delete everything after the cursor so as not to reveal word breaks in the
// masked input.
func (m *Model) deleteAfterCursor() {
m.value = m.value[:m.pos]
m.SetCursor(len(m.value))
}
// deleteWordBackward deletes the word left to the cursor.
func (m *Model) deleteWordBackward() {
if m.pos == 0 || len(m.value) == 0 {
return
}
if m.EchoMode != EchoNormal {
m.deleteBeforeCursor()
return
}
// Linter note: it's critical that we acquire the initial cursor position
// here prior to altering it via SetCursor() below. As such, moving this
// call into the corresponding if clause does not apply here.
oldPos := m.pos //nolint:ifshort
m.SetCursor(m.pos - 1)
for unicode.IsSpace(m.value[m.pos]) {
if m.pos <= 0 {
break
}
// ignore series of whitespace before cursor
m.SetCursor(m.pos - 1)
}
for m.pos > 0 {
if !unicode.IsSpace(m.value[m.pos]) {
m.SetCursor(m.pos - 1)
} else {
if m.pos > 0 {
// keep the previous space
m.SetCursor(m.pos + 1)
}
break
}
}
if oldPos > len(m.value) {
m.value = m.value[:m.pos]
} else {
m.value = append(m.value[:m.pos], m.value[oldPos:]...)
}
}
// deleteWordForward deletes the word right to the cursor If input is masked
// delete everything after the cursor so as not to reveal word breaks in the
// masked input.
func (m *Model) deleteWordForward() {
if m.pos >= len(m.value) || len(m.value) == 0 {
return
}
if m.EchoMode != EchoNormal {
m.deleteAfterCursor()
return
}
oldPos := m.pos
m.SetCursor(m.pos + 1)
for unicode.IsSpace(m.value[m.pos]) {
// ignore series of whitespace after cursor
m.SetCursor(m.pos + 1)
if m.pos >= len(m.value) {
break
}
}
for m.pos < len(m.value) {
if !unicode.IsSpace(m.value[m.pos]) {
m.SetCursor(m.pos + 1)
} else {
break
}
}
if m.pos > len(m.value) {
m.value = m.value[:oldPos]
} else {
m.value = append(m.value[:oldPos], m.value[m.pos:]...)
}
m.SetCursor(oldPos)
}
// wordBackward moves the cursor one word to the left. If input is masked, move
// input to the start so as not to reveal word breaks in the masked input.
func (m *Model) wordBackward() {
if m.pos == 0 || len(m.value) == 0 {
return
}
if m.EchoMode != EchoNormal {
m.CursorStart()
return
}
i := m.pos - 1
for i >= 0 {
if unicode.IsSpace(m.value[i]) {
m.SetCursor(m.pos - 1)
i--
} else {
break
}
}
for i >= 0 {
if !unicode.IsSpace(m.value[i]) {
m.SetCursor(m.pos - 1)
i--
} else {
break
}
}
}
// wordForward moves the cursor one word to the right. If the input is masked,
// move input to the end so as not to reveal word breaks in the masked input.
func (m *Model) wordForward() {
if m.pos >= len(m.value) || len(m.value) == 0 {
return
}
if m.EchoMode != EchoNormal {
m.CursorEnd()
return
}
i := m.pos
for i < len(m.value) {
if unicode.IsSpace(m.value[i]) {
m.SetCursor(m.pos + 1)
i++
} else {
break
}
}
for i < len(m.value) {
if !unicode.IsSpace(m.value[i]) {
m.SetCursor(m.pos + 1)
i++
} else {
break
}
}
}
func (m Model) echoTransform(v string) string {
switch m.EchoMode {
case EchoPassword:
return strings.Repeat(string(m.EchoCharacter), rw.StringWidth(v))
case EchoNone:
return ""
default:
return v
}
}
// Update is the Bubble Tea update loop.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if !m.focus {
return m, nil
}
// Let's remember where the position of the cursor currently is so that if
// the cursor position changes, we can reset the blink.
oldPos := m.pos //nolint
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.KeyMap.DeleteWordBackward):
m.Err = nil
m.deleteWordBackward()
case key.Matches(msg, m.KeyMap.DeleteCharacterBackward):
m.Err = nil
if len(m.value) > 0 {
m.value = append(m.value[:max(0, m.pos-1)], m.value[m.pos:]...)
if m.pos > 0 {
m.SetCursor(m.pos - 1)
}
}
case key.Matches(msg, m.KeyMap.WordBackward):
m.wordBackward()
case key.Matches(msg, m.KeyMap.CharacterBackward):
if m.pos > 0 {
m.SetCursor(m.pos - 1)
}
case key.Matches(msg, m.KeyMap.WordForward):
m.wordForward()
case key.Matches(msg, m.KeyMap.CharacterForward):
if m.pos < len(m.value) {
m.SetCursor(m.pos + 1)
}
case key.Matches(msg, m.KeyMap.DeleteWordBackward):
m.deleteWordBackward()
case key.Matches(msg, m.KeyMap.LineStart):
m.CursorStart()
case key.Matches(msg, m.KeyMap.DeleteCharacterForward):
if len(m.value) > 0 && m.pos < len(m.value) {
m.value = append(m.value[:m.pos], m.value[m.pos+1:]...)
}
case key.Matches(msg, m.KeyMap.LineEnd):
m.CursorEnd()
case key.Matches(msg, m.KeyMap.DeleteAfterCursor):
m.deleteAfterCursor()
case key.Matches(msg, m.KeyMap.DeleteBeforeCursor):
m.deleteBeforeCursor()
case key.Matches(msg, m.KeyMap.Paste):
return m, Paste
case key.Matches(msg, m.KeyMap.DeleteWordForward):
m.deleteWordForward()
default:
// Input one or more regular characters.
m.insertRunesFromUserInput(msg.Runes)
}
case pasteMsg:
m.insertRunesFromUserInput([]rune(msg))
case pasteErrMsg:
m.Err = msg
}
var cmds []tea.Cmd
var cmd tea.Cmd
m.Cursor, cmd = m.Cursor.Update(msg)
cmds = append(cmds, cmd)
if oldPos != m.pos {
m.Cursor.Blink = false
cmds = append(cmds, m.Cursor.BlinkCmd())
}
m.handleOverflow()
return m, tea.Batch(cmds...)
}
// View renders the textinput in its current state.
func (m Model) View() string {
// Placeholder text
if len(m.value) == 0 && m.Placeholder != "" {
return m.placeholderView()
}
styleText := m.TextStyle.Inline(true).Render
value := m.value[m.offset:m.offsetRight]
pos := max(0, m.pos-m.offset)
v := styleText(m.echoTransform(string(value[:pos])))
if pos < len(value) {
char := m.echoTransform(string(value[pos]))
m.Cursor.SetChar(char)
v += m.Cursor.View() // cursor and text under it
v += styleText(m.echoTransform(string(value[pos+1:]))) // text after cursor
} else {
m.Cursor.SetChar(" ")
v += m.Cursor.View()
}
// If a max width and background color were set fill the empty spaces with
// the background color.
valWidth := rw.StringWidth(string(value))
if m.Width > 0 && valWidth <= m.Width {
padding := max(0, m.Width-valWidth)
if valWidth+padding <= m.Width && pos < len(value) {
padding++
}
v += styleText(strings.Repeat(" ", padding))
}
return m.PromptStyle.Render(m.Prompt) + v
}
// placeholderView returns the prompt and placeholder view, if any.
func (m Model) placeholderView() string {
var (
v string
p = m.Placeholder
style = m.PlaceholderStyle.Inline(true).Render
)
m.Cursor.TextStyle = m.PlaceholderStyle
m.Cursor.SetChar(p[:1])
v += m.Cursor.View()
// The rest of the placeholder text
v += style(p[1:])
return m.PromptStyle.Render(m.Prompt) + v
}
// Blink is a command used to initialize cursor blinking.
func Blink() tea.Msg {
return cursor.Blink()
}
// Paste is a command for pasting from the clipboard into the text input.
func Paste() tea.Msg {
str, err := clipboard.ReadAll()
if err != nil {
return pasteErrMsg{err}
}
return pasteMsg(str)
}
func clamp(v, low, high int) int {
if high < low {
low, high = high, low
}
return min(high, max(low, v))
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
// Deprecated.
// Deprecated: use cursor.Mode.
type CursorMode int
const (
// Deprecated: use cursor.CursorBlink.
CursorBlink = CursorMode(cursor.CursorBlink)
// Deprecated: use cursor.CursorStatic.
CursorStatic = CursorMode(cursor.CursorStatic)
// Deprecated: use cursor.CursorHide.
CursorHide = CursorMode(cursor.CursorHide)
)
func (c CursorMode) String() string {
return cursor.Mode(c).String()
}
// Deprecated: use cursor.Mode().
func (m Model) CursorMode() CursorMode {
return CursorMode(m.Cursor.Mode())
}
// Deprecated: use cursor.SetMode().
func (m *Model) SetCursorMode(mode CursorMode) tea.Cmd {
return m.Cursor.SetMode(cursor.Mode(mode))
}

View File

@@ -1,48 +0,0 @@
package viewport
import "github.com/charmbracelet/bubbles/key"
const spacebar = " "
// KeyMap defines the keybindings for the viewport. Note that you don't
// necessary need to use keybindings at all; the viewport can be controlled
// programmatically with methods like Model.LineDown(1). See the GoDocs for
// details.
type KeyMap struct {
PageDown key.Binding
PageUp key.Binding
HalfPageUp key.Binding
HalfPageDown key.Binding
Down key.Binding
Up key.Binding
}
// DefaultKeyMap returns a set of pager-like default keybindings.
func DefaultKeyMap() KeyMap {
return KeyMap{
PageDown: key.NewBinding(
key.WithKeys("pgdown", spacebar, "f"),
key.WithHelp("f/pgdn", "page down"),
),
PageUp: key.NewBinding(
key.WithKeys("pgup", "b"),
key.WithHelp("b/pgup", "page up"),
),
HalfPageUp: key.NewBinding(
key.WithKeys("u", "ctrl+u"),
key.WithHelp("u", "½ page up"),
),
HalfPageDown: key.NewBinding(
key.WithKeys("d", "ctrl+d"),
key.WithHelp("d", "½ page down"),
),
Up: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑/k", "up"),
),
Down: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓/j", "down"),
),
}
}

View File

@@ -1,404 +0,0 @@
package viewport
import (
"math"
"strings"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// New returns a new model with the given width and height as well as default
// key mappings.
func New(width, height int) (m Model) {
m.Width = width
m.Height = height
m.setInitialValues()
return m
}
// Model is the Bubble Tea model for this viewport element.
type Model struct {
Width int
Height int
KeyMap KeyMap
// Whether or not to respond to the mouse. The mouse must be enabled in
// Bubble Tea for this to work. For details, see the Bubble Tea docs.
MouseWheelEnabled bool
// The number of lines the mouse wheel will scroll. By default, this is 3.
MouseWheelDelta int
// YOffset is the vertical scroll position.
YOffset int
// YPosition is the position of the viewport in relation to the terminal
// window. It's used in high performance rendering only.
YPosition int
// Style applies a lipgloss style to the viewport. Realistically, it's most
// useful for setting borders, margins and padding.
Style lipgloss.Style
// HighPerformanceRendering bypasses the normal Bubble Tea renderer to
// provide higher performance rendering. Most of the time the normal Bubble
// Tea rendering methods will suffice, but if you're passing content with
// a lot of ANSI escape codes you may see improved rendering in certain
// terminals with this enabled.
//
// This should only be used in program occupying the entire terminal,
// which is usually via the alternate screen buffer.
HighPerformanceRendering bool
initialized bool
lines []string
}
func (m *Model) setInitialValues() {
m.KeyMap = DefaultKeyMap()
m.MouseWheelEnabled = true
m.MouseWheelDelta = 3
m.initialized = true
}
// Init exists to satisfy the tea.Model interface for composability purposes.
func (m Model) Init() tea.Cmd {
return nil
}
// AtTop returns whether or not the viewport is in the very top position.
func (m Model) AtTop() bool {
return m.YOffset <= 0
}
// AtBottom returns whether or not the viewport is at or past the very bottom
// position.
func (m Model) AtBottom() bool {
return m.YOffset >= m.maxYOffset()
}
// PastBottom returns whether or not the viewport is scrolled beyond the last
// line. This can happen when adjusting the viewport height.
func (m Model) PastBottom() bool {
return m.YOffset > m.maxYOffset()
}
// ScrollPercent returns the amount scrolled as a float between 0 and 1.
func (m Model) ScrollPercent() float64 {
if m.Height >= len(m.lines) {
return 1.0
}
y := float64(m.YOffset)
h := float64(m.Height)
t := float64(len(m.lines) - 1)
v := y / (t - h)
return math.Max(0.0, math.Min(1.0, v))
}
// SetContent set the pager's text content. For high performance rendering the
// Sync command should also be called.
func (m *Model) SetContent(s string) {
s = strings.ReplaceAll(s, "\r\n", "\n") // normalize line endings
m.lines = strings.Split(s, "\n")
if m.YOffset > len(m.lines)-1 {
m.GotoBottom()
}
}
// maxYOffset returns the maximum possible value of the y-offset based on the
// viewport's content and set height.
func (m Model) maxYOffset() int {
return max(0, len(m.lines)-m.Height)
}
// visibleLines returns the lines that should currently be visible in the
// viewport.
func (m Model) visibleLines() (lines []string) {
if len(m.lines) > 0 {
top := max(0, m.YOffset)
bottom := clamp(m.YOffset+m.Height, top, len(m.lines))
lines = m.lines[top:bottom]
}
return lines
}
// scrollArea returns the scrollable boundaries for high performance rendering.
func (m Model) scrollArea() (top, bottom int) {
top = max(0, m.YPosition)
bottom = max(top, top+m.Height)
if top > 0 && bottom > top {
bottom--
}
return top, bottom
}
// SetYOffset sets the Y offset.
func (m *Model) SetYOffset(n int) {
m.YOffset = clamp(n, 0, m.maxYOffset())
}
// ViewDown moves the view down by the number of lines in the viewport.
// Basically, "page down".
func (m *Model) ViewDown() []string {
if m.AtBottom() {
return nil
}
return m.LineDown(m.Height)
}
// ViewUp moves the view up by one height of the viewport. Basically, "page up".
func (m *Model) ViewUp() []string {
if m.AtTop() {
return nil
}
return m.LineUp(m.Height)
}
// HalfViewDown moves the view down by half the height of the viewport.
func (m *Model) HalfViewDown() (lines []string) {
if m.AtBottom() {
return nil
}
return m.LineDown(m.Height / 2)
}
// HalfViewUp moves the view up by half the height of the viewport.
func (m *Model) HalfViewUp() (lines []string) {
if m.AtTop() {
return nil
}
return m.LineUp(m.Height / 2)
}
// LineDown moves the view down by the given number of lines.
func (m *Model) LineDown(n int) (lines []string) {
if m.AtBottom() || n == 0 || len(m.lines) == 0 {
return nil
}
// Make sure the number of lines by which we're going to scroll isn't
// greater than the number of lines we actually have left before we reach
// the bottom.
m.SetYOffset(m.YOffset + n)
// Gather lines to send off for performance scrolling.
bottom := clamp(m.YOffset+m.Height, 0, len(m.lines))
top := clamp(m.YOffset+m.Height-n, 0, bottom)
return m.lines[top:bottom]
}
// LineUp moves the view down by the given number of lines. Returns the new
// lines to show.
func (m *Model) LineUp(n int) (lines []string) {
if m.AtTop() || n == 0 || len(m.lines) == 0 {
return nil
}
// Make sure the number of lines by which we're going to scroll isn't
// greater than the number of lines we are from the top.
m.SetYOffset(m.YOffset - n)
// Gather lines to send off for performance scrolling.
top := max(0, m.YOffset)
bottom := clamp(m.YOffset+n, 0, m.maxYOffset())
return m.lines[top:bottom]
}
// TotalLineCount returns the total number of lines (both hidden and visible) within the viewport.
func (m Model) TotalLineCount() int {
return len(m.lines)
}
// VisibleLineCount returns the number of the visible lines within the viewport.
func (m Model) VisibleLineCount() int {
return len(m.visibleLines())
}
// GotoTop sets the viewport to the top position.
func (m *Model) GotoTop() (lines []string) {
if m.AtTop() {
return nil
}
m.SetYOffset(0)
return m.visibleLines()
}
// GotoBottom sets the viewport to the bottom position.
func (m *Model) GotoBottom() (lines []string) {
m.SetYOffset(m.maxYOffset())
return m.visibleLines()
}
// Sync tells the renderer where the viewport will be located and requests
// a render of the current state of the viewport. It should be called for the
// first render and after a window resize.
//
// For high performance rendering only.
func Sync(m Model) tea.Cmd {
if len(m.lines) == 0 {
return nil
}
top, bottom := m.scrollArea()
return tea.SyncScrollArea(m.visibleLines(), top, bottom)
}
// ViewDown is a high performance command that moves the viewport up by a given
// number of lines. Use Model.ViewDown to get the lines that should be rendered.
// For example:
//
// lines := model.ViewDown(1)
// cmd := ViewDown(m, lines)
func ViewDown(m Model, lines []string) tea.Cmd {
if len(lines) == 0 {
return nil
}
top, bottom := m.scrollArea()
return tea.ScrollDown(lines, top, bottom)
}
// ViewUp is a high performance command the moves the viewport down by a given
// number of lines height. Use Model.ViewUp to get the lines that should be
// rendered.
func ViewUp(m Model, lines []string) tea.Cmd {
if len(lines) == 0 {
return nil
}
top, bottom := m.scrollArea()
return tea.ScrollUp(lines, top, bottom)
}
// Update handles standard message-based viewport updates.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
var cmd tea.Cmd
m, cmd = m.updateAsModel(msg)
return m, cmd
}
// Author's note: this method has been broken out to make it easier to
// potentially transition Update to satisfy tea.Model.
func (m Model) updateAsModel(msg tea.Msg) (Model, tea.Cmd) {
if !m.initialized {
m.setInitialValues()
}
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.KeyMap.PageDown):
lines := m.ViewDown()
if m.HighPerformanceRendering {
cmd = ViewDown(m, lines)
}
case key.Matches(msg, m.KeyMap.PageUp):
lines := m.ViewUp()
if m.HighPerformanceRendering {
cmd = ViewUp(m, lines)
}
case key.Matches(msg, m.KeyMap.HalfPageDown):
lines := m.HalfViewDown()
if m.HighPerformanceRendering {
cmd = ViewDown(m, lines)
}
case key.Matches(msg, m.KeyMap.HalfPageUp):
lines := m.HalfViewUp()
if m.HighPerformanceRendering {
cmd = ViewUp(m, lines)
}
case key.Matches(msg, m.KeyMap.Down):
lines := m.LineDown(1)
if m.HighPerformanceRendering {
cmd = ViewDown(m, lines)
}
case key.Matches(msg, m.KeyMap.Up):
lines := m.LineUp(1)
if m.HighPerformanceRendering {
cmd = ViewUp(m, lines)
}
}
case tea.MouseMsg:
if !m.MouseWheelEnabled {
break
}
switch msg.Type {
case tea.MouseWheelUp:
lines := m.LineUp(m.MouseWheelDelta)
if m.HighPerformanceRendering {
cmd = ViewUp(m, lines)
}
case tea.MouseWheelDown:
lines := m.LineDown(m.MouseWheelDelta)
if m.HighPerformanceRendering {
cmd = ViewDown(m, lines)
}
}
}
return m, cmd
}
// View renders the viewport into a string.
func (m Model) View() string {
if m.HighPerformanceRendering {
// Just send newlines since we're going to be rendering the actual
// content separately. We still need to send something that equals the
// height of this view so that the Bubble Tea standard renderer can
// position anything below this view properly.
return strings.Repeat("\n", max(0, m.Height-1))
}
w, h := m.Width, m.Height
if sw := m.Style.GetWidth(); sw != 0 {
w = min(w, sw)
}
if sh := m.Style.GetHeight(); sh != 0 {
h = min(h, sh)
}
contentWidth := w - m.Style.GetHorizontalFrameSize()
contentHeight := h - m.Style.GetVerticalFrameSize()
contents := lipgloss.NewStyle().
Height(contentHeight). // pad to height.
MaxHeight(contentHeight). // truncate height if taller.
MaxWidth(contentWidth). // truncate width.
Render(strings.Join(m.visibleLines(), "\n"))
return m.Style.Copy().
UnsetWidth().UnsetHeight(). // Style size already applied in contents.
Render(contents)
}
func clamp(v, low, high int) int {
if high < low {
low, high = high, low
}
return min(high, max(low, v))
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func max(a, b int) int {
if a > b {
return a
}
return b
}

View File

@@ -1,22 +0,0 @@
.DS_Store
.envrc
examples/fullscreen/fullscreen
examples/help/help
examples/http/http
examples/list-default/list-default
examples/list-fancy/list-fancy
examples/list-simple/list-simple
examples/mouse/mouse
examples/pager/pager
examples/progress-download/color_vortex.blend
examples/progress-download/progress-download
examples/simple/simple
examples/spinner/spinner
examples/textinput/textinput
examples/textinputs/textinputs
examples/views/views
tutorials/basics/basics
tutorials/commands/commands
.idea
coverage.txt

View File

@@ -1,47 +0,0 @@
run:
tests: false
issues:
include:
- EXC0001
- EXC0005
- EXC0011
- EXC0012
- EXC0013
max-issues-per-linter: 0
max-same-issues: 0
linters:
enable:
# - dupl
- exhaustive
# - exhaustivestruct
- goconst
- godot
- godox
- gomnd
- gomoddirectives
- goprintffuncname
- ifshort
# - lll
- misspell
- nakedret
- nestif
- noctx
- nolintlint
- prealloc
- wrapcheck
# disable default linters, they are already enabled in .golangci.yml
disable:
- deadcode
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- structcheck
- typecheck
- unused
- varcheck

View File

@@ -1,29 +0,0 @@
run:
tests: false
issues:
include:
- EXC0001
- EXC0005
- EXC0011
- EXC0012
- EXC0013
max-issues-per-linter: 0
max-same-issues: 0
linters:
enable:
- bodyclose
- exportloopref
- goimports
- gosec
- nilerr
- predeclared
- revive
- rowserrcheck
- sqlclosecheck
- tparallel
- unconvert
- unparam
- whitespace

View File

@@ -1,13 +0,0 @@
# Contributing
Pull requests are welcome for any changes.
Consider opening an issue for larger changes to get feedback on the idea from the team.
If your change touches parts of the Bubble Tea renderer or internals, make sure
that all the examples in the `examples/` folder continue to run correctly.
For commit messages, please use conventional commits[^1] to make it easier to
generate release notes.
[^1]: https://www.conventionalcommits.org/en/v1.0.0

View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2020 Charmbracelet, Inc
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.

View File

@@ -1,402 +0,0 @@
# Bubble Tea
<p>
<img src="https://stuff.charm.sh/bubbletea/bubbletea-github-header-simple.png" width="313" alt="Bubble Tea Title Treatment"><br>
<a href="https://github.com/charmbracelet/bubbletea/releases"><img src="https://img.shields.io/github/release/charmbracelet/bubbletea.svg" alt="Latest Release"></a>
<a href="https://pkg.go.dev/github.com/charmbracelet/bubbletea?tab=doc"><img src="https://godoc.org/github.com/golang/gddo?status.svg" alt="GoDoc"></a>
<a href="https://github.com/charmbracelet/bubbletea/actions"><img src="https://github.com/charmbracelet/bubbletea/workflows/build/badge.svg" alt="Build Status"></a>
</p>
The fun, functional and stateful way to build terminal apps. A Go framework
based on [The Elm Architecture][elm]. Bubble Tea is well-suited for simple and
complex terminal applications, either inline, full-window, or a mix of both.
<p>
<img src="https://stuff.charm.sh/bubbletea/bubbletea-example.gif" width="100%" alt="Bubble Tea Example">
</p>
Bubble Tea is in use in production and includes a number of features and
performance optimizations weve added along the way. Among those is a standard
framerate-based renderer, a renderer for high-performance scrollable
regions which works alongside the main renderer, and mouse support.
To get started, see the tutorial below, the [examples][examples], the
[docs][docs], the [video tutorials][youtube] and some common [resources](#libraries-we-use-with-bubble-tea).
[youtube]: https://charm.sh/yt
## By the way
Be sure to check out [Bubbles][bubbles], a library of common UI components for Bubble Tea.
<p>
<a href="https://github.com/charmbracelet/bubbles"><img src="https://stuff.charm.sh/bubbles/bubbles-badge.png" width="174" alt="Bubbles Badge"></a>&nbsp;&nbsp;
<a href="https://github.com/charmbracelet/bubbles"><img src="https://stuff.charm.sh/bubbles-examples/textinput.gif" width="400" alt="Text Input Example from Bubbles"></a>
</p>
***
## Tutorial
Bubble Tea is based on the functional design paradigms of [The Elm
Architecture][elm], which happens to work nicely with Go. It's a delightful way
to build applications.
This tutorial assumes you have a working knowledge of Go.
By the way, the non-annotated source code for this program is available
[on GitHub][tut-source].
[elm]: https://guide.elm-lang.org/architecture/
[tut-source]:https://github.com/charmbracelet/bubbletea/tree/master/tutorials/basics
### Enough! Let's get to it.
For this tutorial, we're making a shopping list.
To start we'll define our package and import some libraries. Our only external
import will be the Bubble Tea library, which we'll call `tea` for short.
```go
package main
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
)
```
Bubble Tea programs are comprised of a **model** that describes the application
state and three simple methods on that model:
* **Init**, a function that returns an initial command for the application to run.
* **Update**, a function that handles incoming events and updates the model accordingly.
* **View**, a function that renders the UI based on the data in the model.
### The Model
So let's start by defining our model which will store our application's state.
It can be any type, but a `struct` usually makes the most sense.
```go
type model struct {
choices []string // items on the to-do list
cursor int // which to-do list item our cursor is pointing at
selected map[int]struct{} // which to-do items are selected
}
```
### Initialization
Next, well define our applications initial state. In this case, were defining
a function to return our initial model, however, we could just as easily define
the initial model as a variable elsewhere, too.
```go
func initialModel() model {
return model{
// Our to-do list is a grocery list
choices: []string{"Buy carrots", "Buy celery", "Buy kohlrabi"},
// A map which indicates which choices are selected. We're using
// the map like a mathematical set. The keys refer to the indexes
// of the `choices` slice, above.
selected: make(map[int]struct{}),
}
}
```
Next, we define the `Init` method. `Init` can return a `Cmd` that could perform
some initial I/O. For now, we don't need to do any I/O, so for the command,
we'll just return `nil`, which translates to "no command."
```go
func (m model) Init() tea.Cmd {
// Just return `nil`, which means "no I/O right now, please."
return nil
}
```
### The Update Method
Next up is the update method. The update function is called when ”things
happen.” Its job is to look at what has happened and return an updated model in
response. It can also return a `Cmd` to make more things happen, but for now
don't worry about that part.
In our case, when a user presses the down arrow, `Update`s job is to notice
that the down arrow was pressed and move the cursor accordingly (or not).
The “something happened” comes in the form of a `Msg`, which can be any type.
Messages are the result of some I/O that took place, such as a keypress, timer
tick, or a response from a server.
We usually figure out which type of `Msg` we received with a type switch, but
you could also use a type assertion.
For now, we'll just deal with `tea.KeyMsg` messages, which are automatically
sent to the update function when keys are pressed.
```go
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
// Is it a key press?
case tea.KeyMsg:
// Cool, what was the actual key pressed?
switch msg.String() {
// These keys should exit the program.
case "ctrl+c", "q":
return m, tea.Quit
// The "up" and "k" keys move the cursor up
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
// The "down" and "j" keys move the cursor down
case "down", "j":
if m.cursor < len(m.choices)-1 {
m.cursor++
}
// The "enter" key and the spacebar (a literal space) toggle
// the selected state for the item that the cursor is pointing at.
case "enter", " ":
_, ok := m.selected[m.cursor]
if ok {
delete(m.selected, m.cursor)
} else {
m.selected[m.cursor] = struct{}{}
}
}
}
// Return the updated model to the Bubble Tea runtime for processing.
// Note that we're not returning a command.
return m, nil
}
```
You may have noticed that <kbd>ctrl+c</kbd> and <kbd>q</kbd> above return
a `tea.Quit` command with the model. Thats a special command which instructs
the Bubble Tea runtime to quit, exiting the program.
### The View Method
At last, its time to render our UI. Of all the methods, the view is the
simplest. We look at the model in its current state and use it to return
a `string`. That string is our UI!
Because the view describes the entire UI of your application, you dont have to
worry about redrawing logic and stuff like that. Bubble Tea takes care of it
for you.
```go
func (m model) View() string {
// The header
s := "What should we buy at the market?\n\n"
// Iterate over our choices
for i, choice := range m.choices {
// Is the cursor pointing at this choice?
cursor := " " // no cursor
if m.cursor == i {
cursor = ">" // cursor!
}
// Is this choice selected?
checked := " " // not selected
if _, ok := m.selected[i]; ok {
checked = "x" // selected!
}
// Render the row
s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
}
// The footer
s += "\nPress q to quit.\n"
// Send the UI for rendering
return s
}
```
### All Together Now
The last step is to simply run our program. We pass our initial model to
`tea.NewProgram` and let it rip:
```go
func main() {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
fmt.Printf("Alas, there's been an error: %v", err)
os.Exit(1)
}
}
```
## Whats Next?
This tutorial covers the basics of building an interactive terminal UI, but
in the real world you'll also need to perform I/O. To learn about that have a
look at the [Command Tutorial][cmd]. It's pretty simple.
There are also several [Bubble Tea examples][examples] available and, of course,
there are [Go Docs][docs].
[cmd]: http://github.com/charmbracelet/bubbletea/tree/master/tutorials/commands/
[examples]: http://github.com/charmbracelet/bubbletea/tree/master/examples
[docs]: https://pkg.go.dev/github.com/charmbracelet/bubbletea?tab=doc
## Debugging
### Debugging with Delve
Since Bubble Tea apps assume control of stdin and stdout, youll need to run
delve in headless mode and then connect to it:
```bash
# Start the debugger
$ dlv debug --headless .
API server listening at: 127.0.0.1:34241
# Connect to it from another terminal
$ dlv connect 127.0.0.1:34241
```
Note that the default port used will vary on your system and per run, so
actually watch out what address the first `dlv` run tells you to connect to.
### Logging Stuff
You cant really log to stdout with Bubble Tea because your TUI is busy
occupying that! You can, however, log to a file by including something like
the following prior to starting your Bubble Tea program:
```go
if len(os.Getenv("DEBUG")) > 0 {
f, err := tea.LogToFile("debug.log", "debug")
if err != nil {
fmt.Println("fatal:", err)
os.Exit(1)
}
defer f.Close()
}
```
To see whats being logged in real time, run `tail -f debug.log` while you run
your program in another window.
## Libraries we use with Bubble Tea
* [Bubbles][bubbles]: Common Bubble Tea components such as text inputs, viewports, spinners and so on
* [Lip Gloss][lipgloss]: Style, format and layout tools for terminal applications
* [Harmonica][harmonica]: A spring animation library for smooth, natural motion
* [BubbleZone][bubblezone]: Easy mouse event tracking for Bubble Tea components
* [Termenv][termenv]: Advanced ANSI styling for terminal applications
* [Reflow][reflow]: Advanced ANSI-aware methods for working with text
[bubbles]: https://github.com/charmbracelet/bubbles
[lipgloss]: https://github.com/charmbracelet/lipgloss
[harmonica]: https://github.com/charmbracelet/harmonica
[bubblezone]: https://github.com/lrstanley/bubblezone
[termenv]: https://github.com/muesli/termenv
[reflow]: https://github.com/muesli/reflow
## Bubble Tea in the Wild
For some Bubble Tea programs in production, see:
* [AT CLI](https://github.com/daskycodes/at_cli): execute AT Commands via serial port connections
* [Aztify](https://github.com/Azure/aztfy): bring Microsoft Azure resources under Terraform
* [brows](https://github.com/rubysolo/brows): a GitHub release browser
* [Canard](https://github.com/mrusme/canard): an RSS client
* [charm](https://github.com/charmbracelet/charm): the official Charm user account manager
* [chezmoi](https://github.com/twpayne/chezmoi): securely manage your dotfiles across multiple machines
* [chtop](https://github.com/chhetripradeep/chtop): monitor your ClickHouse node without leaving terminal
* [circumflex](https://github.com/bensadeh/circumflex): read Hacker News in the terminal
* [clidle](https://github.com/ajeetdsouza/clidle): a Wordle clone
* [cLive](https://github.com/koki-develop/clive): automate terminal operations and view them live in a browser
* [container-canary](https://github.com/NVIDIA/container-canary): a container validator
* [dns53](https://github.com/purpleclay/dns53): dynamic DNS with Amazon Route53: expose your EC2 quickly, securely and privately
* [enola](https://github.com/sherlock-project/enola): hunt down social media accounts by username across social networks
* [flapioca](https://github.com/kbrgl/flapioca): Flappy Bird on the CLI!
* [fm](https://github.com/knipferrc/fm): a terminal-based file manager
* [fork-cleaner](https://github.com/caarlos0/fork-cleaner): clean up old and inactive forks in your GitHub account
* [fztea](https://github.com/jon4hz/fztea): a Flipper Zero TUI
* [gambit](https://github.com/maaslalani/gambit): chess in the terminal
* [gembro](https://git.sr.ht/~rafael/gembro): a mouse-driven Gemini browser
* [gh-b](https://github.com/joaom00/gh-b): a GitHub CLI extension for managing branches
* [gh-dash](https://www.github.com/dlvhdr/gh-dash): a GitHub CLI extension for PRs and issues
* [gitflow-toolkit](https://github.com/mritd/gitflow-toolkit): a GitFlow submission tool
* [Glow](https://github.com/charmbracelet/glow): a markdown reader, browser, and online markdown stash
* [gocovsh](https://github.com/orlangure/gocovsh): explore Go coverage reports from the CLI
* [got](https://github.com/fedeztk/got): a simple translator and text-to-speech app build on top of simplytranslate's APIs
* [httpit](https://github.com/gonetx/httpit): a rapid http(s) benchmark tool
* [IDNT](https://github.com/r-darwish/idnt): a batch software uninstaller
* [kboard](https://github.com/CamiloGarciaLaRotta/kboard): a typing game
* [mandelbrot-cli](https://github.com/MicheleFiladelfia/mandelbrot-cli): a multiplatform terminal mandelbrot set explorer
* [mc](https://github.com/minio/mc): the official [MinIO](https://min.io) client
* [mergestat](https://github.com/mergestat/mergestat): run SQL queries on git repositories
* [Neon Modem Overdrive](https://github.com/mrusme/neonmodem): a BBS-style TUI client for Discourse, Lemmy, Lobste.rs and Hacker News
* [Noted](https://github.com/torbratsberg/noted): a note viewer and manager
* [pathos](https://github.com/chip/pathos): a PATH env variable editor
* [portal](https://github.com/ZinoKader/portal): secure transfers between computers
* [redis-viewer](https://github.com/SaltFishPr/redis-viewer): a Redis databases browser
* [sku](https://github.com/fedeztk/sku): Sudoku on the CLI
* [Slides](https://github.com/maaslalani/slides): a markdown-based presentation tool
* [SlurmCommander](https://github.com/CLIP-HPC/SlurmCommander): a Slurm workload manager TUI
* [Soft Serve](https://github.com/charmbracelet/soft-serve): a command-line-first Git server that runs a TUI over SSH
* [solitaire-tui](https://github.com/brianstrauch/solitaire-tui): Klondike Solitaire for the terminal
* [StormForge Optimize Controller](https://github.com/thestormforge/optimize-controller): a tool for experimenting with application configurations in Kubernetes
* [STTG](https://github.com/wille1101/sttg): a teletext client for SVT, Swedens national public television station
* [sttr](https://github.com/abhimanyu003/sttr): a general-purpose text transformer
* [tasktimer](https://github.com/caarlos0/tasktimer): a dead-simple task timer
* [termdbms](https://github.com/mathaou/termdbms): a keyboard and mouse driven database browser
* [ticker](https://github.com/achannarasappa/ticker): a terminal stock viewer and stock position tracker
* [tran](https://github.com/abdfnx/tran): securely transfer stuff between computers (based on [portal][https://github.com/ZinoKader/portal])
* [Typer](https://github.com/maaslalani/typer): a typing test
* [tz](https://github.com/oz/tz): an aid for scheduling across multiple time zones
* [ugm](https://github.com/ariasmn/ugm): a unix user and group browser
* [wander](https://github.com/robinovitch61/wander): a HashiCorp Nomad terminal client
* [wishlist](https://github.com/charmbracelet/wishlist): an SSH directory
## Feedback
We'd love to hear your thoughts on this project. Feel free to drop us a note!
* [Twitter](https://twitter.com/charmcli)
* [The Fediverse](https://mastodon.social/@charmcli)
* [Discord](https://charm.sh/chat)
## Acknowledgments
Bubble Tea is based on the paradigms of [The Elm Architecture][elm] by Evan
Czaplicki et alia and the excellent [go-tea][gotea] by TJ Holowaychuk. Its
inspired by the many great [_Zeichenorientierte Benutzerschnittstellen_][zb]
of days past.
[elm]: https://guide.elm-lang.org/architecture/
[gotea]: https://github.com/tj/go-tea
[zb]: https://de.wikipedia.org/wiki/Zeichenorientierte_Benutzerschnittstelle
## License
[MIT](https://github.com/charmbracelet/bubbletea/raw/master/LICENSE)
***
Part of [Charm](https://charm.sh).
<a href="https://charm.sh/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-badge.jpg" width="400"></a>
Charm热爱开源 • Charm loves open source • نحنُ نحب المصادر المفتوحة

View File

@@ -1,172 +0,0 @@
package tea
import (
"time"
)
// Batch performs a bunch of commands concurrently with no ordering guarantees
// about the results. Use a Batch to return several commands.
//
// Example:
//
// func (m model) Init() Cmd {
// return tea.Batch(someCommand, someOtherCommand)
// }
func Batch(cmds ...Cmd) Cmd {
var validCmds []Cmd
for _, c := range cmds {
if c == nil {
continue
}
validCmds = append(validCmds, c)
}
if len(validCmds) == 0 {
return nil
}
return func() Msg {
return BatchMsg(validCmds)
}
}
// BatchMsg is a message used to perform a bunch of commands concurrently with
// no ordering guarantees. You can send a BatchMsg with Batch.
type BatchMsg []Cmd
// Sequence runs the given commands one at a time, in order. Contrast this with
// Batch, which runs commands concurrently.
func Sequence(cmds ...Cmd) Cmd {
return func() Msg {
return sequenceMsg(cmds)
}
}
// sequenceMsg is used internally to run the given commands in order.
type sequenceMsg []Cmd
// Every is a command that ticks in sync with the system clock. So, if you
// wanted to tick with the system clock every second, minute or hour you
// could use this. It's also handy for having different things tick in sync.
//
// Because we're ticking with the system clock the tick will likely not run for
// the entire specified duration. For example, if we're ticking for one minute
// and the clock is at 12:34:20 then the next tick will happen at 12:35:00, 40
// seconds later.
//
// To produce the command, pass a duration and a function which returns
// a message containing the time at which the tick occurred.
//
// type TickMsg time.Time
//
// cmd := Every(time.Second, func(t time.Time) Msg {
// return TickMsg(t)
// })
//
// Beginners' note: Every sends a single message and won't automatically
// dispatch messages at an interval. To do that, you'll want to return another
// Every command after receiving your tick message. For example:
//
// type TickMsg time.Time
//
// // Send a message every second.
// func tickEvery() Cmd {
// return Every(time.Second, func(t time.Time) Msg {
// return TickMsg(t)
// })
// }
//
// func (m model) Init() Cmd {
// // Start ticking.
// return tickEvery()
// }
//
// func (m model) Update(msg Msg) (Model, Cmd) {
// switch msg.(type) {
// case TickMsg:
// // Return your Every command again to loop.
// return m, tickEvery()
// }
// return m, nil
// }
//
// Every is analogous to Tick in the Elm Architecture.
func Every(duration time.Duration, fn func(time.Time) Msg) Cmd {
return func() Msg {
n := time.Now()
d := n.Truncate(duration).Add(duration).Sub(n)
t := time.NewTimer(d)
return fn(<-t.C)
}
}
// Tick produces a command at an interval independent of the system clock at
// the given duration. That is, the timer begins precisely when invoked,
// and runs for its entire duration.
//
// To produce the command, pass a duration and a function which returns
// a message containing the time at which the tick occurred.
//
// type TickMsg time.Time
//
// cmd := Tick(time.Second, func(t time.Time) Msg {
// return TickMsg(t)
// })
//
// Beginners' note: Tick sends a single message and won't automatically
// dispatch messages at an interval. To do that, you'll want to return another
// Tick command after receiving your tick message. For example:
//
// type TickMsg time.Time
//
// func doTick() Cmd {
// return Tick(time.Second, func(t time.Time) Msg {
// return TickMsg(t)
// })
// }
//
// func (m model) Init() Cmd {
// // Start ticking.
// return doTick()
// }
//
// func (m model) Update(msg Msg) (Model, Cmd) {
// switch msg.(type) {
// case TickMsg:
// // Return your Tick command again to loop.
// return m, doTick()
// }
// return m, nil
// }
func Tick(d time.Duration, fn func(time.Time) Msg) Cmd {
return func() Msg {
t := time.NewTimer(d)
return fn(<-t.C)
}
}
// Sequentially produces a command that sequentially executes the given
// commands.
// The Msg returned is the first non-nil message returned by a Cmd.
//
// func saveStateCmd() Msg {
// if err := save(); err != nil {
// return errMsg{err}
// }
// return nil
// }
//
// cmd := Sequentially(saveStateCmd, Quit)
//
// Deprecated: use Sequence instead.
func Sequentially(cmds ...Cmd) Cmd {
return func() Msg {
for _, cmd := range cmds {
if cmd == nil {
continue
}
if msg := cmd(); msg != nil {
return msg
}
}
return nil
}
}

View File

@@ -1,129 +0,0 @@
package tea
import (
"io"
"os"
"os/exec"
)
// execMsg is used internally to run an ExecCommand sent with Exec.
type execMsg struct {
cmd ExecCommand
fn ExecCallback
}
// Exec is used to perform arbitrary I/O in a blocking fashion, effectively
// pausing the Program while execution is running and resuming it when
// execution has completed.
//
// Most of the time you'll want to use ExecProcess, which runs an exec.Cmd.
//
// For non-interactive i/o you should use a Cmd (that is, a tea.Cmd).
func Exec(c ExecCommand, fn ExecCallback) Cmd {
return func() Msg {
return execMsg{cmd: c, fn: fn}
}
}
// ExecProcess runs the given *exec.Cmd in a blocking fashion, effectively
// pausing the Program while the command is running. After the *exec.Cmd exists
// the Program resumes. It's useful for spawning other interactive applications
// such as editors and shells from within a Program.
//
// To produce the command, pass an *exec.Cmd and a function which returns
// a message containing the error which may have occurred when running the
// ExecCommand.
//
// type VimFinishedMsg struct { err error }
//
// c := exec.Command("vim", "file.txt")
//
// cmd := ExecProcess(c, func(err error) Msg {
// return VimFinishedMsg{err: err}
// })
//
// Or, if you don't care about errors, you could simply:
//
// cmd := ExecProcess(exec.Command("vim", "file.txt"), nil)
//
// For non-interactive i/o you should use a Cmd (that is, a tea.Cmd).
func ExecProcess(c *exec.Cmd, fn ExecCallback) Cmd {
return Exec(wrapExecCommand(c), fn)
}
// ExecCallback is used when executing an *exec.Command to return a message
// with an error, which may or may not be nil.
type ExecCallback func(error) Msg
// ExecCommand can be implemented to execute things in a blocking fashion in
// the current terminal.
type ExecCommand interface {
Run() error
SetStdin(io.Reader)
SetStdout(io.Writer)
SetStderr(io.Writer)
}
// wrapExecCommand wraps an exec.Cmd so that it satisfies the ExecCommand
// interface so it can be used with Exec.
func wrapExecCommand(c *exec.Cmd) ExecCommand {
return &osExecCommand{Cmd: c}
}
// osExecCommand is a layer over an exec.Cmd that satisfies the ExecCommand
// interface.
type osExecCommand struct{ *exec.Cmd }
// SetStdin sets stdin on underlying exec.Cmd to the given io.Reader.
func (c *osExecCommand) SetStdin(r io.Reader) {
// If unset, have the command use the same input as the terminal.
if c.Stdin == nil {
c.Stdin = r
}
}
// SetStdout sets stdout on underlying exec.Cmd to the given io.Writer.
func (c *osExecCommand) SetStdout(w io.Writer) {
// If unset, have the command use the same output as the terminal.
if c.Stdout == nil {
c.Stdout = w
}
}
// SetStderr sets stderr on the underlying exec.Cmd to the given io.Writer.
func (c *osExecCommand) SetStderr(w io.Writer) {
// If unset, use stderr for the command's stderr
if c.Stderr == nil {
c.Stderr = w
}
}
// exec runs an ExecCommand and delivers the results to the program as a Msg.
func (p *Program) exec(c ExecCommand, fn ExecCallback) {
if err := p.ReleaseTerminal(); err != nil {
// If we can't release input, abort.
if fn != nil {
go p.Send(fn(err))
}
return
}
c.SetStdin(p.input)
c.SetStdout(p.output.TTY())
c.SetStderr(os.Stderr)
// Execute system command.
if err := c.Run(); err != nil {
_ = p.RestoreTerminal() // also try to restore the terminal.
if fn != nil {
go p.Send(fn(err))
}
return
}
// Have the program re-capture input.
err := p.RestoreTerminal()
if fn != nil {
go p.Send(fn(err))
}
}

View File

@@ -1,664 +0,0 @@
package tea
import (
"errors"
"io"
"unicode/utf8"
"github.com/mattn/go-localereader"
)
// KeyMsg contains information about a keypress. KeyMsgs are always sent to
// the program's update function. There are a couple general patterns you could
// use to check for keypresses:
//
// // Switch on the string representation of the key (shorter)
// switch msg := msg.(type) {
// case KeyMsg:
// switch msg.String() {
// case "enter":
// fmt.Println("you pressed enter!")
// case "a":
// fmt.Println("you pressed a!")
// }
// }
//
// // Switch on the key type (more foolproof)
// switch msg := msg.(type) {
// case KeyMsg:
// switch msg.Type {
// case KeyEnter:
// fmt.Println("you pressed enter!")
// case KeyRunes:
// switch string(msg.Runes) {
// case "a":
// fmt.Println("you pressed a!")
// }
// }
// }
//
// Note that Key.Runes will always contain at least one character, so you can
// always safely call Key.Runes[0]. In most cases Key.Runes will only contain
// one character, though certain input method editors (most notably Chinese
// IMEs) can input multiple runes at once.
type KeyMsg Key
// String returns a string representation for a key message. It's safe (and
// encouraged) for use in key comparison.
func (k KeyMsg) String() (str string) {
return Key(k).String()
}
// Key contains information about a keypress.
type Key struct {
Type KeyType
Runes []rune
Alt bool
}
// String returns a friendly string representation for a key. It's safe (and
// encouraged) for use in key comparison.
//
// k := Key{Type: KeyEnter}
// fmt.Println(k)
// // Output: enter
func (k Key) String() (str string) {
if k.Alt {
str += "alt+"
}
if k.Type == KeyRunes {
str += string(k.Runes)
return str
} else if s, ok := keyNames[k.Type]; ok {
str += s
return str
}
return ""
}
// KeyType indicates the key pressed, such as KeyEnter or KeyBreak or KeyCtrlC.
// All other keys will be type KeyRunes. To get the rune value, check the Rune
// method on a Key struct, or use the Key.String() method:
//
// k := Key{Type: KeyRunes, Runes: []rune{'a'}, Alt: true}
// if k.Type == KeyRunes {
//
// fmt.Println(k.Runes)
// // Output: a
//
// fmt.Println(k.String())
// // Output: alt+a
//
// }
type KeyType int
func (k KeyType) String() (str string) {
if s, ok := keyNames[k]; ok {
return s
}
return ""
}
// Control keys. We could do this with an iota, but the values are very
// specific, so we set the values explicitly to avoid any confusion.
//
// See also:
// https://en.wikipedia.org/wiki/C0_and_C1_control_codes
const (
keyNUL KeyType = 0 // null, \0
keySOH KeyType = 1 // start of heading
keySTX KeyType = 2 // start of text
keyETX KeyType = 3 // break, ctrl+c
keyEOT KeyType = 4 // end of transmission
keyENQ KeyType = 5 // enquiry
keyACK KeyType = 6 // acknowledge
keyBEL KeyType = 7 // bell, \a
keyBS KeyType = 8 // backspace
keyHT KeyType = 9 // horizontal tabulation, \t
keyLF KeyType = 10 // line feed, \n
keyVT KeyType = 11 // vertical tabulation \v
keyFF KeyType = 12 // form feed \f
keyCR KeyType = 13 // carriage return, \r
keySO KeyType = 14 // shift out
keySI KeyType = 15 // shift in
keyDLE KeyType = 16 // data link escape
keyDC1 KeyType = 17 // device control one
keyDC2 KeyType = 18 // device control two
keyDC3 KeyType = 19 // device control three
keyDC4 KeyType = 20 // device control four
keyNAK KeyType = 21 // negative acknowledge
keySYN KeyType = 22 // synchronous idle
keyETB KeyType = 23 // end of transmission block
keyCAN KeyType = 24 // cancel
keyEM KeyType = 25 // end of medium
keySUB KeyType = 26 // substitution
keyESC KeyType = 27 // escape, \e
keyFS KeyType = 28 // file separator
keyGS KeyType = 29 // group separator
keyRS KeyType = 30 // record separator
keyUS KeyType = 31 // unit separator
keyDEL KeyType = 127 // delete. on most systems this is mapped to backspace, I hear
)
// Control key aliases.
const (
KeyNull KeyType = keyNUL
KeyBreak KeyType = keyETX
KeyEnter KeyType = keyCR
KeyBackspace KeyType = keyDEL
KeyTab KeyType = keyHT
KeyEsc KeyType = keyESC
KeyEscape KeyType = keyESC
KeyCtrlAt KeyType = keyNUL // ctrl+@
KeyCtrlA KeyType = keySOH
KeyCtrlB KeyType = keySTX
KeyCtrlC KeyType = keyETX
KeyCtrlD KeyType = keyEOT
KeyCtrlE KeyType = keyENQ
KeyCtrlF KeyType = keyACK
KeyCtrlG KeyType = keyBEL
KeyCtrlH KeyType = keyBS
KeyCtrlI KeyType = keyHT
KeyCtrlJ KeyType = keyLF
KeyCtrlK KeyType = keyVT
KeyCtrlL KeyType = keyFF
KeyCtrlM KeyType = keyCR
KeyCtrlN KeyType = keySO
KeyCtrlO KeyType = keySI
KeyCtrlP KeyType = keyDLE
KeyCtrlQ KeyType = keyDC1
KeyCtrlR KeyType = keyDC2
KeyCtrlS KeyType = keyDC3
KeyCtrlT KeyType = keyDC4
KeyCtrlU KeyType = keyNAK
KeyCtrlV KeyType = keySYN
KeyCtrlW KeyType = keyETB
KeyCtrlX KeyType = keyCAN
KeyCtrlY KeyType = keyEM
KeyCtrlZ KeyType = keySUB
KeyCtrlOpenBracket KeyType = keyESC // ctrl+[
KeyCtrlBackslash KeyType = keyFS // ctrl+\
KeyCtrlCloseBracket KeyType = keyGS // ctrl+]
KeyCtrlCaret KeyType = keyRS // ctrl+^
KeyCtrlUnderscore KeyType = keyUS // ctrl+_
KeyCtrlQuestionMark KeyType = keyDEL // ctrl+?
)
// Other keys.
const (
KeyRunes KeyType = -(iota + 1)
KeyUp
KeyDown
KeyRight
KeyLeft
KeyShiftTab
KeyHome
KeyEnd
KeyPgUp
KeyPgDown
KeyCtrlPgUp
KeyCtrlPgDown
KeyDelete
KeyInsert
KeySpace
KeyCtrlUp
KeyCtrlDown
KeyCtrlRight
KeyCtrlLeft
KeyCtrlHome
KeyCtrlEnd
KeyShiftUp
KeyShiftDown
KeyShiftRight
KeyShiftLeft
KeyShiftHome
KeyShiftEnd
KeyCtrlShiftUp
KeyCtrlShiftDown
KeyCtrlShiftLeft
KeyCtrlShiftRight
KeyCtrlShiftHome
KeyCtrlShiftEnd
KeyF1
KeyF2
KeyF3
KeyF4
KeyF5
KeyF6
KeyF7
KeyF8
KeyF9
KeyF10
KeyF11
KeyF12
KeyF13
KeyF14
KeyF15
KeyF16
KeyF17
KeyF18
KeyF19
KeyF20
)
// Mappings for control keys and other special keys to friendly consts.
var keyNames = map[KeyType]string{
// Control keys.
keyNUL: "ctrl+@", // also ctrl+` (that's ctrl+backtick)
keySOH: "ctrl+a",
keySTX: "ctrl+b",
keyETX: "ctrl+c",
keyEOT: "ctrl+d",
keyENQ: "ctrl+e",
keyACK: "ctrl+f",
keyBEL: "ctrl+g",
keyBS: "ctrl+h",
keyHT: "tab", // also ctrl+i
keyLF: "ctrl+j",
keyVT: "ctrl+k",
keyFF: "ctrl+l",
keyCR: "enter",
keySO: "ctrl+n",
keySI: "ctrl+o",
keyDLE: "ctrl+p",
keyDC1: "ctrl+q",
keyDC2: "ctrl+r",
keyDC3: "ctrl+s",
keyDC4: "ctrl+t",
keyNAK: "ctrl+u",
keySYN: "ctrl+v",
keyETB: "ctrl+w",
keyCAN: "ctrl+x",
keyEM: "ctrl+y",
keySUB: "ctrl+z",
keyESC: "esc",
keyFS: "ctrl+\\",
keyGS: "ctrl+]",
keyRS: "ctrl+^",
keyUS: "ctrl+_",
keyDEL: "backspace",
// Other keys.
KeyRunes: "runes",
KeyUp: "up",
KeyDown: "down",
KeyRight: "right",
KeySpace: " ", // for backwards compatibility
KeyLeft: "left",
KeyShiftTab: "shift+tab",
KeyHome: "home",
KeyEnd: "end",
KeyCtrlHome: "ctrl+home",
KeyCtrlEnd: "ctrl+end",
KeyShiftHome: "shift+home",
KeyShiftEnd: "shift+end",
KeyCtrlShiftHome: "ctrl+shift+home",
KeyCtrlShiftEnd: "ctrl+shift+end",
KeyPgUp: "pgup",
KeyPgDown: "pgdown",
KeyCtrlPgUp: "ctrl+pgup",
KeyCtrlPgDown: "ctrl+pgdown",
KeyDelete: "delete",
KeyInsert: "insert",
KeyCtrlUp: "ctrl+up",
KeyCtrlDown: "ctrl+down",
KeyCtrlRight: "ctrl+right",
KeyCtrlLeft: "ctrl+left",
KeyShiftUp: "shift+up",
KeyShiftDown: "shift+down",
KeyShiftRight: "shift+right",
KeyShiftLeft: "shift+left",
KeyCtrlShiftUp: "ctrl+shift+up",
KeyCtrlShiftDown: "ctrl+shift+down",
KeyCtrlShiftLeft: "ctrl+shift+left",
KeyCtrlShiftRight: "ctrl+shift+right",
KeyF1: "f1",
KeyF2: "f2",
KeyF3: "f3",
KeyF4: "f4",
KeyF5: "f5",
KeyF6: "f6",
KeyF7: "f7",
KeyF8: "f8",
KeyF9: "f9",
KeyF10: "f10",
KeyF11: "f11",
KeyF12: "f12",
KeyF13: "f13",
KeyF14: "f14",
KeyF15: "f15",
KeyF16: "f16",
KeyF17: "f17",
KeyF18: "f18",
KeyF19: "f19",
KeyF20: "f20",
}
// Sequence mappings.
var sequences = map[string]Key{
// Arrow keys
"\x1b[A": {Type: KeyUp},
"\x1b[B": {Type: KeyDown},
"\x1b[C": {Type: KeyRight},
"\x1b[D": {Type: KeyLeft},
"\x1b[1;2A": {Type: KeyShiftUp},
"\x1b[1;2B": {Type: KeyShiftDown},
"\x1b[1;2C": {Type: KeyShiftRight},
"\x1b[1;2D": {Type: KeyShiftLeft},
"\x1b[OA": {Type: KeyShiftUp}, // DECCKM
"\x1b[OB": {Type: KeyShiftDown}, // DECCKM
"\x1b[OC": {Type: KeyShiftRight}, // DECCKM
"\x1b[OD": {Type: KeyShiftLeft}, // DECCKM
"\x1b[a": {Type: KeyShiftUp}, // urxvt
"\x1b[b": {Type: KeyShiftDown}, // urxvt
"\x1b[c": {Type: KeyShiftRight}, // urxvt
"\x1b[d": {Type: KeyShiftLeft}, // urxvt
"\x1b[1;3A": {Type: KeyUp, Alt: true},
"\x1b[1;3B": {Type: KeyDown, Alt: true},
"\x1b[1;3C": {Type: KeyRight, Alt: true},
"\x1b[1;3D": {Type: KeyLeft, Alt: true},
"\x1b\x1b[A": {Type: KeyUp, Alt: true}, // urxvt
"\x1b\x1b[B": {Type: KeyDown, Alt: true}, // urxvt
"\x1b\x1b[C": {Type: KeyRight, Alt: true}, // urxvt
"\x1b\x1b[D": {Type: KeyLeft, Alt: true}, // urxvt
"\x1b[1;4A": {Type: KeyShiftUp, Alt: true},
"\x1b[1;4B": {Type: KeyShiftDown, Alt: true},
"\x1b[1;4C": {Type: KeyShiftRight, Alt: true},
"\x1b[1;4D": {Type: KeyShiftLeft, Alt: true},
"\x1b\x1b[a": {Type: KeyShiftUp, Alt: true}, // urxvt
"\x1b\x1b[b": {Type: KeyShiftDown, Alt: true}, // urxvt
"\x1b\x1b[c": {Type: KeyShiftRight, Alt: true}, // urxvt
"\x1b\x1b[d": {Type: KeyShiftLeft, Alt: true}, // urxvt
"\x1b[1;5A": {Type: KeyCtrlUp},
"\x1b[1;5B": {Type: KeyCtrlDown},
"\x1b[1;5C": {Type: KeyCtrlRight},
"\x1b[1;5D": {Type: KeyCtrlLeft},
"\x1b[Oa": {Type: KeyCtrlUp, Alt: true}, // urxvt
"\x1b[Ob": {Type: KeyCtrlDown, Alt: true}, // urxvt
"\x1b[Oc": {Type: KeyCtrlRight, Alt: true}, // urxvt
"\x1b[Od": {Type: KeyCtrlLeft, Alt: true}, // urxvt
"\x1b[1;6A": {Type: KeyCtrlShiftUp},
"\x1b[1;6B": {Type: KeyCtrlShiftDown},
"\x1b[1;6C": {Type: KeyCtrlShiftRight},
"\x1b[1;6D": {Type: KeyCtrlShiftLeft},
"\x1b[1;7A": {Type: KeyCtrlUp, Alt: true},
"\x1b[1;7B": {Type: KeyCtrlDown, Alt: true},
"\x1b[1;7C": {Type: KeyCtrlRight, Alt: true},
"\x1b[1;7D": {Type: KeyCtrlLeft, Alt: true},
"\x1b[1;8A": {Type: KeyCtrlShiftUp, Alt: true},
"\x1b[1;8B": {Type: KeyCtrlShiftDown, Alt: true},
"\x1b[1;8C": {Type: KeyCtrlShiftRight, Alt: true},
"\x1b[1;8D": {Type: KeyCtrlShiftLeft, Alt: true},
// Miscellaneous keys
"\x1b[Z": {Type: KeyShiftTab},
"\x1b[2~": {Type: KeyInsert},
"\x1b[3;2~": {Type: KeyInsert, Alt: true},
"\x1b\x1b[2~": {Type: KeyInsert, Alt: true}, // urxvt
"\x1b[3~": {Type: KeyDelete},
"\x1b[3;3~": {Type: KeyDelete, Alt: true},
"\x1b\x1b[3~": {Type: KeyDelete, Alt: true}, // urxvt
"\x1b[5~": {Type: KeyPgUp},
"\x1b[5;3~": {Type: KeyPgUp, Alt: true},
"\x1b\x1b[5~": {Type: KeyPgUp, Alt: true}, // urxvt
"\x1b[5;5~": {Type: KeyCtrlPgUp},
"\x1b[5^": {Type: KeyCtrlPgUp}, // urxvt
"\x1b[5;7~": {Type: KeyCtrlPgUp, Alt: true},
"\x1b\x1b[5^": {Type: KeyCtrlPgUp, Alt: true}, // urxvt
"\x1b[6~": {Type: KeyPgDown},
"\x1b[6;3~": {Type: KeyPgDown, Alt: true},
"\x1b\x1b[6~": {Type: KeyPgDown, Alt: true}, // urxvt
"\x1b[6;5~": {Type: KeyCtrlPgDown},
"\x1b[6^": {Type: KeyCtrlPgDown}, // urxvt
"\x1b[6;7~": {Type: KeyCtrlPgDown, Alt: true},
"\x1b\x1b[6^": {Type: KeyCtrlPgDown, Alt: true}, // urxvt
"\x1b[1~": {Type: KeyHome},
"\x1b[H": {Type: KeyHome}, // xterm, lxterm
"\x1b[1;3H": {Type: KeyHome, Alt: true}, // xterm, lxterm
"\x1b[1;5H": {Type: KeyCtrlHome}, // xterm, lxterm
"\x1b[1;7H": {Type: KeyCtrlHome, Alt: true}, // xterm, lxterm
"\x1b[1;2H": {Type: KeyShiftHome}, // xterm, lxterm
"\x1b[1;4H": {Type: KeyShiftHome, Alt: true}, // xterm, lxterm
"\x1b[1;6H": {Type: KeyCtrlShiftHome}, // xterm, lxterm
"\x1b[1;8H": {Type: KeyCtrlShiftHome, Alt: true}, // xterm, lxterm
"\x1b[4~": {Type: KeyEnd},
"\x1b[F": {Type: KeyEnd}, // xterm, lxterm
"\x1b[1;3F": {Type: KeyEnd, Alt: true}, // xterm, lxterm
"\x1b[1;5F": {Type: KeyCtrlEnd}, // xterm, lxterm
"\x1b[1;7F": {Type: KeyCtrlEnd, Alt: true}, // xterm, lxterm
"\x1b[1;2F": {Type: KeyShiftEnd}, // xterm, lxterm
"\x1b[1;4F": {Type: KeyShiftEnd, Alt: true}, // xterm, lxterm
"\x1b[1;6F": {Type: KeyCtrlShiftEnd}, // xterm, lxterm
"\x1b[1;8F": {Type: KeyCtrlShiftEnd, Alt: true}, // xterm, lxterm
"\x1b[7~": {Type: KeyHome}, // urxvt
"\x1b\x1b[7~": {Type: KeyHome, Alt: true}, // urxvt
"\x1b[7^": {Type: KeyCtrlHome}, // urxvt
"\x1b\x1b[7^": {Type: KeyCtrlHome, Alt: true}, // urxvt
"\x1b[7$": {Type: KeyShiftHome}, // urxvt
"\x1b\x1b[7$": {Type: KeyShiftHome, Alt: true}, // urxvt
"\x1b[7@": {Type: KeyCtrlShiftHome}, // urxvt
"\x1b\x1b[7@": {Type: KeyCtrlShiftHome, Alt: true}, // urxvt
"\x1b[8~": {Type: KeyEnd}, // urxvt
"\x1b\x1b[8~": {Type: KeyEnd, Alt: true}, // urxvt
"\x1b[8^": {Type: KeyCtrlEnd}, // urxvt
"\x1b\x1b[8^": {Type: KeyCtrlEnd, Alt: true}, // urxvt
"\x1b[8$": {Type: KeyShiftEnd}, // urxvt
"\x1b\x1b[8$": {Type: KeyShiftEnd, Alt: true}, // urxvt
"\x1b[8@": {Type: KeyCtrlShiftEnd}, // urxvt
"\x1b\x1b[8@": {Type: KeyCtrlShiftEnd, Alt: true}, // urxvt
// Function keys, Linux console
"\x1b[[A": {Type: KeyF1}, // linux console
"\x1b[[B": {Type: KeyF2}, // linux console
"\x1b[[C": {Type: KeyF3}, // linux console
"\x1b[[D": {Type: KeyF4}, // linux console
"\x1b[[E": {Type: KeyF5}, // linux console
// Function keys, X11
"\x1bOP": {Type: KeyF1}, // vt100, xterm
"\x1bOQ": {Type: KeyF2}, // vt100, xterm
"\x1bOR": {Type: KeyF3}, // vt100, xterm
"\x1bOS": {Type: KeyF4}, // vt100, xterm
"\x1b[1;3P": {Type: KeyF1, Alt: true}, // vt100, xterm
"\x1b[1;3Q": {Type: KeyF2, Alt: true}, // vt100, xterm
"\x1b[1;3R": {Type: KeyF3, Alt: true}, // vt100, xterm
"\x1b[1;3S": {Type: KeyF4, Alt: true}, // vt100, xterm
"\x1b[11~": {Type: KeyF1}, // urxvt
"\x1b[12~": {Type: KeyF2}, // urxvt
"\x1b[13~": {Type: KeyF3}, // urxvt
"\x1b[14~": {Type: KeyF4}, // urxvt
"\x1b\x1b[11~": {Type: KeyF1, Alt: true}, // urxvt
"\x1b\x1b[12~": {Type: KeyF2, Alt: true}, // urxvt
"\x1b\x1b[13~": {Type: KeyF3, Alt: true}, // urxvt
"\x1b\x1b[14~": {Type: KeyF4, Alt: true}, // urxvt
"\x1b[15~": {Type: KeyF5}, // vt100, xterm, also urxvt
"\x1b[15;3~": {Type: KeyF5, Alt: true}, // vt100, xterm, also urxvt
"\x1b\x1b[15~": {Type: KeyF5, Alt: true}, // urxvt
"\x1b[17~": {Type: KeyF6}, // vt100, xterm, also urxvt
"\x1b[18~": {Type: KeyF7}, // vt100, xterm, also urxvt
"\x1b[19~": {Type: KeyF8}, // vt100, xterm, also urxvt
"\x1b[20~": {Type: KeyF9}, // vt100, xterm, also urxvt
"\x1b[21~": {Type: KeyF10}, // vt100, xterm, also urxvt
"\x1b\x1b[17~": {Type: KeyF6, Alt: true}, // urxvt
"\x1b\x1b[18~": {Type: KeyF7, Alt: true}, // urxvt
"\x1b\x1b[19~": {Type: KeyF8, Alt: true}, // urxvt
"\x1b\x1b[20~": {Type: KeyF9, Alt: true}, // urxvt
"\x1b\x1b[21~": {Type: KeyF10, Alt: true}, // urxvt
"\x1b[17;3~": {Type: KeyF6, Alt: true}, // vt100, xterm
"\x1b[18;3~": {Type: KeyF7, Alt: true}, // vt100, xterm
"\x1b[19;3~": {Type: KeyF8, Alt: true}, // vt100, xterm
"\x1b[20;3~": {Type: KeyF9, Alt: true}, // vt100, xterm
"\x1b[21;3~": {Type: KeyF10, Alt: true}, // vt100, xterm
"\x1b[23~": {Type: KeyF11}, // vt100, xterm, also urxvt
"\x1b[24~": {Type: KeyF12}, // vt100, xterm, also urxvt
"\x1b[23;3~": {Type: KeyF11, Alt: true}, // vt100, xterm
"\x1b[24;3~": {Type: KeyF12, Alt: true}, // vt100, xterm
"\x1b\x1b[23~": {Type: KeyF11, Alt: true}, // urxvt
"\x1b\x1b[24~": {Type: KeyF12, Alt: true}, // urxvt
"\x1b[1;2P": {Type: KeyF13},
"\x1b[1;2Q": {Type: KeyF14},
"\x1b[25~": {Type: KeyF13}, // vt100, xterm, also urxvt
"\x1b[26~": {Type: KeyF14}, // vt100, xterm, also urxvt
"\x1b[25;3~": {Type: KeyF13, Alt: true}, // vt100, xterm
"\x1b[26;3~": {Type: KeyF14, Alt: true}, // vt100, xterm
"\x1b\x1b[25~": {Type: KeyF13, Alt: true}, // urxvt
"\x1b\x1b[26~": {Type: KeyF14, Alt: true}, // urxvt
"\x1b[1;2R": {Type: KeyF15},
"\x1b[1;2S": {Type: KeyF16},
"\x1b[28~": {Type: KeyF15}, // vt100, xterm, also urxvt
"\x1b[29~": {Type: KeyF16}, // vt100, xterm, also urxvt
"\x1b[28;3~": {Type: KeyF15, Alt: true}, // vt100, xterm
"\x1b[29;3~": {Type: KeyF16, Alt: true}, // vt100, xterm
"\x1b\x1b[28~": {Type: KeyF15, Alt: true}, // urxvt
"\x1b\x1b[29~": {Type: KeyF16, Alt: true}, // urxvt
"\x1b[15;2~": {Type: KeyF17},
"\x1b[17;2~": {Type: KeyF18},
"\x1b[18;2~": {Type: KeyF19},
"\x1b[19;2~": {Type: KeyF20},
"\x1b[31~": {Type: KeyF17},
"\x1b[32~": {Type: KeyF18},
"\x1b[33~": {Type: KeyF19},
"\x1b[34~": {Type: KeyF20},
"\x1b\x1b[31~": {Type: KeyF17, Alt: true}, // urxvt
"\x1b\x1b[32~": {Type: KeyF18, Alt: true}, // urxvt
"\x1b\x1b[33~": {Type: KeyF19, Alt: true}, // urxvt
"\x1b\x1b[34~": {Type: KeyF20, Alt: true}, // urxvt
// Powershell sequences.
"\x1bOA": {Type: KeyUp, Alt: false},
"\x1bOB": {Type: KeyDown, Alt: false},
"\x1bOC": {Type: KeyRight, Alt: false},
"\x1bOD": {Type: KeyLeft, Alt: false},
}
// readInputs reads keypress and mouse inputs from a TTY and returns messages
// containing information about the key or mouse events accordingly.
func readInputs(input io.Reader) ([]Msg, error) {
var buf [256]byte
// Read and block
numBytes, err := input.Read(buf[:])
if err != nil {
return nil, err
}
b := buf[:numBytes]
b, err = localereader.UTF8(b)
if err != nil {
return nil, err
}
// Check if it's a mouse event. For now we're parsing X10-type mouse events
// only.
mouseEvent, err := parseX10MouseEvents(b)
if err == nil {
var m []Msg
for _, v := range mouseEvent {
m = append(m, MouseMsg(v))
}
return m, nil
}
var runeSets [][]rune
var runes []rune
// Translate input into runes. In most cases we'll receive exactly one
// rune, but there are cases, particularly when an input method editor is
// used, where we can receive multiple runes at once.
for i, w := 0, 0; i < len(b); i += w {
r, width := utf8.DecodeRune(b[i:])
if r == utf8.RuneError {
return nil, errors.New("could not decode rune")
}
if r == '\x1b' && len(runes) > 1 {
// a new key sequence has started
runeSets = append(runeSets, runes)
runes = []rune{}
}
runes = append(runes, r)
w = width
}
// add the final set of runes we decoded
runeSets = append(runeSets, runes)
if len(runeSets) == 0 {
return nil, errors.New("received 0 runes from input")
}
var msgs []Msg
for _, runes := range runeSets {
// Is it a sequence, like an arrow key?
if k, ok := sequences[string(runes)]; ok {
msgs = append(msgs, KeyMsg(k))
continue
}
// Is this an unrecognized CSI sequence? If so, ignore it.
if len(runes) > 2 && runes[0] == 0x1b && (runes[1] == '[' ||
(len(runes) > 3 && runes[1] == 0x1b && runes[2] == '[')) {
continue
}
// Is the alt key pressed? If so, the buffer will be prefixed with an
// escape.
alt := false
if len(runes) > 1 && runes[0] == 0x1b {
alt = true
runes = runes[1:]
}
for _, v := range runes {
// Is the first rune a control character?
r := KeyType(v)
if r <= keyUS || r == keyDEL {
msgs = append(msgs, KeyMsg(Key{Type: r, Alt: alt}))
continue
}
// If it's a space, override the type with KeySpace (but still include
// the rune).
if r == ' ' {
msgs = append(msgs, KeyMsg(Key{Type: KeySpace, Runes: []rune{v}, Alt: alt}))
continue
}
// Welp, just regular, ol' runes.
msgs = append(msgs, KeyMsg(Key{Type: KeyRunes, Runes: []rune{v}, Alt: alt}))
}
}
return msgs, nil
}

View File

@@ -1,39 +0,0 @@
package tea
import (
"log"
"os"
"unicode"
)
// LogToFile sets up default logging to log to a file. This is helpful as we
// can't print to the terminal since our TUI is occupying it. If the file
// doesn't exist it will be created.
//
// Don't forget to close the file when you're done with it.
//
// f, err := LogToFile("debug.log", "debug")
// if err != nil {
// fmt.Println("fatal:", err)
// os.Exit(1)
// }
// defer f.Close()
func LogToFile(path string, prefix string) (*os.File, error) {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644)
if err != nil {
return nil, err
}
log.SetOutput(f)
// Add a space after the prefix if a prefix is being specified and it
// doesn't already have a trailing space.
if len(prefix) > 0 {
finalChar := prefix[len(prefix)-1]
if !unicode.IsSpace(rune(finalChar)) {
prefix += " "
}
}
log.SetPrefix(prefix)
return f, nil
}

View File

@@ -1,149 +0,0 @@
package tea
import (
"bytes"
"errors"
)
// MouseMsg contains information about a mouse event and are sent to a programs
// update function when mouse activity occurs. Note that the mouse must first
// be enabled via in order the mouse events to be received.
type MouseMsg MouseEvent
// MouseEvent represents a mouse event, which could be a click, a scroll wheel
// movement, a cursor movement, or a combination.
type MouseEvent struct {
X int
Y int
Type MouseEventType
Alt bool
Ctrl bool
}
// String returns a string representation of a mouse event.
func (m MouseEvent) String() (s string) {
if m.Ctrl {
s += "ctrl+"
}
if m.Alt {
s += "alt+"
}
s += mouseEventTypes[m.Type]
return s
}
// MouseEventType indicates the type of mouse event occurring.
type MouseEventType int
// Mouse event types.
const (
MouseUnknown MouseEventType = iota
MouseLeft
MouseRight
MouseMiddle
MouseRelease
MouseWheelUp
MouseWheelDown
MouseMotion
)
var mouseEventTypes = map[MouseEventType]string{
MouseUnknown: "unknown",
MouseLeft: "left",
MouseRight: "right",
MouseMiddle: "middle",
MouseRelease: "release",
MouseWheelUp: "wheel up",
MouseWheelDown: "wheel down",
MouseMotion: "motion",
}
// Parse X10-encoded mouse events; the simplest kind. The last release of X10
// was December 1986, by the way.
//
// X10 mouse events look like:
//
// ESC [M Cb Cx Cy
//
// See: http://www.xfree86.org/current/ctlseqs.html#Mouse%20Tracking
func parseX10MouseEvents(buf []byte) ([]MouseEvent, error) {
var r []MouseEvent
seq := []byte("\x1b[M")
if !bytes.Contains(buf, seq) {
return r, errors.New("not an X10 mouse event")
}
for _, v := range bytes.Split(buf, seq) {
if len(v) == 0 {
continue
}
if len(v) != 3 {
return r, errors.New("not an X10 mouse event")
}
var m MouseEvent
const byteOffset = 32
e := v[0] - byteOffset
const (
bitShift = 0b0000_0100
bitAlt = 0b0000_1000
bitCtrl = 0b0001_0000
bitMotion = 0b0010_0000
bitWheel = 0b0100_0000
bitsMask = 0b0000_0011
bitsLeft = 0b0000_0000
bitsMiddle = 0b0000_0001
bitsRight = 0b0000_0010
bitsRelease = 0b0000_0011
bitsWheelUp = 0b0000_0000
bitsWheelDown = 0b0000_0001
)
if e&bitWheel != 0 {
// Check the low two bits.
switch e & bitsMask {
case bitsWheelUp:
m.Type = MouseWheelUp
case bitsWheelDown:
m.Type = MouseWheelDown
}
} else {
// Check the low two bits.
// We do not separate clicking and dragging.
switch e & bitsMask {
case bitsLeft:
m.Type = MouseLeft
case bitsMiddle:
m.Type = MouseMiddle
case bitsRight:
m.Type = MouseRight
case bitsRelease:
if e&bitMotion != 0 {
m.Type = MouseMotion
} else {
m.Type = MouseRelease
}
}
}
if e&bitAlt != 0 {
m.Alt = true
}
if e&bitCtrl != 0 {
m.Ctrl = true
}
// (1,1) is the upper left. We subtract 1 to normalize it to (0,0).
m.X = int(v[1]) - byteOffset - 1
m.Y = int(v[2]) - byteOffset - 1
r = append(r, m)
}
return r, nil
}

View File

@@ -1,19 +0,0 @@
package tea
type nilRenderer struct{}
func (n nilRenderer) start() {}
func (n nilRenderer) stop() {}
func (n nilRenderer) kill() {}
func (n nilRenderer) write(v string) {}
func (n nilRenderer) repaint() {}
func (n nilRenderer) clearScreen() {}
func (n nilRenderer) altScreen() bool { return false }
func (n nilRenderer) enterAltScreen() {}
func (n nilRenderer) exitAltScreen() {}
func (n nilRenderer) showCursor() {}
func (n nilRenderer) hideCursor() {}
func (n nilRenderer) enableMouseCellMotion() {}
func (n nilRenderer) disableMouseCellMotion() {}
func (n nilRenderer) enableMouseAllMotion() {}
func (n nilRenderer) disableMouseAllMotion() {}

View File

@@ -1,153 +0,0 @@
package tea
import (
"context"
"io"
"github.com/muesli/termenv"
)
// ProgramOption is used to set options when initializing a Program. Program can
// accept a variable number of options.
//
// Example usage:
//
// p := NewProgram(model, WithInput(someInput), WithOutput(someOutput))
type ProgramOption func(*Program)
// WithContext lets you specify a context in which to run the Program. This is
// useful if you want to cancel the execution from outside. When a Program gets
// cancelled it will exit with an error ErrProgramKilled.
func WithContext(ctx context.Context) ProgramOption {
return func(p *Program) {
p.ctx = ctx
}
}
// WithOutput sets the output which, by default, is stdout. In most cases you
// won't need to use this.
func WithOutput(output io.Writer) ProgramOption {
return func(p *Program) {
p.output = termenv.NewOutput(output, termenv.WithColorCache(true))
}
}
// WithInput sets the input which, by default, is stdin. In most cases you
// won't need to use this.
func WithInput(input io.Reader) ProgramOption {
return func(p *Program) {
p.input = input
p.startupOptions |= withCustomInput
}
}
// WithInputTTY open a new TTY for input (or console input device on Windows).
func WithInputTTY() ProgramOption {
return func(p *Program) {
p.startupOptions |= withInputTTY
}
}
// WithoutSignalHandler disables the signal handler that Bubble Tea sets up for
// Programs. This is useful if you want to handle signals yourself.
func WithoutSignalHandler() ProgramOption {
return func(p *Program) {
p.startupOptions |= withoutSignalHandler
}
}
// WithoutCatchPanics disables the panic catching that Bubble Tea does by
// default. If panic catching is disabled the terminal will be in a fairly
// unusable state after a panic because Bubble Tea will not perform its usual
// cleanup on exit.
func WithoutCatchPanics() ProgramOption {
return func(p *Program) {
p.startupOptions |= withoutCatchPanics
}
}
// WithAltScreen starts the program with the alternate screen buffer enabled
// (i.e. the program starts in full window mode). Note that the altscreen will
// be automatically exited when the program quits.
//
// Example:
//
// p := tea.NewProgram(Model{}, tea.WithAltScreen())
// if _, err := p.Run(); err != nil {
// fmt.Println("Error running program:", err)
// os.Exit(1)
// }
//
// To enter the altscreen once the program has already started running use the
// EnterAltScreen command.
func WithAltScreen() ProgramOption {
return func(p *Program) {
p.startupOptions |= withAltScreen
}
}
// WithMouseCellMotion starts the program with the mouse enabled in "cell
// motion" mode.
//
// Cell motion mode enables mouse click, release, and wheel events. Mouse
// movement events are also captured if a mouse button is pressed (i.e., drag
// events). Cell motion mode is better supported than all motion mode.
//
// To enable mouse cell motion once the program has already started running use
// the EnableMouseCellMotion command. To disable the mouse when the program is
// running use the DisableMouse command.
//
// The mouse will be automatically disabled when the program exits.
func WithMouseCellMotion() ProgramOption {
return func(p *Program) {
p.startupOptions |= withMouseCellMotion // set
p.startupOptions &^= withMouseAllMotion // clear
}
}
// WithMouseAllMotion starts the program with the mouse enabled in "all motion"
// mode.
//
// EnableMouseAllMotion is a special command that enables mouse click, release,
// wheel, and motion events, which are delivered regardless of whether a mouse
// button is pressed, effectively enabling support for hover interactions.
//
// Many modern terminals support this, but not all. If in doubt, use
// EnableMouseCellMotion instead.
//
// To enable the mouse once the program has already started running use the
// EnableMouseAllMotion command. To disable the mouse when the program is
// running use the DisableMouse command.
//
// The mouse will be automatically disabled when the program exits.
func WithMouseAllMotion() ProgramOption {
return func(p *Program) {
p.startupOptions |= withMouseAllMotion // set
p.startupOptions &^= withMouseCellMotion // clear
}
}
// WithoutRenderer disables the renderer. When this is set output and log
// statements will be plainly sent to stdout (or another output if one is set)
// without any rendering and redrawing logic. In other words, printing and
// logging will behave the same way it would in a non-TUI commandline tool.
// This can be useful if you want to use the Bubble Tea framework for a non-TUI
// application, or to provide an additional non-TUI mode to your Bubble Tea
// programs. For example, your program could behave like a daemon if output is
// not a TTY.
func WithoutRenderer() ProgramOption {
return func(p *Program) {
p.renderer = &nilRenderer{}
}
}
// WithANSICompressor removes redundant ANSI sequences to produce potentially
// smaller output, at the cost of some processing overhead.
//
// This feature is provisional, and may be changed removed in a future version
// of this package.
func WithANSICompressor() ProgramOption {
return func(p *Program) {
p.startupOptions |= withANSICompressor
}
}

View File

@@ -1,56 +0,0 @@
package tea
// renderer is the interface for Bubble Tea renderers.
type renderer interface {
// Start the renderer.
start()
// Stop the renderer, but render the final frame in the buffer, if any.
stop()
// Stop the renderer without doing any final rendering.
kill()
// Write a frame to the renderer. The renderer can write this data to
// output at its discretion.
write(string)
// Request a full re-render. Note that this will not trigger a render
// immediately. Rather, this method causes the next render to be a full
// repaint. Because of this, it's safe to call this method multiple times
// in succession.
repaint()
// Clears the terminal.
clearScreen()
// Whether or not the alternate screen buffer is enabled.
altScreen() bool
// Enable the alternate screen buffer.
enterAltScreen()
// Disable the alternate screen buffer.
exitAltScreen()
// Show the cursor.
showCursor()
// Hide the cursor.
hideCursor()
// enableMouseCellMotion enables mouse click, release, wheel and motion
// events if a mouse button is pressed (i.e., drag events).
enableMouseCellMotion()
// DisableMouseCellMotion disables Mouse Cell Motion tracking.
disableMouseCellMotion()
// EnableMouseAllMotion enables mouse click, release, wheel and motion
// events, regardless of whether a mouse button is pressed. Many modern
// terminals support this, but not all.
enableMouseAllMotion()
// DisableMouseAllMotion disables All Motion mouse tracking.
disableMouseAllMotion()
}
// repaintMsg forces a full repaint.
type repaintMsg struct{}

View File

@@ -1,169 +0,0 @@
package tea
// WindowSizeMsg is used to report the terminal size. It's sent to Update once
// initially and then on every terminal resize. Note that Windows does not
// have support for reporting when resizes occur as it does not support the
// SIGWINCH signal.
type WindowSizeMsg struct {
Width int
Height int
}
// ClearScreen is a special command that tells the program to clear the screen
// before the next update. This can be used to move the cursor to the top left
// of the screen and clear visual clutter when the alt screen is not in use.
//
// Note that it should never be necessary to call ClearScreen() for regular
// redraws.
func ClearScreen() Msg {
return clearScreenMsg{}
}
// clearScreenMsg is an internal message that signals to clear the screen.
// You can send a clearScreenMsg with ClearScreen.
type clearScreenMsg struct{}
// EnterAltScreen is a special command that tells the Bubble Tea program to
// enter the alternate screen buffer.
//
// Because commands run asynchronously, this command should not be used in your
// model's Init function. To initialize your program with the altscreen enabled
// use the WithAltScreen ProgramOption instead.
func EnterAltScreen() Msg {
return enterAltScreenMsg{}
}
// enterAltScreenMsg in an internal message signals that the program should
// enter alternate screen buffer. You can send a enterAltScreenMsg with
// EnterAltScreen.
type enterAltScreenMsg struct{}
// ExitAltScreen is a special command that tells the Bubble Tea program to exit
// the alternate screen buffer. This command should be used to exit the
// alternate screen buffer while the program is running.
//
// Note that the alternate screen buffer will be automatically exited when the
// program quits.
func ExitAltScreen() Msg {
return exitAltScreenMsg{}
}
// exitAltScreenMsg in an internal message signals that the program should exit
// alternate screen buffer. You can send a exitAltScreenMsg with ExitAltScreen.
type exitAltScreenMsg struct{}
// EnableMouseCellMotion is a special command that enables mouse click,
// release, and wheel events. Mouse movement events are also captured if
// a mouse button is pressed (i.e., drag events).
//
// Because commands run asynchronously, this command should not be used in your
// model's Init function. Use the WithMouseCellMotion ProgramOption instead.
func EnableMouseCellMotion() Msg {
return enableMouseCellMotionMsg{}
}
// enableMouseCellMotionMsg is a special command that signals to start
// listening for "cell motion" type mouse events (ESC[?1002l). To send an
// enableMouseCellMotionMsg, use the EnableMouseCellMotion command.
type enableMouseCellMotionMsg struct{}
// EnableMouseAllMotion is a special command that enables mouse click, release,
// wheel, and motion events, which are delivered regardless of whether a mouse
// button is pressed, effectively enabling support for hover interactions.
//
// Many modern terminals support this, but not all. If in doubt, use
// EnableMouseCellMotion instead.
//
// Because commands run asynchronously, this command should not be used in your
// model's Init function. Use the WithMouseAllMotion ProgramOption instead.
func EnableMouseAllMotion() Msg {
return enableMouseAllMotionMsg{}
}
// enableMouseAllMotionMsg is a special command that signals to start listening
// for "all motion" type mouse events (ESC[?1003l). To send an
// enableMouseAllMotionMsg, use the EnableMouseAllMotion command.
type enableMouseAllMotionMsg struct{}
// DisableMouse is a special command that stops listening for mouse events.
func DisableMouse() Msg {
return disableMouseMsg{}
}
// disableMouseMsg is an internal message that signals to stop listening
// for mouse events. To send a disableMouseMsg, use the DisableMouse command.
type disableMouseMsg struct{}
// HideCursor is a special command for manually instructing Bubble Tea to hide
// the cursor. In some rare cases, certain operations will cause the terminal
// to show the cursor, which is normally hidden for the duration of a Bubble
// Tea program's lifetime. You will most likely not need to use this command.
func HideCursor() Msg {
return hideCursorMsg{}
}
// hideCursorMsg is an internal command used to hide the cursor. You can send
// this message with HideCursor.
type hideCursorMsg struct{}
// ShowCursor is a special command for manually instructing Bubble Tea to show
// the cursor.
func ShowCursor() Msg {
return showCursorMsg{}
}
// showCursorMsg is an internal command used to show the cursor. You can send
// this message with ShowCursor.
type showCursorMsg struct{}
// EnterAltScreen enters the alternate screen buffer, which consumes the entire
// terminal window. ExitAltScreen will return the terminal to its former state.
//
// Deprecated: Use the WithAltScreen ProgramOption instead.
func (p *Program) EnterAltScreen() {
if p.renderer != nil {
p.renderer.enterAltScreen()
}
}
// ExitAltScreen exits the alternate screen buffer.
//
// Deprecated: The altscreen will exited automatically when the program exits.
func (p *Program) ExitAltScreen() {
if p.renderer != nil {
p.renderer.exitAltScreen()
}
}
// EnableMouseCellMotion enables mouse click, release, wheel and motion events
// if a mouse button is pressed (i.e., drag events).
//
// Deprecated: Use the WithMouseCellMotion ProgramOption instead.
func (p *Program) EnableMouseCellMotion() {
p.renderer.enableMouseCellMotion()
}
// DisableMouseCellMotion disables Mouse Cell Motion tracking. This will be
// called automatically when exiting a Bubble Tea program.
//
// Deprecated: The mouse will automatically be disabled when the program exits.
func (p *Program) DisableMouseCellMotion() {
p.renderer.disableMouseCellMotion()
}
// EnableMouseAllMotion enables mouse click, release, wheel and motion events,
// regardless of whether a mouse button is pressed. Many modern terminals
// support this, but not all.
//
// Deprecated: Use the WithMouseAllMotion ProgramOption instead.
func (p *Program) EnableMouseAllMotion() {
p.renderer.enableMouseAllMotion()
}
// DisableMouseAllMotion disables All Motion mouse tracking. This will be
// called automatically when exiting a Bubble Tea program.
//
// Deprecated: The mouse will automatically be disabled when the program exits.
func (p *Program) DisableMouseAllMotion() {
p.renderer.disableMouseAllMotion()
}

View File

@@ -1,33 +0,0 @@
//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || aix
// +build darwin dragonfly freebsd linux netbsd openbsd solaris aix
package tea
import (
"os"
"os/signal"
"syscall"
)
// listenForResize sends messages (or errors) when the terminal resizes.
// Argument output should be the file descriptor for the terminal; usually
// os.Stdout.
func (p *Program) listenForResize(done chan struct{}) {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGWINCH)
defer func() {
signal.Stop(sig)
close(done)
}()
for {
select {
case <-p.ctx.Done():
return
case <-sig:
}
p.checkResize()
}
}

View File

@@ -1,10 +0,0 @@
//go:build windows
// +build windows
package tea
// listenForResize is not available on windows because windows does not
// implement syscall.SIGWINCH.
func (p *Program) listenForResize(done chan struct{}) {
close(done)
}

View File

@@ -1,650 +0,0 @@
package tea
import (
"bytes"
"fmt"
"io"
"strings"
"sync"
"time"
"github.com/muesli/ansi/compressor"
"github.com/muesli/reflow/truncate"
"github.com/muesli/termenv"
)
const (
// defaultFramerate specifies the maximum interval at which we should
// update the view.
defaultFramerate = time.Second / 60
)
// standardRenderer is a framerate-based terminal renderer, updating the view
// at a given framerate to avoid overloading the terminal emulator.
//
// In cases where very high performance is needed the renderer can be told
// to exclude ranges of lines, allowing them to be written to directly.
type standardRenderer struct {
mtx *sync.Mutex
out *termenv.Output
buf bytes.Buffer
queuedMessageLines []string
framerate time.Duration
ticker *time.Ticker
done chan struct{}
lastRender string
linesRendered int
useANSICompressor bool
once sync.Once
// cursor visibility state
cursorHidden bool
// essentially whether or not we're using the full size of the terminal
altScreenActive bool
// renderer dimensions; usually the size of the window
width int
height int
// lines explicitly set not to render
ignoreLines map[int]struct{}
}
// newRenderer creates a new renderer. Normally you'll want to initialize it
// with os.Stdout as the first argument.
func newRenderer(out *termenv.Output, useANSICompressor bool) renderer {
r := &standardRenderer{
out: out,
mtx: &sync.Mutex{},
framerate: defaultFramerate,
useANSICompressor: useANSICompressor,
queuedMessageLines: []string{},
}
if r.useANSICompressor {
r.out = termenv.NewOutput(&compressor.Writer{Forward: out})
}
return r
}
// start starts the renderer.
func (r *standardRenderer) start() {
if r.ticker == nil {
r.ticker = time.NewTicker(r.framerate)
}
r.done = make(chan struct{})
go r.listen()
}
// stop permanently halts the renderer, rendering the final frame.
func (r *standardRenderer) stop() {
// flush locks the mutex
r.flush()
r.mtx.Lock()
defer r.mtx.Unlock()
r.out.ClearLine()
r.once.Do(func() {
close(r.done)
})
if r.useANSICompressor {
if w, ok := r.out.TTY().(io.WriteCloser); ok {
_ = w.Close()
}
}
}
// kill halts the renderer. The final frame will not be rendered.
func (r *standardRenderer) kill() {
r.mtx.Lock()
defer r.mtx.Unlock()
r.out.ClearLine()
r.once.Do(func() {
close(r.done)
})
}
// listen waits for ticks on the ticker, or a signal to stop the renderer.
func (r *standardRenderer) listen() {
for {
select {
case <-r.ticker.C:
if r.ticker != nil {
r.flush()
}
case <-r.done:
r.ticker.Stop()
r.ticker = nil
return
}
}
}
// flush renders the buffer.
func (r *standardRenderer) flush() {
r.mtx.Lock()
defer r.mtx.Unlock()
if r.buf.Len() == 0 || r.buf.String() == r.lastRender {
// Nothing to do
return
}
// Output buffer
buf := &bytes.Buffer{}
out := termenv.NewOutput(buf)
newLines := strings.Split(r.buf.String(), "\n")
// If we know the output's height, we can use it to determine how many
// lines we can render. We drop lines from the top of the render buffer if
// necessary, as we can't navigate the cursor into the terminal's scrollback
// buffer.
if r.height > 0 && len(newLines) > r.height {
newLines = newLines[len(newLines)-r.height:]
}
numLinesThisFlush := len(newLines)
oldLines := strings.Split(r.lastRender, "\n")
skipLines := make(map[int]struct{})
flushQueuedMessages := len(r.queuedMessageLines) > 0 && !r.altScreenActive
// Add any queued messages to this render
if flushQueuedMessages {
newLines = append(r.queuedMessageLines, newLines...)
r.queuedMessageLines = []string{}
}
// Clear any lines we painted in the last render.
if r.linesRendered > 0 {
for i := r.linesRendered - 1; i > 0; i-- {
// If the number of lines we want to render hasn't increased and
// new line is the same as the old line we can skip rendering for
// this line as a performance optimization.
if (len(newLines) <= len(oldLines)) && (len(newLines) > i && len(oldLines) > i) && (newLines[i] == oldLines[i]) {
skipLines[i] = struct{}{}
} else if _, exists := r.ignoreLines[i]; !exists {
out.ClearLine()
}
out.CursorUp(1)
}
if _, exists := r.ignoreLines[0]; !exists {
// We need to return to the start of the line here to properly
// erase it. Going back the entire width of the terminal will
// usually be farther than we need to go, but terminal emulators
// will stop the cursor at the start of the line as a rule.
//
// We use this sequence in particular because it's part of the ANSI
// standard (whereas others are proprietary to, say, VT100/VT52).
// If cursor previous line (ESC[ + <n> + F) were better supported
// we could use that above to eliminate this step.
out.CursorBack(r.width)
out.ClearLine()
}
}
// Merge the set of lines we're skipping as a rendering optimization with
// the set of lines we've explicitly asked the renderer to ignore.
if r.ignoreLines != nil {
for k, v := range r.ignoreLines {
skipLines[k] = v
}
}
// Paint new lines
for i := 0; i < len(newLines); i++ {
if _, skip := skipLines[i]; skip {
// Unless this is the last line, move the cursor down.
if i < len(newLines)-1 {
out.CursorDown(1)
}
} else {
line := newLines[i]
// Truncate lines wider than the width of the window to avoid
// wrapping, which will mess up rendering. If we don't have the
// width of the window this will be ignored.
//
// Note that on Windows we only get the width of the window on
// program initialization, so after a resize this won't perform
// correctly (signal SIGWINCH is not supported on Windows).
if r.width > 0 {
line = truncate.String(line, uint(r.width))
}
_, _ = out.WriteString(line)
if i < len(newLines)-1 {
_, _ = out.WriteString("\r\n")
}
}
}
r.linesRendered = numLinesThisFlush
// Make sure the cursor is at the start of the last line to keep rendering
// behavior consistent.
if r.altScreenActive {
// This case fixes a bug in macOS terminal. In other terminals the
// other case seems to do the job regardless of whether or not we're
// using the full terminal window.
out.MoveCursor(r.linesRendered, 0)
} else {
out.CursorBack(r.width)
}
_, _ = r.out.Write(buf.Bytes())
r.lastRender = r.buf.String()
r.buf.Reset()
}
// write writes to the internal buffer. The buffer will be outputted via the
// ticker which calls flush().
func (r *standardRenderer) write(s string) {
r.mtx.Lock()
defer r.mtx.Unlock()
r.buf.Reset()
// If an empty string was passed we should clear existing output and
// rendering nothing. Rather than introduce additional state to manage
// this, we render a single space as a simple (albeit less correct)
// solution.
if s == "" {
s = " "
}
_, _ = r.buf.WriteString(s)
}
func (r *standardRenderer) repaint() {
r.lastRender = ""
}
func (r *standardRenderer) clearScreen() {
r.mtx.Lock()
defer r.mtx.Unlock()
r.out.ClearScreen()
r.out.MoveCursor(1, 1)
r.repaint()
}
func (r *standardRenderer) altScreen() bool {
r.mtx.Lock()
defer r.mtx.Unlock()
return r.altScreenActive
}
func (r *standardRenderer) enterAltScreen() {
r.mtx.Lock()
defer r.mtx.Unlock()
if r.altScreenActive {
return
}
r.altScreenActive = true
r.out.AltScreen()
// Ensure that the terminal is cleared, even when it doesn't support
// alt screen (or alt screen support is disabled, like GNU screen by
// default).
//
// Note: we can't use r.clearScreen() here because the mutex is already
// locked.
r.out.ClearScreen()
r.out.MoveCursor(1, 1)
// cmd.exe and other terminals keep separate cursor states for the AltScreen
// and the main buffer. We have to explicitly reset the cursor visibility
// whenever we enter AltScreen.
if r.cursorHidden {
r.out.HideCursor()
} else {
r.out.ShowCursor()
}
r.repaint()
}
func (r *standardRenderer) exitAltScreen() {
r.mtx.Lock()
defer r.mtx.Unlock()
if !r.altScreenActive {
return
}
r.altScreenActive = false
r.out.ExitAltScreen()
// cmd.exe and other terminals keep separate cursor states for the AltScreen
// and the main buffer. We have to explicitly reset the cursor visibility
// whenever we exit AltScreen.
if r.cursorHidden {
r.out.HideCursor()
} else {
r.out.ShowCursor()
}
r.repaint()
}
func (r *standardRenderer) showCursor() {
r.mtx.Lock()
defer r.mtx.Unlock()
r.cursorHidden = false
r.out.ShowCursor()
}
func (r *standardRenderer) hideCursor() {
r.mtx.Lock()
defer r.mtx.Unlock()
r.cursorHidden = true
r.out.HideCursor()
}
func (r *standardRenderer) enableMouseCellMotion() {
r.mtx.Lock()
defer r.mtx.Unlock()
r.out.EnableMouseCellMotion()
}
func (r *standardRenderer) disableMouseCellMotion() {
r.mtx.Lock()
defer r.mtx.Unlock()
r.out.DisableMouseCellMotion()
}
func (r *standardRenderer) enableMouseAllMotion() {
r.mtx.Lock()
defer r.mtx.Unlock()
r.out.EnableMouseAllMotion()
}
func (r *standardRenderer) disableMouseAllMotion() {
r.mtx.Lock()
defer r.mtx.Unlock()
r.out.DisableMouseAllMotion()
}
// setIgnoredLines specifies lines not to be touched by the standard Bubble Tea
// renderer.
func (r *standardRenderer) setIgnoredLines(from int, to int) {
// Lock if we're going to be clearing some lines since we don't want
// anything jacking our cursor.
if r.linesRendered > 0 {
r.mtx.Lock()
defer r.mtx.Unlock()
}
if r.ignoreLines == nil {
r.ignoreLines = make(map[int]struct{})
}
for i := from; i < to; i++ {
r.ignoreLines[i] = struct{}{}
}
// Erase ignored lines
if r.linesRendered > 0 {
buf := &bytes.Buffer{}
out := termenv.NewOutput(buf)
for i := r.linesRendered - 1; i >= 0; i-- {
if _, exists := r.ignoreLines[i]; exists {
out.ClearLine()
}
out.CursorUp(1)
}
out.MoveCursor(r.linesRendered, 0) // put cursor back
_, _ = r.out.Write(buf.Bytes())
}
}
// clearIgnoredLines returns control of any ignored lines to the standard
// Bubble Tea renderer. That is, any lines previously set to be ignored can be
// rendered to again.
func (r *standardRenderer) clearIgnoredLines() {
r.ignoreLines = nil
}
// insertTop effectively scrolls up. It inserts lines at the top of a given
// area designated to be a scrollable region, pushing everything else down.
// This is roughly how ncurses does it.
//
// To call this function use command ScrollUp().
//
// For this to work renderer.ignoreLines must be set to ignore the scrollable
// region since we are bypassing the normal Bubble Tea renderer here.
//
// Because this method relies on the terminal dimensions, it's only valid for
// full-window applications (generally those that use the alternate screen
// buffer).
//
// This method bypasses the normal rendering buffer and is philosophically
// different than the normal way we approach rendering in Bubble Tea. It's for
// use in high-performance rendering, such as a pager that could potentially
// be rendering very complicated ansi. In cases where the content is simpler
// standard Bubble Tea rendering should suffice.
func (r *standardRenderer) insertTop(lines []string, topBoundary, bottomBoundary int) {
r.mtx.Lock()
defer r.mtx.Unlock()
buf := &bytes.Buffer{}
out := termenv.NewOutput(buf)
out.ChangeScrollingRegion(topBoundary, bottomBoundary)
out.MoveCursor(topBoundary, 0)
out.InsertLines(len(lines))
_, _ = out.WriteString(strings.Join(lines, "\r\n"))
out.ChangeScrollingRegion(0, r.height)
// Move cursor back to where the main rendering routine expects it to be
out.MoveCursor(r.linesRendered, 0)
_, _ = r.out.Write(buf.Bytes())
}
// insertBottom effectively scrolls down. It inserts lines at the bottom of
// a given area designated to be a scrollable region, pushing everything else
// up. This is roughly how ncurses does it.
//
// To call this function use the command ScrollDown().
//
// See note in insertTop() for caveats, how this function only makes sense for
// full-window applications, and how it differs from the normal way we do
// rendering in Bubble Tea.
func (r *standardRenderer) insertBottom(lines []string, topBoundary, bottomBoundary int) {
r.mtx.Lock()
defer r.mtx.Unlock()
buf := &bytes.Buffer{}
out := termenv.NewOutput(buf)
out.ChangeScrollingRegion(topBoundary, bottomBoundary)
out.MoveCursor(bottomBoundary, 0)
_, _ = out.WriteString("\r\n" + strings.Join(lines, "\r\n"))
out.ChangeScrollingRegion(0, r.height)
// Move cursor back to where the main rendering routine expects it to be
out.MoveCursor(r.linesRendered, 0)
_, _ = r.out.Write(buf.Bytes())
}
// handleMessages handles internal messages for the renderer.
func (r *standardRenderer) handleMessages(msg Msg) {
switch msg := msg.(type) {
case repaintMsg:
// Force a repaint by clearing the render cache as we slide into a
// render.
r.mtx.Lock()
r.repaint()
r.mtx.Unlock()
case WindowSizeMsg:
r.mtx.Lock()
r.width = msg.Width
r.height = msg.Height
r.repaint()
r.mtx.Unlock()
case clearScrollAreaMsg:
r.clearIgnoredLines()
// Force a repaint on the area where the scrollable stuff was in this
// update cycle
r.mtx.Lock()
r.repaint()
r.mtx.Unlock()
case syncScrollAreaMsg:
// Re-render scrolling area
r.clearIgnoredLines()
r.setIgnoredLines(msg.topBoundary, msg.bottomBoundary)
r.insertTop(msg.lines, msg.topBoundary, msg.bottomBoundary)
// Force non-scrolling stuff to repaint in this update cycle
r.mtx.Lock()
r.repaint()
r.mtx.Unlock()
case scrollUpMsg:
r.insertTop(msg.lines, msg.topBoundary, msg.bottomBoundary)
case scrollDownMsg:
r.insertBottom(msg.lines, msg.topBoundary, msg.bottomBoundary)
case printLineMessage:
if !r.altScreenActive {
lines := strings.Split(msg.messageBody, "\n")
r.mtx.Lock()
r.queuedMessageLines = append(r.queuedMessageLines, lines...)
r.repaint()
r.mtx.Unlock()
}
}
}
// HIGH-PERFORMANCE RENDERING STUFF
type syncScrollAreaMsg struct {
lines []string
topBoundary int
bottomBoundary int
}
// SyncScrollArea performs a paint of the entire region designated to be the
// scrollable area. This is required to initialize the scrollable region and
// should also be called on resize (WindowSizeMsg).
//
// For high-performance, scroll-based rendering only.
func SyncScrollArea(lines []string, topBoundary int, bottomBoundary int) Cmd {
return func() Msg {
return syncScrollAreaMsg{
lines: lines,
topBoundary: topBoundary,
bottomBoundary: bottomBoundary,
}
}
}
type clearScrollAreaMsg struct{}
// ClearScrollArea deallocates the scrollable region and returns the control of
// those lines to the main rendering routine.
//
// For high-performance, scroll-based rendering only.
func ClearScrollArea() Msg {
return clearScrollAreaMsg{}
}
type scrollUpMsg struct {
lines []string
topBoundary int
bottomBoundary int
}
// ScrollUp adds lines to the top of the scrollable region, pushing existing
// lines below down. Lines that are pushed out the scrollable region disappear
// from view.
//
// For high-performance, scroll-based rendering only.
func ScrollUp(newLines []string, topBoundary, bottomBoundary int) Cmd {
return func() Msg {
return scrollUpMsg{
lines: newLines,
topBoundary: topBoundary,
bottomBoundary: bottomBoundary,
}
}
}
type scrollDownMsg struct {
lines []string
topBoundary int
bottomBoundary int
}
// ScrollDown adds lines to the bottom of the scrollable region, pushing
// existing lines above up. Lines that are pushed out of the scrollable region
// disappear from view.
//
// For high-performance, scroll-based rendering only.
func ScrollDown(newLines []string, topBoundary, bottomBoundary int) Cmd {
return func() Msg {
return scrollDownMsg{
lines: newLines,
topBoundary: topBoundary,
bottomBoundary: bottomBoundary,
}
}
}
type printLineMessage struct {
messageBody string
}
// Println prints above the Program. This output is unmanaged by the program and
// will persist across renders by the Program.
//
// Unlike fmt.Println (but similar to log.Println) the message will be print on
// its own line.
//
// If the altscreen is active no output will be printed.
func Println(args ...interface{}) Cmd {
return func() Msg {
return printLineMessage{
messageBody: fmt.Sprint(args...),
}
}
}
// Printf prints above the Program. It takes a format template followed by
// values similar to fmt.Printf. This output is unmanaged by the program and
// will persist across renders by the Program.
//
// Unlike fmt.Printf (but similar to log.Printf) the message will be print on
// its own line.
//
// If the altscreen is active no output will be printed.
func Printf(template string, args ...interface{}) Cmd {
return func() Msg {
return printLineMessage{
messageBody: fmt.Sprintf(template, args...),
}
}
}

View File

@@ -1,641 +0,0 @@
// Package tea provides a framework for building rich terminal user interfaces
// based on the paradigms of The Elm Architecture. It's well-suited for simple
// and complex terminal applications, either inline, full-window, or a mix of
// both. It's been battle-tested in several large projects and is
// production-ready.
//
// A tutorial is available at https://github.com/charmbracelet/bubbletea/tree/master/tutorials
//
// Example programs can be found at https://github.com/charmbracelet/bubbletea/tree/master/examples
package tea
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/signal"
"runtime/debug"
"sync"
"syscall"
"github.com/containerd/console"
isatty "github.com/mattn/go-isatty"
"github.com/muesli/cancelreader"
"github.com/muesli/termenv"
"golang.org/x/sync/errgroup"
)
// ErrProgramKilled is returned by [Program.Run] when the program got killed.
var ErrProgramKilled = errors.New("program was killed")
// Msg contain data from the result of a IO operation. Msgs trigger the update
// function and, henceforth, the UI.
type Msg interface{}
// Model contains the program's state as well as its core functions.
type Model interface {
// Init is the first function that will be called. It returns an optional
// initial command. To not perform an initial command return nil.
Init() Cmd
// Update is called when a message is received. Use it to inspect messages
// and, in response, update the model and/or send a command.
Update(Msg) (Model, Cmd)
// View renders the program's UI, which is just a string. The view is
// rendered after every Update.
View() string
}
// Cmd is an IO operation that returns a message when it's complete. If it's
// nil it's considered a no-op. Use it for things like HTTP requests, timers,
// saving and loading from disk, and so on.
//
// Note that there's almost never a reason to use a command to send a message
// to another part of your program. That can almost always be done in the
// update function.
type Cmd func() Msg
type handlers []chan struct{}
// Options to customize the program during its initialization. These are
// generally set with ProgramOptions.
//
// The options here are treated as bits.
type startupOptions byte
func (s startupOptions) has(option startupOptions) bool {
return s&option != 0
}
const (
withAltScreen startupOptions = 1 << iota
withMouseCellMotion
withMouseAllMotion
withInputTTY
withCustomInput
withANSICompressor
withoutSignalHandler
// Catching panics is incredibly useful for restoring the terminal to a
// usable state after a panic occurs. When this is set, Bubble Tea will
// recover from panics, print the stack trace, and disable raw mode. This
// feature is on by default.
withoutCatchPanics
)
// Program is a terminal user interface.
type Program struct {
initialModel Model
// Configuration options that will set as the program is initializing,
// treated as bits. These options can be set via various ProgramOptions.
startupOptions startupOptions
ctx context.Context
cancel context.CancelFunc
msgs chan Msg
errs chan error
// where to send output, this will usually be os.Stdout.
output *termenv.Output
restoreOutput func() error
renderer renderer
// where to read inputs from, this will usually be os.Stdin.
input io.Reader
cancelReader cancelreader.CancelReader
readLoopDone chan struct{}
console console.Console
// was the altscreen active before releasing the terminal?
altScreenWasActive bool
ignoreSignals bool
// Stores the original reference to stdin for cases where input is not a
// TTY on windows and we've automatically opened CONIN$ to receive input.
// When the program exits this will be restored.
//
// Lint ignore note: the linter will find false positive on unix systems
// as this value only comes into play on Windows, hence the ignore comment
// below.
windowsStdin *os.File //nolint:golint,structcheck,unused
}
// Quit is a special command that tells the Bubble Tea program to exit.
func Quit() Msg {
return quitMsg{}
}
// quitMsg in an internal message signals that the program should quit. You can
// send a quitMsg with Quit.
type quitMsg struct{}
// NewProgram creates a new Program.
func NewProgram(model Model, opts ...ProgramOption) *Program {
p := &Program{
initialModel: model,
input: os.Stdin,
msgs: make(chan Msg),
}
// Apply all options to the program.
for _, opt := range opts {
opt(p)
}
// A context can be provided with a ProgramOption, but if none was provided
// we'll use the default background context.
if p.ctx == nil {
p.ctx = context.Background()
}
// Initialize context and teardown channel.
p.ctx, p.cancel = context.WithCancel(p.ctx)
// if no output was set, set it to stdout
if p.output == nil {
p.output = termenv.DefaultOutput()
// cache detected color values
termenv.WithColorCache(true)(p.output)
}
p.restoreOutput, _ = termenv.EnableVirtualTerminalProcessing(p.output)
return p
}
func (p *Program) handleSignals() chan struct{} {
ch := make(chan struct{})
// Listen for SIGINT and SIGTERM.
//
// In most cases ^C will not send an interrupt because the terminal will be
// in raw mode and ^C will be captured as a keystroke and sent along to
// Program.Update as a KeyMsg. When input is not a TTY, however, ^C will be
// caught here.
//
// SIGTERM is sent by unix utilities (like kill) to terminate a process.
go func() {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
defer func() {
signal.Stop(sig)
close(ch)
}()
for {
select {
case <-p.ctx.Done():
return
case <-sig:
if !p.ignoreSignals {
p.msgs <- quitMsg{}
return
}
}
}
}()
return ch
}
// handleResize handles terminal resize events.
func (p *Program) handleResize() chan struct{} {
ch := make(chan struct{})
if f, ok := p.output.TTY().(*os.File); ok && isatty.IsTerminal(f.Fd()) {
// Get the initial terminal size and send it to the program.
go p.checkResize()
// Listen for window resizes.
go p.listenForResize(ch)
} else {
close(ch)
}
return ch
}
// handleCommands runs commands in a goroutine and sends the result to the
// program's message channel.
func (p *Program) handleCommands(cmds chan Cmd) chan struct{} {
ch := make(chan struct{})
go func() {
defer close(ch)
for {
select {
case <-p.ctx.Done():
return
case cmd := <-cmds:
if cmd == nil {
continue
}
// Don't wait on these goroutines, otherwise the shutdown
// latency would get too large as a Cmd can run for some time
// (e.g. tick commands that sleep for half a second). It's not
// possible to cancel them so we'll have to leak the goroutine
// until Cmd returns.
go func() {
msg := cmd() // this can be long.
p.Send(msg)
}()
}
}
}()
return ch
}
// eventLoop is the central message loop. It receives and handles the default
// Bubble Tea messages, update the model and triggers redraws.
func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
for {
select {
case <-p.ctx.Done():
return model, nil
case err := <-p.errs:
return model, err
case msg := <-p.msgs:
// Handle special internal messages.
switch msg := msg.(type) {
case quitMsg:
return model, nil
case clearScreenMsg:
p.renderer.clearScreen()
case enterAltScreenMsg:
p.renderer.enterAltScreen()
case exitAltScreenMsg:
p.renderer.exitAltScreen()
case enableMouseCellMotionMsg:
p.renderer.enableMouseCellMotion()
case enableMouseAllMotionMsg:
p.renderer.enableMouseAllMotion()
case disableMouseMsg:
p.renderer.disableMouseCellMotion()
p.renderer.disableMouseAllMotion()
case showCursorMsg:
p.renderer.showCursor()
case hideCursorMsg:
p.renderer.hideCursor()
case execMsg:
// NB: this blocks.
p.exec(msg.cmd, msg.fn)
case BatchMsg:
for _, cmd := range msg {
cmds <- cmd
}
continue
case sequenceMsg:
go func() {
// Execute commands one at a time, in order.
for _, cmd := range msg {
if cmd == nil {
continue
}
msg := cmd()
if batchMsg, ok := msg.(BatchMsg); ok {
g, _ := errgroup.WithContext(p.ctx)
for _, cmd := range batchMsg {
cmd := cmd
g.Go(func() error {
p.Send(cmd())
return nil
})
}
//nolint:errcheck
g.Wait() // wait for all commands from batch msg to finish
continue
} else {
p.Send(msg)
}
}
}()
}
// Process internal messages for the renderer.
if r, ok := p.renderer.(*standardRenderer); ok {
r.handleMessages(msg)
}
var cmd Cmd
model, cmd = model.Update(msg) // run update
cmds <- cmd // process command (if any)
p.renderer.write(model.View()) // send view to renderer
}
}
}
// Run initializes the program and runs its event loops, blocking until it gets
// terminated by either [Program.Quit], [Program.Kill], or its signal handler.
// Returns the final model.
func (p *Program) Run() (Model, error) {
handlers := handlers{}
cmds := make(chan Cmd)
p.errs = make(chan error)
defer p.cancel()
switch {
case p.startupOptions.has(withInputTTY):
// Open a new TTY, by request
f, err := openInputTTY()
if err != nil {
return p.initialModel, err
}
defer f.Close() //nolint:errcheck
p.input = f
case !p.startupOptions.has(withCustomInput):
// If the user hasn't set a custom input, and input's not a terminal,
// open a TTY so we can capture input as normal. This will allow things
// to "just work" in cases where data was piped or redirected into this
// application.
f, isFile := p.input.(*os.File)
if !isFile {
break
}
if isatty.IsTerminal(f.Fd()) {
break
}
f, err := openInputTTY()
if err != nil {
return p.initialModel, err
}
defer f.Close() //nolint:errcheck
p.input = f
}
// Handle signals.
if !p.startupOptions.has(withoutSignalHandler) {
handlers.add(p.handleSignals())
}
// Recover from panics.
if !p.startupOptions.has(withoutCatchPanics) {
defer func() {
if r := recover(); r != nil {
p.shutdown(true)
fmt.Printf("Caught panic:\n\n%s\n\nRestoring terminal...\n\n", r)
debug.PrintStack()
return
}
}()
}
// If no renderer is set use the standard one.
if p.renderer == nil {
p.renderer = newRenderer(p.output, p.startupOptions.has(withANSICompressor))
}
// Check if output is a TTY before entering raw mode, hiding the cursor and
// so on.
if err := p.initTerminal(); err != nil {
return p.initialModel, err
}
// Honor program startup options.
if p.startupOptions&withAltScreen != 0 {
p.renderer.enterAltScreen()
}
if p.startupOptions&withMouseCellMotion != 0 {
p.renderer.enableMouseCellMotion()
} else if p.startupOptions&withMouseAllMotion != 0 {
p.renderer.enableMouseAllMotion()
}
// Initialize the program.
model := p.initialModel
if initCmd := model.Init(); initCmd != nil {
ch := make(chan struct{})
handlers.add(ch)
go func() {
defer close(ch)
select {
case cmds <- initCmd:
case <-p.ctx.Done():
}
}()
}
// Start the renderer.
p.renderer.start()
// Render the initial view.
p.renderer.write(model.View())
// Subscribe to user input.
if p.input != nil {
if err := p.initCancelReader(); err != nil {
return model, err
}
}
// Handle resize events.
handlers.add(p.handleResize())
// Process commands.
handlers.add(p.handleCommands(cmds))
// Run event loop, handle updates and draw.
model, err := p.eventLoop(model, cmds)
killed := p.ctx.Err() != nil
if killed {
err = ErrProgramKilled
} else {
// Ensure we rendered the final state of the model.
p.renderer.write(model.View())
}
// Tear down.
p.cancel()
// Wait for input loop to finish.
if p.cancelReader.Cancel() {
p.waitForReadLoop()
}
_ = p.cancelReader.Close()
// Wait for all handlers to finish.
handlers.shutdown()
// Restore terminal state.
p.shutdown(killed)
return model, err
}
// StartReturningModel initializes the program and runs its event loops,
// blocking until it gets terminated by either [Program.Quit], [Program.Kill],
// or its signal handler. Returns the final model.
//
// Deprecated: please use [Program.Run] instead.
func (p *Program) StartReturningModel() (Model, error) {
return p.Run()
}
// Start initializes the program and runs its event loops, blocking until it
// gets terminated by either [Program.Quit], [Program.Kill], or its signal
// handler.
//
// Deprecated: please use [Program.Run] instead.
func (p *Program) Start() error {
_, err := p.Run()
return err
}
// Send sends a message to the main update function, effectively allowing
// messages to be injected from outside the program for interoperability
// purposes.
//
// If the program hasn't started yet this will be a blocking operation.
// If the program has already been terminated this will be a no-op, so it's safe
// to send messages after the program has exited.
func (p *Program) Send(msg Msg) {
select {
case <-p.ctx.Done():
case p.msgs <- msg:
}
}
// Quit is a convenience function for quitting Bubble Tea programs. Use it
// when you need to shut down a Bubble Tea program from the outside.
//
// If you wish to quit from within a Bubble Tea program use the Quit command.
//
// If the program is not running this will be a no-op, so it's safe to call
// if the program is unstarted or has already exited.
func (p *Program) Quit() {
p.Send(Quit())
}
// Kill stops the program immediately and restores the former terminal state.
// The final render that you would normally see when quitting will be skipped.
// [program.Run] returns a [ErrProgramKilled] error.
func (p *Program) Kill() {
p.cancel()
}
// shutdown performs operations to free up resources and restore the terminal
// to its original state.
func (p *Program) shutdown(kill bool) {
if p.renderer != nil {
if kill {
p.renderer.kill()
} else {
p.renderer.stop()
}
}
_ = p.restoreTerminalState()
if p.restoreOutput != nil {
_ = p.restoreOutput()
}
}
// ReleaseTerminal restores the original terminal state and cancels the input
// reader. You can return control to the Program with RestoreTerminal.
func (p *Program) ReleaseTerminal() error {
p.ignoreSignals = true
p.cancelReader.Cancel()
p.waitForReadLoop()
p.altScreenWasActive = p.renderer.altScreen()
return p.restoreTerminalState()
}
// RestoreTerminal reinitializes the Program's input reader, restores the
// terminal to the former state when the program was running, and repaints.
// Use it to reinitialize a Program after running ReleaseTerminal.
func (p *Program) RestoreTerminal() error {
p.ignoreSignals = false
if err := p.initTerminal(); err != nil {
return err
}
if err := p.initCancelReader(); err != nil {
return err
}
if p.altScreenWasActive {
p.renderer.enterAltScreen()
} else {
// entering alt screen already causes a repaint.
go p.Send(repaintMsg{})
}
// If the output is a terminal, it may have been resized while another
// process was at the foreground, in which case we may not have received
// SIGWINCH. Detect any size change now and propagate the new size as
// needed.
go p.checkResize()
return nil
}
// Println prints above the Program. This output is unmanaged by the program
// and will persist across renders by the Program.
//
// If the altscreen is active no output will be printed.
func (p *Program) Println(args ...interface{}) {
p.msgs <- printLineMessage{
messageBody: fmt.Sprint(args...),
}
}
// Printf prints above the Program. It takes a format template followed by
// values similar to fmt.Printf. This output is unmanaged by the program and
// will persist across renders by the Program.
//
// Unlike fmt.Printf (but similar to log.Printf) the message will be print on
// its own line.
//
// If the altscreen is active no output will be printed.
func (p *Program) Printf(template string, args ...interface{}) {
p.msgs <- printLineMessage{
messageBody: fmt.Sprintf(template, args...),
}
}
// Adds a handler to the list of handlers. We wait for all handlers to terminate
// gracefully on shutdown.
func (h *handlers) add(ch chan struct{}) {
*h = append(*h, ch)
}
// Shutdown waits for all handlers to terminate.
func (h handlers) shutdown() {
var wg sync.WaitGroup
for _, ch := range h {
wg.Add(1)
go func(ch chan struct{}) {
<-ch
wg.Done()
}(ch)
}
wg.Wait()
}

View File

@@ -1,131 +0,0 @@
package tea
import (
"errors"
"io"
"os"
"time"
isatty "github.com/mattn/go-isatty"
"github.com/muesli/cancelreader"
"golang.org/x/term"
)
func (p *Program) initTerminal() error {
err := p.initInput()
if err != nil {
return err
}
if p.console != nil {
err = p.console.SetRaw()
if err != nil {
return err
}
}
p.renderer.hideCursor()
return nil
}
// restoreTerminalState restores the terminal to the state prior to running the
// Bubble Tea program.
func (p *Program) restoreTerminalState() error {
if p.renderer != nil {
p.renderer.showCursor()
p.renderer.disableMouseCellMotion()
p.renderer.disableMouseAllMotion()
if p.renderer.altScreen() {
p.renderer.exitAltScreen()
// give the terminal a moment to catch up
time.Sleep(time.Millisecond * 10)
}
}
if p.console != nil {
err := p.console.Reset()
if err != nil {
return err
}
}
return p.restoreInput()
}
// initCancelReader (re)commences reading inputs.
func (p *Program) initCancelReader() error {
var err error
p.cancelReader, err = cancelreader.NewReader(p.input)
if err != nil {
return err
}
p.readLoopDone = make(chan struct{})
go p.readLoop()
return nil
}
func (p *Program) readLoop() {
defer close(p.readLoopDone)
for {
if p.ctx.Err() != nil {
return
}
msgs, err := readInputs(p.cancelReader)
if err != nil {
if !errors.Is(err, io.EOF) && !errors.Is(err, cancelreader.ErrCanceled) {
select {
case <-p.ctx.Done():
case p.errs <- err:
}
}
return
}
for _, msg := range msgs {
p.msgs <- msg
}
}
}
// waitForReadLoop waits for the cancelReader to finish its read loop.
func (p *Program) waitForReadLoop() {
select {
case <-p.readLoopDone:
case <-time.After(500 * time.Millisecond):
// The read loop hangs, which means the input
// cancelReader's cancel function has returned true even
// though it was not able to cancel the read.
}
}
// checkResize detects the current size of the output and informs the program
// via a WindowSizeMsg.
func (p *Program) checkResize() {
f, ok := p.output.TTY().(*os.File)
if !ok || !isatty.IsTerminal(f.Fd()) {
// can't query window size
return
}
w, h, err := term.GetSize(int(f.Fd()))
if err != nil {
select {
case <-p.ctx.Done():
case p.errs <- err:
}
return
}
p.Send(WindowSizeMsg{
Width: w,
Height: h,
})
}

View File

@@ -1,42 +0,0 @@
//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || aix
// +build darwin dragonfly freebsd linux netbsd openbsd solaris aix
package tea
import (
"os"
"github.com/containerd/console"
)
func (p *Program) initInput() error {
// If input's a file, use console to manage it
if f, ok := p.input.(*os.File); ok {
c, err := console.ConsoleFromFile(f)
if err != nil {
return nil //nolint:nilerr // ignore error, this was just a test
}
p.console = c
}
return nil
}
// On unix systems, RestoreInput closes any TTYs we opened for input. Note that
// we don't do this on Windows as it causes the prompt to not be drawn until
// the terminal receives a keypress rather than appearing promptly after the
// program exits.
func (p *Program) restoreInput() error {
if p.console != nil {
return p.console.Reset()
}
return nil
}
func openInputTTY() (*os.File, error) {
f, err := os.Open("/dev/tty")
if err != nil {
return nil, err
}
return f, nil
}

View File

@@ -1,47 +0,0 @@
//go:build windows
// +build windows
package tea
import (
"os"
"github.com/containerd/console"
)
func (p *Program) initInput() error {
// If input's a file, use console to manage it
if f, ok := p.input.(*os.File); ok {
// Save a reference to the current stdin then replace stdin with our
// input. We do this so we can hand input off to containerd/console to
// set raw mode, and do it in this fashion because the method
// console.ConsoleFromFile isn't supported on Windows.
p.windowsStdin = os.Stdin
os.Stdin = f
// Note: this will panic if it fails.
c := console.Current()
p.console = c
}
return nil
}
// restoreInput restores stdout in the event that we placed it aside to handle
// input with CONIN$, above.
func (p *Program) restoreInput() error {
if p.windowsStdin != nil {
os.Stdin = p.windowsStdin
}
return nil
}
// Open the Windows equivalent of a TTY.
func openInputTTY() (*os.File, error) {
f, err := os.OpenFile("CONIN$", os.O_RDWR, 0644)
if err != nil {
return nil, err
}
return f, nil
}

View File

@@ -1,47 +0,0 @@
run:
tests: false
issues:
include:
- EXC0001
- EXC0005
- EXC0011
- EXC0012
- EXC0013
max-issues-per-linter: 0
max-same-issues: 0
linters:
enable:
# - dupl
- exhaustive
# - exhaustivestruct
- goconst
- godot
- godox
- gomnd
- gomoddirectives
- goprintffuncname
- ifshort
# - lll
- misspell
- nakedret
- nestif
- noctx
- nolintlint
- prealloc
- wrapcheck
# disable default linters, they are already enabled in .golangci.yml
disable:
- deadcode
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- structcheck
- typecheck
- unused
- varcheck

View File

@@ -1,29 +0,0 @@
run:
tests: false
issues:
include:
- EXC0001
- EXC0005
- EXC0011
- EXC0012
- EXC0013
max-issues-per-linter: 0
max-same-issues: 0
linters:
enable:
- bodyclose
- exportloopref
- goimports
- gosec
- nilerr
- predeclared
- revive
- rowserrcheck
- sqlclosecheck
- tparallel
- unconvert
- unparam
- whitespace

View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2021 Charmbracelet, Inc
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.

Some files were not shown because too many files have changed in this diff Show More