Signed-off-by: Hadi <[email protected]>
This commit is contained in:
Hadi
2026-08-27 00:05:08 +02:00
commit 624a723b16
29 changed files with 1989 additions and 0 deletions
+32
View File
@@ -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:"
+10
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
ko_fi: anotherhadi
View File
+28
View File
@@ -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 }}
View File
+21
View File
@@ -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.
+103
View File
@@ -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
<img alt="Default-creds-tui demo" src="./.github/assets/demo.gif" width="600" />
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
<details>
<summary>Go install</summary>
```sh
go install github.com/anotherhadi/default-creds-tui@latest
```
</details>
<details>
<summary>Build from source</summary>
```sh
git clone https://github.com/anotherhadi/default-creds-tui
cd default-creds-tui
go build -o default-creds-tui ./cmd/default-creds-tui
```
</details>
<details>
<summary>NUR (Nix/NixOS)</summary>
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 ];
```
</details>
## 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.
---
<div align="center">
<a href="https://github.com/anotherhadi/default-creds-tui">github</a> |
<a href="https://gitlab.com/anotherhadi_mirror/default-creds-tui">gitlab (mirror)</a> |
<a href="https://git.hadi.icu/anotherhadi/default-creds-tui">gitea (mirror)</a>
</div>
+93
View File
@@ -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")
}
+89
View File
@@ -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")
}
+114
View File
@@ -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
}
}
+58
View File
@@ -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
)
+136
View File
@@ -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=
+23
View File
@@ -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
+36
View File
@@ -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"
+78
View File
@@ -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
}
+113
View File
@@ -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)
}
}
}
+34
View File
@@ -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"`
}
+41
View File
@@ -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)))
}
+104
View File
@@ -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)
}
+30
View File
@@ -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
}
+91
View File
@@ -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)
}
+75
View File
@@ -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"
}
}
+85
View File
@@ -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")))
}
+37
View File
@@ -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)
}
+372
View File
@@ -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"))
}
+61
View File
@@ -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),
)
}
+87
View File
@@ -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)
}
+37
View File
@@ -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)
)