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
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,
|
|
),
|
|
),
|
|
)
|
|
}
|