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
+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)
)