From e89c524438aa94ef58eaa961d6120adeaa4c3c40 Mon Sep 17 00:00:00 2001 From: Pablu Date: Tue, 28 Jul 2026 20:31:44 +0200 Subject: [PATCH] Add shell completion --- .gitignore | 1 + README.md | 23 +++++ cli.go | 238 ++++++++++++++++++++++++++++++++++++++++++++++++++++ cli_test.go | 77 +++++++++++++++++ main.go | 48 +++++++---- 5 files changed, 371 insertions(+), 16 deletions(-) create mode 100644 .gitignore create mode 100644 cli.go create mode 100644 cli_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b328660 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +diple diff --git a/README.md b/README.md index 217e9a3..59b8a48 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,29 @@ diple --repo owner/repository \ --endpoint https://github.example.com/api/graphql ``` +## Shell completion + +`diple` generates completion scripts without contacting GitHub or loading the +configuration. Choose the command for your shell: + +```sh +# Bash: current session +source <(diple completion bash) + +# Zsh: current session +source <(diple completion zsh) + +# Fish: install for the current user +diple completion fish > ~/.config/fish/completions/diple.fish +``` + +For persistent Bash completion, write the generated output to a directory +loaded by your distribution's `bash-completion` package. For persistent Zsh +completion, write it to a file named `_diple` in a directory on `$fpath`, then +run `compinit`. `diple completion --help` lists the supported shells, while +`diple --help` shows grouped command-line options, defaults, configuration +precedence, and authentication behavior. + ## Configuration The optional TOML configuration is loaded from diff --git a/cli.go b/cli.go new file mode 100644 index 0000000..640570e --- /dev/null +++ b/cli.go @@ -0,0 +1,238 @@ +package main + +import ( + "fmt" + "io" + "strings" +) + +var completionShells = []string{"bash", "zsh", "fish"} + +func handleCompletionCommand(args []string, output io.Writer) (bool, error) { + if len(args) == 0 || args[0] != "completion" { + return false, nil + } + if len(args) == 2 && (args[1] == "-h" || args[1] == "--help") { + _, err := fmt.Fprintln(output, completionHelp) + return true, err + } + if len(args) != 2 { + return true, fmt.Errorf("usage: diple completion ") + } + var script string + switch args[1] { + case "bash": + script = bashCompletion + case "zsh": + script = zshCompletion + case "fish": + script = fishCompletion + default: + return true, fmt.Errorf( + "unsupported shell %q; choose %s", + args[1], strings.Join(completionShells, ", "), + ) + } + _, err := io.WriteString(output, script) + return true, err +} + +const completionHelp = `Generate a shell completion script. + +Usage: + diple completion + +Available shells: + bash + zsh + fish + +Run 'diple completion ' and source or install the generated script. +See the README for shell-specific installation paths.` + +func writeCLIHelp(output io.Writer, defaults Config, configPath string) { + fmt.Fprintf(output, `diple — review and manage GitHub pull requests from the terminal + +Usage: + diple [options] + diple completion + diple help + +Pull-request selection: + --repo OWNER/REPOSITORY Limit results to one repository (or use GH_REPO). + --all[=BOOL] Include every open PR in --repo. Default: %t. + --limit NUMBER Maximum PRs to load, from 1 to 1000. Default: %d. + +GitHub and refresh: + --poll DURATION Base refresh interval, at least 2s. Default: %s. + --endpoint URL GitHub GraphQL endpoint. + Default: %s. + +Appearance and navigation: + --theme NAME dark, light, catppuccin, catppuccin-latte, + gruvbox, gruvbox-light, one-dark-pro, github, + github-light, high-contrast, no-color, or custom. + Default: %s. + --dashboard-mode MODE hotkey or intermediate. Default: %s. + --thread-list-width N List width in percent, from 20 to 60. Default: %d. + --fold-resolved[=BOOL] Start resolved threads folded. Default: %t. + --compact-reviews[=BOOL] Aggregate submitted-review history. Default: %t. + --path-scroll[=BOOL] Scroll truncated file paths. Default: %t. + --path-scroll-interval D Path scroll step interval. Default: %s. + --editor-mode MODE vim or standard. Default: %s. + +Local state: + --config FILE TOML configuration file. + Default: %s. + --cache[=BOOL] Enable instant cached startup and offline fallback. + Default: %t. + --cache-max-age DURATION Maximum offline cache age; 0 disables expiry. + Default: %s. + --cache-dir DIRECTORY Override the operating-system cache directory. + +Other: + -h, --help Show this help and exit. + +Boolean options accept explicit values, for example --cache=false. +Command-line options override TOML settings. GH_REPO is used only when +--repo is absent. DIPLE_CONFIG selects a configuration file; GH_THREADS_CONFIG +is retained as a migration fallback. + +Authentication uses GH_TOKEN or GITHUB_TOKEN when set, otherwise the active +credential from 'gh auth login'. Run 'diple completion --help' for completion +installation guidance. +`, + defaults.ShowAll, + defaults.Limit, + defaults.RefreshInterval.Duration, + defaults.Endpoint, + defaults.Theme, + defaults.Display.DashboardMode, + defaults.Display.ThreadListWidthPercent, + defaults.Display.FoldResolved, + defaults.Display.CompactReviews, + defaults.Paths.Scroll, + defaults.Paths.ScrollInterval.Duration, + defaults.Editing.Mode, + configPath, + defaults.Cache.Enabled, + defaults.Cache.MaxAge.Duration, + ) +} + +const bashCompletion = `# bash completion for diple +_diple_completion() { + local current previous + current="${COMP_WORDS[COMP_CWORD]}" + previous="${COMP_WORDS[COMP_CWORD-1]}" + + if [[ ${COMP_CWORD} -eq 1 && ${current} != -* ]]; then + COMPREPLY=($(compgen -W "completion help" -- "${current}")) + return + fi + if [[ ${COMP_WORDS[1]} == completion ]]; then + COMPREPLY=($(compgen -W "bash zsh fish" -- "${current}")) + return + fi + if [[ ${COMP_WORDS[1]} == help ]]; then + COMPREPLY=() + return + fi + + case "${previous}" in + --theme) + COMPREPLY=($(compgen -W "dark light catppuccin catppuccin-mocha catppuccin-latte gruvbox gruvbox-dark gruvbox-light one-dark-pro github github-dark github-light high-contrast no-color custom" -- "${current}")) + return + ;; + --dashboard-mode) + COMPREPLY=($(compgen -W "hotkey intermediate" -- "${current}")) + return + ;; + --editor-mode) + COMPREPLY=($(compgen -W "vim standard" -- "${current}")) + return + ;; + --config|--cache-dir) + COMPREPLY=($(compgen -f -- "${current}")) + return + ;; + esac + + local options="--repo --all --limit --poll --endpoint --theme --dashboard-mode --thread-list-width --fold-resolved --compact-reviews --path-scroll --path-scroll-interval --editor-mode --config --cache --cache-max-age --cache-dir --help -h" + COMPREPLY=($(compgen -W "${options}" -- "${current}")) +} +complete -F _diple_completion diple +` + +const zshCompletion = `#compdef diple + +_diple() { + local -a themes + themes=(dark light catppuccin catppuccin-mocha catppuccin-latte gruvbox gruvbox-dark gruvbox-light one-dark-pro github github-dark github-light high-contrast no-color custom) + + if (( CURRENT == 2 )) && [[ ${PREFIX} != -* ]]; then + _values 'command' \ + 'completion[generate shell completion]' \ + 'help[show command-line help]' + return + fi + if [[ ${words[2]} == completion ]]; then + _values 'shell' bash zsh fish + return + fi + if [[ ${words[2]} == help ]]; then + return + fi + + _arguments -s \ + '--repo[limit results to one repository]:owner/repository:' \ + '--all=[include every open PR in the repository]:boolean:(true false)' \ + '--limit[maximum pull requests to load]:number:' \ + '--poll[base GitHub refresh interval]:duration:' \ + '--endpoint[GitHub GraphQL endpoint]:url:' \ + '--theme[UI and syntax color theme]:theme:($themes)' \ + '--dashboard-mode[dashboard navigation mode]:mode:(hotkey intermediate)' \ + '--thread-list-width[thread-list width percentage]:percent:' \ + '--fold-resolved=[start resolved threads folded]:boolean:(true false)' \ + '--compact-reviews=[aggregate submitted reviews]:boolean:(true false)' \ + '--path-scroll=[scroll truncated paths]:boolean:(true false)' \ + '--path-scroll-interval[path scrolling interval]:duration:' \ + '--editor-mode[description editor mode]:mode:(vim standard)' \ + '--config[TOML configuration file]:file:_files' \ + '--cache=[enable local read cache]:boolean:(true false)' \ + '--cache-max-age[maximum offline cache age]:duration:' \ + '--cache-dir[local read-cache directory]:directory:_directories' \ + '(-h --help)'{-h,--help}'[show help]' +} + +if (( ! ${+functions[compdef]} )); then + autoload -Uz compinit + compinit -D +fi +compdef _diple diple +` + +const fishCompletion = `# fish completion for diple +complete -c diple -f +complete -c diple -n '__fish_use_subcommand' -a completion -d 'Generate shell completion' +complete -c diple -n '__fish_use_subcommand' -a help -d 'Show help' +complete -c diple -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish' +complete -c diple -l repo -d 'Limit results to owner/repository' +complete -c diple -l all -d 'Include every open PR in --repo' +complete -c diple -l limit -x -d 'Maximum pull requests to load' +complete -c diple -l poll -x -d 'Base GitHub refresh interval' +complete -c diple -l endpoint -x -d 'GitHub GraphQL endpoint' +complete -c diple -l theme -x -a 'dark light catppuccin catppuccin-mocha catppuccin-latte gruvbox gruvbox-dark gruvbox-light one-dark-pro github github-dark github-light high-contrast no-color custom' -d 'UI and syntax color theme' +complete -c diple -l dashboard-mode -x -a 'hotkey intermediate' -d 'Dashboard navigation mode' +complete -c diple -l thread-list-width -x -d 'Thread-list width percentage' +complete -c diple -l fold-resolved -d 'Start resolved threads folded' +complete -c diple -l compact-reviews -d 'Aggregate submitted reviews' +complete -c diple -l path-scroll -d 'Scroll truncated paths' +complete -c diple -l path-scroll-interval -x -d 'Path scrolling interval' +complete -c diple -l editor-mode -x -a 'vim standard' -d 'Description editor mode' +complete -c diple -l config -r -F -d 'TOML configuration file' +complete -c diple -l cache -d 'Enable local read cache' +complete -c diple -l cache-max-age -x -d 'Maximum offline cache age' +complete -c diple -l cache-dir -r -a '(__fish_complete_directories)' -d 'Local read-cache directory' +complete -c diple -s h -l help -d 'Show help' +` diff --git a/cli_test.go b/cli_test.go new file mode 100644 index 0000000..cee0dd5 --- /dev/null +++ b/cli_test.go @@ -0,0 +1,77 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +func TestCompletionCommandGeneratesSupportedShells(t *testing.T) { + for _, shell := range completionShells { + t.Run(shell, func(t *testing.T) { + var output bytes.Buffer + handled, err := handleCompletionCommand([]string{"completion", shell}, &output) + if err != nil { + t.Fatal(err) + } + if !handled || output.Len() == 0 || + !strings.Contains(output.String(), "diple") { + t.Fatalf("completion output = %q, handled=%t", output.String(), handled) + } + }) + } +} + +func TestZshCompletionRegistersWithoutCallingCompletionFunction(t *testing.T) { + var output bytes.Buffer + handled, err := handleCompletionCommand([]string{"completion", "zsh"}, &output) + if err != nil { + t.Fatal(err) + } + script := output.String() + if !handled || !strings.Contains(script, "compdef _diple diple") { + t.Fatalf("Zsh completion does not register _diple:\n%s", script) + } + if strings.Contains(script, `_diple "$@"`) { + t.Fatalf("Zsh completion invokes _diple while being sourced:\n%s", script) + } +} + +func TestCompletionCommandRejectsUnknownShell(t *testing.T) { + var output bytes.Buffer + handled, err := handleCompletionCommand( + []string{"completion", "powershell"}, &output, + ) + if !handled || err == nil || !strings.Contains(err.Error(), "unsupported shell") { + t.Fatalf("handled=%t error=%v", handled, err) + } +} + +func TestCompletionCommandDoesNotClaimNormalInvocation(t *testing.T) { + handled, err := handleCompletionCommand([]string{"--repo", "owner/repo"}, &bytes.Buffer{}) + if handled || err != nil { + t.Fatalf("handled=%t error=%v", handled, err) + } +} + +func TestCLIHelpIsGroupedAndActionable(t *testing.T) { + var output bytes.Buffer + writeCLIHelp(&output, defaultConfig(), "/tmp/diple/config.toml") + help := output.String() + for _, expected := range []string{ + "Usage:", + "Pull-request selection:", + "GitHub and refresh:", + "Appearance and navigation:", + "Local state:", + "diple completion ", + "--repo OWNER/REPOSITORY", + "--cache=false", + "gh auth login", + "/tmp/diple/config.toml", + } { + if !strings.Contains(help, expected) { + t.Fatalf("help does not contain %q:\n%s", expected, help) + } + } +} diff --git a/main.go b/main.go index 179939a..5ced35a 100644 --- a/main.go +++ b/main.go @@ -11,31 +11,47 @@ import ( ) func main() { + if handled, err := handleCompletionCommand(os.Args[1:], os.Stdout); handled { + if err != nil { + exitf("%v", err) + } + return + } defaults := defaultConfig() defaultConfigPath, err := configPath() if err != nil { exitf("configuration: %v", err) } + flag.Usage = func() { + writeCLIHelp(flag.CommandLine.Output(), defaults, defaultConfigPath) + } + if len(os.Args) == 2 && os.Args[1] == "help" { + flag.Usage() + return + } var ( configFile = flag.String("config", defaultConfigPath, "TOML configuration file") - repo = flag.String("repo", "", "optional GitHub repository filter as owner/name (or GH_REPO)") - poll = flag.Duration("poll", defaults.RefreshInterval.Duration, "refresh interval") - showAll = flag.Bool("all", defaults.ShowAll, "show all open PRs in --repo, not only PRs assigned to you") - limit = flag.Int("limit", defaults.Limit, "maximum open PRs to load (1-1000)") - endpoint = flag.String("endpoint", defaults.Endpoint, "GitHub GraphQL endpoint") - theme = flag.String("theme", defaults.Theme, "color theme name (including custom, catppuccin, gruvbox, one-dark-pro, and github)") - foldResolved = flag.Bool("fold-resolved", defaults.Display.FoldResolved, "start resolved threads folded") - listWidth = flag.Int("thread-list-width", defaults.Display.ThreadListWidthPercent, "thread list width as terminal percentage (20-60)") - dashboardMode = flag.String("dashboard-mode", defaults.Display.DashboardMode, "dashboard navigation: intermediate or hotkey") - compactReviews = flag.Bool("compact-reviews", defaults.Display.CompactReviews, "aggregate submitted review history") - pathScroll = flag.Bool("path-scroll", defaults.Paths.Scroll, "scroll truncated paths") - pathScrollRate = flag.Duration("path-scroll-interval", defaults.Paths.ScrollInterval.Duration, "path scrolling interval") - cacheEnabled = flag.Bool("cache", defaults.Cache.Enabled, "enable local read cache fallback") - cacheMaxAge = flag.Duration("cache-max-age", defaults.Cache.MaxAge.Duration, "maximum offline cache age (0 disables expiry)") - cacheDir = flag.String("cache-dir", defaults.Cache.Directory, "local read cache directory") - editorMode = flag.String("editor-mode", defaults.Editing.Mode, "description editor mode: standard or vim") + repo = flag.String("repo", "", "limit pull requests to an owner/repository") + poll = flag.Duration("poll", defaults.RefreshInterval.Duration, "base interval between GitHub refreshes") + showAll = flag.Bool("all", defaults.ShowAll, "show every open PR in --repo instead of only assigned PRs") + limit = flag.Int("limit", defaults.Limit, "maximum number of open pull requests to load") + endpoint = flag.String("endpoint", defaults.Endpoint, "GitHub GraphQL API endpoint") + theme = flag.String("theme", defaults.Theme, "built-in or custom color theme") + foldResolved = flag.Bool("fold-resolved", defaults.Display.FoldResolved, "start resolved review threads folded") + listWidth = flag.Int("thread-list-width", defaults.Display.ThreadListWidthPercent, "thread-list width as a percentage of the terminal") + dashboardMode = flag.String("dashboard-mode", defaults.Display.DashboardMode, "open the dashboard by hotkey or as an intermediate screen") + compactReviews = flag.Bool("compact-reviews", defaults.Display.CompactReviews, "aggregate repeated submitted-review entries") + pathScroll = flag.Bool("path-scroll", defaults.Paths.Scroll, "scroll file paths that do not fit") + pathScrollRate = flag.Duration("path-scroll-interval", defaults.Paths.ScrollInterval.Duration, "interval between file-path scroll steps") + cacheEnabled = flag.Bool("cache", defaults.Cache.Enabled, "enable the local read cache and offline fallback") + cacheMaxAge = flag.Duration("cache-max-age", defaults.Cache.MaxAge.Duration, "oldest cache entry accepted for offline fallback") + cacheDir = flag.String("cache-dir", defaults.Cache.Directory, "local read-cache directory") + editorMode = flag.String("editor-mode", defaults.Editing.Mode, "description editor mode") ) flag.Parse() + if flag.NArg() != 0 { + exitf("unexpected argument %q; run 'diple --help' for usage", flag.Arg(0)) + } visited := map[string]bool{} flag.Visit(func(item *flag.Flag) { visited[item.Name] = true })