package main import ( "context" "errors" "flag" "net" "os" "os/signal" "path/filepath" "slices" "strings" "syscall" "time" "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" ) 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 Page int const ( FRONT Page = iota POST_LIST POST ) type model struct { renderer *lipgloss.Renderer termRenderer *glamour.TermRenderer list list.Model allPosts []string width int height int navModel navModel frontPageContent string frontPageModel postModel postModel postModel page Page viewport viewport.Model err error } func getViewPort(renderer *lipgloss.Renderer, width int, height int) viewport.Model { vp := viewport.New(width, height) vp.Style = renderer.NewStyle(). BorderStyle(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("62")). PaddingRight(2) return vp } func initialModel(renderer *lipgloss.Renderer, w int, h int) model { posts, err := getAllPosts(filepath.Join(os.Getenv("BLOG_PATH"), "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} } list := list.New(listItems, list.NewDefaultDelegate(), w, h-4) list.SetShowTitle(false) // -1 to allow for the initial height of the nav model vp := getViewPort(renderer, w, h-3) // 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 termRenderer, err := glamour.NewTermRenderer( glamour.WithAutoStyle(), glamour.WithWordWrap(glamourRenderWidth), ) if err != nil { panic(err) } frontPageContent, err := os.ReadFile(filepath.Join(os.Getenv("BLOG_PATH"), "content/_index.md")) if err != nil { panic(err) } processedFrontPage, err := processMarkdown(string(frontPageContent)) if err != nil { panic(err) } frontPageModel, err := createMarkdownPostModel(processedFrontPage, termRenderer, vp) if err != nil { panic(err) } return model{ list: list, allPosts: posts, page: FRONT, termRenderer: termRenderer, viewport: vp, frontPageModel: frontPageModel, frontPageContent: processedFrontPage, width: w, height: h, navModel: createNavModel(renderer, w), } } func (m model) Init() tea.Cmd { return tea.WindowSize() } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "h": m.page = FRONT return m, nil case "b": m.page = POST_LIST return m, nil } } 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 } } 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 := m.termRenderer.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: return m.navModel.View() + "\n" + m.frontPageModel.View() case POST_LIST: return m.navModel.View() + m.renderer.NewStyle().Margin(1, 2).Render(m.list.View()) case POST: return m.navModel.View() + "\n" + 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 { w, h, err := term.GetSize(os.Stdout.Fd()) if err != nil { panic(err) } p := tea.NewProgram(initialModel(lipgloss.DefaultRenderer(), w, h)) 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) m := initialModel(renderer, pty.Window.Width, pty.Window.Height) return m, []tea.ProgramOption{tea.WithAltScreen()} }