mirror of
https://github.com/anotherhadi/spilltea.git
synced 2026-05-20 17:52:33 +02:00
6f56e0b26a
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
341 lines
7.7 KiB
Go
341 lines
7.7 KiB
Go
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
|
||
}
|
||
|
||
// ProjectSelectedMsg is emitted when the user picks a project from the home screen.
|
||
type ProjectSelectedMsg struct {
|
||
Project *Project
|
||
}
|
||
|
||
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
|
||
width int
|
||
height int
|
||
teapotFrame int
|
||
}
|
||
|
||
|
||
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(keys.Keys.Global.Quit.Help().Key, "quit"))
|
||
}
|
||
|
||
return strings.Join(parts, sep)
|
||
}
|