488 lines
12 KiB
Go
488 lines
12 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"charm.land/bubbles/v2/textarea"
|
|
"charm.land/bubbles/v2/textinput"
|
|
tea "charm.land/bubbletea/v2"
|
|
"charm.land/lipgloss/v2"
|
|
|
|
"dirmd/internal/config"
|
|
)
|
|
|
|
const (
|
|
fieldInputDir = iota
|
|
fieldOutputFile
|
|
fieldAppend
|
|
fieldForce
|
|
fieldVerticalSlices
|
|
fieldSkipFrontend
|
|
fieldFrontendOnly
|
|
fieldOmitTest
|
|
fieldOmitMd
|
|
fieldProtonDrive
|
|
fieldDrivePath
|
|
fieldMaxDepth
|
|
fieldMaxSize
|
|
fieldIgnorePatterns
|
|
fieldGenerateBtn
|
|
fieldCancelBtn
|
|
fieldCount
|
|
)
|
|
|
|
type FormModel struct {
|
|
focusIndex int
|
|
inputDir textinput.Model
|
|
outputFile textinput.Model
|
|
drivePath textinput.Model
|
|
maxDepth textinput.Model
|
|
maxSize textinput.Model
|
|
ignoreArea textarea.Model
|
|
appendMode Checkbox
|
|
force Checkbox
|
|
verticalSlices Checkbox
|
|
skipFrontend Checkbox
|
|
frontendOnly Checkbox
|
|
omitTest Checkbox
|
|
omitMd Checkbox
|
|
protonDrive Checkbox
|
|
isDark bool
|
|
width int
|
|
height int
|
|
err string
|
|
}
|
|
|
|
func NewForm() FormModel {
|
|
inputDir := textinput.New()
|
|
inputDir.Placeholder = "."
|
|
inputDir.SetValue(".")
|
|
inputDir.SetWidth(60)
|
|
|
|
outputFile := textinput.New()
|
|
outputFile.Placeholder = "./output_dirmd/index.md"
|
|
outputFile.SetValue("./output_dirmd/index.md")
|
|
outputFile.SetWidth(60)
|
|
|
|
drivePath := textinput.New()
|
|
drivePath.Placeholder = "/my-files/md/"
|
|
drivePath.SetWidth(60)
|
|
|
|
maxDepth := textinput.New()
|
|
maxDepth.SetValue("20")
|
|
maxDepth.SetWidth(10)
|
|
|
|
maxSize := textinput.New()
|
|
maxSize.SetValue("512")
|
|
maxSize.SetWidth(10)
|
|
|
|
ignoreArea := textarea.New()
|
|
ignoreArea.Placeholder = "One pattern per line..."
|
|
ignoreArea.SetHeight(5)
|
|
ignoreArea.ShowLineNumbers = false
|
|
ignoreArea.CharLimit = 0
|
|
|
|
m := FormModel{
|
|
inputDir: inputDir,
|
|
outputFile: outputFile,
|
|
drivePath: drivePath,
|
|
maxDepth: maxDepth,
|
|
maxSize: maxSize,
|
|
ignoreArea: ignoreArea,
|
|
appendMode: Checkbox{Label: "Append to existing file"},
|
|
force: Checkbox{Label: "Force overwrite"},
|
|
verticalSlices: Checkbox{Label: "Vertical slices"},
|
|
skipFrontend: Checkbox{Label: "Skip frontend files"},
|
|
frontendOnly: Checkbox{Label: "Frontend only"},
|
|
omitTest: Checkbox{Label: "Omit test files"},
|
|
omitMd: Checkbox{Label: "Omit non-README markdown"},
|
|
protonDrive: Checkbox{Label: "Proton Drive upload"},
|
|
focusIndex: fieldInputDir,
|
|
isDark: true,
|
|
}
|
|
|
|
m.applyStyles()
|
|
m.focusActiveField()
|
|
return m
|
|
}
|
|
|
|
func (m *FormModel) SetDark(isDark bool) {
|
|
if m.isDark == isDark {
|
|
return
|
|
}
|
|
m.isDark = isDark
|
|
m.applyStyles()
|
|
}
|
|
|
|
func (m *FormModel) applyStyles() {
|
|
tiStyles := getDefaultInputStyles(m.isDark)
|
|
m.inputDir.SetStyles(tiStyles)
|
|
m.outputFile.SetStyles(tiStyles)
|
|
m.drivePath.SetStyles(tiStyles)
|
|
m.maxDepth.SetStyles(tiStyles)
|
|
m.maxSize.SetStyles(tiStyles)
|
|
|
|
taStyles := getDefaultTextareaStyles(m.isDark)
|
|
m.ignoreArea.SetStyles(taStyles)
|
|
}
|
|
|
|
func (m *FormModel) shouldSkipField(idx int) bool {
|
|
return idx == fieldDrivePath && !m.protonDrive.Checked
|
|
}
|
|
|
|
func isTextInputField(idx int) bool {
|
|
switch idx {
|
|
case fieldInputDir, fieldOutputFile, fieldDrivePath, fieldMaxDepth, fieldMaxSize, fieldIgnorePatterns:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (m *FormModel) focusActiveField() tea.Cmd {
|
|
m.inputDir.Blur()
|
|
m.outputFile.Blur()
|
|
m.drivePath.Blur()
|
|
m.maxDepth.Blur()
|
|
m.maxSize.Blur()
|
|
m.ignoreArea.Blur()
|
|
|
|
var cmd tea.Cmd
|
|
|
|
switch m.focusIndex {
|
|
case fieldInputDir:
|
|
cmd = m.inputDir.Focus()
|
|
case fieldOutputFile:
|
|
cmd = m.outputFile.Focus()
|
|
case fieldDrivePath:
|
|
cmd = m.drivePath.Focus()
|
|
case fieldMaxDepth:
|
|
cmd = m.maxDepth.Focus()
|
|
case fieldMaxSize:
|
|
cmd = m.maxSize.Focus()
|
|
case fieldIgnorePatterns:
|
|
cmd = m.ignoreArea.Focus()
|
|
}
|
|
|
|
return cmd
|
|
}
|
|
|
|
func (m FormModel) Update(msg tea.Msg) (FormModel, tea.Cmd) {
|
|
var cmds []tea.Cmd
|
|
|
|
if k, ok := msg.(tea.KeyPressMsg); ok {
|
|
if isTextInputField(m.focusIndex) {
|
|
if isNavigateDown(k) || isNavigateUp(k) {
|
|
if isNavigateDown(k) {
|
|
m.focusIndex++
|
|
if m.focusIndex >= fieldCount {
|
|
m.focusIndex = 0
|
|
}
|
|
for m.shouldSkipField(m.focusIndex) {
|
|
m.focusIndex++
|
|
if m.focusIndex >= fieldCount {
|
|
m.focusIndex = 0
|
|
}
|
|
}
|
|
} else {
|
|
m.focusIndex--
|
|
if m.focusIndex < 0 {
|
|
m.focusIndex = fieldCount - 1
|
|
}
|
|
for m.shouldSkipField(m.focusIndex) {
|
|
m.focusIndex--
|
|
if m.focusIndex < 0 {
|
|
m.focusIndex = fieldCount - 1
|
|
}
|
|
}
|
|
}
|
|
cmd := m.focusActiveField()
|
|
if cmd != nil {
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
} else {
|
|
switch {
|
|
case isNavigateDown(k):
|
|
m.focusIndex++
|
|
if m.focusIndex >= fieldCount {
|
|
m.focusIndex = 0
|
|
}
|
|
for m.shouldSkipField(m.focusIndex) {
|
|
m.focusIndex++
|
|
if m.focusIndex >= fieldCount {
|
|
m.focusIndex = 0
|
|
}
|
|
}
|
|
cmd := m.focusActiveField()
|
|
if cmd != nil {
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
return m, tea.Batch(cmds...)
|
|
case isNavigateUp(k):
|
|
m.focusIndex--
|
|
if m.focusIndex < 0 {
|
|
m.focusIndex = fieldCount - 1
|
|
}
|
|
for m.shouldSkipField(m.focusIndex) {
|
|
m.focusIndex--
|
|
if m.focusIndex < 0 {
|
|
m.focusIndex = fieldCount - 1
|
|
}
|
|
}
|
|
cmd := m.focusActiveField()
|
|
if cmd != nil {
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
return m, tea.Batch(cmds...)
|
|
case isActivate(k):
|
|
switch m.focusIndex {
|
|
case fieldAppend:
|
|
m.appendMode.Toggle()
|
|
case fieldForce:
|
|
m.force.Toggle()
|
|
case fieldVerticalSlices:
|
|
m.verticalSlices.Toggle()
|
|
case fieldSkipFrontend:
|
|
m.skipFrontend.Toggle()
|
|
case fieldFrontendOnly:
|
|
m.frontendOnly.Toggle()
|
|
case fieldOmitTest:
|
|
m.omitTest.Toggle()
|
|
case fieldOmitMd:
|
|
m.omitMd.Toggle()
|
|
case fieldProtonDrive:
|
|
m.protonDrive.Toggle()
|
|
case fieldGenerateBtn:
|
|
return m, nil
|
|
case fieldCancelBtn:
|
|
return m, tea.Quit
|
|
}
|
|
return m, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
switch m.focusIndex {
|
|
case fieldInputDir:
|
|
var cmd tea.Cmd
|
|
m.inputDir, cmd = m.inputDir.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
case fieldOutputFile:
|
|
var cmd tea.Cmd
|
|
m.outputFile, cmd = m.outputFile.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
case fieldDrivePath:
|
|
var cmd tea.Cmd
|
|
m.drivePath, cmd = m.drivePath.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
case fieldMaxDepth:
|
|
var cmd tea.Cmd
|
|
m.maxDepth, cmd = m.maxDepth.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
case fieldMaxSize:
|
|
var cmd tea.Cmd
|
|
m.maxSize, cmd = m.maxSize.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
case fieldIgnorePatterns:
|
|
var cmd tea.Cmd
|
|
m.ignoreArea, cmd = m.ignoreArea.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
func (m FormModel) View() string {
|
|
usableWidth := m.width - 30
|
|
if usableWidth < 50 {
|
|
usableWidth = 50
|
|
}
|
|
if usableWidth > 80 {
|
|
usableWidth = 80
|
|
}
|
|
m.inputDir.SetWidth(usableWidth)
|
|
m.outputFile.SetWidth(usableWidth)
|
|
m.drivePath.SetWidth(usableWidth)
|
|
m.ignoreArea.SetWidth(usableWidth)
|
|
|
|
var b strings.Builder
|
|
|
|
b.WriteString(titleStyle.Render("dirmd"))
|
|
b.WriteString("\n\n")
|
|
|
|
b.WriteString(m.renderTextField("Input directory", m.inputDir, m.focusIndex == fieldInputDir))
|
|
b.WriteString("\n")
|
|
b.WriteString(m.renderTextField("Output file", m.outputFile, m.focusIndex == fieldOutputFile))
|
|
b.WriteString("\n\n")
|
|
|
|
b.WriteString(m.appendMode.Render(m.focusIndex == fieldAppend))
|
|
b.WriteString("\n")
|
|
b.WriteString(m.force.Render(m.focusIndex == fieldForce))
|
|
b.WriteString("\n")
|
|
b.WriteString(m.verticalSlices.Render(m.focusIndex == fieldVerticalSlices))
|
|
b.WriteString("\n")
|
|
b.WriteString(m.skipFrontend.Render(m.focusIndex == fieldSkipFrontend))
|
|
b.WriteString("\n")
|
|
b.WriteString(m.frontendOnly.Render(m.focusIndex == fieldFrontendOnly))
|
|
b.WriteString("\n")
|
|
b.WriteString(m.omitTest.Render(m.focusIndex == fieldOmitTest))
|
|
b.WriteString("\n")
|
|
b.WriteString(m.omitMd.Render(m.focusIndex == fieldOmitMd))
|
|
b.WriteString("\n")
|
|
b.WriteString(m.protonDrive.Render(m.focusIndex == fieldProtonDrive))
|
|
b.WriteString("\n")
|
|
|
|
if m.protonDrive.Checked {
|
|
b.WriteString(m.renderTextField("Remote path", m.drivePath, m.focusIndex == fieldDrivePath))
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
b.WriteString("\n")
|
|
b.WriteString(m.renderTextField("Max depth", m.maxDepth, m.focusIndex == fieldMaxDepth))
|
|
b.WriteString("\n")
|
|
b.WriteString(m.renderTextField("Max size (KB)", m.maxSize, m.focusIndex == fieldMaxSize))
|
|
b.WriteString("\n\n")
|
|
|
|
ignoreFocused := m.focusIndex == fieldIgnorePatterns
|
|
if ignoreFocused {
|
|
b.WriteString(focusedStyle.Render("▶ Ignore patterns:"))
|
|
} else {
|
|
b.WriteString(" " + labelStyle.Render("Ignore patterns:"))
|
|
}
|
|
b.WriteString("\n")
|
|
b.WriteString(m.ignoreArea.View())
|
|
b.WriteString("\n\n")
|
|
|
|
btnFocused := m.focusIndex == fieldGenerateBtn
|
|
if btnFocused {
|
|
b.WriteString(focusedStyle.Render("▶ [ Generate ]"))
|
|
} else {
|
|
b.WriteString(" " + labelStyle.Render("[ Generate ]"))
|
|
}
|
|
b.WriteString(" ")
|
|
cancelFocused := m.focusIndex == fieldCancelBtn
|
|
if cancelFocused {
|
|
b.WriteString(focusedStyle.Render("▶ [ Cancel ]"))
|
|
} else {
|
|
b.WriteString(" " + labelStyle.Render("[ Cancel ]"))
|
|
}
|
|
|
|
if m.err != "" {
|
|
b.WriteString("\n\n")
|
|
b.WriteString(errorStyle.Render(m.err))
|
|
}
|
|
|
|
b.WriteString("\n\n")
|
|
b.WriteString(footerStyle.Render("Tab/↑↓: navigate Space: toggle Enter: submit Esc: quit"))
|
|
|
|
content := appBorder.Render(b.String())
|
|
|
|
if m.width > 0 && m.height > 0 {
|
|
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, content)
|
|
}
|
|
return content
|
|
}
|
|
|
|
func (m FormModel) renderTextField(label string, input textinput.Model, focused bool) string {
|
|
if focused {
|
|
return focusedStyle.Render("▶ "+label+":") + " " + input.View()
|
|
}
|
|
return " " + labelStyle.Render(label+":") + " " + input.View()
|
|
}
|
|
|
|
func (m FormModel) ToConfig() (*config.Config, error) {
|
|
inputDir := m.inputDir.Value()
|
|
if inputDir == "" {
|
|
inputDir = "."
|
|
}
|
|
|
|
outputFile := m.outputFile.Value()
|
|
if outputFile == "" {
|
|
return nil, fmt.Errorf("output file required")
|
|
}
|
|
|
|
maxDepth, err := parseIntDefault(m.maxDepth.Value(), 20)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("max depth: %w", err)
|
|
}
|
|
|
|
maxSizeKB, err := parseIntDefault(m.maxSize.Value(), 512)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("max size: %w", err)
|
|
}
|
|
|
|
var ignores []string
|
|
raw := strings.TrimSpace(m.ignoreArea.Value())
|
|
if raw != "" {
|
|
for _, line := range strings.Split(raw, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line != "" {
|
|
ignores = append(ignores, line)
|
|
}
|
|
}
|
|
}
|
|
|
|
if m.skipFrontend.Checked && m.frontendOnly.Checked {
|
|
return nil, fmt.Errorf("--skip-frontend and --frontend-only are mutually exclusive")
|
|
}
|
|
|
|
if m.appendMode.Checked && m.verticalSlices.Checked {
|
|
return nil, fmt.Errorf("--append and --vertical-slices are mutually exclusive")
|
|
}
|
|
|
|
excludedExts := append([]string{}, config.DefaultExts...)
|
|
if m.skipFrontend.Checked {
|
|
excludedExts = append(excludedExts, config.FrontendExts...)
|
|
}
|
|
|
|
allIgnores := append([]string{}, config.DefaultIgnores...)
|
|
allIgnores = append(allIgnores, ignores...)
|
|
|
|
cfg := &config.Config{
|
|
InputPath: inputDir,
|
|
OutputPath: outputFile,
|
|
AppendMode: m.appendMode.Checked,
|
|
Force: m.force.Checked,
|
|
MaxSize: int64(maxSizeKB) * 1024,
|
|
MaxDepth: maxDepth,
|
|
Ignores: allIgnores,
|
|
Extensions: excludedExts,
|
|
ProtonDrive: m.protonDrive.Checked,
|
|
DrivePath: m.drivePath.Value(),
|
|
VerticalSlices: m.verticalSlices.Checked,
|
|
SkipFrontend: m.skipFrontend.Checked,
|
|
FrontendOnly: m.frontendOnly.Checked,
|
|
OmitTest: m.omitTest.Checked,
|
|
OmitMd: m.omitMd.Checked,
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
func (m *FormModel) SetError(msg string) {
|
|
m.err = msg
|
|
}
|
|
|
|
func (m *FormModel) SetSize(w, h int) {
|
|
m.width = w
|
|
m.height = h
|
|
}
|
|
|
|
func parseIntDefault(s string, def int) (int, error) {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return def, nil
|
|
}
|
|
var n int
|
|
_, err := fmt.Sscanf(s, "%d", &n)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if n <= 0 {
|
|
return def, nil
|
|
}
|
|
return n, nil
|
|
}
|