commit 624a723b1648ad4c4049659aa687d7c2811e2edf Author: Hadi <112569860+anotherhadi@users.noreply.github.com> Date: Thu Aug 27 00:05:08 2026 +0200 init Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com> diff --git a/.github/.goreleaser.yaml b/.github/.goreleaser.yaml new file mode 100644 index 0000000..a1a5a07 --- /dev/null +++ b/.github/.goreleaser.yaml @@ -0,0 +1,32 @@ +version: 2 + +before: + hooks: + - go mod tidy + +builds: + - binary: default-creds-tui + main: ./cmd/default-creds-tui + goos: + - linux + goarch: + - amd64 + - arm64 + ldflags: + - -s -w -X main.version={{.Version}} + +archives: + - formats: + - tar.gz + name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" + +checksum: + name_template: checksums.txt + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^ci:" diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..7b8ae86 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,10 @@ +# Contributing + +Everybody is invited and welcome to contribute. There is a lot to do... Check the issues! + +The process is straight-forward. + +- Read [How to get faster PR reviews](https://github.com/kubernetes/community/blob/master/contributors/guide/pull-requests.md#best-practices-for-faster-reviews) by Kubernetes (but skip step 0 and 1) +- Fork this git repository +- Write your changes (bug fixes, new features, ...). +- Create a Pull Request against the main branch. diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..b1c5749 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +ko_fi: anotherhadi diff --git a/.github/assets/demo.tape b/.github/assets/demo.tape new file mode 100644 index 0000000..e69de29 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bf265c5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,28 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - uses: goreleaser/goreleaser-action@v6 + with: + version: "~> v2" + args: release --clean --config .github/.goreleaser.yaml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7ce7478 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Hadi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c802a3 --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +``` + ____ __ _ _ ____ _ _____ _ _ ___ +| _ \ ___ / _| __ _ _ _| | |_ / ___|_ __ ___ __| |___ |_ _| | | |_ _| +| | | |/ _ \ |_ / _` | | | | | __| | | | '__/ _ \/ _` / __| | | | | | || | +| |_| | __/ _| (_| | |_| | | |_ | |___| | | __/ (_| \__ \ | | | |_| || | +|____/ \___|_| \__,_|\__,_|_|\__| \____|_| \___|\__,_|___/ |_| \___/|___| +``` + +![GitHub Stars](https://www.shieldcn.dev/github/stars/anotherhadi/default-creds-tui.svg?variant=outline&theme=violet) +![Release](https://www.shieldcn.dev/github/release/anotherhadi/default-creds-tui.svg?variant=outline&theme=violet) +![CI](https://www.shieldcn.dev/github/ci/anotherhadi/default-creds-tui.svg?variant=outline&theme=violet) +[![Ko-fi](https://www.shieldcn.dev/badge/Ko--fi-sponsor-FF5E5B.svg?logo=kofi&variant=secondary&theme=violet)](https://ko-fi.com/anotherhadi) + +# Default Creds TUI + +> Find default credentials for many devices and software, without leaving the terminal + +Default-creds-tui demo + +Built with [bubbletea](https://github.com/charmbracelet/bubbletea) & Golang! + +Colors and styles can be customized using [ilovetui](https://github.com/anotherhadi/ilovetui), which applies theme changes across all compatible TUI applications at once. + +## Installation + +
+Go install + +```sh +go install github.com/anotherhadi/default-creds-tui@latest +``` + +
+ +
+Build from source + +```sh +git clone https://github.com/anotherhadi/default-creds-tui +cd default-creds-tui +go build -o default-creds-tui ./cmd/default-creds-tui +``` + +
+ +
+NUR (Nix/NixOS) + +Available via [NUR](https://github.com/nix-community/NUR), under the `anotherhadi` repo: + +```nix +# configuration.nix / home.nix +environment.systemPackages = [ nur.repos.anotherhadi.default-creds-tui ]; +``` + +
+ +## Usage + +``` +default-creds-tui [flags] + +Flags: + --add-default-config copy the default config file to the config path and exit + -c, --config string path to config file (default "~/.config/default-creds-tui/config.yaml") + -m, --mode string credential source: auto, online or local (overrides the config file) + --no-colors disable colored output (used with --search) + --print pressing enter prints the selected result to stdout and exits, instead of copying the password + -s, --search string search from the CLI and print results directly, without launching the TUI + -v, --version version for default-creds-tui +``` + +Credentials come from one of three sources, controlled by `--mode` (or `app.mode` in the config file): + +- `local`: reads YAML files from `~/.cache/default-creds/data` (e.g. a `git clone` of + [default-creds](https://github.com/anotherhadi/default-creds) into `~/.cache/default-creds`). +- `online`: queries the [default-creds.hadi.icu](https://default-creds.hadi.icu) API. +- `auto` (default): uses the local data directory if present, otherwise falls back to the API. + +Run `default-creds-tui --add-default-config` to write out an editable config file with all +options and keybindings. + +### CLI mode + +Pass `--search` to print results straight to the terminal instead of launching the TUI, handy +for scripting or piping: + +```sh +default-creds-tui --search "d-link" +default-creds-tui --search "cisco" --no-colors +``` + +`--print` (or `app.print_on_enter: true` in the config) turns enter itself into a picker: it prints +the selected result to stdout and exits with status 0, instead of copying the password. Regardless +of that setting, `P` always prints the selected result and quits. + +--- + +
+ github | + gitlab (mirror) | + gitea (mirror) +
diff --git a/cmd/default-creds-tui/app.go b/cmd/default-creds-tui/app.go new file mode 100644 index 0000000..9234fb5 --- /dev/null +++ b/cmd/default-creds-tui/app.go @@ -0,0 +1,93 @@ +package main + +import ( + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/anotherhadi/ilovetui/app" + "github.com/anotherhadi/ilovetui/minsize" + "github.com/anotherhadi/ilovetui/modal" + "github.com/anotherhadi/ilovetui/notification" + zone "github.com/lrstanley/bubblezone/v2" + + "github.com/anotherhadi/default-creds-tui/internal/creds" + "github.com/anotherhadi/default-creds-tui/ui" +) + +const ( + minWidth = 25 + minHeight = 15 +) + +type appModel struct { + core ui.Model + modals modal.Model + notif notification.Model + minsize minsize.Model + width, height int +} + +func newApp(mode creds.Mode, apiURL string, printOnEnter bool) appModel { + zone.NewGlobal() + return appModel{ + core: ui.New(mode, apiURL, printOnEnter), + modals: modal.New(), + notif: notification.New(), + minsize: minsize.New(minWidth, minHeight), + } +} + +func (a appModel) Init() tea.Cmd { + return tea.Batch(a.core.Init(), a.modals.Init(), a.notif.Init()) +} + +func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if _, ok := msg.(app.QuitMsg); ok { + return a, tea.Quit + } + if sizeMsg, ok := msg.(tea.WindowSizeMsg); ok { + a.width, a.height = sizeMsg.Width, sizeMsg.Height + } + + a.core.ModalOpen = a.modals.Open() + var coreCmd tea.Cmd + a.core, coreCmd = a.core.Update(msg) + + var modalCmd, notifCmd tea.Cmd + a.modals, modalCmd = a.modals.Update(msg) + a.notif, notifCmd = a.notif.Update(msg) + + return a, tea.Batch(coreCmd, modalCmd, notifCmd) +} + +func (a appModel) View() tea.View { + if !a.minsize.Fits(a.width, a.height) { + return tea.View{ + Content: a.minsize.View(a.width, a.height), + AltScreen: true, + WindowTitle: "Default Creds TUI", + } + } + + bg := lipgloss.Place(a.width, a.height, lipgloss.Left, lipgloss.Top, padLines(a.core.View(), a.width)) + bg = a.modals.Render(bg) + bg = a.notif.Render(bg) + bg = zone.Scan(bg) + return tea.View{ + Content: bg, + AltScreen: true, + WindowTitle: "Default Creds TUI", + MouseMode: tea.MouseModeCellMotion, + } +} + +func padLines(s string, width int) string { + lines := strings.Split(s, "\n") + for i, l := range lines { + if w := lipgloss.Width(l); w < width { + lines[i] = l + strings.Repeat(" ", width-w) + } + } + return strings.Join(lines, "\n") +} diff --git a/cmd/default-creds-tui/cli.go b/cmd/default-creds-tui/cli.go new file mode 100644 index 0000000..4e8ff48 --- /dev/null +++ b/cmd/default-creds-tui/cli.go @@ -0,0 +1,89 @@ +package main + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + + "github.com/anotherhadi/ilovetui/style" + + "github.com/anotherhadi/default-creds-tui/internal/creds" +) + +func runSearchCLI(mode creds.Mode, apiURL, query string, noColors bool) error { + var entries []creds.Entry + switch mode { + case creds.Local: + all, err := creds.LoadLocal(creds.LocalDataDir()) + if err != nil { + return fmt.Errorf("local data: %w", err) + } + entries = creds.Filter(all, query) + default: + found, _, err := creds.FetchOnline(apiURL, query, 50) + if err != nil { + return fmt.Errorf("online search: %w", err) + } + entries = found + } + + if len(entries) == 0 { + fmt.Printf("No results for %q.\n", query) + return nil + } + + blocks := make([]string, len(entries)) + for i, e := range entries { + blocks[i] = renderEntryCLI(e, noColors) + } + fmt.Println(strings.Join(blocks, "\n\n")) + return nil +} + +func renderEntryCLI(e creds.Entry, noColors bool) string { + manufacturerStyle := lipgloss.NewStyle().Bold(true) + nameStyle := lipgloss.NewStyle().Bold(true) + versionStyle := lipgloss.NewStyle() + credStyle := lipgloss.NewStyle() + commentStyle := lipgloss.NewStyle().Italic(true) + tagStyle := lipgloss.NewStyle() + + if !noColors { + manufacturerStyle = manufacturerStyle.Foreground(style.S.Subtle) + nameStyle = nameStyle.Foreground(style.S.Primary) + versionStyle = versionStyle.Foreground(style.S.Subtle) + credStyle = credStyle.Foreground(style.S.Success) + commentStyle = commentStyle.Foreground(style.S.Muted) + tagStyle = tagStyle.Foreground(style.S.Subtle) + } + + titleParts := []string{manufacturerStyle.Render(strings.ToUpper(e.Manufacturer))} + if e.Version != "" && e.Version != "all" { + titleParts = append(titleParts, versionStyle.Render("v"+e.Version)) + } + + lines := []string{ + strings.Join(titleParts, " · "), + nameStyle.Render(e.Name), + } + + var credLines []string + if e.User != "" { + credLines = append(credLines, credStyle.Render("user: "+e.User)) + } + if e.Pass != "" { + credLines = append(credLines, credStyle.Render("pass: "+e.Pass)) + } + if len(credLines) > 0 { + lines = append(lines, strings.Join(credLines, " ")) + } + + if e.Comment != "" { + lines = append(lines, commentStyle.Render(e.Comment)) + } else if len(e.Tags) > 0 { + lines = append(lines, tagStyle.Render("#"+strings.Join(e.Tags, " #"))) + } + + return strings.Join(lines, "\n") +} diff --git a/cmd/default-creds-tui/main.go b/cmd/default-creds-tui/main.go new file mode 100644 index 0000000..3ca2ff6 --- /dev/null +++ b/cmd/default-creds-tui/main.go @@ -0,0 +1,114 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "runtime/debug" + + tea "charm.land/bubbletea/v2" + zone "github.com/lrstanley/bubblezone/v2" + + "github.com/anotherhadi/ilovetui/style" + "github.com/spf13/cobra" + + "github.com/anotherhadi/default-creds-tui/internal/config" + "github.com/anotherhadi/default-creds-tui/internal/creds" +) + +var version = "dev" + +func main() { + var ( + flagConfig string + flagAddDefaultConfig bool + flagMode string + flagSearch string + flagNoColors bool + flagPrint bool + ) + + rootCmd := &cobra.Command{ + Use: "default-creds-tui", + Short: "Search factory-set default credentials, without leaving the terminal.", + Version: version, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + home, _ := os.UserHomeDir() + cfgPath := filepath.Join(home, ".config", "default-creds-tui", "config.yaml") + if flagConfig != "" { + cfgPath = flagConfig + } + + if flagAddDefaultConfig { + if err := config.WriteDefaultConfig(cfgPath); err != nil { + return fmt.Errorf("add-default-config: %w", err) + } + fmt.Printf("default config written to %s\n", cfgPath) + return nil + } + + if err := config.Load(cfgPath); err != nil { + return fmt.Errorf("config: %w", err) + } + config.Global.Version = version + + modeStr := config.Global.App.Mode + if flagMode != "" { + modeStr = flagMode + } + mode, err := creds.ParseMode(modeStr) + if err != nil { + return fmt.Errorf("mode: %w", err) + } + mode = creds.Resolve(mode) + + style.Init() + + if flagSearch != "" { + return runSearchCLI(mode, config.Global.App.ApiURL, flagSearch, flagNoColors) + } + + zone.NewGlobal() + + printOnEnter := config.Global.App.PrintOnEnter || flagPrint + + p := tea.NewProgram(newApp(mode, config.Global.App.ApiURL, printOnEnter)) + finalModel, err := p.Run() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + if am, ok := finalModel.(appModel); ok { + if e, ok := am.core.PrintEntry(); ok { + fmt.Println(renderEntryCLI(e, flagNoColors)) + } + } + + return nil + }, + } + + rootCmd.Flags().StringVarP(&flagConfig, "config", "c", "", "path to config file") + rootCmd.Flags().BoolVar(&flagAddDefaultConfig, "add-default-config", false, "copy the default config file to the config path and exit") + rootCmd.Flags().StringVarP(&flagMode, "mode", "m", "", "credential source: auto, online or local") + rootCmd.Flags().StringVarP(&flagSearch, "search", "s", "", "search from the CLI and print results directly, without launching the TUI") + rootCmd.Flags().BoolVar(&flagNoColors, "no-colors", false, "disable colored output (used with --search)") + rootCmd.Flags().BoolVar(&flagPrint, "print", false, "pressing enter prints the selected result to stdout and exits, instead of copying the password") + + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func init() { + if version != "dev" { + return + } + if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" { + version = info.Main.Version + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..71ca06e --- /dev/null +++ b/go.mod @@ -0,0 +1,58 @@ +module github.com/anotherhadi/default-creds-tui + +go 1.26.2 + +require ( + charm.land/bubbles/v2 v2.2.0 + charm.land/bubbletea/v2 v2.0.9 + charm.land/lipgloss/v2 v2.0.6 + github.com/anotherhadi/ilovetui v0.2.4 + github.com/charmbracelet/x/ansi v0.11.8 + github.com/go-viper/mapstructure/v2 v2.4.0 + github.com/lrstanley/bubblezone/v2 v2.0.0 + github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + charm.land/glamour/v2 v2.0.1 // indirect + github.com/alecthomas/chroma/v2 v2.27.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260812204455-68fa937c71be // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20260823001701-96af6d2cb5f6 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/dlclark/regexp2/v2 v2.7.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.1 // indirect + github.com/mattn/go-runewidth v0.0.28 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sahilm/fuzzy v0.1.3 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/xo/terminfo v1.0.0 // indirect + github.com/yuin/goldmark v1.8.5 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f9b7ab0 --- /dev/null +++ b/go.sum @@ -0,0 +1,136 @@ +charm.land/bubbles/v2 v2.2.0 h1:9GEMewcejrNtVIxZ9Y2wSsWJamsWNsXa8MpMLnH4yI4= +charm.land/bubbles/v2 v2.2.0/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= +charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8= +charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= +charm.land/glamour/v2 v2.0.1 h1:xl+r00A4aJWU0z8fgwKd9fQQ4rsphqGUzuEiXZP5n+c= +charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k= +charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= +charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs= +github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/anotherhadi/ilovetui v0.2.4 h1:8D2jp/37vOJBRI3DKIHHt9PFsnIyfRxs3R58HuCh0tA= +github.com/anotherhadi/ilovetui v0.2.4/go.mod h1:gAn69amLM3z0x8PV4obyf5bJntL/PTZusEuhu/xdFIY= +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-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/ultraviolet v0.0.0-20260812204455-68fa937c71be h1:qEvkJy1sjJXP+yf8IH2o13NV+Rh4nIUHjaLNJ+pWxpI= +github.com/charmbracelet/ultraviolet v0.0.0-20260812204455-68fa937c71be/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= +github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= +github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/slice v0.0.0-20260823001701-96af6d2cb5f6 h1:je2x9scjVLOqRwO5JbFyRzyG5aCLg64DSOvd463cm04= +github.com/charmbracelet/x/exp/slice v0.0.0-20260823001701-96af6d2cb5f6/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2/v2 v2.7.1 h1:yqDtwI1ptXXvEUNpYTk2lad4jLtAcKqkzepn4savSk4= +github.com/dlclark/regexp2/v2 v2.7.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lrstanley/bubblezone/v2 v2.0.0 h1:pMb9fHKs0slJF6OrzQ2hEgWusqyl9VU/S0UZ5hyh7ZA= +github.com/lrstanley/bubblezone/v2 v2.0.0/go.mod h1:yV/QTjcm4Zu5cqvGvdHi7xVUfnB36w/SafOuDp57dgY= +github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= +github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.28 h1:rPyg2ybwEKPebvpzVWe1gKBkH8EQFkxO4Y0hjBeLaBU= +github.com/mattn/go-runewidth v0.0.28/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU= +github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY= +github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= +github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..4035005 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,23 @@ +package config + +import ( + _ "embed" +) + +//go:embed default_config.yaml +var defaultConfig []byte + +type Config struct { + Version string `mapstructure:"-"` + + App struct { + Mode string `mapstructure:"mode"` + ApiURL string `mapstructure:"api_url"` + FocusSearchOnLaunch bool `mapstructure:"focus_search_on_launch"` + PrintOnEnter bool `mapstructure:"print_on_enter"` + } `mapstructure:"app"` + + Keybindings Keybindings `mapstructure:"keybindings"` +} + +var Global *Config diff --git a/internal/config/default_config.yaml b/internal/config/default_config.yaml new file mode 100644 index 0000000..79a6d04 --- /dev/null +++ b/internal/config/default_config.yaml @@ -0,0 +1,36 @@ +app: + # Where credentials come from: "auto", "online" or "local". + # - auto: use the local data directory if present, otherwise the API + # - online: always query the default-creds API + # - local: always read from the local data directory + mode: "auto" + + # Base URL of the default-creds API (used in "online"/"auto" mode). + api_url: "https://default-creds.hadi.icu" + + # Focus the search bar when the app starts. + focus_search_on_launch: true + + # When true, pressing enter on a result prints it to stdout and exits + print_on_enter: false + +keybindings: + global: + quit: "ctrl+c,q" + escape: "esc" + help: "?" + up: "up,k" + down: "down,j" + goto_top: "home,g" + goto_bottom: "end,G" + + search: + focus: "/" + clear: "ctrl+u" + + results: + copy_user: "u" + copy_pass: "p" + enter: "enter" + print_and_quit: "P" + refresh: "ctrl+r" diff --git a/internal/config/func_config.go b/internal/config/func_config.go new file mode 100644 index 0000000..1fb73a0 --- /dev/null +++ b/internal/config/func_config.go @@ -0,0 +1,78 @@ +package config + +import ( + _ "embed" + "errors" + "fmt" + "maps" + "os" + "path/filepath" + "reflect" + + "github.com/go-viper/mapstructure/v2" + "github.com/spf13/viper" + "gopkg.in/yaml.v3" +) + +func DefaultPath() string { + dir, err := os.UserConfigDir() + if err != nil { + dir = "." + } + return filepath.Join(dir, "default-creds-tui", "config.yaml") +} + +func Load(path string) error { + var defaults map[string]any + if err := yaml.Unmarshal(defaultConfig, &defaults); err != nil { + return fmt.Errorf("default config: %w", err) + } + for k, v := range flatten("", defaults) { + viper.SetDefault(k, v) + } + + viper.SetConfigType("yaml") + viper.SetConfigFile(path) + if err := viper.ReadInConfig(); err != nil { + if !errors.Is(err, os.ErrNotExist) { + return err + } + } + + Global = &Config{} + hook := viper.DecodeHook(mapstructure.ComposeDecodeHookFunc( + stringToKeyBindingHook, + mapstructure.StringToTimeDurationHookFunc(), + )) + if err := viper.Unmarshal(Global, hook); err != nil { + return err + } + fillHelp(reflect.ValueOf(&Global.Keybindings)) + return nil +} + +func WriteDefaultConfig(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return fmt.Errorf("create config dir: %w", err) + } + if err := os.WriteFile(path, defaultConfig, 0o600); err != nil { + return fmt.Errorf("write config: %w", err) + } + return nil +} + +func flatten(prefix string, m map[string]any) map[string]any { + out := make(map[string]any) + for k, v := range m { + key := k + if prefix != "" { + key = prefix + "." + k + } + if nested, ok := v.(map[string]any); ok { + maps.Copy(out, flatten(key, nested)) + } else { + out[key] = v + } + } + return out +} diff --git a/internal/config/func_keybindings.go b/internal/config/func_keybindings.go new file mode 100644 index 0000000..0a89471 --- /dev/null +++ b/internal/config/func_keybindings.go @@ -0,0 +1,113 @@ +package config + +import ( + "fmt" + "reflect" + "slices" + "strings" + + "charm.land/bubbles/v2/key" +) + +func ShortHelp(keymap any) []key.Binding { + return bindingsWithHelp(keymap, "short") +} + +func pageFullHelp(keymap any) []key.Binding { + return bindingsWithHelp(keymap, "short", "full") +} + +func bindingsWithHelp(keymap any, levels ...string) []key.Binding { + v := reflect.ValueOf(keymap) + if v.Kind() == reflect.Pointer { + v = v.Elem() + } + t := v.Type() + + var out []key.Binding + for i := range v.NumField() { + field := t.Field(i) + if field.Type != bindingType { + continue + } + if slices.Contains(levels, field.Tag.Get("help")) { + out = append(out, v.Field(i).Interface().(key.Binding)) + } + } + return out +} + +type HelpKeyMap struct { + pages []any +} + +func Help(pages ...any) HelpKeyMap { + return HelpKeyMap{pages: pages} +} + +func (h HelpKeyMap) ShortHelp() []key.Binding { + var out []key.Binding + for _, p := range h.pages { + out = append(out, ShortHelp(p)...) + } + return out +} + +func (h HelpKeyMap) FullHelp() [][]key.Binding { + cols := make([][]key.Binding, 0, len(h.pages)) + for _, p := range h.pages { + if col := pageFullHelp(p); len(col) > 0 { + cols = append(cols, col) + } + } + return cols +} + +var bindingType = reflect.TypeFor[key.Binding]() + +var validHelpLevels = map[string]bool{"none": true, "short": true, "full": true} + +func stringToKeyBindingHook(from reflect.Type, to reflect.Type, data any) (any, error) { + if to != bindingType || from.Kind() != reflect.String { + return data, nil + } + + var keys []string + for part := range strings.SplitSeq(data.(string), ",") { + if k := strings.TrimSpace(part); k != "" { + keys = append(keys, k) + } + } + return key.NewBinding(key.WithKeys(keys...)), nil +} + +func fillHelp(v reflect.Value) { + if v.Kind() == reflect.Pointer { + v = v.Elem() + } + if v.Kind() != reflect.Struct { + return + } + + t := v.Type() + for i := range t.NumField() { + field := t.Field(i) + fv := v.Field(i) + + if field.Type == bindingType { + b := fv.Addr().Interface().(*key.Binding) + b.SetHelp(strings.Join(b.Keys(), ","), field.Tag.Get("desc")) + + if level := field.Tag.Get("help"); !validHelpLevels[level] { + panic(fmt.Sprintf( + "config: %s.%s has invalid `help` tag %q, want \"none\", \"short\" or \"full\"", + t.Name(), field.Name, level, + )) + } + continue + } + if field.Type.Kind() == reflect.Struct { + fillHelp(fv) + } + } +} diff --git a/internal/config/keybindings.go b/internal/config/keybindings.go new file mode 100644 index 0000000..ab9d489 --- /dev/null +++ b/internal/config/keybindings.go @@ -0,0 +1,34 @@ +package config + +import "charm.land/bubbles/v2/key" + +type GlobalKeys struct { + Quit key.Binding `mapstructure:"quit" desc:"quit" help:"short"` + Escape key.Binding `mapstructure:"escape" desc:"back" help:"short"` + Help key.Binding `mapstructure:"help" desc:"help" help:"none"` + + Up key.Binding `mapstructure:"up" desc:"up" help:"full"` + Down key.Binding `mapstructure:"down" desc:"down" help:"full"` + + GotoTop key.Binding `mapstructure:"goto_top" desc:"go to top" help:"full"` + GotoBottom key.Binding `mapstructure:"goto_bottom" desc:"go to bottom" help:"full"` +} + +type SearchKeys struct { + Focus key.Binding `mapstructure:"focus" desc:"search" help:"short"` + Clear key.Binding `mapstructure:"clear" desc:"clear search" help:"full"` +} + +type ResultsKeys struct { + CopyUser key.Binding `mapstructure:"copy_user" desc:"copy user" help:"short"` + CopyPass key.Binding `mapstructure:"copy_pass" desc:"copy pass" help:"short"` + Enter key.Binding `mapstructure:"enter" desc:"select" help:"short"` + PrintAndQuit key.Binding `mapstructure:"print_and_quit" desc:"print and quit" help:"full"` + Refresh key.Binding `mapstructure:"refresh" desc:"refresh" help:"full"` +} + +type Keybindings struct { + Global GlobalKeys `mapstructure:"global"` + Search SearchKeys `mapstructure:"search"` + Results ResultsKeys `mapstructure:"results"` +} diff --git a/internal/creds/entry.go b/internal/creds/entry.go new file mode 100644 index 0000000..6efdc94 --- /dev/null +++ b/internal/creds/entry.go @@ -0,0 +1,41 @@ +package creds + +import ( + "fmt" + "strings" +) + +type Entry struct { + Manufacturer string + Name string + Icon string + Tags []string + Version string + User string + Pass string + Comment string +} + +func (e Entry) Title() string { return e.Name } +func (e Entry) Description() string { return fmt.Sprintf("%s %s", e.Manufacturer, e.Version) } +func (e Entry) FilterValue() string { return e.searchString() } + +func (e Entry) searchString() string { + return strings.ToLower(strings.Join([]string{ + e.Manufacturer, e.Name, e.Comment, strings.Join(e.Tags, " "), + }, " ")) +} + +func (e Entry) matches(tokens []string) bool { + s := e.searchString() + for _, t := range tokens { + if !strings.Contains(s, t) { + return false + } + } + return true +} + +func queryTokens(query string) []string { + return strings.Fields(strings.ToLower(strings.TrimSpace(query))) +} diff --git a/internal/creds/local.go b/internal/creds/local.go new file mode 100644 index 0000000..85caef8 --- /dev/null +++ b/internal/creds/local.go @@ -0,0 +1,104 @@ +package creds + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +type manufacturerFile struct { + Name string `yaml:"name"` + Icon string `yaml:"icon"` + Tags []string `yaml:"tags"` + Entries []struct { + Name string `yaml:"name"` + Version string `yaml:"version"` + User string `yaml:"user"` + Pass string `yaml:"pass"` + Comment string `yaml:"comment"` + } `yaml:"entries"` +} + +func LocalDataDir() string { + cacheDir, err := os.UserCacheDir() + if err != nil { + cacheDir = "." + } + return filepath.Join(cacheDir, "default-creds", "data") +} + +func HasLocalData() bool { + entries, err := os.ReadDir(LocalDataDir()) + if err != nil { + return false + } + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".yaml") { + return true + } + } + return false +} + +func LoadLocal(dir string) ([]Entry, error) { + files, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("read local data dir: %w", err) + } + + var entries []Entry + for _, f := range files { + if f.IsDir() || !strings.HasSuffix(f.Name(), ".yaml") { + continue + } + data, err := os.ReadFile(filepath.Join(dir, f.Name())) + if err != nil { + continue + } + var doc manufacturerFile + if err := yaml.Unmarshal(data, &doc); err != nil { + continue + } + if doc.Name == "" { + continue + } + for _, e := range doc.Entries { + entries = append(entries, Entry{ + Manufacturer: doc.Name, + Name: e.Name, + Icon: doc.Icon, + Tags: doc.Tags, + Version: e.Version, + User: e.User, + Pass: e.Pass, + Comment: e.Comment, + }) + } + } + return entries, nil +} + +func Filter(entries []Entry, query string) []Entry { + tokens := queryTokens(query) + if len(tokens) == 0 { + return nil + } + var out []Entry + for _, e := range entries { + if e.matches(tokens) { + out = append(out, e) + } + } + return out +} + +func LocalStats(entries []Entry) (manufacturers, credentials int) { + seen := make(map[string]bool) + for _, e := range entries { + seen[e.Manufacturer] = true + } + return len(seen), len(entries) +} diff --git a/internal/creds/mode.go b/internal/creds/mode.go new file mode 100644 index 0000000..f113994 --- /dev/null +++ b/internal/creds/mode.go @@ -0,0 +1,30 @@ +package creds + +import "fmt" + +type Mode string + +const ( + Auto Mode = "auto" + Online Mode = "online" + Local Mode = "local" +) + +func ParseMode(s string) (Mode, error) { + switch Mode(s) { + case Auto, Online, Local: + return Mode(s), nil + default: + return "", fmt.Errorf("invalid mode %q: want \"auto\", \"online\" or \"local\"", s) + } +} + +func Resolve(mode Mode) Mode { + if mode != Auto { + return mode + } + if HasLocalData() { + return Local + } + return Online +} diff --git a/internal/creds/online.go b/internal/creds/online.go new file mode 100644 index 0000000..0bcca8b --- /dev/null +++ b/internal/creds/online.go @@ -0,0 +1,91 @@ +package creds + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "time" +) + +const httpTimeout = 8 * time.Second + +type apiEntry struct { + Manufacturer string `json:"manufacturer"` + Name string `json:"name"` + Icon string `json:"icon"` + Tags []string `json:"tags"` + Version string `json:"version"` + User string `json:"user"` + Pass string `json:"pass"` + Comment string `json:"comment"` +} + +type apiSearchResponse struct { + Results []apiEntry `json:"results"` + Pagination struct { + TotalResults int `json:"totalResults"` + } `json:"pagination"` +} + +type apiStatsResponse struct { + TotalApps int `json:"totalApps"` + TotalPasswords int `json:"totalPasswords"` +} + +func FetchOnline(apiURL, query string, size int) (results []Entry, total int, err error) { + u := apiURL + "/api/search?" + url.Values{ + "q": {query}, + "size": {strconv.Itoa(size)}, + }.Encode() + + var resp apiSearchResponse + if err := getJSON(u, &resp); err != nil { + return nil, 0, err + } + + entries := make([]Entry, len(resp.Results)) + for i, r := range resp.Results { + entries[i] = Entry{ + Manufacturer: r.Manufacturer, + Name: r.Name, + Icon: r.Icon, + Tags: r.Tags, + Version: r.Version, + User: r.User, + Pass: r.Pass, + Comment: r.Comment, + } + } + return entries, resp.Pagination.TotalResults, nil +} + +func FetchStats(apiURL string) (manufacturers, credentials int, err error) { + var resp apiStatsResponse + if err := getJSON(apiURL+"/api/stats", &resp); err != nil { + return 0, 0, err + } + return resp.TotalApps, resp.TotalPasswords, nil +} + +func getJSON(u string, out any) error { + ctx, cancel := context.WithTimeout(context.Background(), httpTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%s: unexpected status %s", u, resp.Status) + } + return json.NewDecoder(resp.Body).Decode(out) +} diff --git a/ui/defaultview.go b/ui/defaultview.go new file mode 100644 index 0000000..6c59ccd --- /dev/null +++ b/ui/defaultview.go @@ -0,0 +1,75 @@ +package ui + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + + "github.com/anotherhadi/ilovetui/style" +) + +type missionCard struct { + title string + body string +} + +var missionCards = []missionCard{ + { + title: "The Mission", + body: "A centralized repository of factory-set credentials. Built for pentesters and security researchers to identify default access points during engagements or infrastructure audits.", + }, + { + title: "How it Works", + body: "Reads structured YAML files (from a local cache or the default-creds API) and performs real-time searches across service names, versions, tags and comments.", + }, + { + title: "Community Driven", + body: "Security is a collective effort. Contribute new credentials at github.com/anotherhadi/default-creds.", + }, +} + +func (m Model) renderDefaultView(height int) string { + if height <= 0 { + return "" + } + cardWidth := max(m.width, 20) + + var blocks []string + blocks = append(blocks, m.renderStatsLine()) + blocks = append(blocks, "") + for _, c := range missionCards { + blocks = append(blocks, renderMissionCard(c, cardWidth), "") + } + + content := strings.TrimRight(strings.Join(blocks, "\n"), "\n") + lines := strings.Split(content, "\n") + if len(lines) > height { + lines = lines[:height] + } + content = strings.Join(lines, "\n") + return lipgloss.Place(m.width, height, lipgloss.Left, lipgloss.Top, content) +} + +func renderMissionCard(c missionCard, width int) string { + title := panelTitleStyle.Render(c.title) + body := lipgloss.NewStyle().Foreground(style.S.Muted).Width(width - 4).Render(c.body) + return style.S.Panel.Width(width).Padding(0, 1).Render(title + "\n" + body) +} + +func (m Model) renderStatsLine() string { + if !m.statsLoaded { + return mutedStyle.Render(" loading stats…") + } + text := fmt.Sprintf(" %d manufacturers · %d credentials", m.statsManufacturers, m.statsCredentials) + return mutedStyle.Render(text) +} + +func (m Model) sourceLabel() string { + switch m.mode { + case modeLocal: + return "local" + default: + return "online" + } +} diff --git a/ui/delegate.go b/ui/delegate.go new file mode 100644 index 0000000..04eb23a --- /dev/null +++ b/ui/delegate.go @@ -0,0 +1,85 @@ +package ui + +import ( + "fmt" + "io" + "strconv" + "strings" + + "charm.land/bubbles/v2/list" + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + zone "github.com/lrstanley/bubblezone/v2" + + "github.com/anotherhadi/default-creds-tui/internal/creds" +) + +const cardHeight = 4 + +func entryUserZoneID(index int) string { return "entry-user-" + strconv.Itoa(index) } +func entryPassZoneID(index int) string { return "entry-pass-" + strconv.Itoa(index) } +func entryZoneID(index int) string { return "entry-" + strconv.Itoa(index) } + +func clampToWidth(s string, width int) string { + if width <= 0 { + return s + } + return ansi.Truncate(s, width, "…") +} + +type entryDelegate struct{} + +func (d entryDelegate) Height() int { return cardHeight } +func (d entryDelegate) Spacing() int { return 1 } +func (d entryDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } + +func (d entryDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { + e, ok := item.(creds.Entry) + if !ok { + return + } + selected := index == m.Index() + width := m.Width() + + side := unselectedIndentStyle + name := nameStyle + if selected { + side = selectedBorderStyle + name = nameSelectedStyle + } + + titleParts := []string{manufacturerStyle.Render(strings.ToUpper(e.Manufacturer))} + if e.Version != "" && e.Version != "all" { + titleParts = append(titleParts, versionStyle.Render("v"+e.Version)) + } + line1 := strings.Join(titleParts, subtleStyle.Render(" · ")) + line2 := name.Render(e.Name) + + var chips []string + if e.User != "" { + chips = append(chips, zone.Mark(entryUserZoneID(index), chipStyle.Render("user: "+e.User))) + } + if e.Pass != "" { + chips = append(chips, zone.Mark(entryPassZoneID(index), chipStyle.Render("pass: "+e.Pass))) + } + line3 := strings.Join(chips, " ") + if line3 == "" { + line3 = mutedStyle.Render("(no credentials listed)") + } + + line4 := "" + switch { + case e.Comment != "": + line4 = commentStyle.Render(e.Comment) + case len(e.Tags) > 0: + line4 = subtleStyle.Render("#" + strings.Join(e.Tags, " #")) + } + + lines := []string{ + clampToWidth(side.Render(line1), width), + clampToWidth(side.Render(line2), width), + clampToWidth(side.Render(line3), width), + clampToWidth(side.Render(line4), width), + } + fmt.Fprint(w, zone.Mark(entryZoneID(index), strings.Join(lines, "\n"))) +} diff --git a/ui/header.go b/ui/header.go new file mode 100644 index 0000000..e219c04 --- /dev/null +++ b/ui/header.go @@ -0,0 +1,37 @@ +package ui + +import ( + "strings" + + "charm.land/lipgloss/v2" + zone "github.com/lrstanley/bubblezone/v2" + + "github.com/anotherhadi/ilovetui/style" +) + +const searchBoxZone = "search-box" + +func (m Model) renderHeader() string { + title := clampToWidth(headerStyle.Render("Default Creds TUI")+subtleStyle.Render(" · "+m.sourceLabel()), m.width) + return strings.Join([]string{title, "", m.renderSearchBox(), ""}, "\n") +} + +func (m Model) renderSearchBox() string { + box := style.S.Panel + titleColor := style.S.Subtle + if m.searching { + box = style.S.PanelFocused + titleColor = style.S.Primary + } + box = box.Padding(0, 1) + width := max(m.width, 10) + + var rendered string + if style.S.NerdFonts { + rendered = box.Width(width).Render(m.searchInput.View()) + } else { + title := lipgloss.NewStyle().Foreground(titleColor).Render("Search") + rendered = style.RenderWithTitle(box, title, m.searchInput.View(), width, 3) + } + return zone.Mark(searchBoxZone, rendered) +} diff --git a/ui/model.go b/ui/model.go new file mode 100644 index 0000000..e8e82d6 --- /dev/null +++ b/ui/model.go @@ -0,0 +1,372 @@ +package ui + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/list" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/anotherhadi/ilovetui/app" + "github.com/anotherhadi/ilovetui/bubbles" + "github.com/anotherhadi/ilovetui/helpbar" + "github.com/anotherhadi/ilovetui/notification" + "github.com/anotherhadi/ilovetui/style" + + "github.com/anotherhadi/default-creds-tui/internal/config" + "github.com/anotherhadi/default-creds-tui/internal/creds" +) + +const ( + modeLocal = creds.Local + modeOnline = creds.Online +) + +type ( + localLoadedMsg struct { + entries []creds.Entry + err error + } + statsMsg struct { + manufacturers, credentials int + err error + } + onlineResultsMsg struct { + gen int + entries []creds.Entry + total int + err error + } + searchTickMsg struct{ gen int } +) + +type Model struct { + mode creds.Mode + apiURL string + printOnEnter bool + printEntry *creds.Entry + + list list.Model + help helpbar.Model + searchInput textinput.Model + searching bool + + allLocal []creds.Entry + statsLoaded bool + statsManufacturers, statsCredentials int + + query string + totalResults int + loading bool + searchGen int + + width, height int + + ModalOpen bool +} + +func New(mode creds.Mode, apiURL string, printOnEnter bool) Model { + l := bubbles.NewList(nil, 0, 0) + l.SetDelegate(entryDelegate{}) + l.SetShowHelp(false) + l.SetFilteringEnabled(false) + l.SetShowStatusBar(false) + l.SetShowTitle(false) + l.DisableQuitKeybindings() + + gk := config.Global.Keybindings.Global + l.KeyMap.CursorUp = gk.Up + l.KeyMap.CursorDown = gk.Down + l.KeyMap.GoToStart = gk.GotoTop + l.KeyMap.GoToEnd = gk.GotoBottom + + h := helpbar.New(helpbar.WithToggle(gk.Help)) + + si := bubbles.NewTextInput() + if style.S.NerdFonts { + si.Prompt = searchIcon + " " + } + si.Placeholder = "search a product, manufacturer, tag…" + + focus := config.Global.App.FocusSearchOnLaunch + if focus { + si.Focus() + } + + return Model{ + mode: mode, + apiURL: apiURL, + printOnEnter: printOnEnter, + list: l, + help: h, + searchInput: si, + searching: focus, + } +} + +func (m Model) PrintEntry() (creds.Entry, bool) { + if m.printEntry == nil { + return creds.Entry{}, false + } + return *m.printEntry, true +} + +func (m Model) printSelectedAndQuit() (Model, tea.Cmd) { + e, ok := m.selectedEntry() + if !ok { + return m, nil + } + m.printEntry = &e + return m, app.Quit() +} + +func (m Model) Init() tea.Cmd { + cmds := []tea.Cmd{app.SetTitle("Default Creds TUI")} + switch m.mode { + case modeLocal: + cmds = append(cmds, loadLocalCmd) + default: + cmds = append(cmds, fetchStatsCmd(m.apiURL)) + } + return tea.Batch(cmds...) +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.help.SetWidth(msg.Width) + m.searchInput.SetWidth(max(msg.Width-6, 10)) + m.resizeList() + return m, nil + + case localLoadedMsg: + if msg.err != nil { + return m, errorToast(msg.err) + } + m.allLocal = msg.entries + m.statsManufacturers, m.statsCredentials = creds.LocalStats(msg.entries) + m.statsLoaded = true + cmd := m.runSearch() + return m, cmd + + case statsMsg: + if msg.err != nil { + return m, nil + } + m.statsManufacturers, m.statsCredentials = msg.manufacturers, msg.credentials + m.statsLoaded = true + return m, nil + + case searchTickMsg: + if msg.gen != m.searchGen { + return m, nil + } + return m, fetchOnlineCmd(m.apiURL, m.query, msg.gen) + + case onlineResultsMsg: + if msg.gen != m.searchGen { + return m, nil + } + m.loading = false + if msg.err != nil { + return m, errorToast(msg.err) + } + m.totalResults = msg.total + cmd := m.setEntries(msg.entries) + return m, cmd + + case tea.KeyPressMsg: + if m.ModalOpen { + return m, nil + } + if m.searching { + return m.updateSearch(msg) + } + return m.updateList(msg) + + case tea.MouseWheelMsg: + if m.ModalOpen { + return m, nil + } + switch msg.Button { + case tea.MouseWheelUp: + m.list.CursorUp() + case tea.MouseWheelDown: + m.list.CursorDown() + } + return m, nil + + case tea.MouseClickMsg: + if m.ModalOpen || msg.Mouse().Button != tea.MouseLeft { + return m, nil + } + return m.updateMouseClick(msg) + } + + if m.ModalOpen { + return m, nil + } + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m Model) updateList(msg tea.KeyPressMsg) (Model, tea.Cmd) { + gk := config.Global.Keybindings.Global + sk := config.Global.Keybindings.Search + rk := config.Global.Keybindings.Results + + switch { + case key.Matches(msg, gk.Quit): + return m, app.Quit() + case key.Matches(msg, gk.Help): + m.help.ShowAll = !m.help.ShowAll + m.resizeList() + return m, nil + case key.Matches(msg, sk.Focus): + m.searching = true + return m, m.searchInput.Focus() + case key.Matches(msg, gk.Escape): + return m, app.Quit() + case key.Matches(msg, rk.Refresh): + cmd := m.runSearch() + return m, cmd + case key.Matches(msg, rk.CopyUser): + return m, m.copySelected(fieldUser) + case key.Matches(msg, rk.CopyPass): + return m, m.copySelected(fieldPass) + case key.Matches(msg, rk.Enter): + if !m.printOnEnter { + return m, m.copySelected(fieldPass) + } + return m.printSelectedAndQuit() + case key.Matches(msg, rk.PrintAndQuit): + return m.printSelectedAndQuit() + } + + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m Model) View() string { + header := m.renderHeader() + helpView := strings.TrimRight(m.help.View(m.helpBindings()...), "\n") + + var body string + if strings.TrimSpace(m.query) == "" { + body = m.renderDefaultView(m.bodyHeight()) + } else { + body = strings.TrimRight(m.renderResultsBody(), "\n") + } + + return strings.Join([]string{header, body, helpView}, "\n") +} + +func (m Model) renderResultsBody() string { + if m.loading { + return lipgloss.Place(m.width, max(m.bodyHeight(), 1), lipgloss.Center, lipgloss.Center, + mutedStyle.Render("searching…")) + } + if len(m.list.Items()) == 0 { + innerWidth := max(m.width-4, 10) + wrapped := lipgloss.NewStyle().Width(innerWidth).Align(lipgloss.Center) + text := strings.Join([]string{ + wrapped.Foreground(style.S.Muted).Render("no results for \"" + m.query + "\""), + "", + wrapped.Foreground(style.S.Subtle).Render("you can contribute new credentials at github.com/anotherhadi/default-creds"), + }, "\n") + return lipgloss.Place(m.width, max(m.bodyHeight(), 1), lipgloss.Center, lipgloss.Center, text) + } + lines := []string{m.renderResultsCountLine(), m.list.View()} + return strings.Join(lines, "\n") +} + +func (m Model) renderResultsCountLine() string { + shown := len(m.list.Items()) + text := "" + switch { + case m.mode == modeOnline && m.totalResults > shown: + text = fmt.Sprintf("showing %d of %d results, refine your search", shown, m.totalResults) + case shown == 1: + text = "1 result" + default: + text = fmt.Sprintf("%d results", shown) + } + return mutedStyle.Render(" " + text) +} + +func (m Model) helpBindings() []key.Binding { + pages := config.Help(config.Global.Keybindings.Global, config.Global.Keybindings.Search, config.Global.Keybindings.Results) + if m.help.ShowAll { + var all []key.Binding + for _, col := range pages.FullHelp() { + all = append(all, col...) + } + return all + } + return pages.ShortHelp() +} + +func (m Model) headerHeight() int { + return lipgloss.Height(m.renderHeader()) +} + +func (m Model) bodyHeight() int { + helpH := m.help.Height(m.helpBindings()...) + h := m.height - m.headerHeight() - helpH + if strings.TrimSpace(m.query) != "" && len(m.list.Items()) > 0 { + h-- + } + return h +} + +func (m *Model) resizeList() { + m.list.SetSize(m.width, m.bodyHeight()) + m.list.SetSize(m.width, m.bodyHeight()) +} + +func (m *Model) setEntries(entries []creds.Entry) tea.Cmd { + items := make([]list.Item, len(entries)) + for i, e := range entries { + items[i] = e + } + cmd := m.list.SetItems(items) + m.resizeList() + return cmd +} + +func (m Model) selectedEntry() (creds.Entry, bool) { + if item := m.list.SelectedItem(); item != nil { + return item.(creds.Entry), true + } + return creds.Entry{}, false +} + +func loadLocalCmd() tea.Msg { + entries, err := creds.LoadLocal(creds.LocalDataDir()) + return localLoadedMsg{entries: entries, err: err} +} + +func fetchStatsCmd(apiURL string) tea.Cmd { + return func() tea.Msg { + manufacturers, credentials, err := creds.FetchStats(apiURL) + return statsMsg{manufacturers: manufacturers, credentials: credentials, err: err} + } +} + +func fetchOnlineCmd(apiURL, query string, gen int) tea.Cmd { + return func() tea.Msg { + entries, total, err := creds.FetchOnline(apiURL, query, 50) + return onlineResultsMsg{gen: gen, entries: entries, total: total, err: err} + } +} + +func errorToast(err error) tea.Cmd { + return notification.Show("Error", err.Error(), notification.Error, notification.WithID("action-error")) +} diff --git a/ui/mouse.go b/ui/mouse.go new file mode 100644 index 0000000..f65e2c7 --- /dev/null +++ b/ui/mouse.go @@ -0,0 +1,61 @@ +package ui + +import ( + tea "charm.land/bubbletea/v2" + zone "github.com/lrstanley/bubblezone/v2" + + "github.com/anotherhadi/ilovetui/notification" + + "github.com/anotherhadi/default-creds-tui/internal/creds" +) + +type copyField int + +const ( + fieldUser copyField = iota + fieldPass +) + +func (m Model) updateMouseClick(msg tea.MouseClickMsg) (Model, tea.Cmd) { + if zone.Get(searchBoxZone).InBounds(msg) { + m.searching = true + return m, m.searchInput.Focus() + } + + items := m.list.VisibleItems() + for i, item := range items { + e := item.(creds.Entry) + switch { + case zone.Get(entryUserZoneID(i)).InBounds(msg): + return m, copyEntryField(e, fieldUser) + case zone.Get(entryPassZoneID(i)).InBounds(msg): + return m, copyEntryField(e, fieldPass) + case zone.Get(entryZoneID(i)).InBounds(msg): + m.list.Select(i) + return m, nil + } + } + return m, nil +} + +func (m Model) copySelected(field copyField) tea.Cmd { + e, ok := m.selectedEntry() + if !ok { + return nil + } + return copyEntryField(e, field) +} + +func copyEntryField(e creds.Entry, field copyField) tea.Cmd { + label, value := "username", e.User + if field == fieldPass { + label, value = "password", e.Pass + } + if value == "" { + return notification.Show("Copy", "No "+label+" set for '"+e.Name+"'.", notification.Warning) + } + return tea.Batch( + tea.SetClipboard(value), + notification.Show("Copied", "'"+e.Name+"' "+label+" copied to clipboard.", notification.Success), + ) +} diff --git a/ui/search.go b/ui/search.go new file mode 100644 index 0000000..19cc60d --- /dev/null +++ b/ui/search.go @@ -0,0 +1,87 @@ +package ui + +import ( + "strings" + "time" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + + "github.com/anotherhadi/ilovetui/app" + + "github.com/anotherhadi/default-creds-tui/internal/config" + "github.com/anotherhadi/default-creds-tui/internal/creds" +) + +const onlineDebounce = 300 * time.Millisecond + +var ( + downKey = key.NewBinding(key.WithKeys("down", "tab")) + enterKey = key.NewBinding(key.WithKeys("enter")) + ctrlCKey = key.NewBinding(key.WithKeys("ctrl+c")) +) + +func (m Model) updateSearch(msg tea.KeyPressMsg) (Model, tea.Cmd) { + gk := config.Global.Keybindings.Global + sk := config.Global.Keybindings.Search + + switch { + case key.Matches(msg, ctrlCKey): + return m, app.Quit() + case key.Matches(msg, gk.Escape): + return m.exitSearch() + case key.Matches(msg, downKey), key.Matches(msg, enterKey): + if len(m.list.Items()) > 0 { + return m.exitSearch() + } + case key.Matches(msg, sk.Clear): + m.searchInput.Reset() + return m.applyQuery("") + } + + prev := m.searchInput.Value() + var cmd tea.Cmd + m.searchInput, cmd = m.searchInput.Update(msg) + if m.searchInput.Value() == prev { + return m, cmd + } + next, queryCmd := m.applyQuery(m.searchInput.Value()) + return next, tea.Batch(cmd, queryCmd) +} + +func (m Model) exitSearch() (Model, tea.Cmd) { + m.searching = false + m.searchInput.Blur() + return m, nil +} + +func (m Model) applyQuery(query string) (Model, tea.Cmd) { + m.query = query + m.searchGen++ + if strings.TrimSpace(query) == "" { + m.loading = false + cmd := m.setEntries(nil) + return m, cmd + } + if m.mode == modeLocal { + cmd := m.runSearch() + return m, cmd + } + m.loading = true + gen := m.searchGen + return m, tea.Tick(onlineDebounce, func(time.Time) tea.Msg { + return searchTickMsg{gen: gen} + }) +} + +func (m *Model) runSearch() tea.Cmd { + if strings.TrimSpace(m.query) == "" { + return m.setEntries(nil) + } + if m.mode != modeLocal { + return fetchOnlineCmd(m.apiURL, m.query, m.searchGen) + } + matches := creds.Filter(m.allLocal, m.query) + m.totalResults = len(matches) + return m.setEntries(matches) +} diff --git a/ui/styles.go b/ui/styles.go new file mode 100644 index 0000000..2b4e425 --- /dev/null +++ b/ui/styles.go @@ -0,0 +1,37 @@ +package ui + +import ( + "charm.land/lipgloss/v2" + "github.com/anotherhadi/ilovetui/style" +) + +const searchIcon = "" + +var ( + headerStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(style.S.Primary). + PaddingLeft(1) + + mutedStyle = lipgloss.NewStyle().Foreground(style.S.Muted) + subtleStyle = lipgloss.NewStyle().Foreground(style.S.Subtle) + + manufacturerStyle = lipgloss.NewStyle().Foreground(style.S.Subtle).Bold(true) + versionStyle = lipgloss.NewStyle().Foreground(style.S.Subtle) + nameStyle = lipgloss.NewStyle().Foreground(style.S.Text).Bold(true) + nameSelectedStyle = lipgloss.NewStyle().Foreground(style.S.Primary).Bold(true) + commentStyle = lipgloss.NewStyle().Foreground(style.S.Muted).Italic(true) + + chipStyle = lipgloss.NewStyle(). + Foreground(style.S.Success). + Background(style.S.SubtleBg). + Padding(0, 1) + + selectedBorderStyle = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), false, false, false, true). + BorderForeground(style.S.Primary). + PaddingLeft(1) + unselectedIndentStyle = lipgloss.NewStyle().PaddingLeft(2) + + panelTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(style.S.Primary) +)