You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

407 lines
8.7 KiB
Go

// main.go
package main
import (
"fmt"
"log"
"os"
"path"
"strings"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type TreeNode struct {
Label string
Children []*TreeNode
Expanded bool
}
type Command struct {
Input string
Result string
}
type model struct {
width int
height int
leftWidth int
treeRoot *TreeNode
treeCursor int
textarea textarea.Model
commands []Command
viewport viewport.Model
focusPane string // "prompt", "fileview", "commands"
}
var (
borderStyle = lipgloss.NormalBorder()
paneBoxStyle = lipgloss.NewStyle().
Padding(0, 1).
Border(borderStyle).
BorderForeground(lipgloss.Color("0"))
paneBoxFocusedStyle = lipgloss.NewStyle().
Padding(0, 1).
Border(borderStyle).
BorderForeground(lipgloss.Color("6"))
commandBoxInputStyle = lipgloss.NewStyle().
Bold(true)
commandBoxResultStyle = lipgloss.NewStyle().
Background(lipgloss.Color("15")).
MaxHeight(8)
)
func main() {
p := tea.NewProgram(initialModel(), tea.WithAltScreen(), tea.WithMouseCellMotion())
_, err := p.Run()
if err != nil {
fmt.Fprintf(os.Stderr, "Error running program: %v\n", err)
os.Exit(1)
}
}
// returns both the string representation and corresponding nodes
func flattenTreeWithNodes(n *TreeNode, depth int) ([]string, []*TreeNode) {
var lines []string
var nodes []*TreeNode
prefix := strings.Repeat(" ", depth)
icon := "▸"
if n.Expanded && len(n.Children) > 0 {
icon = "▾"
} else if len(n.Children) == 0 {
icon = " "
}
lines = append(lines, fmt.Sprintf("%s%s %s", prefix, icon, n.Label))
nodes = append(nodes, n)
if n.Expanded {
for _, c := range n.Children {
childLines, childNodes := flattenTreeWithNodes(c, depth+1)
lines = append(lines, childLines...)
nodes = append(nodes, childNodes...)
}
}
return lines, nodes
}
func flattenTree(n *TreeNode, depth int) []string {
lines, _ := flattenTreeWithNodes(n, depth)
return lines
}
// toggleNodeExpansion toggles the expanded state of a node if it has children
func (m *model) toggleNodeExpansion(node *TreeNode) {
if len(node.Children) > 0 {
node.Expanded = !node.Expanded
}
}
func (m *model) renderCommandsContent() string {
if len(m.commands) == 0 {
return "(no commands yet)"
}
commandBoxes := []string{}
for _, c := range m.commands {
// input header + result with limited height per card (visual truncation)
commandBoxes = append(commandBoxes,
paneBoxStyle.Render(
lipgloss.JoinVertical(
lipgloss.Left,
commandBoxInputStyle.Render(
fmt.Sprintf("$ %s",
strings.ReplaceAll(strings.TrimSpace(c.Input), "\n", " ↵ "),
),
),
commandBoxResultStyle.
Render(limitedResult(c.Result, 8)),
),
),
)
}
return lipgloss.JoinVertical(lipgloss.Left, commandBoxes...)
}
func limitedResult(result string, maxLines int) string {
lines := strings.Split(result, "\n")
if len(lines) <= maxLines {
return strings.Join(lines, "\n")
}
trunc := strings.Join(lines[:maxLines], "\n")
trunc += "\n" + lipgloss.NewStyle().Italic(true).Render("…")
return trunc
}
func (m model) rightWidth() int {
return m.width - m.leftWidth - 1
}
func (m model) rightContentWidth() int {
return m.rightWidth() - 4
}
func (m model) promptContentHeight() int {
return 1 + len(m.textarea.Value())/(m.rightContentWidth()-5)
}
func (m *model) resize(w, h int) {
m.width = w
m.height = h
m.leftWidth = w / 5
m.viewport.Width = m.rightContentWidth()
m.viewport.Height = h - m.promptContentHeight() - 5
m.textarea.SetWidth(m.rightContentWidth() - 2)
m.textarea.SetHeight(m.promptContentHeight())
m.viewport.SetContent(m.renderCommandsContent())
}
func (m model) Init() tea.Cmd {
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds := []tea.Cmd{}
if m.focusPane == "" {
m.focusPane = "prompt"
cmds = append(cmds, m.textarea.Focus())
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "tab":
// cycle focus: prompt -> fileview -> commands -> prompt
switch m.focusPane {
case "prompt":
m.focusPane = "fileview"
m.textarea.Blur()
case "fileview":
m.focusPane = "commands"
m.textarea.Blur()
default:
m.focusPane = "prompt"
return m, m.textarea.Focus()
}
return m, nil
}
// route keys based on which pane has focus
if m.focusPane == "prompt" {
// prompt (textarea) behaviour
// submit on Enter (textarea normally inserts newlines; here Enter submits)
if msg.Type == tea.KeyEnter {
input := strings.TrimSpace(m.textarea.Value())
if input != "" {
mock := mockResultFor(input)
m.commands = append(m.commands, Command{
Input: input,
Result: mock,
})
m.viewport.SetContent(m.renderCommandsContent())
m.viewport.GotoBottom()
m.textarea.SetValue("")
}
if m.width > 0 {
m.resize(m.width, m.height)
}
return m, nil
}
m.textarea.SetHeight(m.promptContentHeight())
// forward other key messages to textarea
var taCmd tea.Cmd
m.textarea, taCmd = m.textarea.Update(msg)
if m.width > 0 {
m.resize(m.width, m.height)
}
cmds = append(cmds, taCmd)
return m, tea.Batch(cmds...)
}
if m.focusPane == "fileview" {
// fileview tree behaviour (navigation, expand/collapse)
_, nodes := flattenTreeWithNodes(m.treeRoot, 0)
switch msg.String() {
case "up", "k":
if m.treeCursor > 0 {
m.treeCursor--
}
case "down", "j":
if m.treeCursor < len(nodes)-1 {
m.treeCursor++
}
case "left", "h":
if m.treeCursor < len(nodes) {
node := nodes[m.treeCursor]
if node.Expanded && len(node.Children) > 0 {
node.Expanded = false
}
}
case "right", "l":
if m.treeCursor < len(nodes) {
node := nodes[m.treeCursor]
if !node.Expanded && len(node.Children) > 0 {
node.Expanded = true
}
}
case "enter", " ":
// toggle
if m.treeCursor < len(nodes) {
m.toggleNodeExpansion(nodes[m.treeCursor])
}
}
return m, nil
}
if m.focusPane == "commands" {
// commands viewport behaviour (scrolling)
switch msg.String() {
case "up", "k":
m.viewport.ScrollUp(1)
case "down", "j":
m.viewport.ScrollDown(1)
case "pgup":
m.viewport.ScrollUp(7)
case "pgdown":
m.viewport.ScrollDown(7)
case "home":
m.viewport.GotoTop()
case "end":
m.viewport.GotoBottom()
}
return m, nil
}
case tea.WindowSizeMsg:
m.resize(msg.Width, msg.Height)
return m, nil
}
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m model) View() string {
var fileviewRows []string
// TODO: not good practice to throw in View, should add a field to model instead
homeDir, err := os.UserHomeDir()
if err != nil {
log.Fatal(err)
}
wd, err := os.Getwd()
if err != nil {
log.Fatal(err)
}
wd = path.Dir(wd)
if strings.HasPrefix(wd, homeDir) {
wd = strings.Replace(wd, homeDir, "~", 1)
}
parts := strings.Split(wd, string(os.PathSeparator))
// show full path as series of folders at top of fileview
for _, part := range parts {
fileviewRows = append(fileviewRows,
lipgloss.NewStyle().
Bold(true).
Render("▼ "+part+"/"),
)
}
treeLines := flattenTree(m.treeRoot, 0)
// highlight the current selection when fileview is focused
if m.focusPane == "fileview" && m.treeCursor < len(treeLines) {
selectedStyle := lipgloss.NewStyle().
Background(lipgloss.Color("6")).
Foreground(lipgloss.Color("0"))
treeLines[m.treeCursor] = selectedStyle.Render(treeLines[m.treeCursor])
}
// show the tree view content
fileviewRows = append(fileviewRows, treeLines...)
// build the main layout
paneStyleDynamic := func(paneName string) lipgloss.Style {
if m.focusPane == paneName {
return paneBoxFocusedStyle
}
return paneBoxStyle
}
fileviewBox := paneStyleDynamic("fileview").
Width(m.leftWidth).
Height(m.height - 2).
Render(
strings.Join(fileviewRows, "\n"),
)
commandsBox := paneStyleDynamic("commands").
Width(m.rightContentWidth()).
Render(m.viewport.View())
promptBox := paneStyleDynamic("prompt").
Width(m.rightContentWidth()).
Height(m.promptContentHeight()).
Render(m.textarea.View())
statusBarHelp := lipgloss.NewStyle().
Faint(true).
Render(
strings.Join([]string{
"tab: toggle focus",
"enter: submit",
"up/down/left/right: tree nav",
"pgup/pgdn: scroll",
"q/ctrl+c: quit",
}, " • "),
)
return lipgloss.JoinHorizontal(
lipgloss.Top,
fileviewBox,
lipgloss.NewStyle().
PaddingLeft(1).
Render(
lipgloss.JoinVertical(
lipgloss.Left,
commandsBox,
promptBox,
statusBarHelp,
),
),
)
}