mirror of
https://github.com/anotherhadi/default-creds-tui.git
synced 2026-10-05 10:58:25 +02:00
@@ -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:"
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
ko_fi: anotherhadi
|
||||
@@ -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 }}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,103 @@
|
||||
```
|
||||
____ __ _ _ ____ _ _____ _ _ ___
|
||||
| _ \ ___ / _| __ _ _ _| | |_ / ___|_ __ ___ __| |___ |_ _| | | |_ _|
|
||||
| | | |/ _ \ |_ / _` | | | | | __| | | | '__/ _ \/ _` / __| | | | | | || |
|
||||
| |_| | __/ _| (_| | |_| | | |_ | |___| | | __/ (_| \__ \ | | | |_| || |
|
||||
|____/ \___|_| \__,_|\__,_|_|\__| \____|_| \___|\__,_|___/ |_| \___/|___|
|
||||
```
|
||||
|
||||

|
||||

|
||||

|
||||
[](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>
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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")))
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
Reference in New Issue
Block a user