diff --git a/.gitignore b/.gitignore index 78d10f0..2678461 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ CLAUDE.md DEPLOYMENT_SUMMARY.md HOMEBREW_COMPLIANCE.md RELEASE_SETUP.md + +# Local/live test configs (cluster-specific, never committed) +.kportal.test.yaml +.kportal.*.local.yaml diff --git a/cmd/kportal/completion.go b/cmd/kportal/completion.go new file mode 100644 index 0000000..e721848 --- /dev/null +++ b/cmd/kportal/completion.go @@ -0,0 +1,119 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/lukaszraczylo/kportal/internal/complete" +) + +// completionCmd handles shell completion generation and installation +func completionCmd(args []string) int { + fs := flag.NewFlagSet("completion", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + + var ( + installFlag bool + shellFlag string + uninstall bool + ) + + fs.BoolVar(&installFlag, "install", false, "Install completions for the shell") + fs.BoolVar(&uninstall, "uninstall", false, "Uninstall completions") + fs.StringVar(&shellFlag, "shell", "", "Shell type: bash, zsh, or fish (auto-detected if empty)") + + if err := fs.Parse(args); err != nil { + if err == flag.ErrHelp { + printCompletionHelp() + return 0 + } + return 2 + } + + // Determine shell type + var shell complete.Shell + if shellFlag != "" { + switch shellFlag { + case "bash": + shell = complete.ShellBash + case "zsh": + shell = complete.ShellZsh + case "fish": + shell = complete.ShellFish + default: + fprintf(os.Stderr, "Error: unknown shell %q (use bash, zsh, or fish)\n", shellFlag) + return 1 + } + } else { + shell = complete.AutoDetectShell() + } + + // Handle uninstall + if uninstall { + installer := complete.NewInstaller(shell) + if err := installer.Uninstall(); err != nil { + fprintf(os.Stderr, "Error uninstalling completions: %v\n", err) + return 1 + } + fmt.Println("✅ Completions uninstalled") + return 0 + } + + // Handle install + if installFlag { + if err := complete.InstallCompletions(shell); err != nil { + fprintf(os.Stderr, "Error installing completions: %v\n", err) + return 1 + } + return 0 + } + + // Print completion script to stdout + if err := complete.Print(shell); err != nil { + fprintf(os.Stderr, "Error generating completions: %v\n", err) + return 1 + } + + return 0 +} + +func printCompletionHelp() { + fprintf(os.Stdout, `Generate shell completions for kportal. + +Usage: + kportal completion [flags] + +Flags: + --install Install completions for the current shell + --uninstall Remove installed completions + --shell Shell type: bash, zsh, or fish (auto-detected) + +Examples: + # Generate and source completions (bash) + source <(kportal completion) + + # Install completions (requires shell restart) + kportal completion --install + + # Install for specific shell + kportal completion --install --shell zsh + + # Uninstall completions + kportal completion --uninstall + +Shell-specific setup: + + Bash (~/.bashrc): + source <(kportal completion) + + Zsh (~/.zshrc): + autoload -Uz compinit && compinit + source <(kportal completion) + + Fish (~/.config/fish/config.fish): + kportal completion --install --shell fish + # Or manually: + kportal completion --shell fish > ~/.config/fish/completions/kportal.fish +`) +} diff --git a/cmd/kportal/main.go b/cmd/kportal/main.go index b369899..99a3898 100644 --- a/cmd/kportal/main.go +++ b/cmd/kportal/main.go @@ -117,9 +117,14 @@ func runMain() int { // of long-running modes (headless, verbose-loop, interactive). func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) int { // Subcommand dispatch must run BEFORE the main flag set is parsed because - // generate has its own FlagSet and must not see kportal's top-level flags. - if len(args) >= 1 && args[0] == "generate" { - return runGenerate(args[1:]) + // generate and completion have their own FlagSets and must not see kportal's top-level flags. + if len(args) >= 1 { + switch args[0] { + case "generate": + return runGenerate(args[1:]) + case "completion": + return completionCmd(args[1:]) + } } opts, code, handled := parseFlags(args, stderr) @@ -498,7 +503,7 @@ func runVerboseTable(ctx context.Context, opts runOptions, cfg *config.Config, d // Background update check (best effort). go func() { checker := version.NewChecker(githubOwner, githubRepo, appVersion) - uctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + uctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() if update := checker.CheckForUpdate(uctx); update != nil { log.Printf("Update available: v%s (current: v%s) - %s", @@ -590,7 +595,7 @@ func runInteractive(ctx context.Context, opts runOptions, cfg *config.Config, de go func() { checker := version.NewChecker(githubOwner, githubRepo, appVersion) - uctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + uctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() if update := checker.CheckForUpdate(uctx); update != nil { bubbleTeaUI.SetUpdateAvailable(update.LatestVersion, update.ReleaseURL) diff --git a/internal/complete/completion.go b/internal/complete/completion.go new file mode 100644 index 0000000..1e134a9 --- /dev/null +++ b/internal/complete/completion.go @@ -0,0 +1,458 @@ +// Package complete provides shell completion generation for kportal. +// It supports bash, zsh, and fish shells with context-aware completions +// for flags, subcommands, config values, and Kubernetes resources. +package complete + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// Shell represents a supported shell type +type Shell string + +const ( + ShellBash Shell = "bash" + ShellZsh Shell = "zsh" + ShellFish Shell = "fish" +) + +// Installer handles shell completion installation +type Installer struct { + shell Shell + prefixDir string +} + +// NewInstaller creates a new completion installer for the specified shell +func NewInstaller(shell Shell) *Installer { + return &Installer{ + shell: shell, + prefixDir: getDefaultPrefixDir(shell), + } +} + +// getDefaultPrefixDir returns the default completion directory for a shell +func getDefaultPrefixDir(shell Shell) string { + home, err := os.UserHomeDir() + if err != nil { + home = os.Getenv("HOME") + } + if home == "" { + home = "/tmp" + } + + switch shell { + case ShellBash: + // Check common bash completion directories + dirs := []string{ + "/etc/bash_completion.d", + filepath.Join(home, ".local", "share", "bash-completion", "completions"), + filepath.Join(home, ".bash_completion.d"), + } + for _, dir := range dirs { + if pathExists(dir) { + return dir + } + } + // Fallback to user dir (best-effort create; Install reports write errors) + userDir := filepath.Join(home, ".local", "share", "bash-completion", "completions") + ensureDir(userDir) + return userDir + + case ShellZsh: + dirs := []string{ + filepath.Join(home, ".zsh", "completions"), + filepath.Join(home, ".oh-my-zsh", "completions"), + } + for _, dir := range dirs { + if pathExists(dir) { + return dir + } + } + // Fallback to standard zsh site-functions + usrShare := "/usr/local/share/zsh/site-functions" + if pathExists(usrShare) { + return usrShare + } + userDir := filepath.Join(home, ".zsh", "completions") + ensureDir(userDir) + return userDir + + case ShellFish: + dir := filepath.Join(home, ".config", "fish", "completions") + ensureDir(dir) + return dir + } + + return "" +} + +// Install installs the completion script for the shell +func (i *Installer) Install() error { + script, err := Generate(i.shell) + if err != nil { + return fmt.Errorf("failed to generate completion script: %w", err) + } + + filename := i.getCompletionFilename() + filepath := filepath.Join(i.prefixDir, filename) + + // Check if already installed + if pathExists(filepath) { + return fmt.Errorf("completion file already exists: %s (remove it first to reinstall)", filepath) + } + + // #nosec G306 -- completion scripts are non-secret and must be world-readable by the shell + if err := os.WriteFile(filepath, []byte(script), 0o644); err != nil { + return fmt.Errorf("failed to write completion file: %w", err) + } + + return nil +} + +// Uninstall removes the completion script +func (i *Installer) Uninstall() error { + filename := i.getCompletionFilename() + filepath := filepath.Join(i.prefixDir, filename) + + if err := os.Remove(filepath); err != nil { + return fmt.Errorf("failed to remove completion file: %w", err) + } + + return nil +} + +// getCompletionFilename returns the filename for the completion script +func (i *Installer) getCompletionFilename() string { + switch i.shell { + case ShellBash: + return "_kportal" + case ShellZsh: + return "_kportal" + case ShellFish: + return "kportal.fish" + } + return "_kportal" +} + +// Print prints the completion script to stdout +func Print(shell Shell) error { + script, err := Generate(shell) + if err != nil { + return err + } + fmt.Print(script) + return nil +} + +// Generate generates the completion script for the specified shell +func Generate(shell Shell) (string, error) { + switch shell { + case ShellBash: + return generateBash() + case ShellZsh: + return generateZsh() + case ShellFish: + return generateFish() + default: + return "", fmt.Errorf("unsupported shell: %s", shell) + } +} + +// generateBash generates bash completion script +func generateBash() (string, error) { + var sb strings.Builder + + bashScript := `# kportal shell completion - bash +# Generated by kportal + +# Don't interfere with other completions +if [[ -n "${BASH_COMPLETION_VERSINFO:-}" ]]; then + return +fi + +_kportal() +{ + local cur prev words cword split=false + _init_completion -s || return + + # Complete the value expected after the previous flag + case "$prev" in + -c|--config) + _filedir yaml + return + ;; + --log-format) + COMPREPLY=( $(compgen -W "text json" -- "$cur") ) + return + ;; + --convert) + _filedir json + return + ;; + --convert-output) + _filedir yaml + return + ;; + --context) + # Complete from kubectl contexts + if command -v kubectl &> /dev/null; then + COMPREPLY=( $(compgen -W "$(kubectl config get-contexts -o name 2>/dev/null)" -- "$cur") ) + fi + return + ;; + --shell) + COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") ) + return + ;; + esac + + # Handle option splitting (e.g., -c=value) + [[ "$cur" == -*=* ]] && split=true + [[ "$split" == true ]] && prev="${cur%%=*}" + + # Subcommand-specific completion + case "${words[1]}" in + generate) + if [[ "$cur" == -* ]]; then + COMPREPLY=( $(compgen -W "--context --config --dry-run" -- "$cur") ) + elif command -v kubectl &> /dev/null; then + COMPREPLY=( $(compgen -W "$(kubectl config get-contexts -o name 2>/dev/null)" -- "$cur") ) + fi + return + ;; + completion) + COMPREPLY=( $(compgen -W "--install --uninstall --shell" -- "$cur") ) + return + ;; + esac + + # Top-level options + if [[ "$cur" == -* ]]; then + COMPREPLY=( $(compgen -W "-c -v --check --headless --log-format --version --update --convert --convert-output" -- "$cur") ) + return + fi + + # Top-level subcommands + COMPREPLY=( $(compgen -W "generate completion" -- "$cur") ) +} + +# Register completion +complete -F _kportal kportal + +# Also complete for common aliases +complete -F _kportal kp +` + + sb.WriteString(bashScript) + return sb.String(), nil +} + +// generateZsh generates zsh completion script +func generateZsh() (string, error) { + var sb strings.Builder + + // Per-flag zsh _arguments descriptors. Completion scripts are static text, so + // these are literals (single source of truth — keep in sync with the CLI flags). + flagDescs := []string{ + `'-c[Path to configuration file]:config file:_files -g "*.yaml"'`, + `'-v[Enable verbose logging]'`, + `'--version[Show version and exit]'`, + `'--update[Check for updates]'`, + `'--check[Validate configuration]'`, + `'--headless[Run without UI]'`, + `'--log-format[Log format: text or json]:format:(text json)'`, + `'--convert[Convert kftray config]:input file:_files -g "*.json"'`, + `'--convert-output[Output file]:output file:_files -g "*.yaml"'`, + } + + sb.WriteString(`#compdef kportal + +# kportal shell completion - zsh +# Generated by kportal + +_kportal() +{ + local -a commands flags generate_flags completion_flags + + commands=( + 'generate:Interactively generate forwards from cluster' + 'completion:Generate shell completion scripts' + ) + + flags=( +`) + + for _, desc := range flagDescs { + fmt.Fprintf(&sb, " %s\n", desc) + } + + sb.WriteString(` ) + + generate_flags=( + '--context[Kubernetes context]:context:->ctx' + '--config[Config file]:file:_files -g "*.yaml"' + '--dry-run[Print without saving]' + ) + + completion_flags=( + '--install[Install completions for the shell]' + '--uninstall[Remove installed completions]' + '--shell[Shell type]:shell:(bash zsh fish)' + ) + + _arguments -s $flags '1: :->command' '*:: :->args' + + case $state in + command) + _describe 'command' commands + ;; + args) + case ${words[1]} in + generate) + _arguments -s $generate_flags + if [[ "$words[CURRENT]" == --context ]]; then + if command -v kubectl &> /dev/null; then + local -a contexts + contexts=(${(f)"$(kubectl config get-contexts -o name 2>/dev/null)"}) + _describe 'context' contexts + fi + fi + ;; + completion) + _arguments -s $completion_flags + ;; + esac + ;; + esac +} + +_kportal "$@" +`) + + return sb.String(), nil +} + +// generateFish generates fish completion script +func generateFish() (string, error) { + var sb strings.Builder + + sb.WriteString(`# kportal shell completion - fish +# Generated by kportal + +# Main completion +complete -c kportal -f + +# Subcommands +complete -c kportal -n '__fish_use_subcommand' -a 'generate' -d 'Interactively generate forwards from cluster' +complete -c kportal -n '__fish_use_subcommand' -a 'completion' -d 'Generate shell completion scripts' + +# Global flags (main command uses single-dash -c and -v; words accept --) +complete -c kportal -s c -r -f -a '( __fish_complete_suffix .yaml )' -d 'Path to configuration file' +complete -c kportal -s v -d 'Enable verbose logging' +complete -c kportal -l version -d 'Show version and exit' +complete -c kportal -l update -d 'Check for updates' +complete -c kportal -l check -d 'Validate configuration' +complete -c kportal -l headless -d 'Run without UI' +complete -c kportal -l log-format -d 'Log format' -a 'text json' -f +complete -c kportal -l convert -r -f -a '( __fish_complete_suffix .json )' -d 'Convert kftray config' +complete -c kportal -l convert-output -r -f -a '( __fish_complete_suffix .yaml )' -d 'Output file' + +# generate subcommand flags +complete -c kportal -n '__fish_seen_subcommand_from generate' -l context -d 'Kubernetes context' -a '(kubectl config get-contexts -o name 2>/dev/null)' -f +complete -c kportal -n '__fish_seen_subcommand_from generate' -l config -r -f -a '( __fish_complete_suffix .yaml )' -d 'Config file' +complete -c kportal -n '__fish_seen_subcommand_from generate' -l dry-run -d 'Print without saving' + +# completion subcommand flags +complete -c kportal -n '__fish_seen_subcommand_from completion' -l install -d 'Install completions for the shell' +complete -c kportal -n '__fish_seen_subcommand_from completion' -l uninstall -d 'Remove installed completions' +complete -c kportal -n '__fish_seen_subcommand_from completion' -l shell -d 'Shell type' -a 'bash zsh fish' -f + +# Aliases +complete -c kp -f +`) + + return sb.String(), nil +} + +// InstallCompletions installs completions for the specified shell +// Prints instructions for manual installation if auto-install fails +func InstallCompletions(shell Shell) error { + installer := NewInstaller(shell) + + if err := installer.Install(); err != nil { + // Check if it's just "already exists" error + if strings.Contains(err.Error(), "already exists") { + fmt.Printf("Completion already installed at: %s/%s\n", + installer.prefixDir, installer.getCompletionFilename()) + fmt.Println("Remove it first to reinstall, or source it manually:") + return nil + } + + // Try to print the script instead + fmt.Println("Could not auto-install completions. To install manually, run:") + fmt.Printf(" # %s\n", getInstallInstructions(shell)) + return nil + } + + fmt.Printf("✅ Completions installed to: %s/%s\n", + installer.prefixDir, installer.getCompletionFilename()) + fmt.Printf("\nTo enable %s completions, restart your shell or run:\n", shell) + fmt.Printf(" source ~/%s\n", getSourceInstruction(shell)) + + return nil +} + +func getInstallInstructions(shell Shell) string { + return fmt.Sprintf("kportal completion %s > /_kportal", shell) +} + +func getSourceInstruction(shell Shell) string { + switch shell { + case ShellBash: + return ".bashrc" + case ShellZsh: + return ".zshrc" + case ShellFish: + return ".config/fish/config.fish # completions load automatically" + } + return "shell config" +} + +// AutoDetectShell detects the current shell +func AutoDetectShell() Shell { + shell := os.Getenv("SHELL") + if shell == "" { + return ShellBash // Default + } + + if strings.HasSuffix(shell, "/bash") { + return ShellBash + } + if strings.HasSuffix(shell, "/zsh") { + return ShellZsh + } + if strings.HasSuffix(shell, "/fish") { + return ShellFish + } + + return ShellBash +} + +// pathExists reports whether path exists on disk. +// #nosec G703 -- callers pass fixed completion paths derived from the user's own +// HOME and constant subdirectory names, never external/untrusted input. +func pathExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// ensureDir best-effort creates a completion directory. Errors are intentionally +// ignored: Install() surfaces any later write failure with a clear message. +// #nosec G703 -- path is derived from the user's own HOME and constant +// subdirectory names; 0o750 is appropriate for user-owned completion dirs. +func ensureDir(path string) { + _ = os.MkdirAll(path, 0o750) +} diff --git a/internal/complete/completion_test.go b/internal/complete/completion_test.go new file mode 100644 index 0000000..b05eae9 --- /dev/null +++ b/internal/complete/completion_test.go @@ -0,0 +1,327 @@ +package complete + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGenerateBash(t *testing.T) { + script, err := Generate(ShellBash) + if err != nil { + t.Fatalf("Generate(ShellBash) failed: %v", err) + } + + // Verify script contains key elements + if !strings.Contains(script, "_kportal()") { + t.Error("Bash script missing _kportal function") + } + if !strings.Contains(script, "complete -F _kportal kportal") { + t.Error("Bash script missing completion registration") + } + if !strings.Contains(script, "--version") { + t.Error("Bash script missing --version flag") + } + if !strings.Contains(script, "generate") { + t.Error("Bash script missing generate subcommand") + } +} + +func TestGenerateZsh(t *testing.T) { + script, err := Generate(ShellZsh) + if err != nil { + t.Fatalf("Generate(ShellZsh) failed: %v", err) + } + + // Verify script contains key elements + if !strings.Contains(script, "#compdef kportal") { + t.Error("Zsh script missing #compdef directive") + } + if !strings.Contains(script, "_kportal()") { + t.Error("Zsh script missing _kportal function") + } + if !strings.Contains(script, "'generate:") { + t.Error("Zsh script missing generate subcommand") + } + if !strings.Contains(script, "'--context[") { + t.Error("Zsh script missing --context flag") + } +} + +func TestGenerateFish(t *testing.T) { + script, err := Generate(ShellFish) + if err != nil { + t.Fatalf("Generate(ShellFish) failed: %v", err) + } + + // Verify script contains key elements + if !strings.Contains(script, "complete -c kportal") { + t.Error("Fish script missing complete directive") + } + if !strings.Contains(script, "-n '__fish_use_subcommand'") { + t.Error("Fish script missing subcommand detection") + } + if !strings.Contains(script, "-l context") { + t.Error("Fish script missing --context flag") + } +} + +func TestGenerateUnsupported(t *testing.T) { + _, err := Generate(Shell("unsupported")) + if err == nil { + t.Error("Expected error for unsupported shell") + } +} + +func TestAutoDetectShell(t *testing.T) { + tests := []struct { + name string + shellEnv string + expected Shell + }{ + {"bash", "/bin/bash", ShellBash}, + {"zsh", "/usr/bin/zsh", ShellZsh}, + {"fish", "/usr/local/bin/fish", ShellFish}, + {"tcsh", "/bin/tcsh", ShellBash}, // Falls back to bash + {"empty", "", ShellBash}, // Falls back to bash + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + detected := autoDetectShellFromEnv(tt.shellEnv) + if detected != tt.expected { + t.Errorf("AutoDetectShell() = %v, want %v", detected, tt.expected) + } + }) + } +} + +// autoDetectShellFromEnv is a test helper that simulates shell detection +func autoDetectShellFromEnv(shellEnv string) Shell { + if shellEnv == "" { + return ShellBash + } + if strings.HasSuffix(shellEnv, "/bash") { + return ShellBash + } + if strings.HasSuffix(shellEnv, "/zsh") { + return ShellZsh + } + if strings.HasSuffix(shellEnv, "/fish") { + return ShellFish + } + return ShellBash +} + +func TestInstaller(t *testing.T) { + // Test with bash + installer := NewInstaller(ShellBash) + if installer.prefixDir == "" { + t.Error("Installer should have a prefix directory") + } + + // Test filename generation + t.Run("filename", func(t *testing.T) { + tests := []struct { + shell Shell + expected string + }{ + {ShellBash, "_kportal"}, + {ShellZsh, "_kportal"}, + {ShellFish, "kportal.fish"}, + } + + for _, tt := range tests { + inst := NewInstaller(tt.shell) + got := inst.getCompletionFilename() + if got != tt.expected { + t.Errorf("getCompletionFilename() for %v = %v, want %v", tt.shell, got, tt.expected) + } + } + }) +} + +func TestInstallCompletion(t *testing.T) { + // Temp directory (auto-removed by the test framework) + tempDir := t.TempDir() + + // Test installation to temp directory + installer := &Installer{ + shell: ShellBash, + prefixDir: tempDir, + } + + // Should fail because file doesn't exist (doesn't matter, just test the method exists) + _ = installer.Install() +} + +func TestPrint(t *testing.T) { + // Test that Print doesn't crash and outputs something + orig := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := Print(ShellBash) + + _ = w.Close() + os.Stdout = orig + + if err != nil { + t.Fatalf("Print(ShellBash) failed: %v", err) + } + + // Read output + buf := make([]byte, 1024) + n, _ := r.Read(buf) + output := string(buf[:n]) + + if !strings.Contains(output, "_kportal") { + t.Error("Print output should contain _kportal") + } +} + +func TestGetCompletionFilename(t *testing.T) { + tests := []struct { + shell Shell + expected string + }{ + {ShellBash, "_kportal"}, + {ShellZsh, "_kportal"}, + {ShellFish, "kportal.fish"}, + {Shell("unknown"), "_kportal"}, // Falls back + } + + for _, tt := range tests { + inst := &Installer{shell: tt.shell} + got := inst.getCompletionFilename() + if got != tt.expected { + t.Errorf("getCompletionFilename() for %v = %v, want %v", tt.shell, got, tt.expected) + } + } +} + +func TestBashCompletionIncludesContextCompletion(t *testing.T) { + script, err := Generate(ShellBash) + if err != nil { + t.Fatalf("Generate(ShellBash) failed: %v", err) + } + + // Should include kubectl context completion + if !strings.Contains(script, "kubectl config get-contexts") { + t.Error("Bash completion should include kubectl context completion") + } +} + +func TestZshCompletionIncludesContextCompletion(t *testing.T) { + script, err := Generate(ShellZsh) + if err != nil { + t.Fatalf("Generate(ShellZsh) failed: %v", err) + } + + // Should include kubectl context completion + if !strings.Contains(script, "kubectl config get-contexts") { + t.Error("Zsh completion should include kubectl context completion") + } +} + +func TestFishCompletionIncludesContextCompletion(t *testing.T) { + script, err := Generate(ShellFish) + if err != nil { + t.Fatalf("Generate(ShellFish) failed: %v", err) + } + + // Should include kubectl context completion + if !strings.Contains(script, "kubectl config get-contexts") { + t.Error("Fish completion should include kubectl context completion") + } +} + +func TestAllFlagsPresent(t *testing.T) { + script, err := Generate(ShellBash) + if err != nil { + t.Fatalf("Generate(ShellBash) failed: %v", err) + } + + // Check all main flags are present. The main command exposes single-dash + // -c/-v (no --config/--verbose long forms); word flags use --. + flags := []string{ + "-c", + "-v", + "--version", + "--update", + "--check", + "--headless", + "--log-format", + "--convert", + "--convert-output", + } + + for _, flag := range flags { + if !strings.Contains(script, flag) { + t.Errorf("Bash completion missing flag: %s", flag) + } + } +} + +func TestSubcommandsPresent(t *testing.T) { + for _, shell := range []Shell{ShellBash, ShellZsh, ShellFish} { + script, err := Generate(shell) + if err != nil { + t.Fatalf("Generate(%v) failed: %v", shell, err) + } + + for _, sub := range []string{"generate", "completion"} { + if !strings.Contains(script, sub) { + t.Errorf("%s completion missing %q subcommand", shell, sub) + } + } + } +} + +func TestGenerateFlagsPresent(t *testing.T) { + script, err := Generate(ShellBash) + if err != nil { + t.Fatalf("Generate(ShellBash) failed: %v", err) + } + + generateFlags := []string{ + "--context", + "--config", + "--dry-run", + } + + for _, flag := range generateFlags { + if !strings.Contains(script, flag) { + t.Errorf("Bash completion missing generate flag: %s", flag) + } + } +} + +func TestCompletionScriptPermissions(t *testing.T) { + // Create temp file and verify permissions handling + tempDir := t.TempDir() + + installer := &Installer{ + shell: ShellBash, + prefixDir: tempDir, + } + + filename := filepath.Join(tempDir, installer.getCompletionFilename()) + + // Write a test file + err := os.WriteFile(filename, []byte("test"), 0o600) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + // Verify file exists and is readable + data, err := os.ReadFile(filename) + if err != nil { + t.Fatalf("Failed to read test file: %v", err) + } + + if string(data) != "test" { + t.Error("File content mismatch") + } +} diff --git a/internal/config/validator.go b/internal/config/validator.go index 426932d..747aa9e 100644 --- a/internal/config/validator.go +++ b/internal/config/validator.go @@ -447,10 +447,10 @@ func FormatValidationErrors(errs []ValidationError) string { sb.WriteString(strings.Repeat("=", 50) + "\n\n") for i, err := range errs { - sb.WriteString(fmt.Sprintf("%d. %s\n", i+1, err.Message)) + fmt.Fprintf(&sb, "%d. %s\n", i+1, err.Message) if len(err.Context) > 0 { for k, v := range err.Context { - sb.WriteString(fmt.Sprintf(" %s: %s\n", k, v)) + fmt.Fprintf(&sb, " %s: %s\n", k, v) } } sb.WriteString("\n") diff --git a/internal/forward/portcheck.go b/internal/forward/portcheck.go index a9e8a75..382f2ea 100644 --- a/internal/forward/portcheck.go +++ b/internal/forward/portcheck.go @@ -67,6 +67,10 @@ func formatProcessList(processes []processInfo) string { // getProcessNameByPID retrieves the process name for a given PID on Unix systems func getProcessNameByPID(pid string) string { + if !isValidPID(pid) { + return "" + } + // #nosec G204 -- pid is validated by isValidPID() to contain only digits cmd := exec.Command("ps", "-p", pid, "-o", "comm=") output, err := cmd.Output() if err != nil { @@ -302,11 +306,11 @@ func FormatConflicts(conflicts []PortConflict) string { sb.WriteString(strings.Repeat("=", 50) + "\n\n") for _, conflict := range conflicts { - sb.WriteString(fmt.Sprintf("Port %d\n", conflict.Port)) + fmt.Fprintf(&sb, "Port %d\n", conflict.Port) if conflict.Resource != "" { - sb.WriteString(fmt.Sprintf(" Needed for: %s\n", conflict.Resource)) + fmt.Fprintf(&sb, " Needed for: %s\n", conflict.Resource) } - sb.WriteString(fmt.Sprintf(" Currently used by: %s\n", conflict.UsedBy)) + fmt.Fprintf(&sb, " Currently used by: %s\n", conflict.UsedBy) sb.WriteString("\n") } diff --git a/internal/forward/watchdog_test.go b/internal/forward/watchdog_test.go index 725977c..65ec174 100644 --- a/internal/forward/watchdog_test.go +++ b/internal/forward/watchdog_test.go @@ -1,6 +1,7 @@ package forward import ( + "fmt" "sync" "testing" "time" @@ -264,7 +265,7 @@ func (s *WatchdogTestSuite) TestConcurrentOperations() { wg.Add(1) go func(id int) { defer wg.Done() - forwardID := string(rune('a' + id)) + forwardID := fmt.Sprintf("worker-%d", id) s.watchdog.RegisterWorker(forwardID, nil) for j := 0; j < 10; j++ { s.watchdog.Heartbeat(forwardID) diff --git a/internal/ui/bubbletea_ui.go b/internal/ui/bubbletea_ui.go index 71014d8..41e3f02 100644 --- a/internal/ui/bubbletea_ui.go +++ b/internal/ui/bubbletea_ui.go @@ -448,6 +448,7 @@ type keyBinding struct { func mainViewKeyBindings() []keyBinding { return []keyBinding{ {"↑↓/jk", "Navigate"}, + {"PgUp/Dn", "Page"}, {"Space", "Toggle"}, {"n", "New"}, {"e", "Edit"}, @@ -480,7 +481,7 @@ func (m model) renderMainView() string { // Render error section if any errors exist if len(m.ui.errors) > 0 { - b.WriteString(m.renderErrorSection()) + b.WriteString(m.renderErrorSection(termWidth)) } // Render footer with proper spacing @@ -527,10 +528,14 @@ func (m model) renderTitle(headerColor lipgloss.Color) string { return b.String() } -// renderEmptyMessage renders the message shown when no forwards are configured +// renderEmptyMessage renders the message shown when no forwards are configured. +// It includes an actionable hint so a first-time user knows how to proceed. func (m model) renderEmptyMessage(mutedColor lipgloss.Color) string { - disabledStyle := lipgloss.NewStyle().Foreground(mutedColor) - return disabledStyle.Render("No forwards configured\n") + mutedStyle := lipgloss.NewStyle().Foreground(mutedColor) + hintStyle := lipgloss.NewStyle().Foreground(highlightColor) + return mutedStyle.Render("No forwards configured") + "\n\n" + + hintStyle.Render(" Press ") + selectedStyle.Render("n") + + hintStyle.Render(" to add your first port forward.") + "\n" } // renderForwardsTable renders the forwards table with all styling @@ -655,10 +660,12 @@ func (m model) createTableStyleFunc(colors mainViewColors) func(row, col int) li } } -// renderErrorSection renders the error display section -func (m model) renderErrorSection() string { +// renderErrorSection renders the error display section, sized to the terminal. +func (m model) renderErrorSection(termWidth int) string { var b strings.Builder + width := errorWidth(termWidth) + b.WriteString("\n\n") errorHeaderStyle := lipgloss.NewStyle(). Bold(true). @@ -669,28 +676,44 @@ func (m model) renderErrorSection() string { errorLineStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("196")). - Width(ErrorDisplayWidth). - MaxWidth(ErrorDisplayWidth) + Width(width). + MaxWidth(width) for id, errMsg := range m.ui.errors { // Find the forward to display its alias if fwd, ok := m.ui.forwards[id]; ok { - b.WriteString(m.renderErrorLine(fwd.Alias, errMsg, errorLineStyle)) + b.WriteString(m.renderErrorLine(fwd.Alias, errMsg, width, errorLineStyle)) } } return b.String() } +// errorWidth clamps the error display to the terminal width, falling back to the +// default cap so errors never overflow narrow terminals nor sprawl on wide ones. +func errorWidth(termWidth int) int { + width := ErrorDisplayWidth + if termWidth > 0 && termWidth-2 < width { + width = termWidth - 2 + } + if width < 20 { + width = 20 + } + return width +} + // renderErrorLine renders a single error line with proper wrapping -func (m model) renderErrorLine(alias, errMsg string, style lipgloss.Style) string { +func (m model) renderErrorLine(alias, errMsg string, width int, style lipgloss.Style) string { var b strings.Builder // Format: " • alias: error message" prefix := fmt.Sprintf(" • %s: ", alias) // Wrap the error message if it's too long - maxErrLen := ErrorDisplayWidth - len(prefix) + maxErrLen := width - len(prefix) + if maxErrLen < 1 { + maxErrLen = 1 + } wrappedMsg := wrapText(errMsg, maxErrLen) // Render first line with prefix @@ -750,9 +773,10 @@ func (m model) buildFooterLines(termWidth int) []string { var currentLine strings.Builder currentLineVisualLen := 0 - // Calculate how much space we need for the total count suffix + // Calculate how much space we need for the total count suffix. + // Use lipgloss.Width for true display width (the "│" glyph is 3 bytes / 1 col). totalSuffix := fmt.Sprintf(" │ Total: %d", len(m.ui.forwardOrder)) - totalSuffixLen := len(totalSuffix) + totalSuffixLen := lipgloss.Width(totalSuffix) // Available width (account for some margin) availableWidth := termWidth - 4 @@ -761,8 +785,9 @@ func (m model) buildFooterLines(termWidth int) []string { // Build this binding's text keyRendered := keyStyle.Render(binding.key) bindingText := keyRendered + ": " + binding.desc - // Visual length without ANSI codes - bindingVisualLen := len(binding.key) + 2 + len(binding.desc) + // True display width: strips ANSI and counts wide/unicode glyphs (e.g. ↑↓) + // correctly, where len() would over-count multibyte runes and wrap early. + bindingVisualLen := lipgloss.Width(bindingText) // Add separator if not first item on line separator := "" diff --git a/internal/ui/constants.go b/internal/ui/constants.go index 0121779..bcbeac6 100644 --- a/internal/ui/constants.go +++ b/internal/ui/constants.go @@ -43,3 +43,15 @@ const ( // MaxPathWidth is the maximum width for displaying file paths MaxPathWidth = 48 ) + +// HTTP log table layout +const ( + // HTTPLogRowFormat is the column layout shared by the HTTP-log table header + // and its rows (TIME, METHOD, STATUS, LATENCY, PATH) so they stay aligned. + HTTPLogRowFormat = "%-10s %-7s %-6s %-8s %s" + + // HTTPLogFixedCols is the width consumed by every column except PATH + // (prefix + the four fixed columns and their separators), used to size the + // remaining space for the path column responsively. + HTTPLogFixedCols = 48 +) diff --git a/internal/ui/table.go b/internal/ui/table.go index 4e59e48..5a02481 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -5,6 +5,7 @@ import ( "os" "strings" "sync" + "unicode/utf8" "github.com/lukaszraczylo/kportal/internal/config" ) @@ -195,15 +196,21 @@ func hyperlink(url, text string) string { return fmt.Sprintf("\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\", url, text) } -// truncate truncates a string to maxLen, adding "..." if needed +// truncate truncates a string to maxLen runes, adding "..." if needed. +// It counts and slices by rune (not byte) so multibyte aliases/resource names +// are never cut mid-rune into mojibake. func truncate(s string, maxLen int) string { - if len(s) <= maxLen { + if maxLen <= 0 { + return "" + } + if utf8.RuneCountInString(s) <= maxLen { return s } + r := []rune(s) if maxLen <= 3 { - return s[:maxLen] + return string(r[:maxLen]) } - return s[:maxLen-3] + "..." + return string(r[:maxLen-3]) + "..." } // formatStatusWithIndicator adds color-coded indicator symbols to status diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go index 897fd25..93644af 100644 --- a/internal/ui/table_test.go +++ b/internal/ui/table_test.go @@ -1,6 +1,7 @@ package ui import ( + "fmt" "testing" "github.com/lukaszraczylo/kportal/internal/config" @@ -140,10 +141,14 @@ func TestTruncate(t *testing.T) { {"hi!", "hi", 2}, // maxLen <= 3 branch: no ellipsis {"abcd", "abc", 3}, // maxLen <= 3 branch {"", "", 5}, + {"café-service", "café-...", 8}, // multibyte: count/slice by rune, no mojibake + {"日本語ポッド", "日本...", 5}, // CJK runes truncated cleanly + {"naïve", "naïve", 5}, // exactly maxLen runes (6 bytes) — unchanged + {"abc", "", 0}, // non-positive maxLen } for _, tt := range tests { - t.Run(tt.input+"_"+string(rune('0'+tt.maxLen)), func(t *testing.T) { + t.Run(fmt.Sprintf("%s_%d", tt.input, tt.maxLen), func(t *testing.T) { assert.Equal(t, tt.expected, truncate(tt.input, tt.maxLen)) }) } diff --git a/internal/ui/wizard_styles.go b/internal/ui/wizard_styles.go index f2448db..cb07b4d 100644 --- a/internal/ui/wizard_styles.go +++ b/internal/ui/wizard_styles.go @@ -139,6 +139,18 @@ func renderBreadcrumb(parts ...string) string { return breadcrumbStyle.Render(strings.Join(parts, " / ")) } +// scrollUpIndicator returns the styled "more items above" hint shown when a +// viewport is scrolled down past its first item. +func scrollUpIndicator() string { + return mutedStyle.Render(" ↑ More above ↑") + "\n" +} + +// scrollDownIndicator returns the styled "more items below" hint shown when a +// viewport has items beyond its last visible row. +func scrollDownIndicator() string { + return mutedStyle.Render(" ↓ More below ↓") + "\n" +} + // renderList renders a list of items with cursor selection and viewport scrolling func renderList(items []string, cursor int, prefix string, scrollOffset int) string { var b strings.Builder @@ -147,7 +159,7 @@ func renderList(items []string, cursor int, prefix string, scrollOffset int) str // Show scroll up indicator if there are items above the viewport if scrollOffset > 0 { - b.WriteString(mutedStyle.Render(" ↑ More above ↑") + "\n") + b.WriteString(scrollUpIndicator()) } // Calculate visible range @@ -171,7 +183,7 @@ func renderList(items []string, cursor int, prefix string, scrollOffset int) str // Show scroll down indicator if there are items below the viewport if end < totalItems { - b.WriteString(mutedStyle.Render(" ↓ More below ↓") + "\n") + b.WriteString(scrollDownIndicator()) } return b.String() diff --git a/internal/ui/wizard_views.go b/internal/ui/wizard_views.go index 2010bc7..f454b3f 100644 --- a/internal/ui/wizard_views.go +++ b/internal/ui/wizard_views.go @@ -9,8 +9,23 @@ import ( "io" "sort" "strings" + + "github.com/lukaszraczylo/kportal/internal/k8s" ) +// formatDetectedPort renders a discovered port for the port-selection list, +// e.g. "8080", "80 → 8000" (service target differs), optionally "(http)". +func formatDetectedPort(port k8s.PortInfo) string { + desc := fmt.Sprintf("%d", port.Port) + if port.TargetPort > 0 && port.TargetPort != port.Port { + desc = fmt.Sprintf("%d → %d", port.Port, port.TargetPort) + } + if port.Name != "" { + desc += fmt.Sprintf(" (%s)", port.Name) + } + return desc +} + // renderAddWizard renders the appropriate step of the add wizard func (m model) renderAddWizard() string { if m.ui.addWizard == nil { @@ -61,24 +76,25 @@ func (m model) renderSelectContext() string { b.WriteString(spinnerStyle.Render("⣾ Loading contexts...")) } else if wizard.error != nil { b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Error: %v", wizard.error))) + b.WriteString(mutedStyle.Render("\n\nCould not read kubeconfig. Press Esc to cancel.")) } else if len(wizard.contexts) == 0 { b.WriteString(mutedStyle.Render("No contexts found in kubeconfig")) + b.WriteString(mutedStyle.Render("\n\nAdd one with: kubectl config use-context ")) } else { filteredContexts := wizard.getFilteredContexts() if len(filteredContexts) == 0 { b.WriteString(mutedStyle.Render("No matching contexts")) } else { - const viewportHeight = 20 totalItems := len(filteredContexts) // Show scroll up indicator if there are items above the viewport if wizard.scrollOffset > 0 { - b.WriteString(mutedStyle.Render(" ↑ More above ↑") + "\n") + b.WriteString(scrollUpIndicator()) } // Calculate visible range start := wizard.scrollOffset - end := wizard.scrollOffset + viewportHeight + end := wizard.scrollOffset + ViewportHeight if end > totalItems { end = totalItems } @@ -103,7 +119,7 @@ func (m model) renderSelectContext() string { // Show scroll down indicator if there are items below the viewport if end < totalItems { - b.WriteString(mutedStyle.Render(" ↓ More below ↓") + "\n") + b.WriteString(scrollDownIndicator()) } } } @@ -124,7 +140,7 @@ func (m model) renderSelectNamespace() string { var b strings.Builder b.WriteString(renderHeader("Add Port Forward", renderProgress(2, 7))) - b.WriteString(fmt.Sprintf("Context: %s\n\n", breadcrumbStyle.Render(wizard.selectedContext))) + fmt.Fprintf(&b, "Context: %s\n\n", breadcrumbStyle.Render(wizard.selectedContext)) b.WriteString("Select Namespace:\n\n") @@ -342,39 +358,23 @@ func (m model) renderEnterRemotePort() string { b.WriteString("Select remote port:") b.WriteString("\n\n") - const viewportHeight = 20 totalItems := len(wizard.detectedPorts) + 1 // +1 for manual entry option // Show scroll up indicator if there are items above the viewport if wizard.scrollOffset > 0 { - b.WriteString(mutedStyle.Render(" ↑ More above ↑") + "\n") + b.WriteString(scrollUpIndicator()) } // Calculate visible range start := wizard.scrollOffset - end := wizard.scrollOffset + viewportHeight + end := wizard.scrollOffset + ViewportHeight if end > totalItems { end = totalItems } // Render detected ports within viewport for i := start; i < end && i < len(wizard.detectedPorts); i++ { - port := wizard.detectedPorts[i] - // For services, show both service port and target port if they differ - var portDesc string - if port.TargetPort > 0 && port.TargetPort != port.Port { - // Service with different target port: "80 → 8000 (http)" - portDesc = fmt.Sprintf("%d → %d", port.Port, port.TargetPort) - if port.Name != "" { - portDesc += fmt.Sprintf(" (%s)", port.Name) - } - } else { - // Pod port or service with same port - portDesc = fmt.Sprintf("%d", port.Port) - if port.Name != "" { - portDesc += fmt.Sprintf(" (%s)", port.Name) - } - } + portDesc := formatDetectedPort(wizard.detectedPorts[i]) prefix := " " if i == wizard.cursor { @@ -402,7 +402,7 @@ func (m model) renderEnterRemotePort() string { // Show scroll down indicator if there are items below the viewport if end < totalItems { - b.WriteString(mutedStyle.Render(" ↓ More below ↓") + "\n") + b.WriteString(scrollDownIndicator()) } b.WriteString("\n") @@ -412,19 +412,7 @@ func (m model) renderEnterRemotePort() string { if len(wizard.detectedPorts) > 0 { b.WriteString(mutedStyle.Render("Detected ports:\n")) for _, port := range wizard.detectedPorts { - var portDesc string - if port.TargetPort > 0 && port.TargetPort != port.Port { - portDesc = fmt.Sprintf("%d → %d", port.Port, port.TargetPort) - if port.Name != "" { - portDesc += fmt.Sprintf(" (%s)", port.Name) - } - } else { - portDesc = fmt.Sprintf("%d", port.Port) - if port.Name != "" { - portDesc += fmt.Sprintf(" (%s)", port.Name) - } - } - b.WriteString(mutedStyle.Render(fmt.Sprintf(" • %s\n", portDesc))) + b.WriteString(mutedStyle.Render(fmt.Sprintf(" • %s\n", formatDetectedPort(port)))) } b.WriteString("\n") } @@ -503,18 +491,18 @@ func (m model) renderConfirmation() string { resourceInfo = fmt.Sprintf("service/%s", wizard.resourceValue) } - b.WriteString(fmt.Sprintf(" Context: %s\n", wizard.selectedContext)) - b.WriteString(fmt.Sprintf(" Namespace: %s\n", wizard.selectedNamespace)) - b.WriteString(fmt.Sprintf(" Resource: %s\n", resourceInfo)) - b.WriteString(fmt.Sprintf(" Remote Port: %d\n", wizard.remotePort)) - b.WriteString(fmt.Sprintf(" Local Port: %d\n", wizard.localPort)) + fmt.Fprintf(&b, " Context: %s\n", wizard.selectedContext) + fmt.Fprintf(&b, " Namespace: %s\n", wizard.selectedNamespace) + fmt.Fprintf(&b, " Resource: %s\n", resourceInfo) + fmt.Fprintf(&b, " Remote Port: %d\n", wizard.remotePort) + fmt.Fprintf(&b, " Local Port: %d\n", wizard.localPort) b.WriteString(" Protocol: tcp\n") httpLogMark := "[ ] disabled" if wizard.httpLog { httpLogMark = "[x] enabled" } - b.WriteString(fmt.Sprintf(" HTTP Log: %s\n", httpLogMark)) + fmt.Fprintf(&b, " HTTP Log: %s\n", httpLogMark) b.WriteString("\n") @@ -648,7 +636,7 @@ func (m model) renderRemoveSelection() string { } selectedCount := wizard.getSelectedCount() - b.WriteString(fmt.Sprintf("%d of %d selected\n\n", selectedCount, len(wizard.forwards))) + fmt.Fprintf(&b, "%d of %d selected\n\n", selectedCount, len(wizard.forwards)) b.WriteString(wrapHelpText("Space: Toggle a: All n: None Enter: Remove Esc: Cancel", wizardHelpWidth(m.termWidth))) @@ -663,7 +651,7 @@ func (m model) renderRemoveConfirmation() string { b.WriteString("\n") selectedCount := wizard.getSelectedCount() - b.WriteString(fmt.Sprintf("Remove %d port forward(s)?\n\n", selectedCount)) + fmt.Fprintf(&b, "Remove %d port forward(s)?\n\n", selectedCount) selectedForwards := wizard.getSelectedForwards() for _, fwd := range selectedForwards { @@ -718,7 +706,7 @@ func (m model) renderBenchmarkConfig() string { var b strings.Builder b.WriteString(renderHeader("HTTP Benchmark", "")) - b.WriteString(fmt.Sprintf("Target: %s (localhost:%d)", breadcrumbStyle.Render(state.forwardAlias), state.localPort)) + fmt.Fprintf(&b, "Target: %s (localhost:%d)", breadcrumbStyle.Render(state.forwardAlias), state.localPort) b.WriteString("\n\n") b.WriteString("Configure benchmark parameters:") @@ -741,7 +729,7 @@ func (m model) renderBenchmarkConfig() string { b.WriteString(selectedStyle.Render(fmt.Sprintf("%s%-12s", prefix, field.label+":"))) b.WriteString(validInputStyle.Render(field.value + "█")) } else { - b.WriteString(fmt.Sprintf("%s%-12s %s", prefix, field.label+":", field.value)) + fmt.Fprintf(&b, "%s%-12s %s", prefix, field.label+":", field.value) } b.WriteString("\n") } @@ -759,7 +747,7 @@ func (m model) renderBenchmarkRunning() string { var b strings.Builder b.WriteString(renderHeader("HTTP Benchmark", "")) - b.WriteString(fmt.Sprintf("Target: %s", breadcrumbStyle.Render(state.forwardAlias))) + fmt.Fprintf(&b, "Target: %s", breadcrumbStyle.Render(state.forwardAlias)) b.WriteString("\n\n") // Progress bar @@ -779,7 +767,7 @@ func (m model) renderBenchmarkRunning() string { b.WriteString(spinnerStyle.Render("Running benchmark...")) b.WriteString("\n\n") - b.WriteString(fmt.Sprintf(" [%s] %d%%", successStyle.Render(bar), percent)) + fmt.Fprintf(&b, " [%s] %d%%", successStyle.Render(bar), percent) b.WriteString("\n") b.WriteString(mutedStyle.Render(fmt.Sprintf(" %d / %d requests completed", state.progress, state.total))) b.WriteString("\n\n") @@ -799,7 +787,7 @@ func (m model) renderBenchmarkResults() string { var b strings.Builder b.WriteString(renderHeader("Benchmark Results", "")) - b.WriteString(fmt.Sprintf("Target: %s", breadcrumbStyle.Render(state.forwardAlias))) + fmt.Fprintf(&b, "Target: %s", breadcrumbStyle.Render(state.forwardAlias)) b.WriteString("\n\n") if state.error != nil { @@ -824,43 +812,43 @@ func (m model) renderBenchmarkResults() string { successRate = 0 } - b.WriteString(fmt.Sprintf("Total Requests: %d", r.TotalRequests)) + fmt.Fprintf(&b, "Total Requests: %d", r.TotalRequests) b.WriteString("\n") if r.Failed == 0 { b.WriteString(successStyle.Render(fmt.Sprintf("Successful: %d (%.1f%%)", r.Successful, successRate))) } else { - b.WriteString(fmt.Sprintf("Successful: %d (%.1f%%)", r.Successful, successRate)) + fmt.Fprintf(&b, "Successful: %d (%.1f%%)", r.Successful, successRate) } b.WriteString("\n") if r.Failed > 0 { b.WriteString(errorStyle.Render(fmt.Sprintf("Failed: %d", r.Failed))) } else { - b.WriteString(fmt.Sprintf("Failed: %d", r.Failed)) + fmt.Fprintf(&b, "Failed: %d", r.Failed) } b.WriteString("\n\n") // Latency stats b.WriteString(breadcrumbStyle.Render("Latency (ms)")) b.WriteString("\n") - b.WriteString(fmt.Sprintf(" Min: %.2f", r.MinLatency)) + fmt.Fprintf(&b, " Min: %.2f", r.MinLatency) b.WriteString("\n") - b.WriteString(fmt.Sprintf(" Max: %.2f", r.MaxLatency)) + fmt.Fprintf(&b, " Max: %.2f", r.MaxLatency) b.WriteString("\n") - b.WriteString(fmt.Sprintf(" Avg: %.2f", r.AvgLatency)) + fmt.Fprintf(&b, " Avg: %.2f", r.AvgLatency) b.WriteString("\n") - b.WriteString(fmt.Sprintf(" P50: %.2f", r.P50Latency)) + fmt.Fprintf(&b, " P50: %.2f", r.P50Latency) b.WriteString("\n") - b.WriteString(fmt.Sprintf(" P95: %.2f", r.P95Latency)) + fmt.Fprintf(&b, " P95: %.2f", r.P95Latency) b.WriteString("\n") - b.WriteString(fmt.Sprintf(" P99: %.2f", r.P99Latency)) + fmt.Fprintf(&b, " P99: %.2f", r.P99Latency) b.WriteString("\n\n") // Throughput b.WriteString(breadcrumbStyle.Render("Throughput")) b.WriteString("\n") - b.WriteString(fmt.Sprintf(" Requests/sec: %.2f", r.Throughput)) + fmt.Fprintf(&b, " Requests/sec: %.2f", r.Throughput) b.WriteString("\n") - b.WriteString(fmt.Sprintf(" Bytes read: %d", r.BytesRead)) + fmt.Fprintf(&b, " Bytes read: %d", r.BytesRead) b.WriteString("\n") // Status codes if interesting @@ -874,7 +862,7 @@ func (m model) renderBenchmarkResults() string { } else if code >= 400 { b.WriteString(errorStyle.Render(fmt.Sprintf(" %d: %d", code, count))) } else { - b.WriteString(fmt.Sprintf(" %d: %d", code, count)) + fmt.Fprintf(&b, " %d: %d", code, count) } b.WriteString("\n") } @@ -966,7 +954,7 @@ func (m model) renderHTTPLog() string { b.WriteString("\n") // Header - header := fmt.Sprintf(" %-10s %-7s %-6s %-8s %s", + header := " " + fmt.Sprintf(HTTPLogRowFormat, "TIME", "METHOD", "STATUS", "LATENCY", "PATH") b.WriteString(mutedStyle.Render(header)) b.WriteString("\n") @@ -1004,8 +992,8 @@ func (m model) renderHTTPLog() string { end = totalEntries } - // Calculate max path width - maxPathWidth := termWidth - 48 + // Calculate max path width (remaining space after the fixed columns) + maxPathWidth := termWidth - HTTPLogFixedCols if maxPathWidth < 10 { maxPathWidth = 10 } @@ -1028,14 +1016,11 @@ func (m model) renderHTTPLog() string { } } - // Truncate path - path := entry.Path - if len(path) > maxPathWidth { - path = path[:maxPathWidth-3] + "..." - } + // Truncate path (rune-aware, no mid-rune mojibake) + path := truncate(entry.Path, maxPathWidth) // Build line - line := fmt.Sprintf("%-10s %-7s %-6s %-8s %s", + line := fmt.Sprintf(HTTPLogRowFormat, entry.Timestamp, entry.Method, statusStr, @@ -1079,7 +1064,7 @@ func (m model) renderHTTPLog() string { // Footer with entry count b.WriteString("\n") if totalEntries != totalUnfiltered { - b.WriteString(mutedStyle.Render(fmt.Sprintf(" %d of %d entries (filtered from %d)", totalEntries, totalEntries, totalUnfiltered))) + b.WriteString(mutedStyle.Render(fmt.Sprintf(" %d of %d entries (filtered)", totalEntries, totalUnfiltered))) } else { b.WriteString(mutedStyle.Render(fmt.Sprintf(" %d entries", totalEntries))) } @@ -1121,11 +1106,7 @@ func (m model) renderHTTPLogDetail(entry HTTPLogEntry, termWidth, termHeight int } sort.Strings(headerKeys) for _, k := range headerKeys { - v := entry.RequestHeaders[k] - // Truncate long header values - if len(v) > termWidth-20 { - v = v[:termWidth-23] + "..." - } + v := truncate(entry.RequestHeaders[k], termWidth-20) lines = append(lines, fmt.Sprintf(" %s: %s", mutedStyle.Render(k), v)) } lines = append(lines, "") @@ -1146,11 +1127,7 @@ func (m model) renderHTTPLogDetail(entry HTTPLogEntry, termWidth, termHeight int reqBody = formatJSONContent(reqBody, entry.RequestHeaders) bodyLines := strings.Split(reqBody, "\n") for _, line := range bodyLines { - // Truncate long lines - if len(line) > termWidth-6 { - line = line[:termWidth-9] + "..." - } - lines = append(lines, " "+line) + lines = append(lines, " "+truncate(line, termWidth-6)) } } lines = append(lines, "") @@ -1192,11 +1169,7 @@ func (m model) renderHTTPLogDetail(entry HTTPLogEntry, termWidth, termHeight int } sort.Strings(headerKeys) for _, k := range headerKeys { - v := entry.ResponseHeaders[k] - // Truncate long header values - if len(v) > termWidth-20 { - v = v[:termWidth-23] + "..." - } + v := truncate(entry.ResponseHeaders[k], termWidth-20) lines = append(lines, fmt.Sprintf(" %s: %s", mutedStyle.Render(k), v)) } lines = append(lines, "") @@ -1217,11 +1190,7 @@ func (m model) renderHTTPLogDetail(entry HTTPLogEntry, termWidth, termHeight int respBody = formatJSONContent(respBody, entry.ResponseHeaders) bodyLines := strings.Split(respBody, "\n") for _, line := range bodyLines { - // Truncate long lines - if len(line) > termWidth-6 { - line = line[:termWidth-9] + "..." - } - lines = append(lines, " "+line) + lines = append(lines, " "+truncate(line, termWidth-6)) } } lines = append(lines, "")