From ffdc0a5c34a0b78cec776049efde074147112ae5 Mon Sep 17 00:00:00 2001 From: a73x Date: Sun, 25 Aug 2024 15:14:42 +0100 Subject: feat: ssh access --- tui/post.go | 126 ++++++++++++++++++++++++++++++++++++++ tui/ssh.go | 197 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 323 insertions(+) create mode 100644 tui/post.go create mode 100644 tui/ssh.go (limited to 'tui') diff --git a/tui/post.go b/tui/post.go new file mode 100644 index 0000000..7762b56 --- /dev/null +++ b/tui/post.go @@ -0,0 +1,126 @@ +package tui + +// An example program demonstrating the pager component from the Bubbles +// component library. + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// You generally won't need this unless you're processing stuff with +// complicated ANSI escape sequences. Turn it on if you notice flickering. +// +// Also keep in mind that high performance rendering only works for programs +// that use the full size of the terminal. We're enabling that below with +// tea.EnterAltScreen(). +const useHighPerformanceRenderer = false + +var ( + titleStyle = func() lipgloss.Style { + b := lipgloss.RoundedBorder() + b.Right = "├" + return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) + }() + + infoStyle = func() lipgloss.Style { + b := lipgloss.RoundedBorder() + b.Left = "┤" + return titleStyle.BorderStyle(b) + }() +) + +type PostModel struct { + content string + ready bool + viewport viewport.Model +} + +func (m PostModel) Init() tea.Cmd { + return nil +} + +func (m PostModel) Update(msg tea.Msg) (PostModel, tea.Cmd) { + var ( + cmd tea.Cmd + cmds []tea.Cmd + ) + + switch msg := msg.(type) { + case tea.KeyMsg: + if k := msg.String(); k == "ctrl+c" || k == "q" || k == "esc" { + return m, tea.Quit + } + + case tea.WindowSizeMsg: + headerHeight := lipgloss.Height(m.headerView()) + footerHeight := lipgloss.Height(m.footerView()) + verticalMarginHeight := headerHeight + footerHeight + + if !m.ready { + // Since this program is using the full size of the viewport we + // need to wait until we've received the window dimensions before + // we can initialize the viewport. The initial dimensions come in + // quickly, though asynchronously, which is why we wait for them + // here. + m.viewport = viewport.New(msg.Width, msg.Height-verticalMarginHeight) + m.viewport.YPosition = headerHeight + m.viewport.HighPerformanceRendering = useHighPerformanceRenderer + m.viewport.SetContent(m.content) + m.ready = true + + // This is only necessary for high performance rendering, which in + // most cases you won't need. + // + // Render the viewport one line below the header. + m.viewport.YPosition = headerHeight + 1 + } else { + m.viewport.Width = msg.Width + m.viewport.Height = msg.Height - verticalMarginHeight + } + + if useHighPerformanceRenderer { + // Render (or re-render) the whole viewport. Necessary both to + // initialize the viewport and when the window is resized. + // + // This is needed for high-performance rendering only. + cmds = append(cmds, viewport.Sync(m.viewport)) + } + } + + // Handle keyboard and mouse events in the viewport + m.viewport, cmd = m.viewport.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m PostModel) View() string { + if !m.ready { + return "\n Initializing..." + } + return fmt.Sprintf("%s\n%s\n%s", m.headerView(), m.viewport.View(), m.footerView()) +} + +func (m PostModel) headerView() string { + title := titleStyle.Render("Mr. Pager") + line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(title))) + return lipgloss.JoinHorizontal(lipgloss.Center, title, line) +} + +func (m PostModel) footerView() string { + info := infoStyle.Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100)) + line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(info))) + return lipgloss.JoinHorizontal(lipgloss.Center, line, info) +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/tui/ssh.go b/tui/ssh.go new file mode 100644 index 0000000..2309b3f --- /dev/null +++ b/tui/ssh.go @@ -0,0 +1,197 @@ +package tui + +// An example Bubble Tea server. This will put an ssh session into alt screen +// and continually print up to date terminal information. + +import ( + "fmt" + "io/fs" + "net" + + "git.sr.ht/~a73x/home" + 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" +) + +func New(host, port string) (*ssh.Server, error) { + s, err := wish.NewServer( + wish.WithAddress(net.JoinHostPort(host, port)), + wish.WithHostKeyPath(".ssh/id_ed25519"), + 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) + } + + return s, 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" + } + + posts, err := home.Content.ReadDir("posts") + if err != nil { + // fk knows + } + + postFiles := make([]string, 0, len(posts)) + for _, post := range posts { + postFiles = append(postFiles, "posts/"+post.Name()) + } + + m := model{ + term: pty.Term, + profile: renderer.ColorProfile().Name(), + width: pty.Window.Width, + height: pty.Window.Height, + bg: bg, + txtStyle: txtStyle, + quitStyle: quitStyle, + posts: postFiles, + } + return m, []tea.ProgramOption{tea.WithAltScreen()} +} + +// Just a generic tea.Model to demo terminal information of ssh. +type model struct { + term string + profile string + width int + height int + bg string + txtStyle lipgloss.Style + quitStyle lipgloss.Style + posts []string + cursor int + viewer *PostModel + err error +} + +func (m model) Init() tea.Cmd { + return nil +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + if m.viewer != nil { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + m.viewer = nil + return m, nil + case "esc": + return m, nil + } + } + viewer, cmd := m.viewer.Update(msg) + m.viewer = &viewer + return m, cmd + } + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.height = msg.Height + m.width = msg.Width + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.posts)-1 { + m.cursor++ + } + case "enter": + content, err := fs.ReadFile(home.Content, m.posts[m.cursor]) + if err != nil { + m.err = err + return m, nil + } + + out, err := glamour.Render(string(content), "dark") + if err != nil { + m.err = err + return m, nil + } + + m.viewer = &PostModel{ + content: out, + } + fakeWindowMsg := tea.WindowSizeMsg{ + Width: m.width, + Height: m.height - 1, + } + + viewer, cmd := m.viewer.Update(fakeWindowMsg) + m.viewer = &viewer + return m, cmd + } + } + + return m, tea.Batch(cmds...) +} + +func (m model) View() string { + if m.err != nil { + return m.err.Error() + } + + if m.viewer != nil { + return m.viewer.View() + "\n arrow keys to scroll and q to go back" + } + + s := "a73x\n\n" + for i, choice := range m.posts { + // Is the cursor pointing at this choice? + cursor := " " // no cursor + if m.cursor == i { + cursor = ">" // cursor! + } + + // Render the row + s += fmt.Sprintf("%s %s\n", cursor, choice) + } + s += "\narrow keys to select post and enter to select" + s += "\npress q to quit" + + return s +} -- cgit v1.2.3