initial commit
commit
fe45414521
@ -0,0 +1,9 @@
|
|||||||
|
# Compiled files
|
||||||
|
bin/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# Local files
|
||||||
|
*.local*
|
||||||
|
*.env
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
# NextShell
|
||||||
|
|
||||||
|
Next generation terminal TUI shell built with Go and Bubble Tea.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- [x] Basic layout with tree view and scrollable commands output cells
|
||||||
|
|
||||||
|
- [ ] File system tree view with basic navigation
|
||||||
|
|
||||||
|
- [ ] Basic shell (`sh`) command execution
|
||||||
|
|
||||||
|
- [ ] Parallel command execution with live output streaming
|
||||||
|
|
||||||
|
- [ ] Simple shell-like language inspired by shell, lisp and tcl
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ git clone git@git.phc.dm.unipi.it:aziis98/tui-shell.git
|
||||||
|
$ cd tui-shell
|
||||||
|
$ go run -v .
|
||||||
|
```
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
module nextshell
|
||||||
|
|
||||||
|
go 1.24.5
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/charmbracelet/bubbles v0.21.0
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.6
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.3.2 // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
golang.org/x/sync v0.16.0 // indirect
|
||||||
|
golang.org/x/sys v0.35.0 // indirect
|
||||||
|
golang.org/x/text v0.28.0 // indirect
|
||||||
|
)
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||||
|
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||||
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||||
|
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||||
|
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
|
||||||
|
github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI=
|
||||||
|
github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||||
|
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||||
|
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||||
|
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||||
|
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
|
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||||
@ -0,0 +1,349 @@
|
|||||||
|
// 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
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func flattenTree(n *TreeNode, depth int) []string {
|
||||||
|
var out []string
|
||||||
|
prefix := strings.Repeat(" ", depth)
|
||||||
|
icon := "▸"
|
||||||
|
if n.Expanded && len(n.Children) > 0 {
|
||||||
|
icon = "▾"
|
||||||
|
} else if len(n.Children) == 0 {
|
||||||
|
icon = " "
|
||||||
|
}
|
||||||
|
out = append(out, fmt.Sprintf("%s%s %s", prefix, icon, n.Label))
|
||||||
|
if n.Expanded {
|
||||||
|
for _, c := range n.Children {
|
||||||
|
out = append(out, flattenTree(c, depth+1)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,130 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/charmbracelet/bubbles/textarea"
|
||||||
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
|
)
|
||||||
|
|
||||||
|
func initialModel() model {
|
||||||
|
// detailed tree to showcase design
|
||||||
|
root := &TreeNode{
|
||||||
|
Label: "nextshell-mock-folder",
|
||||||
|
Expanded: true,
|
||||||
|
Children: []*TreeNode{
|
||||||
|
{
|
||||||
|
Label: "cmd",
|
||||||
|
Expanded: true,
|
||||||
|
Children: []*TreeNode{
|
||||||
|
{Label: "build"},
|
||||||
|
{Label: "deploy"},
|
||||||
|
{Label: "test"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Label: "pkg",
|
||||||
|
Expanded: true,
|
||||||
|
Children: []*TreeNode{
|
||||||
|
{
|
||||||
|
Label: "http",
|
||||||
|
Expanded: true,
|
||||||
|
Children: []*TreeNode{
|
||||||
|
{Label: "client.go"},
|
||||||
|
{Label: "server.go"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Label: "ui",
|
||||||
|
Expanded: true,
|
||||||
|
Children: []*TreeNode{
|
||||||
|
{Label: "main.go"},
|
||||||
|
{Label: "widgets.go"},
|
||||||
|
{Label: "styles.go"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Label: "configs",
|
||||||
|
Children: []*TreeNode{
|
||||||
|
{Label: "dev.yaml"},
|
||||||
|
{Label: "prod.yaml"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{Label: "README.md"},
|
||||||
|
{Label: "Makefile"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// sample commands to showcase cards, various sizes
|
||||||
|
commands := []Command{
|
||||||
|
{
|
||||||
|
Input: "help",
|
||||||
|
Result: dedent(`
|
||||||
|
Available commands:
|
||||||
|
- help Show this message
|
||||||
|
- ls List files
|
||||||
|
- cat FILE Show file contents
|
||||||
|
- run TASK Run a task
|
||||||
|
|
||||||
|
Tip: Use 'ls -la' for details.`,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Input: "ls -la /home/alice/projects",
|
||||||
|
Result: dedent(`
|
||||||
|
total 96
|
||||||
|
drwxr-xr-x 12 alice alice 4096 Aug 22 21:00 .
|
||||||
|
drwxr-xr-x 3 alice alice 4096 Aug 10 09:10 ..
|
||||||
|
-rw-r--r-- 1 alice alice 220 Aug 1 12:00 README.md
|
||||||
|
drwxr-xr-x 4 alice alice 4096 Aug 22 20:58 src
|
||||||
|
drwxr-xr-x 3 alice alice 4096 Aug 22 20:55 pkg
|
||||||
|
drwxr-xr-x 5 alice alice 4096 Aug 22 20:59 ui
|
||||||
|
`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Input: "cat src/ui/main.go",
|
||||||
|
Result: dedent(`
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Println("ui starting...")
|
||||||
|
// ...lots of code...
|
||||||
|
}`,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Input: "run test-suite --verbose",
|
||||||
|
Result: longMockOutput(60), // long output to show truncation and scrolling in card result
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Input: "grep -R \"TODO\" .",
|
||||||
|
Result: dedent(`
|
||||||
|
src/ui/widgets.go:23:// TODO: refactor toolbar
|
||||||
|
pkg/http/server.go:78:// TODO: handle timeouts
|
||||||
|
README.md:4:<!-- TODO: add usage examples -->`,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
v := viewport.New(90, 30)
|
||||||
|
|
||||||
|
t := textarea.New()
|
||||||
|
t.ShowLineNumbers = false
|
||||||
|
t.Prompt = "$ "
|
||||||
|
|
||||||
|
m := model{
|
||||||
|
treeRoot: root,
|
||||||
|
viewport: v,
|
||||||
|
commands: commands,
|
||||||
|
textarea: t,
|
||||||
|
}
|
||||||
|
|
||||||
|
// set viewport content with current commands
|
||||||
|
m.resize(120, 40) // initial reasonable size for demo; WindowSizeMsg will update on real terminal
|
||||||
|
m.viewport.SetContent(m.renderCommandsContent())
|
||||||
|
m.viewport.GotoBottom()
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func dedent(s string) string {
|
||||||
|
lines := strings.Split(s, "\n")
|
||||||
|
|
||||||
|
// Remove leading/trailing empty lines
|
||||||
|
for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" {
|
||||||
|
lines = lines[1:]
|
||||||
|
}
|
||||||
|
for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
|
||||||
|
lines = lines[:len(lines)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the next line to determine indentation pattern
|
||||||
|
indent := regexp.MustCompile(`^\s*`).FindString(lines[0])
|
||||||
|
for i, line := range lines {
|
||||||
|
lines[i] = strings.TrimPrefix(line, indent)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func longMockOutput(lines int) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("Running test-suite...\n\n")
|
||||||
|
for i := 1; i <= lines; i++ {
|
||||||
|
fmt.Fprintf(&b, "ok %02d package/name - test case %d passed\n", i%10, i)
|
||||||
|
}
|
||||||
|
b.WriteString("\nSUMMARY: 58 passed, 2 skipped, 0 failed\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func mockResultFor(input string) string {
|
||||||
|
// simple mock: echo input and add a few lines for realism
|
||||||
|
switch input {
|
||||||
|
case "whoami":
|
||||||
|
return "alice"
|
||||||
|
case "date":
|
||||||
|
return "Fri Aug 22 21:23:00 CEST 2025"
|
||||||
|
case "uptime":
|
||||||
|
return " 21:23:00 up 3 days, 4:12, 2 users, load average: 0.42, 0.50, 0.47"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("Mock result for: %s\n\n%s", input, longMockOutput(20))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue