From 969a0578513008317b0e15de58138e4491bc5e96 Mon Sep 17 00:00:00 2001 From: Travis Shears Date: Tue, 30 Sep 2025 09:52:13 +0200 Subject: [PATCH] start with bubble example --- go.mod | 22 +++++++ go.sum | 55 ++++++++++++++++ main.go | 152 ++++++++++++++++++--------------------------- test_pocketbase.go | 23 ------- 4 files changed, 138 insertions(+), 114 deletions(-) create mode 100644 go.sum delete mode 100644 test_pocketbase.go diff --git a/go.mod b/go.mod index a5895a6..7697495 100644 --- a/go.mod +++ b/go.mod @@ -3,5 +3,27 @@ module gemini_site go 1.25.0 require ( + github.com/charmbracelet/bubbletea v1.3.10 github.com/kulak/gemini v1.2.2 ) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..37970ec --- /dev/null +++ b/go.sum @@ -0,0 +1,55 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/kulak/gemini v1.2.2 h1:wPFOAFFdOf9ZaHcpMwTq1xYUWxmyV3h0uQl0OXCGa+A= +github.com/kulak/gemini v1.2.2/go.mod h1:8yiD7yhLkUGvOpdvgd/0nKQD2I0ChIAKD3yHuT13R5k= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 4a93954..71cef0a 100644 --- a/main.go +++ b/main.go @@ -1,116 +1,86 @@ package main import ( - "errors" - "flag" "fmt" - "log/slog" "os" - "strconv" - "strings" - "time" - "gemini_site/internal/microblog" - "gemini_site/internal/pocketbase" - - gemini "github.com/kulak/gemini" + tea "github.com/charmbracelet/bubbletea" ) -type MainHandler struct { - blog microblog.Handler +type model struct { + cursor int + choices []string + selected map[int]struct{} } -func (h MainHandler) ServeGemini(w gemini.ResponseWriter, req *gemini.Request) { - slog.Info("gemini request", - "path", req.URL.Path, - "user", strings.Join(userName(req), " ")) +func initialModel() model { + return model{ + choices: []string{"Buy carrots", "Buy celery", "Buy kohlrabi"}, - // Check if this is a blog request - if strings.HasPrefix(req.URL.Path, "/microblog") { - h.blog.HandleBlogRequest(w, req) - return + // A map which indicates which choices are selected. We're using + // the map like a mathematical set. The keys refer to the indexes + // of the `choices` slice, above. + selected: make(map[int]struct{}), } +} - switch req.URL.Path { - case "/": - gemini.ServeFileName("pages/home.gmi", "text/gemini")(w, req) - // err := w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") - // requireNoError(err) - // _, err = w.WriteBody([]byte("Hello, world!")) - // requireNoError(err) - case "/user": - if req.Certificate() == nil { - w.WriteStatusMsg(gemini.StatusCertRequired, "Authentication Required") - return +func (m model) Init() tea.Cmd { + return tea.SetWindowTitle("Grocery List") +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.choices)-1 { + m.cursor++ + } + case "enter", " ": + _, ok := m.selected[m.cursor] + if ok { + delete(m.selected, m.cursor) + } else { + m.selected[m.cursor] = struct{}{} + } } - w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") - w.WriteBody([]byte(req.Certificate().Subject.CommonName)) - case "/die": - requireNoError(errors.New("must die")) - case "/file": - gemini.ServeFileName("cmd/example/hello.gmi", "text/gemini")(w, req) - case "/post": - if req.URL.Scheme != gemini.SchemaTitan { - w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") - w.WriteBody([]byte("Use titan scheme to upload data")) - return + } + + return m, nil +} + +func (m model) View() string { + s := "What should we buy at the market?\n\n" + + for i, choice := range m.choices { + cursor := " " + if m.cursor == i { + cursor = ">" } - payload, err := req.ReadTitanPayload() - requireNoError(err) - w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") - w.WriteBody([]byte("Titan Upload Parameters\r\n")) - w.WriteBody([]byte(fmt.Sprintf("Upload MIME Type: %s\r\n", req.Titan.Mime))) - w.WriteBody([]byte(fmt.Sprintf("Token: %s\r\n", req.Titan.Token))) - w.WriteBody([]byte(fmt.Sprintf("Size: %v\r\n", req.Titan.Size))) - w.WriteBody([]byte("Payload:\r\n")) - w.WriteBody(payload) - default: - w.WriteStatusMsg(gemini.StatusNotFound, req.URL.Path) + checked := " " + if _, ok := m.selected[i]; ok { + checked = "x" + } + + s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice) } -} + s += "\nPress q to quit.\n" -func requireNoError(err error) { - if err != nil { - panic(err) - } -} - -func dateToStr(t time.Time) string { - return strconv.FormatInt(t.Unix(), 36) -} - -func userName(r *gemini.Request) []string { - cert := r.Certificate() - if cert == nil { - return []string{""} - } - return []string{cert.Subject.CommonName, cert.SerialNumber.String(), dateToStr(cert.NotBefore), dateToStr(cert.NotAfter)} + return s } func main() { - // Set up structured JSON logging - logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ - Level: slog.LevelInfo, - })) - slog.SetDefault(logger) - - slog.Info("Starting gemini server") - var host, cert, key string - flag.StringVar(&host, "host", ":1965", "listen on host and port. Example: hostname:1965") - flag.StringVar(&cert, "cert", "server.crt.pem", "certificate file") - flag.StringVar(&key, "key", "server.key.pem", "private key associated with certificate file") - flag.Parse() - - pbClient := pocketbase.NewPocketBaseClient() - handler := MainHandler{ - blog: microblog.NewHandler(pbClient), - } - - err := gemini.ListenAndServe(host, cert, key, gemini.TrapPanic(handler.ServeGemini)) - if err != nil { - slog.Error("server failed to start", "error", err) + p := tea.NewProgram(initialModel()) + if _, err := p.Run(); err != nil { + fmt.Printf("Alas, there's been an error: %v", err) os.Exit(1) } } diff --git a/test_pocketbase.go b/test_pocketbase.go deleted file mode 100644 index 5edddf0..0000000 --- a/test_pocketbase.go +++ /dev/null @@ -1,23 +0,0 @@ -package main - -import ( - "fmt" - "log/slog" - - "gemini_site/internal/microblog" - "gemini_site/internal/pocketbase" -) - -func main() { - pbClient := pocketbase.NewPocketBaseClient() - mb := microblog.NewMicroBlog(pbClient) - res, err := mb.GetRecentPosts(10) - // res, err := pbClient.GetList("micro_blog_posts", 1, 10, "-posted") - fmt.Println("Getting page 1 of microblog posts, 10 posts") - // posts, err := client.GetPosts(1) - if err != nil { - slog.Error("Error getting posts", "error", err) - } else { - slog.Info("Got microblog posts", "posts", res) - } -}