mirror of
https://github.com/anotherhadi/default-creds-tui.git
synced 2026-10-05 10:58:25 +02:00
@@ -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