405 lines
9.5 KiB
Go
405 lines
9.5 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"net"
|
|
"os"
|
|
"os/signal"
|
|
"slices"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/help"
|
|
"github.com/charmbracelet/bubbles/key"
|
|
"github.com/charmbracelet/bubbles/list"
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/glamour"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/charmbracelet/log"
|
|
"github.com/charmbracelet/ssh"
|
|
"github.com/charmbracelet/wish"
|
|
"github.com/charmbracelet/wish/activeterm"
|
|
"github.com/charmbracelet/wish/bubbletea"
|
|
"github.com/charmbracelet/wish/logging"
|
|
"github.com/charmbracelet/x/term"
|
|
)
|
|
|
|
var docStyle = lipgloss.NewStyle().Margin(1, 2)
|
|
|
|
type errMsg error
|
|
|
|
type item struct {
|
|
title, desc string
|
|
index int // This is stupid but it'll work for now
|
|
}
|
|
|
|
func (i item) Title() string { return i.title }
|
|
func (i item) Description() string { return i.desc }
|
|
func (i item) FilterValue() string { return i.title }
|
|
|
|
// TODO: this should take in a parsed object, not just a string.
|
|
// That way we don't have to handle the parsing errors here.
|
|
func getPostWithoutMetaData(post string) (string, error) {
|
|
splitPost := strings.Split(post, "\n")
|
|
|
|
index := slices.Index(splitPost[1:], "+++")
|
|
if index == -1 {
|
|
return "", errors.New("Could not find close +++")
|
|
}
|
|
|
|
return strings.Join(splitPost[index+2:], "\n"), nil
|
|
}
|
|
|
|
type keyMap struct {
|
|
Up key.Binding
|
|
Down key.Binding
|
|
Left key.Binding
|
|
Right key.Binding
|
|
Blog key.Binding
|
|
Help key.Binding
|
|
Quit key.Binding
|
|
}
|
|
|
|
func (k keyMap) ShortHelp() []key.Binding {
|
|
return []key.Binding{k.Help, k.Quit}
|
|
}
|
|
|
|
func (k keyMap) FullHelp() [][]key.Binding {
|
|
return [][]key.Binding{
|
|
{k.Up, k.Down, k.Left, k.Right},
|
|
{k.Blog, k.Help, k.Quit},
|
|
}
|
|
}
|
|
|
|
var keys = keyMap{
|
|
Up: key.NewBinding(
|
|
key.WithKeys("up", "k"),
|
|
key.WithHelp("↑/k", "move up"),
|
|
),
|
|
Down: key.NewBinding(
|
|
key.WithKeys("down", "j"),
|
|
key.WithHelp("↓/j", "move down"),
|
|
),
|
|
Left: key.NewBinding(
|
|
key.WithKeys("left", "h"),
|
|
key.WithHelp("←/h", "move left"),
|
|
),
|
|
Right: key.NewBinding(
|
|
key.WithKeys("right", "l"),
|
|
key.WithHelp("→/l", "move right"),
|
|
),
|
|
Blog: key.NewBinding(
|
|
key.WithKeys("b"),
|
|
key.WithHelp("b", "go to blog"),
|
|
),
|
|
Help: key.NewBinding(
|
|
key.WithKeys("?"),
|
|
key.WithHelp("?", "toggle help"),
|
|
),
|
|
Quit: key.NewBinding(
|
|
key.WithKeys("q", "esc", "ctrl+c"),
|
|
key.WithHelp("q", "quit"),
|
|
),
|
|
}
|
|
|
|
type Page int
|
|
|
|
const (
|
|
FRONT Page = iota
|
|
POST_LIST
|
|
POST
|
|
)
|
|
|
|
type model struct {
|
|
list list.Model
|
|
allPosts []string
|
|
|
|
keys keyMap
|
|
help help.Model
|
|
|
|
width int
|
|
height int
|
|
|
|
frontPageContent string
|
|
frontPageModel postModel
|
|
postModel postModel
|
|
|
|
page Page
|
|
|
|
viewport viewport.Model
|
|
termRenderer *glamour.TermRenderer
|
|
|
|
err error
|
|
}
|
|
|
|
func getViewPort(width int, height int) viewport.Model {
|
|
vp := viewport.New(width, height)
|
|
vp.Style = lipgloss.NewStyle().
|
|
BorderStyle(lipgloss.RoundedBorder()).
|
|
BorderForeground(lipgloss.Color("62")).
|
|
PaddingRight(2)
|
|
|
|
return vp
|
|
}
|
|
|
|
func initialModel() model {
|
|
posts, err := getAllPosts("/home/johnc/Code/JohnTech/content/blog")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
listItems := make([]list.Item, len(posts))
|
|
for i, post := range posts {
|
|
postInfo, err := getPostInfo(post)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
listItems[i] = item{title: postInfo.Title, desc: postInfo.Date.String(), index: i}
|
|
}
|
|
|
|
w, h, err := term.GetSize(os.Stdout.Fd())
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
list := list.New(listItems, list.NewDefaultDelegate(), w, h)
|
|
list.Title = "Blog Posts"
|
|
|
|
// -1 to allow for the initial height of the help model
|
|
vp := getViewPort(w, h-1)
|
|
|
|
// We need to adjust the width of the glamour render from our main width
|
|
// to account for a few things:
|
|
//
|
|
// * The viewport border width
|
|
// * The viewport padding
|
|
// * The viewport margins
|
|
// * The gutter glamour applies to the left side of the content
|
|
//
|
|
const glamourGutter = 2
|
|
glamourRenderWidth := w - vp.Style.GetHorizontalFrameSize() - glamourGutter
|
|
|
|
renderer, err := glamour.NewTermRenderer(
|
|
glamour.WithAutoStyle(),
|
|
glamour.WithWordWrap(glamourRenderWidth),
|
|
)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
frontPageContent, err := os.ReadFile("/home/johnc/Code/JohnTech/content/_index.md")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
processedFrontPage, err := processMarkdown(string(frontPageContent))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
frontPageModel, err := createMarkdownPostModel(processedFrontPage, renderer, vp)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
return model{list: list, allPosts: posts, page: FRONT, termRenderer: renderer, viewport: vp, frontPageModel: frontPageModel, frontPageContent: processedFrontPage, help: help.New(), keys: keys, width: w, height: h}
|
|
}
|
|
|
|
func (m model) Init() tea.Cmd {
|
|
return tea.WindowSize()
|
|
}
|
|
|
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch m.page {
|
|
case FRONT:
|
|
updatedFrontPageModel, cmd := m.frontPageModel.Update(msg)
|
|
m.frontPageModel = updatedFrontPageModel.(postModel)
|
|
|
|
if cmd != nil {
|
|
if _, ok := cmd().(childMessage); ok {
|
|
return m, tea.Quit
|
|
}
|
|
}
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
switch {
|
|
case key.Matches(msg, m.keys.Help):
|
|
m.help.ShowAll = !m.help.ShowAll
|
|
|
|
adjustedViewportHeight := 0
|
|
if m.help.ShowAll {
|
|
adjustedViewportHeight = 4
|
|
} else {
|
|
adjustedViewportHeight = 1
|
|
}
|
|
|
|
viewportYOffset := m.frontPageModel.viewport.YOffset
|
|
m.viewport = getViewPort(m.width, m.height-adjustedViewportHeight)
|
|
|
|
frontPageModel, err := createMarkdownPostModel(m.frontPageContent, m.termRenderer, m.viewport)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
m.frontPageModel = frontPageModel
|
|
m.frontPageModel.viewport.SetYOffset(viewportYOffset)
|
|
case key.Matches(msg, m.keys.Blog):
|
|
m.page = POST_LIST
|
|
}
|
|
}
|
|
|
|
return m, cmd
|
|
case POST_LIST:
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case tea.KeyEnter.String():
|
|
m.page = POST
|
|
selectedItem := m.list.SelectedItem().(item)
|
|
|
|
processedPost, err := processMarkdown(m.allPosts[selectedItem.index])
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
postModel, err := createMarkdownPostModel(processedPost, m.termRenderer, m.viewport)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
m.postModel = postModel
|
|
return m, nil
|
|
}
|
|
case tea.WindowSizeMsg:
|
|
h, v := docStyle.GetFrameSize()
|
|
m.list.SetSize(msg.Width-h, msg.Height-v)
|
|
|
|
m.height = msg.Height
|
|
m.width = msg.Width
|
|
}
|
|
|
|
list, cmd := m.list.Update(msg)
|
|
m.list = list
|
|
|
|
return m, cmd
|
|
case POST:
|
|
updatedPostModel, cmd := m.postModel.Update(msg)
|
|
if cmd != nil {
|
|
if _, ok := cmd().(childMessage); ok {
|
|
m.page = POST_LIST
|
|
}
|
|
}
|
|
|
|
m.postModel = updatedPostModel.(postModel)
|
|
|
|
return m, nil
|
|
default:
|
|
panic("unreachable")
|
|
}
|
|
}
|
|
|
|
func (m model) View() string {
|
|
// TODO: better type safety would be to do this over the type.
|
|
switch m.page {
|
|
case FRONT:
|
|
helpView := m.help.View(m.keys)
|
|
|
|
return m.frontPageModel.View() + "\n" + helpView
|
|
case POST_LIST:
|
|
return docStyle.Render(m.list.View())
|
|
case POST:
|
|
return m.postModel.View()
|
|
default:
|
|
panic("unreachable")
|
|
}
|
|
}
|
|
|
|
const (
|
|
host = "localhost"
|
|
port = "23234"
|
|
)
|
|
|
|
func initServer() {
|
|
s, err := wish.NewServer(
|
|
wish.WithAddress(net.JoinHostPort(host, port)),
|
|
wish.WithHostKeyPath(".ssh/id_rsa"),
|
|
wish.WithMiddleware(
|
|
bubbletea.Middleware(teaHandler),
|
|
activeterm.Middleware(), // Bubble Tea apps usually require a PTY.
|
|
logging.Middleware(),
|
|
),
|
|
)
|
|
if err != nil {
|
|
log.Error("Could not start server", "error", err)
|
|
}
|
|
|
|
done := make(chan os.Signal, 1)
|
|
signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
|
|
log.Info("Starting SSH server", "host", host, "port", port)
|
|
go func() {
|
|
if err = s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
|
|
log.Error("Could not start server", "error", err)
|
|
done <- nil
|
|
}
|
|
}()
|
|
|
|
<-done
|
|
log.Info("Stopping SSH server")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer func() { cancel() }()
|
|
if err := s.Shutdown(ctx); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
|
|
log.Error("Could not stop server", "error", err)
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
var isSsh bool
|
|
flag.BoolVar(&isSsh, "ssh", false, "Start an SSH server instead of the TUI application.")
|
|
|
|
flag.Parse()
|
|
|
|
if isSsh {
|
|
initServer()
|
|
} else {
|
|
p := tea.NewProgram(initialModel())
|
|
if _, err := p.Run(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// You can wire any Bubble Tea model up to the middleware with a function that
|
|
// handles the incoming ssh.Session. Here we just grab the terminal info and
|
|
// pass it to the new model. You can also return tea.ProgramOptions (such as
|
|
// tea.WithAltScreen) on a session by session basis.
|
|
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
|
// This should never fail, as we are using the activeterm middleware.
|
|
// pty, _, _ := s.Pty()
|
|
|
|
// When running a Bubble Tea app over SSH, you shouldn't use the default
|
|
// lipgloss.NewStyle function.
|
|
// That function will use the color profile from the os.Stdin, which is the
|
|
// server, not the client.
|
|
// We provide a MakeRenderer function in the bubbletea middleware package,
|
|
// so you can easily get the correct renderer for the current session, and
|
|
// use it to create the styles.
|
|
// The recommended way to use these styles is to then pass them down to
|
|
// your Bubble Tea model.
|
|
// renderer := bubbletea.MakeRenderer(s)
|
|
// txtStyle := renderer.NewStyle().Foreground(lipgloss.Color("10"))
|
|
// quitStyle := renderer.NewStyle().Foreground(lipgloss.Color("8"))
|
|
//
|
|
// bg := "light"
|
|
// if renderer.HasDarkBackground() {
|
|
// bg = "dark"
|
|
// }
|
|
|
|
m := initialModel()
|
|
return m, []tea.ProgramOption{tea.WithAltScreen()}
|
|
}
|