package lib import ( "os" "os/exec" "path/filepath" "plandex-cli/api" "plandex-cli/term" shared "plandex-shared" "sort" "strings" ) func MaybePromptAndOpen(path string, defaultConfig *shared.PlanConfig, planConfig *shared.PlanConfig) bool { var cmd string var args []string var openManually bool var checkConfig *shared.PlanConfig if planConfig != nil { checkConfig = planConfig } else if defaultConfig != nil { checkConfig = defaultConfig } else { term.OutputErrorAndExit("Missing config") } if checkConfig.EditorOpenManually { return false } if checkConfig.EditorCommand == "" { editorRes := SelectEditor(true) cmd = editorRes.Cmd args = editorRes.Args openManually = editorRes.OpenManually // update the default editor config toUpdateDefault := *defaultConfig toUpdateDefault.Editor = editorRes.Name toUpdateDefault.EditorCommand = cmd toUpdateDefault.EditorArgs = args toUpdateDefault.EditorOpenManually = openManually apiErr := api.Client.UpdateDefaultPlanConfig(shared.UpdateDefaultPlanConfigRequest{ Config: &toUpdateDefault, }) if apiErr != nil { term.OutputErrorAndExit("Error updating default config: %v", apiErr) } // also update the current plan config if planConfig != nil { toUpdate := *planConfig toUpdate.Editor = editorRes.Name toUpdate.EditorCommand = cmd toUpdate.EditorArgs = args toUpdate.EditorOpenManually = openManually apiErr = api.Client.UpdatePlanConfig(CurrentPlanId, shared.UpdatePlanConfigRequest{ Config: &toUpdate, }) if apiErr != nil { term.OutputErrorAndExit("Error updating plan config: %v", apiErr) } } } else { cmd = checkConfig.EditorCommand args = checkConfig.EditorArgs } if openManually { return false } err := exec.Command(cmd, append(args, path)...).Start() if err != nil { term.OutputErrorAndExit("Error opening template: %v", err) } return true } type SelectEditorResult struct { Name string Cmd string Args []string OpenManually bool } func SelectEditor(includeOpenManuallyOpt bool) SelectEditorResult { editors := detectEditors() opts := []string{} for _, c := range editors { opts = append(opts, c.name) } const otherOpt = "Other (custom command)" opts = append(opts, otherOpt) const openManuallyOpt = "Open files manually" if includeOpenManuallyOpt { opts = append(opts, openManuallyOpt) } choice, err := term.SelectFromList("What's your preferred editor?", opts) if err != nil { term.OutputErrorAndExit("Error selecting editor: %v", err) } var name string var cmd string var args []string if choice == otherOpt { choice, err = term.GetRequiredUserStringInput("Enter the command to open the editor") if err != nil { term.OutputErrorAndExit("Error getting editor command: %v", err) } name = choice parts := strings.Fields(choice) if len(parts) == 0 { term.OutputErrorAndExit("Invalid editor command: %s", choice) } cmd = parts[0] if len(parts) > 1 { args = parts[1:] } } else if choice == openManuallyOpt { return SelectEditorResult{ Name: "Open manually", Cmd: "", Args: []string{}, OpenManually: true, } } else { var candidate editorCandidate for _, c := range editors { if c.name == choice { candidate = c break } } name = candidate.name cmd = candidate.cmd args = candidate.args } return SelectEditorResult{ Name: name, Cmd: cmd, Args: args, } } type editorCandidate struct { name string cmd string args []string isJetBrains bool } const maxEditorOpts = 5 func detectEditors() []editorCandidate { guess := []editorCandidate{ // Popular non-JetBrains launchers {"VS Code", "code", nil, false}, {"Cursor", "cursor", nil, false}, {"Zed", "zed", nil, false}, {"Neovim", "nvim", nil, false}, // JetBrains IDE-specific launchers {"IntelliJ IDEA", "idea", nil, true}, {"GoLand", "goland", nil, true}, {"PyCharm", "pycharm", nil, true}, {"CLion", "clion", nil, true}, {"WebStorm", "webstorm", nil, true}, {"PhpStorm", "phpstorm", nil, true}, {"DataGrip", "datagrip", nil, true}, {"RubyMine", "rubymine", nil, true}, {"Rider", "rider", nil, true}, {"DataSpell", "dataspell", nil, true}, // JetBrains universal CLI (2023.2+) {"JetBrains (jb)", "jb", []string{"open"}, true}, {"Vim", "vim", nil, false}, {"Nano", "nano", nil, false}, {"Helix", "hx", nil, false}, {"Micro", "micro", nil, false}, {"Sublime Text", "subl", nil, false}, {"TextMate", "mate", nil, false}, {"Kakoune", "kak", nil, false}, {"Emacs", "emacs", nil, false}, {"Kate", "kate", nil, false}, } pref := map[string]bool{} for _, env := range []string{"VISUAL", "EDITOR"} { if v := os.Getenv(env); v != "" { // keep only the binary name, drop path/flags cmd := filepath.Base(strings.Fields(v)[0]) pref[cmd] = true } } _, err := exec.LookPath("jb") // true if universal launcher exists jbOnPath := err == nil var found []editorCandidate for _, c := range guess { if _, err := exec.LookPath(c.cmd); err != nil { continue // not on PATH } // If jb is present, drop per-IDE launchers *unless* this exact cmd // is marked preferred by VISUAL/EDITOR. if jbOnPath && c.isJetBrains && !pref[c.cmd] { continue } found = append(found, c) } for cmd := range pref { if _, err := exec.LookPath(cmd); err == nil { already := false for _, c := range found { if c.cmd == cmd { already = true break } } if !already { found = append(found, editorCandidate{name: cmd, cmd: cmd}) } } } sort.SliceStable(found, func(i, j int) bool { pi, pj := pref[found[i].cmd], pref[found[j].cmd] if pi == pj { return false // keep original order } return pi // true → i comes before j }) if len(found) > maxEditorOpts { found = found[:maxEditorOpts] } return found }