package main import ( "context" "errors" "net" "os" "os/signal" "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" ) 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 Page int const ( FRONT Page = iota POST ) type model struct { list list.Model allPosts []string width int height int postModel postModel page Page viewport viewport.Model termRenderer *glamour.TermRenderer err error } 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} } list := list.New(listItems, list.NewDefaultDelegate(), 0, 0) list.Title = "Blog Posts" w, h, err := term.GetSize(os.Stdout.Fd()) if err != nil { panic(err) } vp := viewport.New(w, h) vp.Style = lipgloss.NewStyle(). BorderStyle(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("62")). PaddingRight(2) // 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) } return model{list: list, allPosts: posts, page: FRONT, termRenderer: renderer, viewport: vp} } 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: switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case tea.KeyEnter.String(): m.page = POST selectedItem := m.list.SelectedItem().(item) postModel, err := createPostModel(m.allPosts[selectedItem.index], 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 = FRONT } } 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 docStyle.Render(m.list.View()) case POST: return m.postModel.View() default: panic("unreachable") } } const ( host = "localhost" port = "23234" ) func main() { 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) } } // 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()} }