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)
}
+180
View File
@@ -0,0 +1,180 @@
package home
import (
crypto "crypto/rand"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/ui/components/teapot"
)
type teapotTickMsg struct{}
func teapotTick() tea.Cmd {
return tea.Tick(2*time.Second, func(time.Time) tea.Msg {
return teapotTickMsg{}
})
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if ws, ok := msg.(tea.WindowSizeMsg); ok {
m.SetSize(ws.Width, ws.Height)
return m, nil
}
if _, ok := msg.(teapotTickMsg); ok {
frames := teapot.TeapotFrames()
m.teapotFrame = (m.teapotFrame + 1) % len(frames)
return m, teapotTick()
}
if m.mode == modeNaming {
if kp, ok := msg.(tea.KeyPressMsg); ok {
return m.updateNaming(kp)
}
return m, nil
}
if kp, ok := msg.(tea.KeyPressMsg); ok {
if !m.list.SettingFilter() {
if key.Matches(kp, keys.Keys.Global.Quit) {
return m, tea.Quit
}
if key.Matches(kp, keys.Keys.Home.Open) {
return m.handleSelection()
}
if key.Matches(kp, keys.Keys.Home.Delete) {
return m.deleteSelected()
}
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m Model) handleSelection() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(listItem)
if !ok {
return m, nil
}
switch item.kind {
case kindNew:
m.mode = modeNaming
m.nameInput.SetValue("")
return m, m.nameInput.Focus()
case kindTemp:
dir := tempDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return m, nil
}
initProjectFiles(dir)
m.selected = &Project{Name: "temporary", Path: filepath.Join(dir, "data.db")}
return m, tea.Quit
default:
m.selected = &Project{Name: item.name, Path: item.path}
return m, tea.Quit
}
}
func (m Model) deleteSelected() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(listItem)
if !ok || item.kind != kindExisting {
return m, nil
}
dir := filepath.Dir(item.path) // parent dir of data.db
os.RemoveAll(dir)
idx := m.list.GlobalIndex()
m.list.RemoveItem(idx)
if idx > 0 && idx >= len(m.list.Items()) {
m.list.Select(idx - 1)
}
return m, nil
}
func (m Model) updateNaming(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, keys.Keys.Global.Escape):
m.mode = modeSelect
m.nameInput.Blur()
return m, nil
case msg.String() == "enter":
name := m.nameInput.Value()
if name == "" {
return m, nil
}
m.mode = modeSelect
m.nameInput.Blur()
dir := filepath.Join(m.projectDir, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
return m, nil
}
initProjectFiles(dir)
m.selected = &Project{Name: name, Path: filepath.Join(dir, "data.db")}
return m, tea.Quit
default:
var cmd tea.Cmd
m.nameInput, cmd = m.nameInput.Update(msg)
m.nameInput.SetValue(sanitizeName(m.nameInput.Value()))
return m, cmd
}
}
func sanitizeName(s string) string {
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
b.WriteRune(r)
}
}
return b.String()
}
func IsValidProjectName(s string) bool {
if s == "tmp" {
return true
}
return s != "" && s == sanitizeName(s)
}
func OpenProject(projectDir, name string) (*Project, error) {
if name == "tmp" {
dir := tempDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
initProjectFiles(dir)
return &Project{Name: "temporary", Path: filepath.Join(dir, "data.db")}, nil
}
dir := filepath.Join(projectDir, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
initProjectFiles(dir)
return &Project{Name: name, Path: filepath.Join(dir, "data.db")}, nil
}
func tempDir() string {
b := make([]byte, 4)
_, _ = crypto.Read(b)
return filepath.Join(os.TempDir(), "spilltea", fmt.Sprintf("%08x", b))
}
func initProjectFiles(dir string) {
for _, name := range []string{"data.db", "logs.log"} {
p := filepath.Join(dir, name)
if _, err := os.Stat(p); os.IsNotExist(err) {
f, err := os.Create(p)
if err == nil {
f.Close()
}
}
}
}
+101
View File
@@ -0,0 +1,101 @@
package home
import (
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/ui/components/teapot"
)
const inputPanelMaxW = 44
func (m Model) View() tea.View {
s := style.S
iw := m.innerW()
var sb strings.Builder
sb.WriteString("\n")
if m.height > teapotMinH {
frames := teapot.TeapotFrames()
frame := lipgloss.NewStyle().Foreground(s.Primary).Render(frames[m.teapotFrame])
sb.WriteString(center(iw, frame))
sb.WriteString("\n\n")
} else {
sb.WriteString("\n")
}
sb.WriteString(center(iw, lipgloss.NewStyle().Bold(true).Foreground(s.Primary).Render("SPILLTEA")))
sb.WriteString("\n")
sb.WriteString(center(iw, s.Faint.Render("choose a project to get started")))
sb.WriteString("\n\n")
if m.mode == modeNaming {
sb.WriteString(m.renderNamingPanel())
} else {
lw := m.listWidth()
leftPad := (iw - lw) / 2
sb.WriteString(padLeft(m.list.View(), leftPad))
sb.WriteString("\n")
sb.WriteString(center(iw, m.renderHelpLine()))
}
box := lipgloss.NewStyle().Padding(1, 1).Render(sb.String())
content := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box)
v := tea.NewView(content)
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
return v
}
func (m Model) renderNamingPanel() string {
s := style.S
iw := m.innerW()
panelW := inputPanelMaxW
if iw < panelW+4 {
panelW = iw - 4
}
if panelW < 10 {
panelW = 10
}
innerW := inputPanelInnerW(iw)
inputLine := lipgloss.NewStyle().Width(innerW).Render(m.nameInput.View())
label := lipgloss.NewStyle().Foreground(s.MutedFg).Render("Project name")
panel := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(s.Primary).
Padding(1, 2).
Width(panelW).
Render(label + "\n" + inputLine)
hint := s.Faint.Render("[enter] confirm [esc] cancel")
var sb strings.Builder
sb.WriteString(center(iw, panel))
sb.WriteString("\n")
sb.WriteString(center(iw, hint))
sb.WriteString("\n")
return sb.String()
}
// padLeft prepends n spaces to every non-empty line.
func padLeft(content string, n int) string {
if n <= 0 {
return content
}
pad := strings.Repeat(" ", n)
lines := strings.Split(content, "\n")
for i, l := range lines {
if l != "" {
lines[i] = pad + l
}
}
return strings.Join(lines, "\n")
}
func center(width int, s string) string {
return lipgloss.PlaceHorizontal(width, lipgloss.Center, s)
}