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