Files
default-creds-tui/ui/model.go
T
Hadi 624a723b16 init
Signed-off-by: Hadi <[email protected]>
2026-08-27 00:05:08 +02:00

373 lines
8.6 KiB
Go

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"))
}