package tui import ( "fmt" "os" "os/exec" "path/filepath" "strconv" "strings" "charm.land/bubbles/v2/key" "charm.land/huh/v2" "charm.land/huh/v2/spinner" "git.flo.fo/FLO/dirmd/internal/config" ) func Run() error { clearScreen() var ( inputDir string outputDir string options []string drivePath string maxDepthStr string maxSizeStr string ignorePatterns string finalConfirm bool ) inputDir = "." maxDepthStr = "20" maxSizeStr = "512" defOut := defaultOutputDir() outputDir = defOut drivePath = "/my-files/md/" km := huh.NewDefaultKeyMap() km.Input.Next = key.NewBinding(key.WithKeys("tab", "down"), key.WithHelp("tab/↓", "next")) km.Input.Prev = key.NewBinding(key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab/↑", "prev")) km.Input.Submit = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")) km.Text.Next = key.NewBinding(key.WithKeys("tab", "down"), key.WithHelp("tab/↓", "next")) km.Text.Prev = key.NewBinding(key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab/↑", "prev")) km.Text.NewLine = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "new line")) km.Text.Submit = key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "submit")) km.Confirm.Next = key.NewBinding(key.WithKeys("tab", "down"), key.WithHelp("tab/↓", "next")) km.Confirm.Prev = key.NewBinding(key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab/↑", "prev")) km.Confirm.Toggle = key.NewBinding(key.WithKeys("left", "right", "h", "l"), key.WithHelp("←/→", "toggle")) km.Confirm.Submit = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")) km.Confirm.Accept = key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "yes")) km.Confirm.Reject = key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "no")) km.MultiSelect.Next = key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next field")) km.MultiSelect.Prev = key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev field")) km.MultiSelect.Up = key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")) km.MultiSelect.Down = key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")) km.MultiSelect.Toggle = key.NewBinding(key.WithKeys("enter", " "), key.WithHelp("enter/space", "toggle")) km.MultiSelect.Submit = key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next")) km.MultiSelect.Filter = key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")) km.MultiSelect.SelectAll = key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "select all")) km.MultiSelect.SelectNone = key.NewBinding(key.WithKeys("A"), key.WithHelp("A", "select none")) form := huh.NewForm( huh.NewGroup( huh.NewInput().Title("Input directory").Value(&inputDir).Placeholder("."), huh.NewInput().Title("Output directory").Value(&outputDir).Placeholder(defOut), huh.NewMultiSelect[string](). Title("Options"). Description("↑↓ navigate • enter/space toggle • tab next field"). Options( huh.NewOption("Force overwrite", "force"), huh.NewOption("Vertical slices", "vertical-slices"), huh.NewOption("Skip frontend files", "skip-frontend"), huh.NewOption("Frontend only", "frontend-only"), huh.NewOption("Omit test files", "omit-test"), huh.NewOption("Omit non-README markdown", "omit-md"), huh.NewOption("Upload to Proton Drive", "proton-drive"), huh.NewOption("Include .dirmd instructions", "instructions"), ). Value(&options). Height(10), huh.NewInput(). Title("Proton Drive path"). Description("Used if 'Upload to Proton Drive' is toggled"). Value(&drivePath). Placeholder("/my-files/md/"), 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)"). Description("enter = new line • tab = next field"). Value(&ignorePatterns). Placeholder("One pattern per line..."). Lines(4), huh.NewConfirm(). Title("Confirm and proceed?"). Description("←/→ or y/n to choose • enter to confirm"). Value(&finalConfirm). Affirmative("Yes"). Negative("No"), ), ).WithWidth(80).WithKeyMap(km) if err := form.Run(); err != nil { return fmt.Errorf("form: %w", err) } force := containsOption(options, "force") verticalSlices := containsOption(options, "vertical-slices") skipFrontend := containsOption(options, "skip-frontend") frontendOnly := containsOption(options, "frontend-only") omitTest := containsOption(options, "omit-test") omitMd := containsOption(options, "omit-md") protonDrive := containsOption(options, "proton-drive") includeInstructions := containsOption(options, "instructions") if skipFrontend && frontendOnly { return fmt.Errorf("--skip-frontend and --frontend-only are mutually exclusive") } if !finalConfirm { fmt.Fprintln(os.Stderr, "Cancelled.") os.Exit(0) } 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) } } } 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: omitMd, IncludeInstructions: includeInstructions, } 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 clearScreen() { cmd := exec.Command("clear") cmd.Stdout = os.Stdout cmd.Run() } func containsOption(opts []string, target string) bool { for _, o := range opts { if o == target { return true } } return false } 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 }