Optimisation & fixes

This commit is contained in:
2026-06-21 12:59:55 +01:00
parent ab65c2e17b
commit d5125a0d62
14 changed files with 1074 additions and 126 deletions
+4
View File
@@ -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
+119
View File
@@ -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 <type> 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
`)
}
+10 -5
View File
@@ -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)
+458
View File
@@ -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 > <your-completion-dir>/_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)
}
+327
View File
@@ -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")
}
}
+2 -2
View File
@@ -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")
+7 -3
View File
@@ -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")
}
+2 -1
View File
@@ -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)
+40 -15
View File
@@ -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 := ""
+12
View File
@@ -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
)
+11 -4
View File
@@ -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
+6 -1
View File
@@ -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))
})
}
+14 -2
View File
@@ -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()
+62 -93
View File
@@ -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 <name>"))
} 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, "")