├── yamlfmt.yml ├── .gitignore ├── ui ├── config.go ├── msgs.go ├── utils.go ├── ui.go ├── cmds.go ├── model.go ├── initial.go ├── styles.go ├── view.go ├── update.go └── results.go ├── server ├── static │ └── img │ │ └── favicon.ico ├── html │ ├── partials │ │ └── nav.tmpl │ ├── pages │ │ └── home.tmpl │ └── base.tmpl └── server.go ├── examples ├── README.md ├── rust │ └── README.md ├── python │ └── README.md └── go │ └── README.md ├── .github ├── scripts │ └── run.sh ├── workflows │ ├── vulncheck.yml │ ├── release.yml │ ├── scan.yml │ ├── pr.yml │ └── main.yml └── dependabot.yml ├── main.go ├── tsutils ├── testcode │ ├── scala │ │ ├── objects.txt │ │ ├── funcs.txt │ │ └── classes.txt │ ├── python.txt │ ├── go.txt │ ├── rust │ │ ├── funcs.txt │ │ └── types.txt │ └── scala.txt ├── results.go ├── types.go ├── tspy_test.go ├── file_utils.go ├── tsutils.go ├── tspython.go ├── tsgo_test.go ├── query.go ├── tssscala_test.go ├── file_utils_test.go ├── tsrust.go ├── tsrust_test.go ├── tsgo.go └── tsscala.go ├── internal └── utils │ ├── loading_bars.go │ └── page.go ├── cmd ├── utils.go ├── utils_test.go └── root.go ├── Makefile ├── LICENSE ├── .goreleaser.yml ├── .golangci.yml ├── go.mod ├── README.md ├── go.sum └── filepicker └── filepicker.go /yamlfmt.yml: -------------------------------------------------------------------------------- 1 | formatter: 2 | retain_line_breaks_single: true 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dstll 2 | .quickrun 3 | cosign.key 4 | justfile 5 | dstll-output 6 | -------------------------------------------------------------------------------- /ui/config.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | type Config struct { 4 | ViewFileCmd []string 5 | } 6 | -------------------------------------------------------------------------------- /server/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhth/dstll/HEAD/server/static/img/favicon.ico -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # dstll 2 | 3 | This directory holds example outputs from `dstll` when run on various kinds 4 | of code files. 5 | 6 | - [go](./go) 7 | - [python](./python) 8 | - [rust](./rust) 9 | -------------------------------------------------------------------------------- /.github/scripts/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # shellcheck disable=SC2046 4 | dstll $(git ls-files '**/*.go' | head -n 10) -p 5 | dstll write $(git ls-files '**/*.go' | head -n 10) -o /var/tmp/dstll 6 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/dhth/dstll/cmd" 7 | ) 8 | 9 | func main() { 10 | err := cmd.Execute() 11 | if err != nil { 12 | os.Exit(1) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tsutils/testcode/scala/objects.txt: -------------------------------------------------------------------------------- 1 | // a simple object 2 | 3 | object HelloWorld { 4 | def main(args: Array[String]): Unit = { 5 | println("Hello, world!") 6 | } 7 | } 8 | 9 | object Container { 10 | def apply[T](value: T): Container[T] = new Container(value) 11 | } 12 | -------------------------------------------------------------------------------- /ui/msgs.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import "github.com/dhth/dstll/tsutils" 4 | 5 | type hideHelpMsg struct{} 6 | 7 | type FileRead struct { 8 | contents string 9 | err error 10 | } 11 | 12 | type FileResultsReceivedMsg struct { 13 | result tsutils.Result 14 | } 15 | 16 | type ViewFileFinishedmsg struct { 17 | filePath string 18 | err error 19 | } 20 | -------------------------------------------------------------------------------- /tsutils/results.go: -------------------------------------------------------------------------------- 1 | package tsutils 2 | 3 | func GetResults(fPaths []string) []Result { 4 | resultsChan := make(chan Result) 5 | results := make([]Result, len(fPaths)) 6 | 7 | for _, fPath := range fPaths { 8 | go GetLayout(resultsChan, fPath) 9 | } 10 | 11 | for i := range fPaths { 12 | r := <-resultsChan 13 | results[i] = r 14 | } 15 | 16 | return results 17 | } 18 | -------------------------------------------------------------------------------- /server/html/partials/nav.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "nav" }} 2 | 11 | {{ end }} 12 | 13 | -------------------------------------------------------------------------------- /tsutils/types.go: -------------------------------------------------------------------------------- 1 | package tsutils 2 | 3 | const ( 4 | nodeTypeIdentifier = "identifier" 5 | nodeTypeModifiers = "modifiers" 6 | nodeTypeTypeParameters = "type_parameters" 7 | nodeTypeParameters = "parameters" 8 | ) 9 | 10 | type Result struct { 11 | FPath string 12 | Results []string 13 | Err error 14 | } 15 | type FileType uint 16 | 17 | const ( 18 | FTNone FileType = iota 19 | FTGo 20 | FTPython 21 | FTRust 22 | FTScala 23 | ) 24 | -------------------------------------------------------------------------------- /internal/utils/loading_bars.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | func Loader(delay time.Duration, done <-chan struct{}) { 9 | dots := []string{ 10 | "⠷", 11 | "⠯", 12 | "⠟", 13 | "⠻", 14 | "⠽", 15 | "⠾", 16 | } 17 | for { 18 | select { 19 | case <-done: 20 | fmt.Print("\r") 21 | return 22 | default: 23 | for _, r := range dots { 24 | fmt.Printf("\rfetching %s ", r) 25 | time.Sleep(delay) 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /ui/utils.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | func RightPadTrim(s string, length int) string { 8 | if len(s) >= length { 9 | if length > 3 { 10 | return s[:length-3] + "..." 11 | } 12 | return s[:length] 13 | } 14 | return s + strings.Repeat(" ", length-len(s)) 15 | } 16 | 17 | func Trim(s string, length int) string { 18 | if len(s) >= length { 19 | if length > 3 { 20 | return s[:length-3] + "..." 21 | } 22 | return s[:length] 23 | } 24 | return s 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/vulncheck.yml: -------------------------------------------------------------------------------- 1 | name: vulncheck 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: '0 2 * * 2,6' 7 | 8 | jobs: 9 | vulncheck: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v6 13 | - name: Set up Go 14 | uses: actions/setup-go@v6 15 | with: 16 | go-version-file: 'go.mod' 17 | - name: govulncheck 18 | run: | 19 | go install golang.org/x/vuln/cmd/govulncheck@latest 20 | govulncheck ./... 21 | -------------------------------------------------------------------------------- /internal/utils/page.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "errors" 4 | 5 | var ErrPageExceedsBounds = errors.New("page value exceeds bounds") 6 | 7 | func GetIndexRange(page, total, perPage int) (int, int, error) { 8 | if page < 1 { 9 | return -1, -1, ErrPageExceedsBounds 10 | } 11 | start := (page - 1) * perPage 12 | if start >= total { 13 | return -1, -1, ErrPageExceedsBounds 14 | } 15 | 16 | end := start + perPage 17 | if end >= total { 18 | return start, total, nil 19 | } 20 | return start, end, nil 21 | } 22 | -------------------------------------------------------------------------------- /cmd/utils.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | ) 9 | 10 | func expandTilde(path string, homeDir string) string { 11 | pathWithoutTilde, found := strings.CutPrefix(path, "~/") 12 | if !found { 13 | return path 14 | } 15 | return filepath.Join(homeDir, pathWithoutTilde) 16 | } 17 | 18 | func createDir(path string) error { 19 | _, err := os.Stat(path) 20 | if errors.Is(err, os.ErrNotExist) { 21 | mkDirErr := os.MkdirAll(path, 0o755) 22 | if mkDirErr != nil { 23 | return mkDirErr 24 | } 25 | } 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | groups: 8 | patch-updates: 9 | update-types: ["patch"] 10 | minor-updates: 11 | update-types: ["minor"] 12 | labels: 13 | - "dependencies" 14 | commit-message: 15 | prefix: "build" 16 | - package-ecosystem: "github-actions" 17 | directory: "/" 18 | schedule: 19 | interval: "monthly" 20 | labels: 21 | - "dependencies" 22 | commit-message: 23 | prefix: "ci" 24 | -------------------------------------------------------------------------------- /tsutils/tspy_test.go: -------------------------------------------------------------------------------- 1 | package tsutils 2 | 3 | import ( 4 | _ "embed" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | //go:embed testcode/python.txt 12 | var pyCode []byte 13 | 14 | func TestGetPyData(t *testing.T) { 15 | expected := []string{ 16 | "def say_something()", 17 | "def say_something(x)", 18 | "def say_something(x: str, y: str)", 19 | "def say_something() -> str", 20 | "def say_something(x: str, y: str) -> str", 21 | } 22 | 23 | got, err := getPyData(pyCode) 24 | 25 | require.NoError(t, err) 26 | assert.Equal(t, expected, got) 27 | } 28 | -------------------------------------------------------------------------------- /ui/ui.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | ) 10 | 11 | var errFailedToConfigureDebugging = errors.New("failed to configure debugging") 12 | 13 | func RenderUI(config Config) error { 14 | if len(os.Getenv("DEBUG")) > 0 { 15 | f, err := tea.LogToFile("debug.log", "debug") 16 | if err != nil { 17 | return fmt.Errorf("%w: %s", errFailedToConfigureDebugging, err.Error()) 18 | } 19 | defer f.Close() 20 | } 21 | 22 | p := tea.NewProgram(InitialModel(config), tea.WithAltScreen()) 23 | _, err := p.Run() 24 | if err != nil { 25 | return err 26 | } 27 | return nil 28 | } 29 | -------------------------------------------------------------------------------- /tsutils/testcode/python.txt: -------------------------------------------------------------------------------- 1 | # ==== FUNCS ==== 2 | 3 | # a function with no parameters or return type hint 4 | def say_something(): 5 | print("hola") 6 | 7 | # function with parameters but no return type hint 8 | def say_something(x): 9 | print("hola " + x) 10 | 11 | # a function with parameters with types but no return type hint 12 | def say_something(x: str, y: str): 13 | print(f"hola, {x} and {y}") 14 | 15 | # a function with return type hint but no parameters 16 | def say_something() -> str: 17 | return "hola" 18 | 19 | # a function with parameters and return type hints 20 | def say_something(x: str, y: str) -> str: 21 | return f"hola, {x} and {y}" 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | id-token: write 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v6 16 | with: 17 | fetch-depth: 0 18 | - name: Set up Go 19 | uses: actions/setup-go@v6 20 | with: 21 | go-version-file: 'go.mod' 22 | - name: Build 23 | run: go build -v ./... 24 | - name: Test 25 | run: go test -v ./... 26 | - name: Release 27 | env: 28 | GITHUB_TOKEN: ${{secrets.GH_PAT}} 29 | run: | 30 | make release 31 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PACKAGE_NAME := github.com/dhth/dstll 2 | DOCKER_IMAGE = ghcr.io/goreleaser/goreleaser-cross:v1.25.0 3 | 4 | .PHONY: release-dry-run 5 | release-dry-run: 6 | @docker run \ 7 | --rm \ 8 | -e CGO_ENABLED=1 \ 9 | -v /var/run/docker.sock:/var/run/docker.sock \ 10 | -v `pwd`:/go/src/$(PACKAGE_NAME) \ 11 | -w /go/src/$(PACKAGE_NAME) \ 12 | $(DOCKER_IMAGE) \ 13 | build --clean --snapshot 14 | 15 | .PHONY: release 16 | release: 17 | @docker run \ 18 | --rm \ 19 | -e CGO_ENABLED=1 \ 20 | -e GITHUB_TOKEN=${GITHUB_TOKEN} \ 21 | -v /var/run/docker.sock:/var/run/docker.sock \ 22 | -v `pwd`:/go/src/$(PACKAGE_NAME) \ 23 | -w /go/src/$(PACKAGE_NAME) \ 24 | $(DOCKER_IMAGE) \ 25 | release --clean 26 | -------------------------------------------------------------------------------- /.github/workflows/scan.yml: -------------------------------------------------------------------------------- 1 | name: scan 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: '0 18 10 * *' 7 | 8 | jobs: 9 | virus-total: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v6 13 | - name: Set up Go 14 | uses: actions/setup-go@v6 15 | with: 16 | go-version-file: 'go.mod' 17 | - name: Build Binaries 18 | run: | 19 | make release-dry-run 20 | - name: List binaries 21 | run: | 22 | ls -lh ./dist/*/dstll 23 | - uses: dhth/composite-actions/.github/actions/scan-files@main 24 | with: 25 | files: './dist/*/dstll' 26 | vt-api-key: ${{ secrets.VT_API_KEY }} 27 | -------------------------------------------------------------------------------- /server/html/pages/home.tmpl: -------------------------------------------------------------------------------- 1 | {{define "title"}}Home{{end}} 2 | {{define "main"}} 3 |
6 |{{ $fPath }}
10 | {{range $elements -}} 11 |( 99 | &mut self, 100 | glob: &str, 101 | program: P, 102 | args: I, 103 | ) -> &mut DecompressionMatcherBuilder 104 | 105 | pub fn try_associate
(
106 | &mut self,
107 | glob: &str,
108 | program: P,
109 | args: I,
110 | ) -> Result<&mut DecompressionMatcherBuilder, CommandError>
111 |
112 | fn default() -> DecompressionMatcher
113 |
114 | pub fn new() -> DecompressionMatcher
115 |
116 | pub fn command
2 | dstll
3 |
9 |
15 |
16 |
68 |
69 |
72 |
73 |
76 |
77 |
%s
", elementsCombined))) 154 | } else { 155 | htmlResults = append(htmlResults, htemplate.HTML(b.String())) 156 | } 157 | results[r.FPath] = htmlResults 158 | } 159 | 160 | return results 161 | } 162 | 163 | func Start(fPaths []string) { 164 | mux := http.NewServeMux() 165 | 166 | mux.HandleFunc("GET /{$}", serveResults(fPaths)) 167 | mux.HandleFunc("GET /page/{page}", serveResults(fPaths)) 168 | 169 | port, err := findOpenPort(startPort, endPort) 170 | if err != nil { 171 | fmt.Printf("Couldn't find an open port between %d-%d", startPort, endPort) 172 | } 173 | server := &http.Server{ 174 | Addr: fmt.Sprintf("127.0.0.1:%d", port), 175 | Handler: mux, 176 | } 177 | 178 | go func() { 179 | fmt.Printf("Starting server. Open http://%s/ in your browser.\n", server.Addr) 180 | err := server.ListenAndServe() 181 | if !errors.Is(err, http.ErrServerClosed) { 182 | fmt.Printf("Error running server: %q", err) 183 | } 184 | }() 185 | 186 | sigChan := make(chan os.Signal, 1) 187 | signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) 188 | <-sigChan 189 | 190 | shutDownCtx, shutDownRelease := context.WithTimeout(context.Background(), time.Second*3) 191 | defer shutDownRelease() 192 | 193 | err = server.Shutdown(shutDownCtx) 194 | if err != nil { 195 | fmt.Printf("Error shutting down: %v\nTrying forceful shutdown\n", err) 196 | closeErr := server.Close() 197 | if closeErr != nil { 198 | fmt.Printf("Forceful shutdown failed: %v\n", closeErr) 199 | } else { 200 | fmt.Printf("Forceful shutdown successful\n") 201 | } 202 | } 203 | fmt.Printf("\nbye 👋\n") 204 | } 205 | 206 | func findOpenPort(startPort, endPort int) (int, error) { 207 | for port := startPort; port <= endPort; port++ { 208 | address := fmt.Sprintf("127.0.0.1:%d", port) 209 | listener, err := net.Listen("tcp", address) 210 | if err == nil { 211 | defer listener.Close() 212 | return port, nil 213 | } 214 | } 215 | return 0, ErrNoPortOpen 216 | } 217 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | // "bufio" 5 | "errors" 6 | "fmt" 7 | "io/fs" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | 12 | server "github.com/dhth/dstll/server" 13 | "github.com/dhth/dstll/tsutils" 14 | "github.com/dhth/dstll/ui" 15 | "github.com/spf13/cobra" 16 | "github.com/spf13/pflag" 17 | "github.com/spf13/viper" 18 | ) 19 | 20 | const ( 21 | configFileName = "dstll/dstll.toml" 22 | envPrefix = "DSTLL" 23 | ) 24 | 25 | var ( 26 | errCouldntGetHomeDir = errors.New("couldn't get home directory") 27 | errCouldntGetConfigDir = errors.New("couldn't get config directory") 28 | errConfigFileExtIncorrect = errors.New("config file must be a TOML file") 29 | errConfigFileDoesntExist = errors.New("config file does not exist") 30 | errCouldntCreateDirectory = errors.New("could not create directory") 31 | errNoPathsProvided = errors.New("no file paths provided") 32 | ) 33 | 34 | func Execute() error { 35 | rootCmd, err := NewRootCommand() 36 | if err != nil { 37 | return err 38 | } 39 | 40 | err = rootCmd.Execute() 41 | return err 42 | } 43 | 44 | func NewRootCommand() (*cobra.Command, error) { 45 | var ( 46 | trimPrefix string 47 | fPaths []string 48 | userHomeDir string 49 | configFilePath string 50 | configPathFull string 51 | 52 | // root 53 | plainOutput bool 54 | 55 | // write 56 | writeOutputDir string 57 | writeQuiet bool 58 | 59 | // tui 60 | tuiViewFileCmdInput []string 61 | tuiViewFileCmd []string 62 | ) 63 | 64 | rootCmd := &cobra.Command{ 65 | Use: "dstll [PATH ...]", 66 | Short: "dstll gives you a high level overview of various constructs in your code", 67 | Long: `dstll gives you a high level overview of various constructs in your code. 68 | 69 | Its findings can be printed to stdout, written to files, or presented via a TUI/web interface. 70 | `, 71 | SilenceUsage: true, 72 | Args: cobra.MinimumNArgs(1), 73 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { 74 | configPathFull = expandTilde(configFilePath, userHomeDir) 75 | 76 | if filepath.Ext(configPathFull) != ".toml" { 77 | return errConfigFileExtIncorrect 78 | } 79 | _, err := os.Stat(configPathFull) 80 | 81 | fl := cmd.Flags() 82 | if fl != nil { 83 | cf := fl.Lookup("config-path") 84 | if cf != nil && cf.Changed && errors.Is(err, fs.ErrNotExist) { 85 | return errConfigFileDoesntExist 86 | } 87 | } 88 | 89 | var v *viper.Viper 90 | v, err = initializeConfig(cmd, configPathFull) 91 | if err != nil { 92 | return err 93 | } 94 | 95 | calledAs := cmd.CalledAs() 96 | if calledAs == "tui" { 97 | // pretty ugly hack to get around the fact that 98 | // v.GetStringSlice("view_file_command") always seems to prioritize the config file 99 | if len(tuiViewFileCmdInput) > 0 && len(tuiViewFileCmdInput[0]) > 0 && !strings.HasPrefix(tuiViewFileCmdInput[0], "[") { 100 | tuiViewFileCmd = tuiViewFileCmdInput 101 | } else { 102 | tuiViewFileCmd = v.GetStringSlice("view-file-command") 103 | } 104 | return nil 105 | } 106 | 107 | fPaths = args 108 | if len(fPaths) == 0 { 109 | return errNoPathsProvided 110 | } 111 | 112 | return nil 113 | }, 114 | Run: func(_ *cobra.Command, _ []string) { 115 | results := tsutils.GetResults(fPaths) 116 | if len(results) == 0 { 117 | return 118 | } 119 | 120 | ui.ShowResults(results, trimPrefix, plainOutput) 121 | }, 122 | } 123 | 124 | writeCmd := &cobra.Command{ 125 | Use: "write [PATH ...]", 126 | Short: "Write findings to files", 127 | Args: cobra.MinimumNArgs(1), 128 | RunE: func(_ *cobra.Command, _ []string) error { 129 | results := tsutils.GetResults(fPaths) 130 | if len(results) == 0 { 131 | return nil 132 | } 133 | 134 | err := createDir(writeOutputDir) 135 | if err != nil { 136 | return fmt.Errorf("%w: %s", errCouldntCreateDirectory, err.Error()) 137 | } 138 | 139 | ui.WriteResults(results, writeOutputDir, writeQuiet) 140 | return nil 141 | }, 142 | } 143 | 144 | tuiCmd := &cobra.Command{ 145 | Use: "tui", 146 | Short: "Open dstll TUI", 147 | Args: cobra.NoArgs, 148 | RunE: func(_ *cobra.Command, _ []string) error { 149 | config := ui.Config{ 150 | ViewFileCmd: tuiViewFileCmd, 151 | } 152 | return ui.RenderUI(config) 153 | }, 154 | } 155 | 156 | serveCmd := &cobra.Command{ 157 | Use: "serve [PATH ...]", 158 | Short: "Serve findings via a web server", 159 | Args: cobra.MinimumNArgs(1), 160 | Run: func(_ *cobra.Command, _ []string) { 161 | server.Start(fPaths) 162 | }, 163 | } 164 | 165 | userHomeDir, err := os.UserHomeDir() 166 | if err != nil { 167 | return nil, fmt.Errorf("%w: %s", errCouldntGetHomeDir, err.Error()) 168 | } 169 | 170 | userConfigDir, err := os.UserConfigDir() 171 | if err != nil { 172 | return nil, fmt.Errorf("%w: %s", errCouldntGetConfigDir, err.Error()) 173 | } 174 | 175 | var emptySlice []string 176 | defaultConfigPath := filepath.Join(userConfigDir, configFileName) 177 | 178 | // rootCmd.Flags().StringSliceVarP(&fPaths, "files", "f", emptySlice, "paths of files to run dstll on") 179 | rootCmd.Flags().StringVarP(&configFilePath, "config-path", "c", defaultConfigPath, "location of dstll's config file") 180 | rootCmd.Flags().StringVarP(&trimPrefix, "trim-prefix", "t", "", "prefix to trim from the file path") 181 | rootCmd.Flags().BoolVarP(&plainOutput, "plain", "p", false, "output plain text") 182 | 183 | writeCmd.Flags().StringVarP(&writeOutputDir, "output-dir", "o", "dstll-output", "directory to write findings in") 184 | writeCmd.Flags().BoolVarP(&writeQuiet, "quiet", "q", false, "suppress output") 185 | 186 | tuiCmd.Flags().StringSliceVar(&tuiViewFileCmdInput, "view-file-command", emptySlice, "command to use to view, eg. --view-file-command='bat,--style,plain,--paging,always'") 187 | 188 | rootCmd.AddCommand(writeCmd) 189 | rootCmd.AddCommand(tuiCmd) 190 | rootCmd.AddCommand(serveCmd) 191 | 192 | rootCmd.CompletionOptions.DisableDefaultCmd = true 193 | 194 | return rootCmd, nil 195 | } 196 | 197 | func initializeConfig(cmd *cobra.Command, configFile string) (*viper.Viper, error) { 198 | v := viper.New() 199 | 200 | v.SetConfigName(filepath.Base(configFile)) 201 | v.SetConfigType("toml") 202 | v.AddConfigPath(filepath.Dir(configFile)) 203 | 204 | err := v.ReadInConfig() 205 | if err != nil && !errors.As(err, &viper.ConfigFileNotFoundError{}) { 206 | return v, err 207 | } 208 | 209 | v.SetEnvPrefix(envPrefix) 210 | v.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) 211 | v.AutomaticEnv() 212 | 213 | err = bindFlags(cmd, v) 214 | if err != nil { 215 | return v, err 216 | } 217 | 218 | return v, nil 219 | } 220 | 221 | func bindFlags(cmd *cobra.Command, v *viper.Viper) error { 222 | var err error 223 | cmd.Flags().VisitAll(func(f *pflag.Flag) { 224 | configName := strings.ReplaceAll(f.Name, "-", "_") 225 | 226 | if !f.Changed && v.IsSet(configName) { 227 | val := v.Get(configName) 228 | fErr := cmd.Flags().Set(f.Name, fmt.Sprintf("%v", val)) 229 | if fErr != nil { 230 | err = fErr 231 | return 232 | } 233 | } 234 | }) 235 | return err 236 | } 237 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= 2 | github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= 3 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 5 | github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 6 | github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 7 | github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= 8 | github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= 9 | github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= 10 | github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= 11 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 12 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 13 | github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= 14 | github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 15 | github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= 16 | github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 17 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 18 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 19 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 20 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 21 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 22 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 23 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 24 | github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= 25 | github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= 26 | github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 27 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 28 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 29 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 30 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 31 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 32 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 33 | github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= 34 | github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 35 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 36 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 37 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 38 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 39 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 40 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 41 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 42 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 43 | github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= 44 | github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 45 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 46 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 47 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 48 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 49 | github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ= 50 | github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 51 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 52 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 53 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 54 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 55 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 56 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 57 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 58 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 59 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 60 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 61 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 62 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 63 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 64 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 65 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 66 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 67 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 68 | github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= 69 | github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= 70 | github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 h1:6C8qej6f1bStuePVkLSFxoU22XBS165D3klxlzRg8F4= 71 | github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82/go.mod h1:xe4pgH49k4SsmkQq5OT8abwhWmnzkhpgnXeekbx2efw= 72 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= 73 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= 74 | github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= 75 | github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= 76 | github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= 77 | github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= 78 | github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= 79 | github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= 80 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 81 | github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= 82 | github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 83 | github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= 84 | github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= 85 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 86 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 87 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 88 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 89 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 90 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 91 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 92 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 93 | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= 94 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 95 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= 96 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= 97 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 98 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 99 | golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= 100 | golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 101 | golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= 102 | golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= 103 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 104 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 105 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 106 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 107 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 108 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 109 | -------------------------------------------------------------------------------- /filepicker/filepicker.go: -------------------------------------------------------------------------------- 1 | // A modded version of https://github.com/charmbracelet/bubbles/blob/master/filepicker/filepicker.go 2 | package filepicker 3 | 4 | import ( 5 | "os" 6 | "path/filepath" 7 | "sort" 8 | "strings" 9 | "sync" 10 | 11 | "github.com/charmbracelet/bubbles/key" 12 | tea "github.com/charmbracelet/bubbletea" 13 | "github.com/charmbracelet/lipgloss" 14 | ) 15 | 16 | var ( 17 | lastID int 18 | idMtx sync.Mutex 19 | ) 20 | 21 | // IsHidden reports whether a file is hidden or not. 22 | func IsHidden(file string) (bool, error) { 23 | return strings.HasPrefix(file, "."), nil 24 | } 25 | 26 | // Return the next ID we should use on the Model. 27 | func nextID() int { 28 | idMtx.Lock() 29 | defer idMtx.Unlock() 30 | lastID++ 31 | return lastID 32 | } 33 | 34 | // New returns a new filepicker model with default styling and key bindings. 35 | func New() Model { 36 | return Model{ 37 | id: nextID(), 38 | CurrentDirectory: ".", 39 | cursor: ">", 40 | AllowedTypes: []string{}, 41 | selected: 0, 42 | ShowHidden: false, 43 | DirAllowed: false, 44 | FileAllowed: true, 45 | AutoHeight: true, 46 | Height: 0, 47 | Width: 40, 48 | max: 0, 49 | min: 0, 50 | selectedStack: newStack(), 51 | minStack: newStack(), 52 | maxStack: newStack(), 53 | KeyMap: DefaultKeyMap(), 54 | Styles: DefaultStyles(), 55 | } 56 | } 57 | 58 | type errorMsg struct { 59 | err error 60 | } 61 | 62 | type readDirMsg struct { 63 | id int 64 | entries []os.DirEntry 65 | } 66 | 67 | const ( 68 | marginBottom = 5 69 | fileSizeWidth = 7 70 | paddingLeft = 2 71 | ) 72 | 73 | // KeyMap defines key bindings for each user action. 74 | type KeyMap struct { 75 | GoToTop key.Binding 76 | GoToLast key.Binding 77 | Down key.Binding 78 | Up key.Binding 79 | PageUp key.Binding 80 | PageDown key.Binding 81 | Back key.Binding 82 | Open key.Binding 83 | } 84 | 85 | // DefaultKeyMap defines the default keybindings. 86 | func DefaultKeyMap() KeyMap { 87 | return KeyMap{ 88 | GoToTop: key.NewBinding(key.WithKeys("g"), key.WithHelp("g", "first")), 89 | GoToLast: key.NewBinding(key.WithKeys("G"), key.WithHelp("G", "last")), 90 | Down: key.NewBinding(key.WithKeys("j", "down", "ctrl+n"), key.WithHelp("j", "down")), 91 | Up: key.NewBinding(key.WithKeys("k", "up", "ctrl+p"), key.WithHelp("k", "up")), 92 | PageUp: key.NewBinding(key.WithKeys("K", "pgup"), key.WithHelp("pgup", "page up")), 93 | PageDown: key.NewBinding(key.WithKeys("J", "pgdown"), key.WithHelp("pgdown", "page down")), 94 | Back: key.NewBinding(key.WithKeys("h", "backspace", "left", "esc"), key.WithHelp("h", "back")), 95 | Open: key.NewBinding(key.WithKeys("l", "right", "enter"), key.WithHelp("l", "open")), 96 | } 97 | } 98 | 99 | // Styles defines the possible customizations for styles in the file picker. 100 | type Styles struct { 101 | DisabledCursor lipgloss.Style 102 | Cursor lipgloss.Style 103 | Symlink lipgloss.Style 104 | Directory lipgloss.Style 105 | File lipgloss.Style 106 | DisabledFile lipgloss.Style 107 | Permission lipgloss.Style 108 | Selected lipgloss.Style 109 | DisabledSelected lipgloss.Style 110 | FileSize lipgloss.Style 111 | EmptyDirectory lipgloss.Style 112 | } 113 | 114 | // DefaultStyles defines the default styling for the file picker. 115 | func DefaultStyles() Styles { 116 | return DefaultStylesWithRenderer(lipgloss.DefaultRenderer()) 117 | } 118 | 119 | // DefaultStylesWithRenderer defines the default styling for the file picker, 120 | // with a given Lip Gloss renderer. 121 | func DefaultStylesWithRenderer(r *lipgloss.Renderer) Styles { 122 | return Styles{ 123 | DisabledCursor: r.NewStyle().Foreground(lipgloss.Color("247")), 124 | Cursor: r.NewStyle().Foreground(lipgloss.Color("212")), 125 | Symlink: r.NewStyle().Foreground(lipgloss.Color("36")), 126 | Directory: r.NewStyle().Foreground(lipgloss.Color("99")), 127 | File: r.NewStyle(), 128 | DisabledFile: r.NewStyle().Foreground(lipgloss.Color("243")), 129 | DisabledSelected: r.NewStyle().Foreground(lipgloss.Color("247")), 130 | Permission: r.NewStyle().Foreground(lipgloss.Color("244")), 131 | Selected: r.NewStyle().Foreground(lipgloss.Color("212")).Bold(true), 132 | FileSize: r.NewStyle().Foreground(lipgloss.Color("240")).Width(fileSizeWidth).Align(lipgloss.Right), 133 | EmptyDirectory: r.NewStyle().Foreground(lipgloss.Color("240")).PaddingLeft(paddingLeft).SetString("Bummer. No Files Found."), 134 | } 135 | } 136 | 137 | // Model represents a file picker. 138 | type Model struct { 139 | id int 140 | 141 | // Path is the path which the user has selected with the file picker. 142 | Path string 143 | Current string 144 | IsCurrentAFile bool 145 | 146 | // CurrentDirectory is the directory that the user is currently in. 147 | CurrentDirectory string 148 | 149 | // AllowedTypes specifies which file types the user may select. 150 | // If empty the user may select any file. 151 | AllowedTypes []string 152 | 153 | KeyMap KeyMap 154 | files []os.DirEntry 155 | ShowHidden bool 156 | DirAllowed bool 157 | FileAllowed bool 158 | 159 | FileSelected string 160 | selected int 161 | selectedStack stack 162 | 163 | min int 164 | max int 165 | maxStack stack 166 | minStack stack 167 | 168 | Height int 169 | Width int 170 | AutoHeight bool 171 | 172 | cursor string 173 | Styles Styles 174 | } 175 | 176 | type stack struct { 177 | Push func(int) 178 | Pop func() int 179 | Length func() int 180 | } 181 | 182 | func newStack() stack { 183 | slice := make([]int, 0) 184 | return stack{ 185 | Push: func(i int) { 186 | slice = append(slice, i) 187 | }, 188 | Pop: func() int { 189 | res := slice[len(slice)-1] 190 | slice = slice[:len(slice)-1] 191 | return res 192 | }, 193 | Length: func() int { 194 | return len(slice) 195 | }, 196 | } 197 | } 198 | 199 | func (m *Model) pushView(selected, minItem, maxItem int) { 200 | m.selectedStack.Push(selected) 201 | m.minStack.Push(minItem) 202 | m.maxStack.Push(maxItem) 203 | } 204 | 205 | func (m *Model) popView() (int, int, int) { 206 | return m.selectedStack.Pop(), m.minStack.Pop(), m.maxStack.Pop() 207 | } 208 | 209 | func (m Model) readDir(path string, showHidden bool) tea.Cmd { 210 | return func() tea.Msg { 211 | dirEntries, err := os.ReadDir(path) 212 | if err != nil { 213 | return errorMsg{err} 214 | } 215 | 216 | sort.Slice(dirEntries, func(i, j int) bool { 217 | if dirEntries[i].IsDir() == dirEntries[j].IsDir() { 218 | return dirEntries[i].Name() < dirEntries[j].Name() 219 | } 220 | return dirEntries[i].IsDir() 221 | }) 222 | 223 | if showHidden { 224 | return readDirMsg{id: m.id, entries: dirEntries} 225 | } 226 | 227 | var sanitizedDirEntries []os.DirEntry 228 | for _, dirEntry := range dirEntries { 229 | isHidden, _ := IsHidden(dirEntry.Name()) 230 | if isHidden { 231 | continue 232 | } 233 | sanitizedDirEntries = append(sanitizedDirEntries, dirEntry) 234 | } 235 | return readDirMsg{id: m.id, entries: sanitizedDirEntries} 236 | } 237 | } 238 | 239 | // Init initializes the file picker model. 240 | func (m Model) Init() tea.Cmd { 241 | return m.readDir(m.CurrentDirectory, m.ShowHidden) 242 | } 243 | 244 | func (m *Model) setCurrent() { 245 | f := m.files[m.selected] 246 | m.IsCurrentAFile = !f.IsDir() 247 | m.Current = filepath.Join(m.CurrentDirectory, f.Name()) 248 | } 249 | 250 | // Update handles user interactions within the file picker model. 251 | func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { 252 | switch msg := msg.(type) { 253 | case readDirMsg: 254 | if msg.id != m.id { 255 | break 256 | } 257 | m.files = msg.entries 258 | m.max = maxInt(m.max, m.Height-1) 259 | 260 | m.setCurrent() 261 | 262 | case tea.WindowSizeMsg: 263 | if m.AutoHeight { 264 | m.Height = msg.Height - marginBottom 265 | } 266 | m.max = m.Height - 1 267 | case tea.KeyMsg: 268 | switch { 269 | case key.Matches(msg, m.KeyMap.GoToTop): 270 | m.selected = 0 271 | m.min = 0 272 | m.max = m.Height - 1 273 | 274 | m.setCurrent() 275 | case key.Matches(msg, m.KeyMap.GoToLast): 276 | m.selected = len(m.files) - 1 277 | m.min = len(m.files) - m.Height 278 | m.max = len(m.files) - 1 279 | 280 | m.setCurrent() 281 | case key.Matches(msg, m.KeyMap.Down): 282 | m.selected++ 283 | if m.selected >= len(m.files) { 284 | m.selected = len(m.files) - 1 285 | } 286 | if m.selected > m.max { 287 | m.min++ 288 | m.max++ 289 | } 290 | m.setCurrent() 291 | case key.Matches(msg, m.KeyMap.Up): 292 | m.selected-- 293 | if m.selected < 0 { 294 | m.selected = 0 295 | } 296 | if m.selected < m.min { 297 | m.min-- 298 | m.max-- 299 | } 300 | m.setCurrent() 301 | case key.Matches(msg, m.KeyMap.PageDown): 302 | m.selected += m.Height 303 | if m.selected >= len(m.files) { 304 | m.selected = len(m.files) - 1 305 | } 306 | m.min += m.Height 307 | m.max += m.Height 308 | 309 | if m.max >= len(m.files) { 310 | m.max = len(m.files) - 1 311 | m.min = m.max - m.Height 312 | } 313 | m.setCurrent() 314 | case key.Matches(msg, m.KeyMap.PageUp): 315 | m.selected -= m.Height 316 | if m.selected < 0 { 317 | m.selected = 0 318 | } 319 | m.min -= m.Height 320 | m.max -= m.Height 321 | 322 | if m.min < 0 { 323 | m.min = 0 324 | m.max = m.min + m.Height 325 | } 326 | m.setCurrent() 327 | case key.Matches(msg, m.KeyMap.Back): 328 | m.CurrentDirectory = filepath.Dir(m.CurrentDirectory) 329 | if m.selectedStack.Length() > 0 { 330 | m.selected, m.min, m.max = m.popView() 331 | } else { 332 | m.selected = 0 333 | m.min = 0 334 | m.max = m.Height - 1 335 | } 336 | return m, m.readDir(m.CurrentDirectory, m.ShowHidden) 337 | case key.Matches(msg, m.KeyMap.Open): 338 | if len(m.files) == 0 { 339 | break 340 | } 341 | 342 | f := m.files[m.selected] 343 | info, err := f.Info() 344 | if err != nil { 345 | break 346 | } 347 | isSymlink := info.Mode()&os.ModeSymlink != 0 348 | isDir := f.IsDir() 349 | 350 | if isSymlink { 351 | symlinkPath, _ := filepath.EvalSymlinks(filepath.Join(m.CurrentDirectory, f.Name())) 352 | info, err := os.Stat(symlinkPath) 353 | if err != nil { 354 | break 355 | } 356 | if info.IsDir() { 357 | isDir = true 358 | } 359 | } 360 | 361 | if (!isDir && m.FileAllowed) || (isDir && m.DirAllowed) { 362 | // Select the current path as the selection 363 | m.Path = filepath.Join(m.CurrentDirectory, f.Name()) 364 | } 365 | 366 | if !isDir { 367 | break 368 | } 369 | 370 | m.CurrentDirectory = filepath.Join(m.CurrentDirectory, f.Name()) 371 | m.pushView(m.selected, m.min, m.max) 372 | m.selected = 0 373 | m.min = 0 374 | m.max = m.Height - 1 375 | return m, m.readDir(m.CurrentDirectory, m.ShowHidden) 376 | } 377 | } 378 | return m, nil 379 | } 380 | 381 | // View returns the view of the file picker. 382 | func (m Model) View() string { 383 | if len(m.files) == 0 { 384 | return m.Styles.EmptyDirectory.Height(m.Height).MaxHeight(m.Height).String() 385 | } 386 | var s strings.Builder 387 | 388 | for i, f := range m.files { 389 | if i < m.min || i > m.max { 390 | continue 391 | } 392 | 393 | var symlinkPath string 394 | info, _ := f.Info() 395 | isSymlink := info.Mode()&os.ModeSymlink != 0 396 | name := f.Name() 397 | 398 | if isSymlink { 399 | symlinkPath, _ = filepath.EvalSymlinks(filepath.Join(m.CurrentDirectory, name)) 400 | } 401 | 402 | disabled := !m.CanSelect(name) && !f.IsDir() 403 | 404 | if m.selected == i { 405 | selected := "" 406 | selected += " " + name 407 | if isSymlink { 408 | selected += " → " + symlinkPath 409 | } 410 | if disabled { 411 | s.WriteString(m.Styles.DisabledSelected.Render(trim(m.cursor+selected, m.Width))) 412 | } else { 413 | s.WriteString(m.Styles.Selected.Render(trim(m.cursor+selected, m.Width))) 414 | } 415 | s.WriteRune('\n') 416 | continue 417 | } 418 | 419 | style := m.Styles.File 420 | if f.IsDir() { 421 | style = m.Styles.Directory 422 | } else if isSymlink { 423 | style = m.Styles.Symlink 424 | } else if disabled { 425 | style = m.Styles.DisabledFile 426 | } 427 | 428 | fileName := name 429 | if isSymlink { 430 | fileName += " → " + symlinkPath 431 | } 432 | s.WriteString(style.Render(trim(" "+fileName, m.Width))) 433 | s.WriteRune('\n') 434 | } 435 | 436 | for i := lipgloss.Height(s.String()); i <= m.Height; i++ { 437 | s.WriteRune('\n') 438 | } 439 | 440 | return s.String() 441 | } 442 | 443 | // DidSelectDisabledFile returns whether a user tried to select a disabled file 444 | // (on this msg). This is necessary only if you would like to warn the user that 445 | // they tried to select a disabled file. 446 | func (m Model) DidSelectDisabledFile(msg tea.Msg) (bool, string) { 447 | didSelect, path := m.didSelectFile(msg) 448 | if didSelect && !m.CanSelect(path) { 449 | return true, path 450 | } 451 | return false, "" 452 | } 453 | 454 | func (m Model) didSelectFile(msg tea.Msg) (bool, string) { 455 | if len(m.files) == 0 { 456 | return false, "" 457 | } 458 | switch msg.(type) { 459 | case tea.KeyMsg: 460 | // If the msg does not match the Select keymap then this could not have been a selection. 461 | 462 | // The key press was a selection, let's confirm whether the current file could 463 | // be selected or used for navigating deeper into the stack. 464 | f := m.files[m.selected] 465 | info, err := f.Info() 466 | if err != nil { 467 | return false, "" 468 | } 469 | isSymlink := info.Mode()&os.ModeSymlink != 0 470 | isDir := f.IsDir() 471 | 472 | if isSymlink { 473 | symlinkPath, _ := filepath.EvalSymlinks(filepath.Join(m.CurrentDirectory, f.Name())) 474 | info, err := os.Stat(symlinkPath) 475 | if err != nil { 476 | break 477 | } 478 | if info.IsDir() { 479 | isDir = true 480 | } 481 | } 482 | 483 | if (!isDir && m.FileAllowed) || (isDir && m.DirAllowed) && m.Path != "" { 484 | return true, m.Path 485 | } 486 | 487 | // If the msg was not a KeyMsg, then the file could not have been selected this iteration. 488 | // Only a KeyMsg can select a file. 489 | default: 490 | return false, "" 491 | } 492 | return false, "" 493 | } 494 | 495 | func (m Model) CanSelect(file string) bool { 496 | if len(m.AllowedTypes) <= 0 { 497 | return true 498 | } 499 | 500 | for _, ext := range m.AllowedTypes { 501 | if strings.HasSuffix(file, ext) { 502 | return true 503 | } 504 | } 505 | return false 506 | } 507 | 508 | func maxInt(a, b int) int { 509 | if a > b { 510 | return a 511 | } 512 | return b 513 | } 514 | 515 | func trim(s string, length int) string { 516 | if len(s) >= length { 517 | if length > 3 { 518 | return s[:length-3] + "..." 519 | } 520 | return s[:length] 521 | } 522 | return s 523 | } 524 | --------------------------------------------------------------------------------