Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
Hadi
2026-05-12 19:12:29 +02:00
commit e8e64eff12
101 changed files with 10081 additions and 0 deletions
+339
View File
@@ -0,0 +1,339 @@
package home
import (
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
"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/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/ui/components/teapot"
)
type itemKind int
const (
kindNew itemKind = iota
kindTemp
kindExisting
)
type listItem struct {
kind itemKind
name string
path string
count int
modTime time.Time
}
func (i listItem) icon() string {
ic := icons.I
switch i.kind {
case kindNew:
return ic.New
case kindTemp:
return ic.Temp
default:
return ic.Project
}
}
func (i listItem) title() string {
switch i.kind {
case kindNew:
return "New Project"
case kindTemp:
return "Temporary Session"
default:
return i.name
}
}
func (i listItem) description() string {
switch i.kind {
case kindNew:
return "create and name a new project"
case kindTemp:
return "isolated session, deleted on exit"
default:
date := i.modTime.Format("Jan 2, 2006")
if i.count == 1 {
return fmt.Sprintf("1 request · %s", date)
}
return fmt.Sprintf("%d requests · %s", i.count, date)
}
}
// FilterValue contains only the text (no icon) so fuzzy match indices map
// directly onto title() and don't need an offset to account for icon width.
func (i listItem) FilterValue() string { return i.title() }
type homeDelegate struct {
normalTitle lipgloss.Style
normalDesc lipgloss.Style
selectedTitle lipgloss.Style
selectedDesc lipgloss.Style
filterMatch lipgloss.Style
}
func newHomeDelegate() homeDelegate {
s := style.S
leftBorder := lipgloss.Border{Left: "│"}
return homeDelegate{
normalTitle: lipgloss.NewStyle().Foreground(s.Text).PaddingLeft(4),
normalDesc: lipgloss.NewStyle().Foreground(s.Subtle).Faint(true).PaddingLeft(4),
selectedTitle: lipgloss.NewStyle().
Border(leftBorder, false, false, false, true).
BorderForeground(s.Primary).
Foreground(s.Primary).Bold(true).PaddingLeft(3),
selectedDesc: lipgloss.NewStyle().
Border(leftBorder, false, false, false, true).
BorderForeground(s.Primary).
Foreground(s.MutedFg).PaddingLeft(3),
filterMatch: lipgloss.NewStyle().Underline(true),
}
}
func (d homeDelegate) Height() int { return 2 }
func (d homeDelegate) Spacing() int { return 1 }
func (d homeDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }
func (d homeDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
li := item.(listItem)
selected := index == m.Index()
// Apply match highlighting only to the title text
// separately so its width never shifts the highlight indices.
titleText := li.title()
if m.IsFiltered() {
if matches := m.MatchesForItem(index); len(matches) > 0 {
base := lipgloss.NewStyle()
titleText = lipgloss.StyleRunes(titleText, matches, d.filterMatch.Inherit(base), base)
}
}
full := li.icon() + titleText
var titleLine, descLine string
if selected {
titleLine = d.selectedTitle.Render(full)
descLine = d.selectedDesc.Render(li.description())
} else {
titleLine = d.normalTitle.Render(full)
descLine = d.normalDesc.Render(li.description())
}
fmt.Fprintf(w, "%s\n%s", titleLine, descLine)
}
type Project struct {
Name string
Path string
Count int
ModTime time.Time
}
type inputMode int
const (
modeSelect inputMode = iota
modeNaming
)
const (
baseHeaderLines = 1 + 1 + 1 + 2
teapotMinH = 28 // minimum terminal height to show the teapot
maxInnerW = 80 // max content width inside the padding box
maxInnerH = 50 // max content height inside the padding box
)
type Model struct {
mode inputMode
list list.Model
projectDir string
nameInput textinput.Model
selected *Project
width int
height int
teapotFrame int
}
// Selected returns the project chosen by the user, or nil if the program was
// quit without making a selection.
func (m Model) Selected() *Project { return m.selected }
func New(projectDir string) Model {
projects := loadProjects(projectDir)
l := list.New(buildItems(projects), newHomeDelegate(), 0, 0)
l.SetShowTitle(false)
l.SetShowStatusBar(false)
l.SetShowHelp(false)
l.SetFilteringEnabled(true)
l.KeyMap.Quit.SetEnabled(false)
l.KeyMap.ForceQuit.SetEnabled(false)
l.KeyMap.ShowFullHelp.SetEnabled(false)
l.KeyMap.CloseFullHelp.SetEnabled(false)
ti := textinput.New()
ti.Placeholder = "my-project"
ti.CharLimit = 64
ti.SetWidth(inputPanelMaxW - 2 - 4)
return Model{
projectDir: projectDir,
list: l,
nameInput: ti,
}
}
func (m Model) Init() tea.Cmd { return teapotTick() }
func (m Model) innerW() int {
w := m.width - 2
if w > maxInnerW {
w = maxInnerW
}
if w < 0 {
return 0
}
return w
}
func (m Model) innerH() int {
h := m.height - 2
if h > maxInnerH {
h = maxInnerH
}
if h < 0 {
return 0
}
return h
}
func (m Model) headerHeight() int {
if m.height > teapotMinH {
// teapot block replaces 1 \n (else branch) with frame \n's + \n\n
// net addition = FrameLines() (= frame_internal_\n + \n\n - else_\n)
return baseHeaderLines + teapot.FrameLines()
}
return baseHeaderLines
}
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
lw := m.listWidth()
lh := m.innerH() - m.headerHeight() - 1
if lh < 0 {
lh = 0
}
m.list.SetSize(lw, lh)
m.nameInput.SetWidth(inputPanelInnerW(m.innerW()))
}
func (m Model) IsEditing() bool { return m.mode == modeNaming }
func (m Model) listWidth() int {
return m.innerW()
}
func inputPanelInnerW(termW int) int {
panelW := inputPanelMaxW
if termW < panelW+4 {
panelW = termW - 4
}
if panelW < 10 {
panelW = 10
}
return panelW - 2 - 4 // border (2) + padding (2×2)
}
func loadProjects(projectDir string) []Project {
entries, err := os.ReadDir(projectDir)
if err != nil {
return nil
}
var projects []Project
for _, e := range entries {
if !e.IsDir() {
continue
}
dbPath := filepath.Join(projectDir, e.Name(), "data.db")
info, err := os.Stat(dbPath)
if err != nil {
continue
}
projects = append(projects, Project{
Name: e.Name(),
Path: dbPath,
Count: db.CountEntriesAt(dbPath),
ModTime: info.ModTime(),
})
}
sort.Slice(projects, func(i, j int) bool {
return projects[i].ModTime.After(projects[j].ModTime)
})
return projects
}
func buildItems(projects []Project) []list.Item {
items := []list.Item{
listItem{kind: kindNew},
listItem{kind: kindTemp},
}
for _, p := range projects {
items = append(items, listItem{
kind: kindExisting,
name: p.Name,
path: p.Path,
count: p.Count,
modTime: p.ModTime,
})
}
return items
}
func (m Model) renderHelpLine() string {
s := style.S
k := keys.Keys.Home
fs := m.list.FilterState()
kStyle := lipgloss.NewStyle().Foreground(s.MutedFg).Inline(true)
dStyle := s.Faint.Inline(true)
sep := s.Faint.Inline(true).Render(" • ")
item := func(keyStr, desc string) string {
return kStyle.Render(keyStr) + " " + dStyle.Render(desc)
}
binding := func(b key.Binding) string {
return item(b.Help().Key, b.Help().Desc)
}
var parts []string
if fs == list.Filtering {
parts = append(parts, item("enter", "apply filter"))
parts = append(parts, item("esc", "cancel"))
} else {
parts = append(parts, item("↑/↓", "navigate"))
if fs == list.FilterApplied {
parts = append(parts, item("esc", "clear filter"))
} else {
parts = append(parts, binding(k.Filter))
}
parts = append(parts, binding(k.Open))
parts = append(parts, binding(k.Delete))
parts = append(parts, item("q", "quit"))
}
return strings.Join(parts, sep)
}