diff --git a/cmd/root.go b/cmd/root.go index 85d6f21..5986da6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -10,6 +10,7 @@ import ( "dirmd/internal/config" "dirmd/internal/drive" "dirmd/internal/renderer" + "dirmd/internal/tui" "dirmd/internal/walker" ) @@ -51,7 +52,10 @@ Examples: dirmd -o ./docs ~/repos/myproject --proton-drive # Generate and upload to a specific remote path - dirmd -o ./docs ~/repos/myproject --proton-drive --drive-path /my-files/docs/api`, + dirmd -o ./docs ~/repos/myproject --proton-drive --drive-path /my-files/docs/api + + # Interactive TUI mode + dirmd --tui`, RunE: run, } @@ -70,6 +74,7 @@ Examples: frontendOnly bool omitTest bool omitMd bool + tuiMode bool ) func Execute() { @@ -94,11 +99,18 @@ func init() { rootCmd.Flags().BoolVar(&frontendOnly, "frontend-only", false, "include only frontend file types and README.md") rootCmd.Flags().BoolVar(&omitTest, "omit-test", false, "exclude test files (Go *_test.go, Java *Test.java) and strip #[cfg(test)] blocks from Rust") rootCmd.Flags().BoolVar(&omitMd, "omit-md", false, "omit all .md files except README.md") - - rootCmd.MarkPersistentFlagRequired("output") + rootCmd.Flags().BoolVar(&tuiMode, "tui", false, "launch interactive TUI configuration wizard") } func run(cmd *cobra.Command, args []string) error { + if tuiMode { + return tui.Run() + } + + if outputPath == "" { + return fmt.Errorf("-o is required (or use --tui for interactive mode)") + } + if drivePath != "" && !protonDrive { return fmt.Errorf("--drive-path requires --proton-drive") } diff --git a/go.mod b/go.mod index 135878a..2c147d7 100644 --- a/go.mod +++ b/go.mod @@ -2,9 +2,30 @@ module dirmd go 1.26.4 -require github.com/spf13/cobra v1.10.2 +require ( + charm.land/bubbles/v2 v2.2.1 + charm.land/bubbletea/v2 v2.0.9 + charm.land/lipgloss/v2 v2.0.6 + github.com/spf13/cobra v1.10.2 +) require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect + github.com/charmbracelet/x/ansi v0.11.8 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.1 // indirect + github.com/mattn/go-runewidth v0.0.27 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect ) diff --git a/go.sum b/go.sum index a6ee3e0..4d80f3f 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,56 @@ +charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU= +charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= +charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8= +charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= +charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= +charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= +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-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= +github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= +github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= +github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= +github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +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= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/tui/checkbox.go b/internal/tui/checkbox.go new file mode 100644 index 0000000..f53f6d1 --- /dev/null +++ b/internal/tui/checkbox.go @@ -0,0 +1,24 @@ +package tui + +type Checkbox struct { + Label string + Checked bool +} + +func (c *Checkbox) Toggle() { + c.Checked = !c.Checked +} + +func (c Checkbox) Render(focused bool) string { + marker := checkBoxUnchecked + if c.Checked { + marker = checkBoxChecked + } + + style := labelStyle + if focused { + style = focusedStyle.Bold(true) + } + + return style.Render(marker + " " + c.Label) +} diff --git a/internal/tui/form.go b/internal/tui/form.go new file mode 100644 index 0000000..3635b1e --- /dev/null +++ b/internal/tui/form.go @@ -0,0 +1,362 @@ +package tui + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "dirmd/internal/config" +) + +const ( + fieldInputDir = iota + fieldOutputDir + fieldForce + fieldVerticalSlices + fieldSkipFrontend + fieldProtonDrive + fieldDrivePath + fieldMaxDepth + fieldMaxSize + fieldIgnorePatterns + fieldGenerateBtn + fieldCancelBtn + fieldCount +) + +type FormModel struct { + focusIndex int + inputDir textinput.Model + outputDir textinput.Model + drivePath textinput.Model + maxDepth textinput.Model + maxSize textinput.Model + ignoreArea textarea.Model + force Checkbox + verticalSlices Checkbox + skipFrontend Checkbox + protonDrive Checkbox + width int + height int + err string +} + +func NewForm() FormModel { + inputDir := textinput.New() + inputDir.Placeholder = "." + inputDir.SetValue(".") + inputDir.SetWidth(40) + + outputDir := textinput.New() + outputDir.Placeholder = "./dirmd_output" + outputDir.SetValue("./dirmd_output") + outputDir.SetWidth(40) + + drivePath := textinput.New() + drivePath.Placeholder = "/my-files/md/" + drivePath.SetValue("") + drivePath.SetWidth(40) + + maxDepth := textinput.New() + maxDepth.SetValue("20") + maxDepth.SetWidth(5) + + maxSize := textinput.New() + maxSize.SetValue("512") + maxSize.SetWidth(5) + + ignoreArea := textarea.New() + ignoreArea.Placeholder = "node_modules\n.venv" + ignoreArea.SetHeight(3) + + m := FormModel{ + inputDir: inputDir, + outputDir: outputDir, + drivePath: drivePath, + maxDepth: maxDepth, + maxSize: maxSize, + ignoreArea: ignoreArea, + force: Checkbox{Label: "Force overwrite"}, + verticalSlices: Checkbox{Label: "Vertical slices"}, + skipFrontend: Checkbox{Label: "Skip frontend files"}, + protonDrive: Checkbox{Label: "Proton Drive upload"}, + focusIndex: fieldInputDir, + } + + m.focusActiveField() + return m +} + +func (m *FormModel) focusActiveField() { + m.inputDir.Blur() + m.outputDir.Blur() + m.drivePath.Blur() + m.maxDepth.Blur() + m.maxSize.Blur() + m.ignoreArea.Blur() + + switch m.focusIndex { + case fieldInputDir: + m.inputDir.Focus() + case fieldOutputDir: + m.outputDir.Focus() + case fieldDrivePath: + m.drivePath.Focus() + case fieldMaxDepth: + m.maxDepth.Focus() + case fieldMaxSize: + m.maxSize.Focus() + case fieldIgnorePatterns: + m.ignoreArea.Focus() + } +} + +func (m FormModel) Update(msg tea.Msg) (FormModel, tea.Cmd) { + var cmds []tea.Cmd + + if k, ok := msg.(tea.KeyPressMsg); ok { + switch { + case isNavigateDown(k): + m.focusIndex++ + if m.focusIndex >= fieldCount { + m.focusIndex = 0 + } + if m.focusIndex == fieldDrivePath && !m.protonDrive.Checked { + m.focusIndex = fieldMaxDepth + } + m.focusActiveField() + return m, nil + case isNavigateUp(k): + m.focusIndex-- + if m.focusIndex < 0 { + m.focusIndex = fieldCount - 1 + } + if m.focusIndex == fieldDrivePath && !m.protonDrive.Checked { + m.focusIndex = fieldProtonDrive + } + m.focusActiveField() + return m, nil + case isActivate(k): + switch m.focusIndex { + case fieldForce: + m.force.Toggle() + case fieldVerticalSlices: + m.verticalSlices.Toggle() + case fieldSkipFrontend: + m.skipFrontend.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 fieldOutputDir: + var cmd tea.Cmd + m.outputDir, cmd = m.outputDir.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 { + 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 directory", m.outputDir, m.focusIndex == fieldOutputDir)) + b.WriteString("\n\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.protonDrive.Render(m.focusIndex == fieldProtonDrive)) + b.WriteString("\n") + + if m.protonDrive.Checked { + b.WriteString(" ") + b.WriteString(m.renderTextField("Remote path", m.drivePath, m.focusIndex == fieldDrivePath)) + b.WriteString("\n") + } + + b.WriteString("\n") + depthLabel := labelStyle.Render("Max depth:") + if m.focusIndex == fieldMaxDepth { + depthLabel = focusedStyle.Bold(true).Render("Max depth:") + } + sizeLabel := labelStyle.Render("Max size (KB):") + if m.focusIndex == fieldMaxSize { + sizeLabel = focusedStyle.Bold(true).Render("Max size (KB):") + } + b.WriteString(fmt.Sprintf("%s %s %s %s", + depthLabel, m.maxDepth.View(), + sizeLabel, m.maxSize.View(), + )) + b.WriteString("\n\n") + + ignoreLabel := labelStyle.Render("Ignore patterns:") + if m.focusIndex == fieldIgnorePatterns { + ignoreLabel = focusedStyle.Bold(true).Render("Ignore patterns:") + } + b.WriteString(ignoreLabel) + b.WriteString("\n") + b.WriteString(m.ignoreArea.View()) + b.WriteString("\n\n") + + btnStyle := labelStyle + if m.focusIndex == fieldGenerateBtn { + btnStyle = focusedStyle.Bold(true) + } + b.WriteString(btnStyle.Render("[ Generate ]")) + b.WriteString(" ") + cancelStyle := labelStyle + if m.focusIndex == fieldCancelBtn { + cancelStyle = focusedStyle.Bold(true) + } + b.WriteString(cancelStyle.Render("[ Cancel ]")) + + if m.err != "" { + b.WriteString("\n\n") + b.WriteString(errorStyle.Render(m.err)) + } + + b.WriteString("\n") + b.WriteString(footerStyle.Render("Tab/j/k: navigate Space: toggle Enter: submit Esc: quit")) + + return appBorder.Render(b.String()) +} + +func (m FormModel) renderTextField(label string, input textinput.Model, focused bool) string { + style := labelStyle + if focused { + style = focusedStyle + } + return fmt.Sprintf("%s %s", style.Render(label+":"), input.View()) +} + +func (m FormModel) ToConfig() (*config.Config, error) { + inputDir := m.inputDir.Value() + if inputDir == "" { + inputDir = "." + } + + outputDir := m.outputDir.Value() + if outputDir == "" { + return nil, fmt.Errorf("output directory 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) + } + } + } + + 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: outputDir, + 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, + } + + 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 + usable := w - 30 + if usable < 20 { + usable = 20 + } + if usable > 60 { + usable = 60 + } + m.inputDir.SetWidth(usable) + m.outputDir.SetWidth(usable) + m.drivePath.SetWidth(usable) +} + +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 +} diff --git a/internal/tui/generate.go b/internal/tui/generate.go new file mode 100644 index 0000000..faab09a --- /dev/null +++ b/internal/tui/generate.go @@ -0,0 +1,98 @@ +package tui + +import ( + "fmt" + "os" + "path/filepath" + + "dirmd/internal/config" + "dirmd/internal/drive" + "dirmd/internal/renderer" + "dirmd/internal/walker" +) + +type GenerateResult struct { + Files []string + Uploaded bool + Error error +} + +func Generate(cfg *config.Config) GenerateResult { + result := GenerateResult{} + + abs, err := filepath.Abs(cfg.InputPath) + if err != nil { + result.Error = fmt.Errorf("resolve input path: %w", err) + return result + } + cfg.AbsRoot = abs + + absOutput, err := filepath.Abs(cfg.OutputPath) + if err != nil { + result.Error = fmt.Errorf("resolve output path: %w", err) + return result + } + absOutput = filepath.Join(filepath.Dir(absOutput), filepath.Base(absOutput)+"_dirmd") + + if !cfg.AppendMode { + if _, err := os.Stat(absOutput); err == nil && !cfg.Force { + result.Error = fmt.Errorf("output %s exists (enable force)", absOutput) + return result + } + } + + if err := os.MkdirAll(absOutput, 0755); err != nil { + result.Error = fmt.Errorf("create output dir: %w", err) + return result + } + + walkResult, err := walker.Run(cfg) + if err != nil { + result.Error = err + return result + } + + if len(walkResult.Entries) == 0 { + result.Error = fmt.Errorf("no readable files in %s", cfg.AbsRoot) + return result + } + + repoName := filepath.Base(cfg.AbsRoot) + + if cfg.VerticalSlices { + generated, err := renderer.WriteSlices(walkResult, absOutput, repoName) + if err != nil { + result.Error = err + return result + } + result.Files = generated + } else { + indexBasename := "index_" + repoName + ".md" + indexPath := filepath.Join(absOutput, indexBasename) + if err := renderer.Write(walkResult, indexPath, cfg.AppendMode, cfg.SingleFile); err != nil { + result.Error = err + return result + } + result.Files = []string{indexPath} + } + + if cfg.ProtonDrive { + remoteDir := cfg.DrivePath + if remoteDir == "" { + remoteDir = "/my-files/md/" + filepath.Base(absOutput) + } + + if err := drive.CheckAuth(); err != nil { + result.Error = fmt.Errorf("drive auth: %w", err) + return result + } + + if err := drive.UploadDir(absOutput, remoteDir); err != nil { + result.Error = fmt.Errorf("drive upload: %w", err) + return result + } + result.Uploaded = true + } + + return result +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go new file mode 100644 index 0000000..4c45f75 --- /dev/null +++ b/internal/tui/keys.go @@ -0,0 +1,29 @@ +package tui + +import ( + tea "charm.land/bubbletea/v2" +) + +func isQuit(msg tea.KeyPressMsg) bool { + s := msg.String() + return s == "q" || s == "ctrl+c" || s == "esc" +} + +func isNavigateDown(msg tea.KeyPressMsg) bool { + s := msg.String() + return s == "down" || s == "j" || s == "tab" +} + +func isNavigateUp(msg tea.KeyPressMsg) bool { + s := msg.String() + return s == "up" || s == "k" || s == "shift+tab" +} + +func isActivate(msg tea.KeyPressMsg) bool { + s := msg.String() + return s == "enter" || s == " " +} + +func isSubmit(msg tea.KeyPressMsg) bool { + return msg.String() == "enter" +} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..6ac9f52 --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,165 @@ +package tui + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" +) + +type State int + +const ( + StateForm State = iota + StateGenerating + StateDone + StateError +) + +type genResultMsg struct { + result GenerateResult +} + +type spinnerState struct { + frame int +} + +func (s *spinnerState) View() string { + frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + return frames[s.frame%len(frames)] +} + +type Model struct { + state State + form FormModel + result GenerateResult + spinner spinnerState + width int + height int +} + +func New() Model { + return Model{ + state: StateForm, + form: NewForm(), + } +} + +func (m Model) Init() tea.Cmd { + return textinput.Blink +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.form.SetSize(msg.Width, msg.Height) + return m, nil + + case tea.KeyPressMsg: + if isQuit(msg) && m.state != StateGenerating { + return m, tea.Quit + } + + switch m.state { + case StateForm: + if isSubmit(msg) && m.form.focusIndex == fieldGenerateBtn { + cfg, err := m.form.ToConfig() + if err != nil { + m.form.SetError(err.Error()) + return m, nil + } + m.state = StateGenerating + return m, func() tea.Msg { + return genResultMsg{result: Generate(cfg)} + } + } + if isSubmit(msg) && m.form.focusIndex == fieldCancelBtn { + return m, tea.Quit + } + var cmd tea.Cmd + m.form, cmd = m.form.Update(msg) + return m, cmd + + case StateDone, StateError: + return m, tea.Quit + } + + case genResultMsg: + m.result = msg.result + if m.result.Error != nil { + m.state = StateError + } else { + m.state = StateDone + } + return m, nil + } + + if m.state == StateForm { + var cmd tea.Cmd + m.form, cmd = m.form.Update(msg) + return m, cmd + } + + return m, nil +} + +func (m Model) View() tea.View { + var content string + + switch m.state { + case StateForm: + content = m.form.View() + case StateGenerating: + content = m.renderGenerating() + case StateDone: + content = m.renderDone() + case StateError: + content = m.renderError() + } + + v := tea.NewView(content) + v.AltScreen = true + return v +} + +func (m Model) renderGenerating() string { + var b strings.Builder + b.WriteString(titleStyle.Render("dirmd")) + b.WriteString("\n\n") + b.WriteString(fmt.Sprintf(" %s Generating...\n", m.spinner.View())) + b.WriteString("\n") + b.WriteString(footerStyle.Render("Please wait.")) + return appBorder.Render(b.String()) +} + +func (m Model) renderDone() string { + var b strings.Builder + b.WriteString(titleStyle.Render("dirmd")) + b.WriteString("\n\n") + b.WriteString(successStyle.Render("✓ Done")) + b.WriteString("\n\n") + for _, f := range m.result.Files { + b.WriteString(fmt.Sprintf(" %s\n", f)) + } + if m.result.Uploaded { + b.WriteString(successStyle.Render("\n Uploaded to Proton Drive")) + } + b.WriteString("\n\n") + b.WriteString(footerStyle.Render("Press any key to exit.")) + return appBorder.Render(b.String()) +} + +func (m Model) renderError() string { + var b strings.Builder + b.WriteString(titleStyle.Render("dirmd")) + b.WriteString("\n\n") + b.WriteString(errorStyle.Render("✗ Error")) + b.WriteString("\n\n") + b.WriteString(fmt.Sprintf(" %s\n", m.result.Error.Error())) + b.WriteString("\n") + b.WriteString(footerStyle.Render("Press any key to exit.")) + return appBorder.Render(b.String()) +} diff --git a/internal/tui/run.go b/internal/tui/run.go new file mode 100644 index 0000000..7df772c --- /dev/null +++ b/internal/tui/run.go @@ -0,0 +1,15 @@ +package tui + +import ( + "fmt" + + tea "charm.land/bubbletea/v2" +) + +func Run() error { + p := tea.NewProgram(New()) + if _, err := p.Run(); err != nil { + return fmt.Errorf("tui error: %w", err) + } + return nil +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 0000000..cdc4424 --- /dev/null +++ b/internal/tui/styles.go @@ -0,0 +1,36 @@ +package tui + +import ( + "charm.land/lipgloss/v2" +) + +var ( + appBorder = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + Padding(1, 2) + + titleStyle = lipgloss.NewStyle(). + Bold(true). + MarginBottom(1) + + labelStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("244")). + Width(20) + + focusedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("39")) + + checkBoxChecked = "[x]" + checkBoxUnchecked = "[ ]" + + footerStyle = lipgloss.NewStyle(). + MarginTop(1). + Foreground(lipgloss.Color("241")) + + errorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). + Bold(true) + + successStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("46")) +)