diff --git a/cmd/root.go b/cmd/root.go index 5986da6..d295b34 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -87,7 +87,8 @@ func init() { rootCmd.PersistentFlags().StringVarP(&outputPath, "output", "o", "", "output directory (required)") rootCmd.Flags().BoolVarP(&appendMode, "append", "a", false, "append to existing index.md") rootCmd.Flags().BoolVarP(&force, "force", "f", false, "overwrite existing output directory") - rootCmd.Flags().StringVarP(&singleFile, "input-file", "i", "", "single file input (append mode only)") + rootCmd.Flags().StringVar(&singleFile, "input-file", "", "single file input (append mode only)") + rootCmd.Flags().BoolVarP(&tuiMode, "interactive", "i", false, "launch interactive TUI configuration wizard") rootCmd.Flags().Int64Var(&maxSize, "max-size", config.DefaultMaxSize, "max file size in bytes") rootCmd.Flags().IntVar(&maxDepth, "max-depth", config.DefaultMaxDepth, "max directory recursion depth") rootCmd.Flags().StringArrayVar(&ignorePatterns, "ignore", nil, "additional ignore patterns (glob)") @@ -99,7 +100,6 @@ 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.Flags().BoolVar(&tuiMode, "tui", false, "launch interactive TUI configuration wizard") } func run(cmd *cobra.Command, args []string) error { diff --git a/go.mod b/go.mod index 2c147d7..20e430f 100644 --- a/go.mod +++ b/go.mod @@ -5,23 +5,29 @@ go 1.26.4 require ( charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.0.9 + charm.land/huh/v2 v2.0.3 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/catppuccin/go v0.2.0 // 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/exp/ordered v0.1.0 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // 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/dustin/go-humanize v1.0.1 // 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/mitchellh/hashstructure/v2 v2.0.2 // 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 diff --git a/go.sum b/go.sum index 4d80f3f..ae07e00 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ 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/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= +charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= 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= @@ -10,31 +12,49 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z 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/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= +github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= 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/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= +github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= 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/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= +github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= 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/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= +github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= 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/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= 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= diff --git a/internal/tui/checkbox.go b/internal/tui/checkbox.go deleted file mode 100644 index a2e45a8..0000000 --- a/internal/tui/checkbox.go +++ /dev/null @@ -1,29 +0,0 @@ -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 := "[ ]" - if c.Checked { - marker = "[x]" - } - - if c.Checked { - marker = checkedStyle.Render(marker) - } else { - marker = uncheckedStyle.Render(marker) - } - - if focused { - return focusedStyle.Render("▶ " + marker + " " + c.Label) - } - - return " " + marker + " " + labelStyle.Render(c.Label) -} diff --git a/internal/tui/form.go b/internal/tui/form.go deleted file mode 100644 index 439e7b3..0000000 --- a/internal/tui/form.go +++ /dev/null @@ -1,487 +0,0 @@ -package tui - -import ( - "fmt" - "os" - "path/filepath" - "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 - fieldOutputDir - fieldForce - fieldVerticalSlices - fieldSkipFrontend - fieldFrontendOnly - fieldOmitTest - fieldOmitMd - 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 - frontendOnly Checkbox - omitTest Checkbox - omitMd Checkbox - protonDrive Checkbox - isDark bool - width int - height int - err string -} - -func defaultOutputDir() string { - cwd, err := os.Getwd() - if err != nil { - return "/tmp/output_dirmd" - } - base := filepath.Base(cwd) - return filepath.Join("/tmp", base+"_dirmd") -} - -func NewForm() FormModel { - inputDir := textinput.New() - inputDir.Placeholder = "." - inputDir.SetValue(".") - inputDir.SetWidth(60) - - defOut := defaultOutputDir() - outputDir := textinput.New() - outputDir.Placeholder = defOut - outputDir.SetValue(defOut) - outputDir.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, - 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"}, - 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.outputDir.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, fieldOutputDir, fieldDrivePath, fieldMaxDepth, fieldMaxSize, fieldIgnorePatterns: - return true - } - return false -} - -func (m *FormModel) focusActiveField() tea.Cmd { - m.inputDir.Blur() - m.outputDir.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 fieldOutputDir: - cmd = m.outputDir.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 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 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 { - usableWidth := m.width - 30 - if usableWidth < 50 { - usableWidth = 50 - } - if usableWidth > 80 { - usableWidth = 80 - } - m.inputDir.SetWidth(usableWidth) - m.outputDir.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 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.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 = "." - } - - 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) - } - } - } - - if m.skipFrontend.Checked && m.frontendOnly.Checked { - return nil, fmt.Errorf("--skip-frontend and --frontend-only 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: 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, - 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 -} diff --git a/internal/tui/keys.go b/internal/tui/keys.go deleted file mode 100644 index 94b983e..0000000 --- a/internal/tui/keys.go +++ /dev/null @@ -1,29 +0,0 @@ -package tui - -import ( - tea "charm.land/bubbletea/v2" -) - -func isQuit(msg tea.KeyPressMsg) bool { - s := msg.String() - return s == "ctrl+c" || s == "esc" -} - -func isNavigateDown(msg tea.KeyPressMsg) bool { - s := msg.String() - return s == "down" || s == "tab" -} - -func isNavigateUp(msg tea.KeyPressMsg) bool { - s := msg.String() - return s == "up" || s == "shift+tab" -} - -func isActivate(msg tea.KeyPressMsg) bool { - s := msg.String() - return s == "enter" || s == "space" -} - -func isSubmit(msg tea.KeyPressMsg) bool { - return msg.String() == "enter" -} diff --git a/internal/tui/model.go b/internal/tui/model.go deleted file mode 100644 index 4b2be35..0000000 --- a/internal/tui/model.go +++ /dev/null @@ -1,253 +0,0 @@ -package tui - -import ( - "fmt" - "strings" - - "charm.land/bubbles/v2/textinput" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - - "dirmd/internal/config" -) - -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 - isDark bool - width int - height int - command string -} - -func New() Model { - return Model{ - state: StateForm, - form: NewForm(), - isDark: true, - } -} - -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.BackgroundColorMsg: - m.isDark = msg.IsDark() - m.form.SetDark(m.isDark) - return m, nil - - 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.command = buildCommand(cfg) - 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 buildCommand(cfg *config.Config) string { - var parts []string - parts = append(parts, "dirmd") - - parts = append(parts, "-o", cfg.OutputPath) - - if cfg.Force { - parts = append(parts, "-f") - } - if cfg.VerticalSlices { - parts = append(parts, "--vertical-slices") - } - if cfg.SkipFrontend { - parts = append(parts, "--skip-frontend") - } - if cfg.FrontendOnly { - parts = append(parts, "--frontend-only") - } - if cfg.OmitTest { - parts = append(parts, "--omit-test") - } - if cfg.OmitMd { - parts = append(parts, "--omit-md") - } - if cfg.ProtonDrive { - parts = append(parts, "--proton-drive") - if cfg.DrivePath != "" { - parts = append(parts, "--drive-path", cfg.DrivePath) - } - } - if cfg.MaxDepth != 20 { - parts = append(parts, "--max-depth", fmt.Sprintf("%d", cfg.MaxDepth)) - } - if cfg.MaxSize != 512*1024 { - parts = append(parts, "--max-size", fmt.Sprintf("%d", cfg.MaxSize)) - } - - extraIgnores := extraIgnores(cfg) - for _, ig := range extraIgnores { - parts = append(parts, "--ignore", ig) - } - - parts = append(parts, cfg.InputPath) - - return strings.Join(parts, " ") -} - -func extraIgnores(cfg *config.Config) []string { - defaultSet := make(map[string]bool) - for _, d := range config.DefaultIgnores { - defaultSet[d] = true - } - var extra []string - for _, ig := range cfg.Ignores { - if !defaultSet[ig] { - extra = append(extra, ig) - } - } - return extra -} - -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.")) - - 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 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.")) - - 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 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.")) - - 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 -} diff --git a/internal/tui/run.go b/internal/tui/run.go index eae2be9..dd127ab 100644 --- a/internal/tui/run.go +++ b/internal/tui/run.go @@ -3,27 +3,202 @@ package tui import ( "fmt" "os" + "path/filepath" + "strconv" + "strings" - tea "charm.land/bubbletea/v2" + "charm.land/huh/v2" + "charm.land/huh/v2/spinner" + + "dirmd/internal/config" ) func Run() error { - p := tea.NewProgram(New()) - m, err := p.Run() - if err != nil { - return fmt.Errorf("tui error: %w", err) + var ( + inputDir string + outputDir string + force bool + verticalSlices bool + skipFrontend bool + frontendOnly bool + omitTest bool + mitMd bool + protonDrive bool + drivePath string + maxDepthStr string + maxSizeStr string + ignorePatterns string + ) + + inputDir = "." + maxDepthStr = "20" + maxSizeStr = "512" + defOut := defaultOutputDir() + outputDir = defOut + + form := huh.NewForm( + huh.NewGroup( + huh.NewInput().Title("Input directory").Value(&inputDir).Placeholder("."), + huh.NewInput().Title("Output directory").Value(&outputDir).Placeholder(defOut), + huh.NewConfirm().Title("Force overwrite?").Value(&force), + huh.NewConfirm().Title("Vertical slices?").Value(&verticalSlices), + huh.NewConfirm().Title("Skip frontend files?").Value(&skipFrontend), + huh.NewConfirm().Title("Frontend only?").Value(&frontendOnly), + huh.NewConfirm().Title("Omit test files?").Value(&omitTest), + huh.NewConfirm().Title("Omit non-README markdown?").Value(&mitMd), + huh.NewConfirm().Title("Upload to Proton Drive?").Value(&protonDrive), + huh.NewInput().Title("Max depth").Value(&maxDepthStr).Placeholder("20"), + huh.NewInput().Title("Max size (KB)").Value(&maxSizeStr).Placeholder("512"), + huh.NewText().Title("Ignore patterns (one per line)").Value(&ignorePatterns).Placeholder("One pattern per line..."), + ), + huh.NewGroup( + huh.NewInput().Title("Remote path on Proton Drive").Value(&drivePath).Placeholder("/my-files/md/"), + ).WithHideFunc(func() bool { + return !protonDrive + }), + ) + + if err := form.Run(); err != nil { + return fmt.Errorf("form: %w", err) } - if model, ok := m.(Model); ok { - if model.command != "" { - if model.result.Error != nil { - fmt.Fprintf(os.Stderr, "failed: %s\n", model.command) - fmt.Fprintf(os.Stderr, "error: %v\n", model.result.Error) - } else { - fmt.Fprintf(os.Stdout, "%s\n", model.command) + maxDepth, err := strconv.Atoi(strings.TrimSpace(maxDepthStr)) + if err != nil || maxDepth <= 0 { + maxDepth = 20 + } + + maxSizeKB, err := strconv.Atoi(strings.TrimSpace(maxSizeStr)) + if err != nil || maxSizeKB <= 0 { + maxSizeKB = 512 + } + + var ignores []string + raw := strings.TrimSpace(ignorePatterns) + if raw != "" { + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + if line != "" { + ignores = append(ignores, line) } } } + if skipFrontend && frontendOnly { + return fmt.Errorf("--skip-frontend and --frontend-only are mutually exclusive") + } + + excludedExts := append([]string{}, config.DefaultExts...) + if skipFrontend { + excludedExts = append(excludedExts, config.FrontendExts...) + } + + allIgnores := append([]string{}, config.DefaultIgnores...) + allIgnores = append(allIgnores, ignores...) + + cfg := &config.Config{ + InputPath: inputDir, + OutputPath: outputDir, + Force: force, + MaxSize: int64(maxSizeKB) * 1024, + MaxDepth: maxDepth, + Ignores: allIgnores, + Extensions: excludedExts, + ProtonDrive: protonDrive, + DrivePath: drivePath, + VerticalSlices: verticalSlices, + SkipFrontend: skipFrontend, + FrontendOnly: frontendOnly, + OmitTest: omitTest, + OmitMd: mitMd, + } + + cmd := buildCommand(cfg) + fmt.Fprintf(os.Stderr, "%s\n", cmd) + + var result GenerateResult + if err := spinner.New(). + Title("Generating..."). + Action(func() { + result = Generate(cfg) + }). + Run(); err != nil { + return fmt.Errorf("interrupted: %w", err) + } + + if result.Error != nil { + return result.Error + } + + for _, f := range result.Files { + fmt.Fprintf(os.Stdout, " %s\n", f) + } + if result.Uploaded { + fmt.Fprintln(os.Stdout, "Uploaded to Proton Drive") + } + return nil } + +func defaultOutputDir() string { + cwd, err := os.Getwd() + if err != nil { + return "/tmp/output_dirmd" + } + base := filepath.Base(cwd) + return filepath.Join("/tmp", base+"_dirmd") +} + +func buildCommand(cfg *config.Config) string { + var parts []string + parts = append(parts, "dirmd") + parts = append(parts, "-o", cfg.OutputPath) + if cfg.Force { + parts = append(parts, "-f") + } + if cfg.VerticalSlices { + parts = append(parts, "--vertical-slices") + } + if cfg.SkipFrontend { + parts = append(parts, "--skip-frontend") + } + if cfg.FrontendOnly { + parts = append(parts, "--frontend-only") + } + if cfg.OmitTest { + parts = append(parts, "--omit-test") + } + if cfg.OmitMd { + parts = append(parts, "--omit-md") + } + if cfg.ProtonDrive { + parts = append(parts, "--proton-drive") + if cfg.DrivePath != "" { + parts = append(parts, "--drive-path", cfg.DrivePath) + } + } + if cfg.MaxDepth != 20 { + parts = append(parts, "--max-depth", strconv.Itoa(cfg.MaxDepth)) + } + if cfg.MaxSize != 512*1024 { + parts = append(parts, "--max-size", strconv.FormatInt(cfg.MaxSize, 10)) + } + for _, ig := range extraIgnores(cfg) { + parts = append(parts, "--ignore", ig) + } + parts = append(parts, cfg.InputPath) + return strings.Join(parts, " ") +} + +func extraIgnores(cfg *config.Config) []string { + defaultSet := make(map[string]bool) + for _, d := range config.DefaultIgnores { + defaultSet[d] = true + } + var extra []string + for _, ig := range cfg.Ignores { + if !defaultSet[ig] { + extra = append(extra, ig) + } + } + return extra +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go deleted file mode 100644 index 1da1d97..0000000 --- a/internal/tui/styles.go +++ /dev/null @@ -1,68 +0,0 @@ -package tui - -import ( - "charm.land/bubbles/v2/textarea" - "charm.land/bubbles/v2/textinput" - "charm.land/lipgloss/v2" -) - -var ( - appBorder = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("62")). - Padding(1, 2) - - titleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("117")). - MarginBottom(1) - - labelStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("250")) - - focusedStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("117")) - - checkedStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("82")) - - uncheckedStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("250")) - - footerStyle = lipgloss.NewStyle(). - MarginTop(1). - Foreground(lipgloss.Color("248")) - - errorStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("203")). - Bold(true) - - successStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("82")). - Bold(true) -) - -func getDefaultInputStyles(isDark bool) textinput.Styles { - s := textinput.DefaultStyles(isDark) - s.Focused.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color("117")).Bold(true) - s.Focused.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("255")) - s.Focused.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("243")) - s.Blurred.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) - s.Blurred.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) - s.Blurred.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - return s -} - -func getDefaultTextareaStyles(isDark bool) textarea.Styles { - s := textarea.DefaultStyles(isDark) - s.Focused.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color("117")).Bold(true) - s.Focused.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("255")) - s.Focused.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("243")) - s.Focused.Base = lipgloss.NewStyle().Foreground(lipgloss.Color("255")) - s.Blurred.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) - s.Blurred.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) - s.Blurred.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - s.Blurred.Base = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) - return s -}