Compare commits

...
7 Commits
Author SHA1 Message Date
github-actions[bot]andGitHub 7e3c112738 chore(deps): update module github.com/fxamacker/cbor/v2 to v2.9.6
Automated dependency update. All tests passed.
2026-10-09 03:24:20 +00:00
lukaszraczylo f7287b3ef6 refactor(ui): migrate to bubbletea v2 and lipgloss v2
Switch imports to charm.land/bubbletea/v2 and charm.land/lipgloss/v2.
View now returns tea.View with AltScreen set, key handling uses
KeyPressMsg, and colour fields use image/color.
2026-10-04 10:14:28 +01:00
github-actions[bot]andGitHub 985220b6e6 chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-04 04:21:13 +00:00
lukaszraczylo b940fa7f7a ci(deps): update dependencies with Renovate [skip ci] 2026-10-03 18:44:42 +01:00
lukaszraczyloandGitHub 63c905dead Update go.mod and go.sum (#91) 2026-10-03 04:24:43 +01:00
lukaszraczyloandGitHub 1c419ab1c2 docs: rebuild the landing page and README with comparison, install and reference sections (#90)
The earlier rewrite dropped content the page needs. The landing page
has a hero with an install command, a terminal mock, the real
screenshot, feature rows with snippets from the repository, a dated
comparison with kubectl, k9s, kubefwd and kftray with source links,
install tabs, config and key references, troubleshooting and an FAQ.

The README restores the comparison, advanced features, retry strategy,
kftray migration, signal handling, signature verification and build
from source. Cells that a project does not document show as Not
documented.

It fixes four claims the code contradicts: the maxBodySize default, the
deployment resource type, the file watcher delay and the verbose table
status markers.
2026-10-02 03:25:29 +01:00
lukaszraczyloandGitHub 260a909e3d docs: rewrite the product page and README, update the feature list (#88)
* docs(site): rewrite the product page in plain language

The page lists what kportal does with the flags, keys and config keys
that exist in the code. It drops the CDN assets, gradients and icon
grids, and keeps the light and dark themes.

It now covers tui.columns, typed namespace entry, completion, the log
format flag and the --context flag. The claim about a 100ms file watcher
is removed because the code has no such debounce.

* docs: rewrite the README and update the feature list

The README now has a feature table, install steps, a flag table, a
config reference and troubleshooting. It documents tui.columns, typed
namespace entry, shell completion and the --context flag.

It drops the comparison table with other tools, which the repository
cannot back up, and corrects the supported resource types and the Go
version.
2026-10-02 02:54:50 +01:00
15 changed files with 1821 additions and 1696 deletions
+7 -7
View File
@@ -1,4 +1,4 @@
name: Autoupdate go.mod and go.sum
name: Update dependencies
on:
workflow_dispatch:
@@ -6,15 +6,15 @@ on:
- cron: "0 3 * * *"
permissions:
contents: write
actions: write
contents: write
issues: write
pull-requests: write
security-events: write
jobs:
autoupdate:
uses: lukaszraczylo/shared-actions/.github/workflows/go-autoupdate.yaml@main
update:
uses: lukaszraczylo/shared-actions/.github/workflows/renovate-autoupdate.yaml@main
with:
go-version: ">=1.24"
release-workflow: "release.yml"
secrets: inherit
node: false
release-workflow: release.yml
+336 -312
View File
@@ -8,51 +8,99 @@
<a href="https://goreportcard.com/report/github.com/lukaszraczylo/kportal"><img src="https://goreportcard.com/badge/github.com/lukaszraczylo/kportal" alt="Go Report Card"></a>
</p>
<p align="center">
<strong>Kubernetes port-forward manager with interactive terminal UI</strong>
</p>
# kportal
kportal manages multiple Kubernetes port-forwards with an interactive terminal interface. It provides real-time status updates, automatic reconnection, hot-reload configuration, and mDNS hostname publishing.
kportal runs many Kubernetes port-forwards from one YAML file and shows their status in a terminal UI. It reconnects dropped forwards, reloads the file when it changes, and can run without the UI.
![kportal Screenshot](docs/kportal-screenshot.png)
## ✨ Features
Screenshot from kportal v0.2.17. The current key bar also lists `PgUp`/`PgDn`, `b` (Bench) and `l` (Logs).
- **Interactive TUI** - Terminal interface with keyboard navigation
- **Live management** - Add, edit, and delete port-forwards without restarting
- **Auto-reconnect** - Exponential backoff retry on connection failures
- **Hot-reload** - Configuration changes applied automatically
- **Health monitoring** - Multiple check methods with stale connection detection
- **Multi-context** - Support for multiple Kubernetes contexts and namespaces
- **Pod restart handling** - Automatic reconnection when pods restart
- **Label selectors** - Dynamic pod targeting using label selectors
- **Port conflict detection** - Validates port availability with PID information
- **mDNS hostnames** - Access forwards via `.local` hostnames
- **HTTP traffic logging** - Real-time HTTP request/response logging for debugging
- **Connection benchmarking** - Built-in HTTP benchmarking with latency statistics
- **Headless mode** - Background operation for scripting and automation
Website: <https://lukaszraczylo.github.io/kportal>
## 🔄 Comparison with Other Tools
## Contents
| Feature | kportal | [k9s](https://k9scli.io/) | [Kube Forwarder](https://kube-forwarder.pixelpoint.io/) | [kftray](https://kftray.app/) |
|---------|---------|------|----------------|--------|
| **Interface** | Terminal TUI | Terminal TUI | Desktop GUI (Electron) | Desktop GUI + TUI |
| **Persistent Config** | ✅ YAML file | ❌ Session only | ✅ JSON bookmarks | ✅ JSON + Git sync |
| **Auto-reconnect** | ✅ Exponential backoff | ❌ Manual | ✅ Basic | ✅ Watch API |
| **Hot-reload Config** | ✅ File watch + SIGHUP | ❌ | ❌ | ❌ |
| **Health Checks** | ✅ TCP + data-transfer | ❌ | ❌ | ❌ |
| **Stale Connection Detection** | ✅ Age + idle tracking | ❌ | ❌ | ❌ |
| **HTTP Traffic Logging** | ✅ Built-in viewer | ❌ | ❌ | ✅ |
| **Connection Benchmarking** | ✅ Built-in | ✅ Via Hey | ❌ | ❌ |
| **mDNS Hostnames** | ✅ `.local` domains | ❌ | ❌ | ❌ |
| **Label Selectors** | ✅ | ✅ | ❌ | ✅ |
| **Multi-context** | ✅ | ✅ | ✅ | ✅ |
| **Headless Mode** | ✅ | ❌ | ❌ | ❌ |
| **System Tray** | ❌ | ❌ | ❌ | ✅ |
| **UDP Support** | ❌ | ❌ | ❌ | ✅ Proxy relay |
| **Dependencies** | Single binary | Single binary | Electron | Tauri + kubectl |
- [Features](#features)
- [Comparison](#comparison-with-other-tools)
- [Installation](#installation)
- [Quick start](#quick-start)
- [Configuration](#configuration)
- [Usage](#usage)
- [Status indicators](#status-indicators)
- [Advanced features](#advanced-features)
- [Migration from kftray](#migration-from-kftray)
- [Signal handling](#signal-handling)
- [Troubleshooting](#troubleshooting)
- [Development](#development)
- [Telemetry](#telemetry)
- [License](#license)
## 📦 Installation
## Features
| Area | What kportal does |
|------|-------------------|
| Forwards | Forwards to `service/<name>`, `pod/<name>` (exact name or name prefix), or `pod` with a label `selector`. Only TCP is supported. |
| Terminal UI | Add (`n`), edit (`e`), delete (`d`) and toggle (`Space`) forwards without a restart. The add wizard writes the config file. |
| Table columns | `tui.columns` sets which columns the forwards table shows, their order and their width. It applies to the UI and to the `-v` table. |
| Add wizard | If the cluster does not allow listing namespaces, the wizard asks you to type the namespace. |
| Reconnect | Retries with exponential backoff: 1s, 2s, 4s, 8s, then 10s, with 10% jitter. It does not stop retrying. |
| Pod restarts | Forwards by pod prefix or selector connect to a new pod after a restart. |
| Health checks | `tcp-dial` or `data-transfer` check every 3s by default. A connection is stale when it is older than `maxConnectionAge` and idle, or idle longer than `maxIdleTime`. |
| Hot reload | The file watcher and `SIGHUP` reload the config. An invalid config is rejected and the previous one stays active. |
| Contexts | Several Kubernetes contexts and namespaces in one file. `--context` selects which contexts run. |
| Port conflicts | Startup and reload checks report a busy local port and the process that holds it. |
| mDNS | With `mdns.enabled: true`, each forward is reachable as `<alias>.local`. Without an alias, the resource name is used. |
| HTTP log | Per-forward request and response log in the UI, with detail view, filter, search, JSON formatting, gzip/deflate decoding and clipboard copy. |
| Header redaction | With `includeHeaders: true`, values of sensitive headers such as `Authorization` and `Cookie` become `[REDACTED]`. You cannot turn this off. |
| Benchmark | Press `b` to send HTTP requests through a forward. Reports success and failure counts, min/max/avg latency, P50/P95/P99, requests per second and status codes. |
| Generate | `kportal generate` lists services in a cluster and appends the ones you pick to the config with consecutive local ports. |
| Headless | `-headless` runs without the UI and logs to stderr. `-log-format json` switches the log format. |
| Import | `-convert` turns a kftray JSON file into a kportal YAML file. |
| Shell completion | `kportal completion` prints a bash, zsh or fish completion script, or installs it. |
| Verified installer | `install.sh` checks the archive SHA-256 and, if `cosign` is on `PATH`, the keyless signature of the checksums file. |
| Updates | `-update` checks for a newer release. `-version` prints the version. |
## Comparison with other tools
Compared on 2026-10-02 against each project's README and docs. Check their repositories for changes. The kportal column comes from this repository's code. "Not documented" means the project's README and docs do not say.
| Feature | kportal | kubectl port-forward | k9s | kubefwd | kftray |
|---------|---------|----------------------|-----|---------|--------|
| Many forwards in one config file | Yes | No | Partial (annotations configure forwards; no forward list file) | Partial (`example.fwdconf.yml` exists) | Yes |
| Automatic reconnect | Yes | Not documented | Not documented | Yes | Yes |
| Follows pod restarts by label or name prefix | Yes | Not documented | Not documented | Yes (label filter) | Yes |
| Terminal UI of all forwards | Yes | No | Yes (PortForward view, alias `pf`; forwards last for the session) | Yes | Yes |
| GUI | No | No | No | No | Yes |
| Config hot reload | Yes (file watcher, `SIGHUP`) | No | Partial (`benchmarks.yaml` only) | Not documented | Not documented |
| HTTP request and response logging | Yes | No | Not documented | Not documented | Yes |
| Health checks | Yes (`tcp-dial`, `data-transfer`) | Not documented | Not documented | Not documented | Partial (README says it tracks pod health) |
| mDNS or friendly hostnames | Yes (mDNS) | No | No | Partial (edits `/etc/hosts`, not mDNS; needs root) | Not documented |
| Built-in benchmark | Yes | No | Yes (`hey`, key `b`) | Not documented | Not documented |
| Imports another tool's config | Yes (kftray JSON) | No | No | Not documented | Partial (JSON, GitHub repos, service annotations; not other tools' formats) |
| Several contexts in one config | Yes | No | Partial | Partial | Not documented |
| Local port conflict detection | Yes (reports the holding process) | Partial (`:remotePort` picks a free port) | Not documented | Yes | Not documented |
| Platforms | Linux, macOS, Windows | Not documented | macOS, Linux, Windows | macOS, Linux, Windows | macOS, Linux, Windows |
| Licence | MIT | Not documented | Apache-2.0 | Apache-2.0 | GPL-3.0 |
| Language | Go | Not documented | Go | Go | Rust (Tauri, Ratatui) |
| Latest release (2026-10-02) | [Releases](https://github.com/lukaszraczylo/kportal/releases) | Not documented | v0.51.0 (2026-06-06) | v1.25.16 (2026-06-20) | v0.29.2 (2026-09-29) |
When another tool fits better:
- kubectl port-forward for a one-off forward. It needs no config file and ships with kubectl.
- k9s for general cluster browsing and its built-in benchmark.
- kubefwd to forward every service in a namespace with local DNS names.
- kftray for a tray GUI, shared JSON configs and UDP.
Kube Forwarder is a GUI-only forwarder. Its last release was 2019-08-14.
Sources:
- kubectl: [Use Port Forwarding to Access Applications in a Cluster](https://kubernetes.io/docs/tasks/access-application-cluster/port-forward-access-application-cluster/)
- k9s: <https://github.com/derailed/k9s> and <https://k9scli.io/topics/bench/>
- kubefwd: <https://github.com/txn2/kubefwd>
- kftray: <https://github.com/hcavarsan/kftray>
## Installation
### Homebrew (macOS)
@@ -60,29 +108,37 @@ kportal manages multiple Kubernetes port-forwards with an interactive terminal i
brew install --cask lukaszraczylo/taps/kportal
```
> **Note**: If you previously installed via `brew install lukaszraczylo/taps/kportal` (formula), uninstall first:
> ```bash
> brew uninstall kportal
> ```
If you installed the formula (`brew install lukaszraczylo/taps/kportal`) earlier, run `brew uninstall kportal` first.
### Quick Install
### Quick install
```bash
curl -fsSL https://raw.githubusercontent.com/lukaszraczylo/kportal/main/install.sh | bash
```
The installer downloads `kportal-<version>-checksums.txt` from the same release and verifies the archive's SHA-256 before installing. If [`cosign`](https://github.com/sigstore/cosign) is on your `PATH`, the checksums file's keyless cosign signature is also verified against the shared-actions reusable workflow identity.
The script downloads `kportal-<version>-checksums.txt` from the same release and verifies the archive SHA-256. If [`cosign`](https://github.com/sigstore/cosign) is on `PATH`, it also verifies the keyless signature of the checksums file.
| Variable | Effect |
|----------|--------|
| `DRY_RUN=1` | Download and verify only; do not install |
| `SKIP_COSIGN=1` | Skip cosign signature verification (SHA-256 is still enforced) |
| `DRY_RUN=1` | Download and verify only. Do not install. |
| `SKIP_COSIGN=1` | Skip the cosign check. The SHA-256 check still runs. |
| `INSTALL_DIR` | Target directory. Default `/usr/local/bin`. |
| `KPORTAL_VERSION` | Install a specific version, for example `1.2.3`, instead of the latest. |
| `COSIGN_CERT_IDENTITY_REGEXP` | Certificate identity to accept. Set it if you fork the release pipeline. |
### Manual Download
Example:
Download binaries from the [releases page](https://github.com/lukaszraczylo/kportal/releases).
```bash
curl -fsSL https://raw.githubusercontent.com/lukaszraczylo/kportal/main/install.sh | DRY_RUN=1 bash
```
### Build from Source
### Manual download
The [releases page](https://github.com/lukaszraczylo/kportal/releases) has archives for Linux, macOS and Windows on amd64 and arm64. Archives are named `kportal-<version>-<os>-<arch>.tar.gz`, and `.zip` on Windows. Each release also has a `kportal-<version>-checksums.txt` file.
### Build from source
Go 1.26 or later is required (see `go.mod`).
```bash
git clone https://github.com/lukaszraczylo/kportal.git
@@ -90,12 +146,13 @@ cd kportal
make build && make install
```
### Verifying Release Signatures
`make install` copies the binary to `/opt/homebrew/bin` if that directory exists, otherwise to `~/.local/bin` on macOS and Linux.
All release checksums are signed with [cosign](https://github.com/sigstore/cosign) using keyless signing. To verify:
### Verifying release signatures
Release checksums are signed with [cosign](https://github.com/sigstore/cosign) using keyless signing. Download the checksums file and its sigstore bundle from the release, then run:
```bash
# Download the checksum file and its sigstore bundle from the release
cosign verify-blob \
--certificate-identity-regexp "^https://github\.com/lukaszraczylo/shared-actions/\.github/workflows/go-release\.yaml@refs/heads/main$" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
@@ -103,7 +160,9 @@ cosign verify-blob \
kportal-<version>-checksums.txt
```
## 🚀 Quick Start
Replace `<version>` with the release version, for example `1.2.3`. Then check the archive against the verified checksums file with `shasum -a 256` or `sha256sum`.
## Quick start
Create `.kportal.yaml`:
@@ -124,31 +183,40 @@ contexts:
port: 8080
localPort: 8080
alias: api
httpLog: true # Enable HTTP traffic logging
httpLog: true
```
Each `name` under `contexts` must exist in your kubeconfig. List them with `kubectl config get-contexts`.
Run:
```bash
kportal
```
### Keyboard Controls
### Keyboard controls
Forwards list:
| Key | Action |
|-----|--------|
| `↑↓` / `j/k` | Navigate |
| `Space` / `Enter` | Toggle forward |
| `n` | Add new forward |
| `e` | Edit forward |
| `d` | Delete forward |
| `b` | Benchmark connection |
| `l` | View HTTP logs |
| `q` | Quit |
| `Up` `Down` / `j` `k` | Move the selection |
| `PgUp` `PgDn` / `Ctrl+u` `Ctrl+d` | Move by 10 rows |
| `Space` / `Enter` | Toggle the forward |
| `n` | Add a forward |
| `e` | Edit the forward |
| `d` | Delete the forward (asks to confirm) |
| `b` | Benchmark the forward |
| `l` | Open the HTTP log |
| `q` / `Ctrl+c` | Quit |
## 📖 Configuration
In the add and edit wizard, `h` on the confirmation step toggles `httpLog`. Advanced `httpLog` keys you set in YAML stay unchanged.
### Basic Structure
HTTP log keys are listed under [HTTP traffic logging](#http-traffic-logging).
## Configuration
### Basic structure
```yaml
contexts:
@@ -162,56 +230,56 @@ contexts:
localPort: <local-port>
alias: <display-name> # optional
selector: <label-selector> # optional
httpLog: true # optional - enable HTTP logging
httpLog: true # optional
```
### Forward Options
### Forward options
| Field | Required | Description |
|-------|----------|-------------|
| `resource` | Yes | Resource type and name (e.g., `service/postgres`, `pod/my-app`) |
| `protocol` | Yes | Protocol (`tcp`) |
| `resource` | Yes | `service/<name>`, `pod/<name>`, or `pod` with `selector` |
| `protocol` | No | `tcp`. No other value is accepted. |
| `port` | Yes | Remote port |
| `localPort` | Yes | Local port |
| `alias` | No | Display name and mDNS hostname |
| `selector` | No | Label selector for pod resolution |
| `httpLog` | No | Enable HTTP traffic logging (`true`/`false`) |
| `selector` | With bare `pod` | Label selector. Not allowed with `pod/<name>`. |
| `httpLog` | No | `true`, or a map of options (see [HTTP traffic logging](#http-traffic-logging)) |
### Resource Formats
### Resource formats
| Format | Description |
|--------|-------------|
| `service/name` | Service forwarding |
| `pod/name` | Direct pod by name |
| `pod/prefix` | Pod by prefix (matches `prefix-*`) |
| `pod` + `selector` | Pod by label selector |
| `deployment/name` | Deployment |
| `service/name` | Forward to a service |
| `pod/name` | Forward to a pod by exact name, or by name prefix |
| `pod` with `selector` | Forward to a pod picked by label selector |
### Health Check Configuration
Valid resource types are `pod` and `service`. To reach a deployment, use `pod` with a `selector`.
### Health checks and reliability
```yaml
healthCheck:
interval: "3s" # Check frequency
timeout: "2s" # Check timeout
method: "data-transfer" # tcp-dial or data-transfer
maxConnectionAge: "25m" # Reconnect before k8s timeout
maxIdleTime: "10m" # Detect idle connections
interval: "3s"
timeout: "2s"
method: "data-transfer" # or tcp-dial
maxConnectionAge: "25m"
maxIdleTime: "10m"
reliability:
tcpKeepalive: "30s"
dialTimeout: "30s"
retryOnStale: true
watchdogPeriod: "30s"
```
Health check methods:
- `tcp-dial` - Fast TCP connection test
- `data-transfer` - Verifies tunnel functionality by attempting data read
The values above are the defaults from `.kportal.yaml`.
Connection age reconnection only triggers when the connection is also idle, preventing interruption of active transfers like database dumps.
- `tcp-dial` opens a TCP connection to the local port.
- `data-transfer` also tries to read data through the tunnel. It is the default.
- A connection past `maxConnectionAge` reconnects only when it is also idle, so a running transfer is not cut.
- `watchdogPeriod` is the interval of the check for hung workers.
### mDNS Hostnames
Enable mDNS to access forwards via `.local` hostnames:
### mDNS
```yaml
mdns:
@@ -225,22 +293,22 @@ contexts:
- resource: service/postgres
port: 5432
localPort: 5432
alias: prod-db # Accessible via prod-db.local:5432
alias: prod-db # reachable as prod-db.local:5432
```
- Explicit `alias` becomes `<alias>.local`
- Without alias, hostname is generated from resource name (`service/redis` → `redis.local`)
- Works on macOS (Bonjour) and Linux (avahi-daemon)
- `alias: prod-db` publishes `prod-db.local`.
- Without an alias, `service/redis` publishes `redis.local`.
- A bare `pod` with a selector and no alias is not published.
- Services register as `_kportal._tcp`.
Check registration:
Verify registration:
```bash
dns-sd -B _kportal._tcp local # macOS
avahi-browse -t _kportal._tcp # Linux
```
### Table Columns
Choose which columns the forwards table shows, and in what order. The setting applies to the interactive UI and the `-verbose` table:
### Table columns
```yaml
tui:
@@ -252,90 +320,83 @@ tui:
- name: status
```
- Valid names: `context`, `namespace`, `alias`, `type`, `resource`, `remote`, `local`, `status`
- The list order is the render order, and unlisted columns are hidden
- `width` is the maximum text width, from 1 to 200. If you omit it, the column keeps its default width
- If you omit `tui`, the table keeps its default columns and order
- Changes apply on hot-reload
- Valid names: `context`, `namespace`, `alias`, `type`, `resource`, `remote`, `local`, `status`.
- List order is render order. Unlisted columns are hidden.
- `width` is the maximum text width, 1 to 200. Without it, the column keeps its default width.
- Without `tui`, the table keeps its default columns and order.
- The setting applies to the UI and to the `-v` table, and reloads with the file.
## Usage
### Interactive Mode
### Command line
| Command or flag | Effect |
|-----------------|--------|
| `kportal` | Start the terminal UI. |
| `-c <file>` | Config file. Default `.kportal.yaml`. |
| `-v` | Verbose logging. In headless mode it sets the log level only. |
| `-headless` | No UI. Logs go to stderr. |
| `-check` | Validate the config and exit. |
| `-context <name>` | Forward only the named contexts. Repeatable and comma-separated. |
| `-log-format text\|json` | Log format. Default `text`. |
| `-convert <file>` | Convert a kftray JSON file to kportal YAML. |
| `-convert-output <file>` | Output of `-convert`. Default `.kportal.yaml`. |
| `-update` | Check for updates and exit. |
| `-version` | Print the version and exit. |
| `kportal generate` | Add forwards from a cluster. |
| `kportal completion` | Print or install a shell completion script. |
Each flag also accepts the double-dash form, for example `--check`.
### Interactive mode
```bash
kportal
```
### Verbose Mode
### Verbose mode
```bash
kportal -v
```
### Headless Mode
Without `-headless`, `-v` prints a plain-text table that redraws. The table uses the same status words as the UI. When stdout is not a terminal, it draws `✓ Active`, `⋯ Starting`, `↻ Reconnecting` and `✗ Error`.
Run without TUI for scripting and automation:
### Headless mode
```bash
kportal -headless
```
Headless mode emits both structured and standard-library logs to stderr by default
(suitable for redirecting to a log file or systemd journal). The `-v` flag controls
log level (debug vs info), not destination.
Combines well with verbose mode for background operation:
```bash
kportal -headless -v 2>kportal.log &
kportal -headless -log-format json
```
### Validate Configuration
Headless mode logs to stderr, so you can redirect it to a file or the systemd journal. `-v` sets the log level, not the destination.
### Validate configuration
```bash
kportal --check
kportal -check
```
### Custom Config File
### Custom config file
```bash
kportal -c /path/to/config.yaml
```
### Select Contexts
### Select contexts
By default every context in the config file is forwarded. `--context` limits the
run to the contexts you name, which is useful when the file describes several
clusters but you only work against one at a time:
By default kportal forwards every context in the file. `--context` limits the run to the contexts you name:
```bash
kportal --context development-team-a
```
The flag is repeatable and accepts comma-separated values, so these are
equivalent:
```bash
kportal --context team-a --context team-b
kportal --context team-a,team-b
```
It composes with the other modes, and works well as a per-cluster shell alias:
An unknown name is an error that lists the contexts in the file. Those are the `contexts[].name` values in `.kportal.yaml`, not every context in your kubeconfig.
```bash
alias kp-a='kportal --context development-team-a'
kportal --context development-team-a -headless
```
An unknown context name is an error that lists the contexts the file defines, so
a typo fails loudly instead of silently forwarding nothing.
#### Reusing local ports across contexts
Local-port conflicts are only reported between forwards that could run at the
same time. Contexts that are never selected together may therefore reuse the
same `localPort`, so the same service keeps the same local port in every
cluster and the app you are developing never needs reconfiguring:
Local-port conflicts are only reported between forwards that can run together. With `--context`, contexts you never select together can reuse a `localPort`:
```yaml
contexts:
@@ -357,15 +418,9 @@ contexts:
- <<: *rate-service # same local port, no conflict
```
This requires passing `--context`. Without it every context is active, so the
duplicate ports above would genuinely collide and are still reported as an
error - as is naming both contexts at once (`--context team-a,team-b`).
Without `--context`, or with both contexts named, the duplicate port is an error.
### Generate Forwards from a Cluster
The `generate` subcommand discovers services in a Kubernetes context and lets you
interactively pick which ones to forward. Selected entries are appended to the
config file with consecutive local ports starting from a value you choose.
### Generate
```bash
kportal generate --context=my-cluster
@@ -375,97 +430,51 @@ kportal generate --context=my-cluster --dry-run
| Flag | Description |
|------|-------------|
| `--context` | (required) Kubernetes context to scan |
| `--config` | Path to kportal config file (default: `.kportal.yaml`) |
| `--dry-run` | Print the planned forwards but do not modify the config |
| `--context` | Required. Kubernetes context to scan. |
| `--config` | Config file to append to. Default `.kportal.yaml`. |
| `--dry-run` | Print the planned forwards. Do not change the config. |
The interactive flow has three steps:
Steps:
1. **Namespaces** — multi-select with `space`, toggle-all with `a`, filter with `/`.
2. **Services** — same controls; rows already present in the config are locked off, and non-TCP ports are skipped (UDP is not supported by kportal's forward layer).
3. **Port assignment** — choose a starting local port (default `10000`, must be ≥ `1024`). Local ports are assigned consecutively in stable order, skipping any already in use.
1. Namespaces: select with `space`, toggle all with `a`, filter with `/`.
2. Services: same keys. Services already in the config are locked. Non-TCP ports are skipped.
3. Port assignment: choose a first local port (default `10000`, minimum `1024`). Ports are assigned in order and skip ports in use.
Press `enter` on the final step to save (or to print and exit when `--dry-run` is set), `b` to go back, or `esc` to cancel.
Press `enter` on the last step to save (or print, with `--dry-run`), `b` to go back, `esc` to cancel.
## Status Indicators
The `--dry-run` output has this form (example values):
| Indicator | Description |
|-----------|-------------|
```text
[dry-run] Would add 2 forwards to .kportal.yaml
10000 → my-cluster/default/service/api:8080
10001 → my-cluster/default/service/postgres:5432
```
### Shell completion
```bash
kportal completion --shell zsh # print the script
kportal completion --shell zsh --install # install it
kportal completion --uninstall # remove it
```
Without `--shell`, kportal detects the shell. Supported shells: bash, zsh, fish.
## Status indicators
| Indicator | Meaning |
|-----------|---------|
| `● Active` | Connection healthy |
| `○ Starting` | Initial connection (10s grace period) |
| `◐ Reconnecting` | Reconnecting after failure |
| `○ Starting` | First connection. Failed health checks show Starting for the first 10s. |
| `◐ Reconnecting` | Reconnecting after a failure |
| `✗ Error` | Connection failed |
| `○ Disabled` | Manually disabled |
| `○ Disabled` | Turned off in the UI |
## Advanced Features
## Advanced features
### HTTP Traffic Logging
### HTTP traffic logging
Press `l` in the TUI to view real-time HTTP traffic for a selected forward. The log viewer shows:
| Column | Description |
|--------|-------------|
| TIME | Request timestamp |
| METHOD | HTTP method (GET, POST, etc.) |
| STATUS | Response status code |
| LATENCY | Request duration |
| PATH | Request path |
**List view shortcuts:**
| Key | Action |
|-----|--------|
| `↑/↓` | Navigate entries |
| `Enter` | View request details |
| `g/G` | Jump to top/bottom |
| `a` | Toggle auto-scroll |
| `f` | Cycle filter mode (All → Non-2xx → Errors) |
| `/` | Search by path or method |
| `c` | Clear all filters |
| `q` | Close log viewer |
**Detail view:**
Press `Enter` on any entry to see full request/response details including:
- Request and response headers (alphabetically sorted)
- Request and response bodies
- Timing information and status codes
| Key | Action |
|-----|--------|
| `↑/↓` | Scroll content |
| `PgUp/PgDn` | Scroll by page |
| `g` | Jump to top |
| `c` | Copy response body to clipboard |
| `Esc/q` | Return to list |
**Body display features:**
- **JSON formatting** - JSON bodies are pretty-printed with syntax highlighting
- **Compression handling** - gzip/deflate content is automatically decompressed
- **Binary detection** - Binary content shows a placeholder instead of garbled data
**Filter modes:**
- **All** - Show all entries
- **Non-2xx** - Hide successful (2xx) responses
- **Errors** - Show only 4xx and 5xx responses
**Toggling per-forward logging:**
In the add/edit wizard, press `h` on the confirmation step to toggle `httpLog` on or
off for the current forward. The wizard preserves any advanced `httpLog` keys
(`logFile`, `includeHeaders`, `maxBodySize`, `filterPath`) you set in YAML.
**Header redaction:**
When `httpLog.includeHeaders: true` is set, sensitive header values are
automatically replaced with `[REDACTED]`. The header name is preserved so you can
see that an `Authorization` header was present without exposing its value. Redacted
headers include `Authorization`, `Cookie`, `Set-Cookie`, `X-Api-Key`,
`X-Auth-Token`, `X-Csrf-Token`, `Proxy-Authorization`, `X-Access-Token`, and any
header whose name contains `token`, `secret`, `password`, or `apikey`. This is
always on and cannot be disabled.
**Advanced configuration:**
Press `l` on a forward that has `httpLog` set.
```yaml
forwards:
@@ -474,48 +483,88 @@ forwards:
localPort: 8080
httpLog:
enabled: true
includeHeaders: true # values of sensitive headers are redacted
maxBodySize: 65536 # bytes; 0 = unlimited
filterPath: "/api/" # only log paths matching this substring
logFile: "api.log" # append entries to a file in addition to the in-memory ring
includeHeaders: true # sensitive values are redacted
maxBodySize: 65536 # bytes
filterPath: "/api/" # log only paths containing this text
logFile: "api.log" # also append entries to this file
```
### Connection Benchmarking
`maxBodySize` defaults to 1 MB (1048576 bytes) when you omit it or set a value of 0 or less.
Press `b` in the TUI to benchmark a selected forward. Configure:
List view columns: TIME, METHOD, STATUS, LATENCY, PATH.
- **URL Path** - Target endpoint (default: `/`)
- **Method** - HTTP method (GET, POST, etc.)
- **Concurrency** - Number of parallel workers
- **Requests** - Total number of requests
| Key | Action |
|-----|--------|
| `Up` `Down` | Move |
| `Enter` | Open details |
| `g` / `G` | Top / bottom |
| `a` | Toggle auto-scroll |
| `f` | Cycle filter: All, Non-2xx, Errors |
| `/` | Search path or method |
| `c` | Clear filters |
| `q` | Close |
Results include:
- Success/failure counts
- Min/Max/Avg latency
- P50/P95/P99 percentiles
- Throughput (requests/sec)
- Status code distribution
The detail view shows sorted request and response headers, bodies and timing. JSON bodies are pretty-printed, gzip and deflate bodies are decoded, and binary bodies show a placeholder.
### Hot-Reload
| Key | Action |
|-----|--------|
| `Up` `Down` / `PgUp` `PgDn` | Scroll |
| `g` | Top |
| `c` | Copy the response body |
| `Esc` / `q` / `Enter` | Back to the list |
Configuration changes are applied automatically. Manual reload:
#### Sensitive header redaction
With `includeHeaders: true`, kportal replaces these header values with `[REDACTED]` and keeps the name: `Authorization`, `Cookie`, `Set-Cookie`, `X-Api-Key`, `X-Auth-Token`, `X-Csrf-Token`, `Proxy-Authorization`, `X-Access-Token`, and any name containing `token`, `secret`, `password` or `apikey` (any case). You cannot turn this off. Source: `internal/httplog/proxy.go`.
### Benchmarking
Press `b` on a forward. Set the URL path (default `/`), the HTTP method, the concurrency and the request count. The wizard starts at 10 workers and 100 requests.
The results screen has this layout (`x.xx` marks a measured value):
```text
Total Requests: 100
Successful: 100 (100.0%)
Failed: 0
Latency (ms)
Min: x.xx
Max: x.xx
Avg: x.xx
P50: x.xx
P95: x.xx
P99: x.xx
Throughput
Requests/sec: x.xx
Bytes read: n
Status Codes
200: n
```
Only 2xx responses count as successful.
### Hot reload
kportal reloads when the config file changes. To reload by hand:
```bash
kill -HUP $(pgrep kportal)
```
### Port Conflict Detection
New forwards start, removed forwards stop and unchanged forwards keep running. An invalid config is rejected and the previous one stays active.
kportal validates port availability at startup and during hot-reload, showing which process is using conflicting ports.
### Port conflict detection
Conflicts are only reported between forwards that could be running together.
With `--context`, forwards in contexts outside the selection are ignored, which
is what lets each context reuse the same `localPort` values - see
[Reusing local ports across contexts](#reusing-local-ports-across-contexts).
kportal checks that local ports are free at startup and on reload. The report names the process that holds a busy port. kportal finds it with `lsof` on Linux and macOS and `tasklist` on Windows. A reload that would bind a busy port is rejected and the previous config stays active.
### Retry Strategy
Conflicts are only reported between forwards that can run together. See [Select contexts](#select-contexts).
Exponential backoff: 1s → 2s → 4s → 8s → 10s (max). Retries continue indefinitely until connection succeeds.
### Retry strategy
Exponential backoff with 10% jitter: 1s, 2s, 4s, 8s, then 10s (cap). Retries continue until the connection succeeds.
## Migration from kftray
@@ -523,88 +572,63 @@ Exponential backoff: 1s → 2s → 4s → 8s → 10s (max). Retries continue ind
kportal --convert configs.json --convert-output .kportal.yaml
```
## Signal Handling
`-convert-output` defaults to `.kportal.yaml`.
- `Ctrl+C` / `SIGTERM` - Graceful shutdown
- `SIGHUP` - Reload configuration
## Signal handling
## 🐛 Troubleshooting
| Signal | Effect |
|--------|--------|
| `Ctrl+C`, `SIGTERM` | Graceful shutdown |
| `SIGHUP` | Reload the configuration |
### Port Already in Use
## Troubleshooting
Port already in use:
```bash
lsof -i :<port>
kill <pid>
```
### Connection Refused
Connection refused:
1. Verify pod is running: `kubectl get pods -n <namespace>`
2. Verify port is correct: `kubectl describe pod <pod>`
3. Check service endpoints: `kubectl get endpoints <service>`
1. Check the pod: `kubectl get pods -n <namespace>`
2. Check the port: `kubectl describe pod <pod>`
3. Check the service endpoints: `kubectl get endpoints <service>`
### Context Not Found
Context not found: list your contexts with `kubectl config get-contexts`. kportal reports `context <name> not found in kubeconfig` when a `contexts[].name` value is not in your kubeconfig. Names with `@`, `.`, `:` or `/` (for example `admin@home` or an EKS ARN) are valid. If `--context` reports `unknown context`, the name is missing from `contexts[].name` in the config file.
The add wizard shows a namespace prompt instead of a list: the cluster refused or returned an empty namespace list. Type the namespace name.
## Development
Requires Go 1.26 or later, kubectl and access to a cluster.
```bash
kubectl config get-contexts
```
Context names containing `@`, `.`, `:`, or `/` (e.g. `admin@home`,
`[email protected]`, GKE dotted names, EKS ARNs) are accepted by the
config validator.
If `--context` reports `unknown context "..."`, the name does not match any
`contexts[].name` in the config file. The error lists the names that are
defined - note these are the contexts in your `.kportal.yaml`, which need not be
every context in your kubeconfig.
## 🔧 Development
### Prerequisites
- Go 1.23+
- Kubernetes cluster access
- kubectl configured
### Build
```bash
make build # Build binary
make test # Run tests
make build # build the binary
make test # run tests
make all # fmt, vet, staticcheck, test
make install # Install locally
make install # install locally
```
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. Release history is in [CHANGELOG.md](CHANGELOG.md).
## Telemetry
On startup this binary sends a single anonymous adoption ping — project name,
version, timestamp; no identifiers, no command output, no payload contents.
Fire-and-forget with a 2-second timeout; cannot block startup or panic.
On startup kportal sends one anonymous adoption ping: project name, version and timestamp. It has a 2-second timeout and cannot block startup.
See **[oss-telemetry — Disabling telemetry](https://github.com/lukaszraczylo/oss-telemetry#disabling-telemetry)**
for the exact wire format, source, and full opt-out documentation.
Quick opt-out: set any of `DO_NOT_TRACK=1`, `OSS_TELEMETRY_DISABLED=1`,
or `KPORTAL_DISABLE_TELEMETRY=1`.
To opt out, set `DO_NOT_TRACK=1`, `OSS_TELEMETRY_DISABLED=1` or `KPORTAL_DISABLE_TELEMETRY=1`. See [oss-telemetry](https://github.com/lukaszraczylo/oss-telemetry#disabling-telemetry) for the wire format and source.
## License
MIT License - see [LICENSE](LICENSE).
MIT. See [LICENSE](LICENSE).
## Acknowledgments
## Credits
- [Bubble Tea](https://github.com/charmbracelet/bubbletea) - Terminal UI framework
- [Lipgloss](https://github.com/charmbracelet/lipgloss) - Terminal styling
- [client-go](https://github.com/kubernetes/client-go) - Kubernetes client
- [kftray](https://github.com/hcavarsan/kftray) - Inspiration
[Bubble Tea](https://github.com/charmbracelet/bubbletea), [Lipgloss](https://github.com/charmbracelet/lipgloss), [client-go](https://github.com/kubernetes/client-go). [kftray](https://github.com/hcavarsan/kftray) was the inspiration.
## Links
- [Website](https://lukaszraczylo.github.io/kportal)
- [Issues](https://github.com/lukaszraczylo/kportal/issues)
- [Releases](https://github.com/lukaszraczylo/kportal/releases)
- [Changelog](CHANGELOG.md)
+1175 -1071
View File
File diff suppressed because it is too large Load Diff
+30 -33
View File
@@ -3,12 +3,12 @@ module github.com/lukaszraczylo/kportal
go 1.26.0
require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
charm.land/bubbletea/v2 v2.0.10
charm.land/lipgloss/v2 v2.0.6
github.com/fsnotify/fsnotify v1.10.1
github.com/go-logr/logr v1.4.4
github.com/grandcat/zeroconf v1.0.0
github.com/lukaszraczylo/oss-telemetry v0.2.3
github.com/lukaszraczylo/oss-telemetry v0.2.5
github.com/stretchr/testify v1.12.1
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.37.1
@@ -18,60 +18,57 @@ require (
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fxamacker/cbor/v2 v2.9.3 // indirect
github.com/go-openapi/jsonpointer v1.0.0 // indirect
github.com/go-openapi/jsonreference v1.0.1 // indirect
github.com/go-openapi/swag v0.29.1 // indirect
github.com/go-openapi/swag/cmdutils v0.29.1 // indirect
github.com/go-openapi/swag/conv v0.29.1 // indirect
github.com/go-openapi/swag/fileutils v0.29.1 // indirect
github.com/go-openapi/swag/jsonutils v0.29.1 // indirect
github.com/go-openapi/swag/loading v0.29.1 // indirect
github.com/go-openapi/swag/mangling v0.29.1 // indirect
github.com/go-openapi/swag/netutils v0.29.1 // indirect
github.com/go-openapi/swag/pools v0.29.1 // indirect
github.com/go-openapi/swag/stringutils v0.29.1 // indirect
github.com/go-openapi/swag/typeutils v0.29.1 // indirect
github.com/go-openapi/swag/yamlutils v0.29.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.6 // indirect
github.com/go-openapi/jsonpointer v1.0.2 // indirect
github.com/go-openapi/jsonreference v1.0.3 // indirect
github.com/go-openapi/swag v0.29.2 // indirect
github.com/go-openapi/swag/cmdutils v0.29.2 // indirect
github.com/go-openapi/swag/conv v0.29.2 // indirect
github.com/go-openapi/swag/fileutils v0.29.2 // indirect
github.com/go-openapi/swag/jsonutils v0.29.2 // indirect
github.com/go-openapi/swag/loading v0.29.2 // indirect
github.com/go-openapi/swag/mangling v0.29.2 // indirect
github.com/go-openapi/swag/netutils v0.29.2 // indirect
github.com/go-openapi/swag/pools v0.29.2 // indirect
github.com/go-openapi/swag/stringutils v0.29.2 // indirect
github.com/go-openapi/swag/typeutils v0.29.2 // indirect
github.com/go-openapi/swag/yamlutils v0.29.2 // indirect
github.com/google/gnostic-models v0.7.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.29 // indirect
github.com/mattn/go-runewidth v0.0.30 // indirect
github.com/miekg/dns v1.1.73 // indirect
github.com/moby/spdystream v0.5.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xo/terminfo v1.0.0 // indirect
github.com/xo/terminfo v1.2.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/net v0.59.0 // indirect
golang.org/x/oauth2 v0.37.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/term v0.46.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/time v0.16.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
+70 -81
View File
@@ -1,25 +1,27 @@
charm.land/bubbletea/v2 v2.0.10 h1:oolvo20VBpI0PfqE7iFjkZ1bx0WpmXGfnKz5Yldjq5o=
charm.land/bubbletea/v2 v2.0.10/go.mod h1:QOatcnhOjYIfxzUSTz6raF7Ex4R/rIuHa3SnBdCCpMc=
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA=
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
@@ -30,48 +32,46 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q=
github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fxamacker/cbor/v2 v2.9.6 h1:NHZPxuUjJXQjX9itA785y/5NKSlI4HQ+S7noZsmI7hs=
github.com/fxamacker/cbor/v2 v2.9.6/go.mod h1:4EaZp6ClVunETjQ4GHmGNzysDK91Lr+r6Yz6tJqzVCw=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
github.com/go-openapi/jsonreference v1.0.1 h1:4zJ7AmYDKNmD3aSpfPnFNCFA5E80/xMHUNKgydaLh38=
github.com/go-openapi/jsonreference v1.0.1/go.mod h1:dYplQXa6p5lXprLcJ8LE2iU7vNpXsAHDQ5ZAgL+Qx3A=
github.com/go-openapi/swag v0.29.1 h1:C6EeWzUwQtcWEhE9eqBdUubGXxhWY4PlzHMLD7kLaiQ=
github.com/go-openapi/swag v0.29.1/go.mod h1:BzxEXKiPlSXRsRTv1KSBF/BpGKHxA/YciCnr4tv9bvA=
github.com/go-openapi/swag/cmdutils v0.29.1 h1:3DorPGfUdE80BogKY22EzoHBcHMrkVomZMoV7kS4ANY=
github.com/go-openapi/swag/cmdutils v0.29.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM=
github.com/go-openapi/swag/conv v0.29.1 h1:AC4Eh/5c/eUDOUCzzsRC9ghmFgOSBHeRMGIngY0ZUGA=
github.com/go-openapi/swag/conv v0.29.1/go.mod h1:S1X7/ZrBEZOC0Wc8AGxjbcGS92l3WEjA7aPtpl+RaqM=
github.com/go-openapi/swag/fileutils v0.29.1 h1:ZcPzMceVhU1WPbK6N1G6sNQKdd1CWJlf3cA08UHuoM0=
github.com/go-openapi/swag/fileutils v0.29.1/go.mod h1:/wofKYckbtRl2p3+EwQsosie5CT1B38+dQ+PS579BzI=
github.com/go-openapi/swag/jsonutils v0.29.1 h1:AFCxs0eQZ24/QyfhVHM2t49rMz7Vv3XCsZQI6yrNy+c=
github.com/go-openapi/swag/jsonutils v0.29.1/go.mod h1:u3+sCfJpttDpcmS5kpm0yxL6GK0eWgODsx8Yw8fcqNM=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.1 h1:BiiXE31Bx9SfpsMmOQj5KYpUhTZBpLVriVhJDuLuY2o=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.1/go.mod h1:julgTUKZ9/D0j6O7GKajmRs+812FWxQg/mMpGunWSjg=
github.com/go-openapi/swag/loading v0.29.1 h1:FCv5fG8UhTdDJa2R7w+5O9Ekpcbw7tt0nFWvmDKGBjc=
github.com/go-openapi/swag/loading v0.29.1/go.mod h1:N0ESuem4p2oedKal8EJhciqnJ9Q9Wmt83L1CRB3Fouw=
github.com/go-openapi/swag/mangling v0.29.1 h1:lHALtvYCdxVnRl4GrHmFPwfBTZYIObqdGNSKyu/8D6I=
github.com/go-openapi/swag/mangling v0.29.1/go.mod h1:SAop9pB7PUjQ/CGCNf/JmCKTRK+GDO+RqE9UHqC/N6s=
github.com/go-openapi/swag/netutils v0.29.1 h1:IjIvdEP5duKcghFqJEPSUraRnkKYHoM65kTluTu+Jb4=
github.com/go-openapi/swag/netutils v0.29.1/go.mod h1:DUde7x4Bx00k5jYl2AdRpNAO0m7atUvD2x6X+bWkbno=
github.com/go-openapi/swag/pools v0.29.1 h1:NRogYxdEW9SjRM4mkAOji9iefO4MRXq3p/ZJcoQbUKg=
github.com/go-openapi/swag/pools v0.29.1/go.mod h1:leDcaghjkRAhCuCRv9NfJU5f0mjoU3cT/XZObhMk3pc=
github.com/go-openapi/swag/stringutils v0.29.1 h1:1ykunK7iJQk1uOO7+oUH1ukbsK85fFCOiCFMOVSY+F0=
github.com/go-openapi/swag/stringutils v0.29.1/go.mod h1:7fSqZ+z8Qc0tOfAAK0jVa5qFGrnIlRi6n7NeGGrr1vc=
github.com/go-openapi/swag/typeutils v0.29.1 h1:Nzv9nhnlLCRBPQqfOX+7lB6Guju370or8StT+lIOf6M=
github.com/go-openapi/swag/typeutils v0.29.1/go.mod h1:hxpgDZJVBkBsi/d3MIUosafoFdE5exaQRmVp0zwu3YE=
github.com/go-openapi/swag/yamlutils v0.29.1 h1:69w3tsBajm7MR/fejLy7HD/3J68Ys1SeeZMEzZ3w2sk=
github.com/go-openapi/swag/yamlutils v0.29.1/go.mod h1:rgsp3vT/QdWzKwn43CigDwjOGIenPyTZMKnxEM8jZOA=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.1 h1:Jm+/ze2rMtbD98yen92AhATGLGREDYXG56Xr4gMjEtE=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.1/go.mod h1:YDPnwCRDu38/oJBVMBVXOUDiJ9cIeBHWvfImHaXqnv4=
github.com/go-openapi/testify/v2 v2.6.1 h1:6CNJhTjMzgaeaH8WhshcsZNPIvRemiOcFpU7seO/y7Q=
github.com/go-openapi/testify/v2 v2.6.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-openapi/jsonpointer v1.0.2 h1:NbW7xT2b+l7x+JefiPGDsZWsrLk1ogrz4AHK1TTzMt8=
github.com/go-openapi/jsonpointer v1.0.2/go.mod h1:N0rI4MbNKD7xAFTiHtB+5Nhrg2j/wwYg7m7Fmb4/jk8=
github.com/go-openapi/jsonreference v1.0.3 h1:5rDbq+JsjCv6/nUDDJt83nLZoADI4w03uvHXer+4aYQ=
github.com/go-openapi/jsonreference v1.0.3/go.mod h1:OJSlMXHysgH272D9z2tiy2MGe5A9vQLHaCPb6KbYdE4=
github.com/go-openapi/swag v0.29.2 h1:9n8frkcsuQRA0INU31VbxNxt1zBaHxrHYGEf5BUjFi4=
github.com/go-openapi/swag v0.29.2/go.mod h1:RkCiX1gCVXWgLOavDXE3ALJrQ/VO2ziiIzOUs3z10xo=
github.com/go-openapi/swag/cmdutils v0.29.2 h1:cXEzX/nWCODon251f+1HgL75/R4XXML5IZxgLCWMoro=
github.com/go-openapi/swag/cmdutils v0.29.2/go.mod h1:XziaSVzYqkDvpWXzASfWajw2YvJrLscq610O2XiyqJw=
github.com/go-openapi/swag/conv v0.29.2 h1:8c9shoB8l0QRSR6ymq1llHdBWDqpIgJfjTGHx3Vuhm0=
github.com/go-openapi/swag/conv v0.29.2/go.mod h1:AZS0YigTNf8qNtD6WqJz/N4RUNmpr76SyjFGkq4+p6Q=
github.com/go-openapi/swag/fileutils v0.29.2 h1:mdUL+Vw5ah1fO1AvFCoHeIyv7YZsCz2MN3KNqd/lLVI=
github.com/go-openapi/swag/fileutils v0.29.2/go.mod h1:7QKmmodjAebaq61lGVJa8ldLdGeMF4DSgYv8tXOGGo0=
github.com/go-openapi/swag/jsonutils v0.29.2 h1:uZNSD2/rJDYAfvsLYkykTzXuyxM3QpDKV9jhRHrMu6k=
github.com/go-openapi/swag/jsonutils v0.29.2/go.mod h1:ONTdNvj3Y+IXRzfa17E+Rgt2ns/qiSOYjIo2K7v2yDs=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2 h1:w+Fd6EBOMGlC74GYGHqCLGyb3Bpams8TtNrgM/SG4jo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2/go.mod h1:HC2egPORFzbj/gf05Zrfgz8Mgplx3oW7hgGi7pjZVhM=
github.com/go-openapi/swag/loading v0.29.2 h1:QU1ry24e6r6Shoxe380Nx0X5iKkpRO+hJffsyJ2dVlY=
github.com/go-openapi/swag/loading v0.29.2/go.mod h1:DqilDjuiJKETecsS4TRopWG1acUDsfqN39ZmQhPu6uI=
github.com/go-openapi/swag/mangling v0.29.2 h1:ltdo3K0tTP4P6zWhVVWDU9D37/YYBuMJRdZxiS2Dtog=
github.com/go-openapi/swag/mangling v0.29.2/go.mod h1:RjDi4TnItAenS3cgCXSpIl1kKbjG7il6pnmQVl/8bp8=
github.com/go-openapi/swag/netutils v0.29.2 h1:dBli6jyUa93sDS/XYWShJzf4+nnVfFKfvY9SLUaUzB8=
github.com/go-openapi/swag/netutils v0.29.2/go.mod h1:1pM6Xg/Um8E1eps3umWl8Hme3ByWXOtHsvFC0CULHUE=
github.com/go-openapi/swag/pools v0.29.2 h1:PlGoDRF8WtSyXkedZZkpW3f9wDhFf3u8KtaBcmgmh8c=
github.com/go-openapi/swag/pools v0.29.2/go.mod h1:V3lhxVT4qDYRqc2MRSSbLsTYIYV/2HlHafhyEkmzLIc=
github.com/go-openapi/swag/stringutils v0.29.2 h1:lcnBxwAaysT3bMUVBfn9V/PkfVqkurpqufKU6rTUMGk=
github.com/go-openapi/swag/stringutils v0.29.2/go.mod h1:i9cdh7sGaa0nm3CqIvH5IM5h2QClk1wns2ktKeILvRI=
github.com/go-openapi/swag/typeutils v0.29.2 h1:O7aVvkTs3pXwikgtrPLenigEMdQFgONKRooQA9pgf2I=
github.com/go-openapi/swag/typeutils v0.29.2/go.mod h1:7+GDG+uz9Ke+eVt4rClZg7wklSDp8hT/mKNh/pC26TE=
github.com/go-openapi/swag/yamlutils v0.29.2 h1:IFKFFeDnuIwzfsuWRQU+rI8iL3g4XyAYjV/RlnlxPLM=
github.com/go-openapi/swag/yamlutils v0.29.2/go.mod h1:7MGqtcrK73sxQ4ceiyIf8qiXS/s09JLMdR5esK5euYQ=
github.com/go-openapi/testify/enable/yaml/v2 v2.7.0 h1:wPW6YRgx3+SID1yUy/Xwa17L8kFEaEKod2VRbJDZNUs=
github.com/go-openapi/testify/enable/yaml/v2 v2.7.0/go.mod h1:mI1M88etYbc3PhgHsWQK2kwvNwW5aGFqMPbmib+SGIs=
github.com/go-openapi/testify/v2 v2.8.0 h1:19QDx5b57p8KjO2E4sgs/woL4Akp+FGLRlVm2xn0Rho=
github.com/go-openapi/testify/v2 v2.8.0/go.mod h1:4gUN8jC+RqOE2qjW/vDmfyTvl5BCht4qL7oca0odrak=
github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -91,14 +91,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lukaszraczylo/oss-telemetry v0.2.3 h1:xoDtBqeZGmXj7IteiE1M5WMuzeoqag58qEleI0Cf2Ms=
github.com/lukaszraczylo/oss-telemetry v0.2.3/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.29 h1:3oGF3R/S2N9DQ3ptftzVIvg2eicmojCzlwBEmqEPDfQ=
github.com/mattn/go-runewidth v0.0.29/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/lukaszraczylo/oss-telemetry v0.2.5 h1:W6Qgwp7IY4yBRO361MmnEAmM59motdQpbAFAx8v6fJM=
github.com/lukaszraczylo/oss-telemetry v0.2.5/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
@@ -110,12 +106,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -137,41 +129,38 @@ github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWD
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4=
github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
+22 -15
View File
@@ -21,13 +21,14 @@ package ui
import (
"fmt"
"image/color"
"log"
"strings"
"sync"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/table"
"github.com/lukaszraczylo/kportal/internal/config"
"github.com/lukaszraczylo/kportal/internal/k8s"
)
@@ -173,7 +174,7 @@ func (ui *BubbleTeaUI) SetUpdateAvailable(version, url string) {
// Start starts the bubbletea application
func (ui *BubbleTeaUI) Start() error {
m := model{ui: ui}
ui.program = tea.NewProgram(m, tea.WithAltScreen())
ui.program = tea.NewProgram(m)
_, err := ui.program.Run()
return err
}
@@ -325,7 +326,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.termHeight = msg.Height
return m, nil
case tea.KeyMsg:
case tea.KeyPressMsg:
// Route based on current view mode
switch viewMode {
case ViewModeMain:
@@ -390,7 +391,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m model) View() string {
func (m model) View() tea.View {
v := tea.NewView(m.view())
v.AltScreen = true
return v
}
func (m model) view() string {
m.ui.mu.RLock()
viewMode := m.ui.viewMode
deleteConfirming := m.ui.deleteConfirming
@@ -438,13 +445,13 @@ func (m model) View() string {
// mainViewColors holds the color palette for the main view
type mainViewColors struct {
header lipgloss.Color
active lipgloss.Color
warning lipgloss.Color
errorColor lipgloss.Color
muted lipgloss.Color
selectedBg lipgloss.Color
selectedFg lipgloss.Color
header color.Color
active color.Color
warning color.Color
errorColor color.Color
muted color.Color
selectedBg color.Color
selectedFg color.Color
}
// defaultMainViewColors returns the default color palette
@@ -526,7 +533,7 @@ func (m model) getTermDimensions() (width, height int) {
}
// renderTitle renders the title bar with version and optional update notification
func (m model) renderTitle(headerColor lipgloss.Color) string {
func (m model) renderTitle(headerColor color.Color) string {
var b strings.Builder
titleStyle := lipgloss.NewStyle().
@@ -552,7 +559,7 @@ func (m model) renderTitle(headerColor lipgloss.Color) string {
// 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 {
func (m model) renderEmptyMessage(mutedColor color.Color) string {
mutedStyle := lipgloss.NewStyle().Foreground(mutedColor)
hintStyle := lipgloss.NewStyle().Foreground(highlightColor)
return mutedStyle.Render("No forwards configured") + "\n\n" +
+27 -23
View File
@@ -9,7 +9,7 @@ import (
"sync"
"time"
tea "github.com/charmbracelet/bubbletea"
tea "charm.land/bubbletea/v2"
"github.com/lukaszraczylo/kportal/internal/config"
)
@@ -323,13 +323,13 @@ func (m *GenerateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.step = GenerateStepDone
return m, tea.Quit
case tea.KeyMsg:
case tea.KeyPressMsg:
return m.handleKey(msg)
}
return m, nil
}
func (m *GenerateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m *GenerateModel) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
if m.loading {
// Allow only ctrl+c / esc while loading
switch msg.String() {
@@ -354,12 +354,12 @@ func (m *GenerateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// ---------- Namespace step ----------
func (m *GenerateModel) handleNamespaceKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m *GenerateModel) handleNamespaceKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
if m.nsFiltering {
switch msg.Type {
case tea.KeyEnter, tea.KeyEsc:
switch msg.Code {
case tea.KeyEnter, tea.KeyEscape:
m.nsFiltering = false
if msg.Type == tea.KeyEsc {
if msg.Code == tea.KeyEscape {
m.nsFilter = ""
m.recomputeNamespaceFilter()
}
@@ -370,12 +370,11 @@ func (m *GenerateModel) handleNamespaceKey(msg tea.KeyMsg) (tea.Model, tea.Cmd)
m.recomputeNamespaceFilter()
}
return m, nil
case tea.KeyRunes, tea.KeySpace:
m.nsFilter += string(msg.Runes)
default:
m.nsFilter += msg.Text
m.recomputeNamespaceFilter()
return m, nil
}
return m, nil
}
switch msg.String() {
@@ -391,7 +390,7 @@ func (m *GenerateModel) handleNamespaceKey(msg tea.KeyMsg) (tea.Model, tea.Cmd)
m.moveCursor(&m.nsCursor, &m.nsScroll, len(m.nsFilteredView), -10)
case "pgdown":
m.moveCursor(&m.nsCursor, &m.nsScroll, len(m.nsFilteredView), 10)
case " ":
case "space":
if len(m.nsFilteredView) > 0 {
ns := m.nsFilteredView[m.nsCursor]
m.nsSelected[ns] = !m.nsSelected[ns]
@@ -475,12 +474,12 @@ func (m *GenerateModel) buildServiceOrder() {
}
}
func (m *GenerateModel) handleServiceKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m *GenerateModel) handleServiceKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
if m.svcFiltering {
switch msg.Type {
case tea.KeyEnter, tea.KeyEsc:
switch msg.Code {
case tea.KeyEnter, tea.KeyEscape:
m.svcFiltering = false
if msg.Type == tea.KeyEsc {
if msg.Code == tea.KeyEscape {
m.svcFilter = ""
m.recomputeServiceFilter()
}
@@ -491,12 +490,11 @@ func (m *GenerateModel) handleServiceKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.recomputeServiceFilter()
}
return m, nil
case tea.KeyRunes, tea.KeySpace:
m.svcFilter += string(msg.Runes)
default:
m.svcFilter += msg.Text
m.recomputeServiceFilter()
return m, nil
}
return m, nil
}
switch msg.String() {
@@ -515,7 +513,7 @@ func (m *GenerateModel) handleServiceKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.moveCursor(&m.svcCursor, &m.svcScroll, len(m.svcFilteredView), -10)
case "pgdown":
m.moveCursor(&m.svcCursor, &m.svcScroll, len(m.svcFilteredView), 10)
case " ":
case "space":
if len(m.svcFilteredView) > 0 {
c := m.svcFilteredView[m.svcCursor]
if !m.svcLocked[c.Key()] && c.Protocol == "TCP" {
@@ -593,7 +591,7 @@ func (m *GenerateModel) selectedCandidates() []ServiceCandidate {
// ---------- Port assignment step ----------
func (m *GenerateModel) handlePortKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m *GenerateModel) handlePortKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
m.step = GenerateStepCancelled
@@ -627,7 +625,7 @@ func (m *GenerateModel) handlePortKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
// Digit-only input
for _, r := range msg.Runes {
for _, r := range msg.Text {
if r >= '0' && r <= '9' && len(m.startingPortStr) < 5 {
m.startingPortStr += string(r)
m.portError = ""
@@ -716,7 +714,13 @@ func (m *GenerateModel) countSkippedNonTCP() int {
// ---------- View ----------
// View implements tea.Model.
func (m *GenerateModel) View() string {
func (m *GenerateModel) View() tea.View {
v := tea.NewView(m.view())
v.AltScreen = true
return v
}
func (m *GenerateModel) view() string {
var b strings.Builder
b.WriteString(wizardHeaderStyle.Render(fmt.Sprintf("kportal generate · context: %s", m.contextName)))
b.WriteString("\n")
@@ -975,7 +979,7 @@ func RunGenerate(
existingForwards []config.Forward,
) (GenerateResult, error) {
m := NewGenerateModel(discovery, mutator, contextName, configPath, dryRun, existingForwards)
prog := tea.NewProgram(m, tea.WithAltScreen())
prog := tea.NewProgram(m)
finalModel, err := prog.Run()
if err != nil {
return GenerateResult{}, err
+11 -11
View File
@@ -8,7 +8,7 @@ import (
"sync"
"testing"
tea "github.com/charmbracelet/bubbletea"
tea "charm.land/bubbletea/v2"
"github.com/lukaszraczylo/kportal/internal/config"
"github.com/lukaszraczylo/kportal/internal/k8s"
)
@@ -72,22 +72,22 @@ func (f *fakeDiscovery) ListServices(_ context.Context, _, ns string) ([]k8s.Ser
return f.servicesByNS[ns], nil
}
// keyOf builds a tea.KeyMsg the same way bubbletea does for typed runes.
func keyOf(s string) tea.KeyMsg {
// keyOf builds a tea.KeyPressMsg the same way bubbletea does for typed runes.
func keyOf(s string) tea.KeyPressMsg {
switch s {
case "enter":
return tea.KeyMsg{Type: tea.KeyEnter}
return tea.KeyPressMsg{Code: tea.KeyEnter}
case "esc":
return tea.KeyMsg{Type: tea.KeyEsc}
return tea.KeyPressMsg{Code: tea.KeyEscape}
case "space":
return tea.KeyMsg{Type: tea.KeySpace, Runes: []rune(" ")}
return tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}
case "backspace":
return tea.KeyMsg{Type: tea.KeyBackspace}
return tea.KeyPressMsg{Code: tea.KeyBackspace}
}
if len(s) == 1 {
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
return tea.KeyPressMsg{Text: s}
}
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
return tea.KeyPressMsg{Text: s}
}
// drainModel applies a sequence of messages and returns the final model.
@@ -169,7 +169,7 @@ func TestGenerateModel_NamespaceFilter(t *testing.T) {
t.Fatalf("expected [beta], got %v", gm.nsFilteredView)
}
// Exit filter
gm = drainModel(t, gm, tea.KeyMsg{Type: tea.KeyEnter}).(*GenerateModel)
gm = drainModel(t, gm, tea.KeyPressMsg{Code: tea.KeyEnter}).(*GenerateModel)
if gm.nsFiltering {
t.Fatal("expected filtering to be off after enter")
}
@@ -496,7 +496,7 @@ func TestGenerateModel_PortStepView(t *testing.T) {
m.step = GenerateStepPortAssign
m.svcOrder = []ServiceCandidate{{Namespace: "ns", Service: "svc", Port: 80, Protocol: "TCP"}}
m.svcSelected[m.svcOrder[0].Key()] = true
view := m.View()
view := m.View().Content
if !contains(view, "Step 3 / 3") {
t.Fatalf("expected step header in view, got: %s", view)
}
+34 -34
View File
@@ -5,7 +5,7 @@ import (
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
tea "charm.land/bubbletea/v2"
"github.com/lukaszraczylo/kportal/internal/config"
"github.com/lukaszraczylo/kportal/internal/k8s"
"github.com/stretchr/testify/assert"
@@ -44,10 +44,10 @@ func TestHandleMainViewKeys_Quit(t *testing.T) {
for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
m := newTestModel()
_, cmd := m.handleMainViewKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tt.key)})
_, cmd := m.handleMainViewKeys(tea.KeyPressMsg{Text: tt.key})
if tt.key == "ctrl+c" {
keyMsg := tea.KeyMsg{Type: tea.KeyCtrlC}
keyMsg := tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}
_, cmd = m.handleMainViewKeys(keyMsg)
}
@@ -76,14 +76,14 @@ func TestHandleMainViewKeys_Navigation(t *testing.T) {
tests := []struct {
name string
key string
keyType tea.KeyType
code rune
initialIndex int
expectedIndex int
}{
{"down arrow", "down", tea.KeyDown, 0, 1},
{"j key", "j", tea.KeyRunes, 0, 1},
{"j key", "j", 0, 0, 1},
{"up arrow", "up", tea.KeyUp, 2, 1},
{"k key", "k", tea.KeyRunes, 2, 1},
{"k key", "k", 0, 2, 1},
}
for _, tt := range tests {
@@ -92,11 +92,11 @@ func TestHandleMainViewKeys_Navigation(t *testing.T) {
m.ui.selectedIndex = tt.initialIndex
m.ui.mu.Unlock()
var keyMsg tea.KeyMsg
if tt.keyType == tea.KeyRunes {
keyMsg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tt.key)}
var keyMsg tea.KeyPressMsg
if tt.code == 0 {
keyMsg = tea.KeyPressMsg{Text: tt.key}
} else {
keyMsg = tea.KeyMsg{Type: tt.keyType}
keyMsg = tea.KeyPressMsg{Code: tt.code}
}
m.handleMainViewKeys(keyMsg)
@@ -123,7 +123,7 @@ func TestHandleMainViewKeys_Toggle(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Toggle with space
keyMsg := tea.KeyMsg{Type: tea.KeySpace}
keyMsg := tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}
m.handleMainViewKeys(keyMsg)
// Check disabled state changed
@@ -151,7 +151,7 @@ func TestHandleMainViewKeys_NewWizard(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Without dependencies, 'n' should do nothing
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}
keyMsg := tea.KeyPressMsg{Text: "n"}
m.handleMainViewKeys(keyMsg)
m.ui.mu.RLock()
@@ -180,7 +180,7 @@ func TestHandleMainViewKeys_DeleteConfirmation(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press 'd' to show delete confirmation
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}
keyMsg := tea.KeyPressMsg{Text: "d"}
m.handleMainViewKeys(keyMsg)
m.ui.mu.RLock()
@@ -204,7 +204,7 @@ func TestHandleMainViewKeys_DeleteConfirmation_PreventsDuplicate(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press 'd' for first forward
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}
keyMsg := tea.KeyPressMsg{Text: "d"}
m.handleMainViewKeys(keyMsg)
// Change selection
@@ -234,7 +234,7 @@ func TestHandleDeleteConfirmation_Cancel(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press Esc
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
m.handleDeleteConfirmation(keyMsg)
m.ui.mu.RLock()
@@ -257,7 +257,7 @@ func TestHandleDeleteConfirmation_NavigateAndConfirm(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Navigate left to "Yes"
keyMsg := tea.KeyMsg{Type: tea.KeyLeft}
keyMsg := tea.KeyPressMsg{Code: tea.KeyLeft}
m.handleDeleteConfirmation(keyMsg)
m.ui.mu.RLock()
@@ -265,7 +265,7 @@ func TestHandleDeleteConfirmation_NavigateAndConfirm(t *testing.T) {
m.ui.mu.RUnlock()
// Navigate right back to "No"
keyMsg = tea.KeyMsg{Type: tea.KeyRight}
keyMsg = tea.KeyPressMsg{Code: tea.KeyRight}
m.handleDeleteConfirmation(keyMsg)
m.ui.mu.RLock()
@@ -288,7 +288,7 @@ func TestHandleDeleteConfirmation_ConfirmYes(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press Enter on "Yes"
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleDeleteConfirmation(keyMsg)
// Should return a command to remove the forward
@@ -314,7 +314,7 @@ func TestHandleDeleteConfirmation_QuickYKey(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press 'y' - should confirm regardless of cursor position
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}
keyMsg := tea.KeyPressMsg{Text: "y"}
_, cmd := m.handleDeleteConfirmation(keyMsg)
assert.NotNil(t, cmd)
@@ -336,7 +336,7 @@ func TestHandleDeleteConfirmation_QuickNKey(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press 'n' - should cancel regardless of cursor position
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}
keyMsg := tea.KeyPressMsg{Text: "n"}
m.handleDeleteConfirmation(keyMsg)
m.ui.mu.RLock()
@@ -358,7 +358,7 @@ func TestHandleBenchmarkKeys_Cancel(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press Esc
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
m.handleBenchmarkKeys(keyMsg)
assert.True(t, cancelled, "Cancel function should be called")
@@ -385,7 +385,7 @@ func TestHandleBenchmarkKeys_Navigation(t *testing.T) {
m.ui.mu.RUnlock()
// Move down
keyMsg := tea.KeyMsg{Type: tea.KeyDown}
keyMsg := tea.KeyPressMsg{Code: tea.KeyDown}
m.handleBenchmarkKeys(keyMsg)
m.ui.mu.RLock()
@@ -400,7 +400,7 @@ func TestHandleBenchmarkKeys_Navigation(t *testing.T) {
m.ui.mu.RUnlock()
// Move up
keyMsg = tea.KeyMsg{Type: tea.KeyUp}
keyMsg = tea.KeyPressMsg{Code: tea.KeyUp}
m.handleBenchmarkKeys(keyMsg)
m.ui.mu.RLock()
@@ -422,7 +422,7 @@ func TestHandleHTTPLogKeys_Close(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press Esc
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
m.handleHTTPLogKeys(keyMsg)
m.ui.mu.RLock()
@@ -451,7 +451,7 @@ func TestHandleHTTPLogKeys_FilterCycle(t *testing.T) {
m.ui.mu.RUnlock()
// Press 'f' to cycle - should skip Text mode and go to Non200
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("f")}
keyMsg := tea.KeyPressMsg{Text: "f"}
m.handleHTTPLogKeys(keyMsg)
m.ui.mu.RLock()
@@ -484,7 +484,7 @@ func TestHandleHTTPLogKeys_TextFilter(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press '/'
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
keyMsg := tea.KeyPressMsg{Text: "/"}
m.handleHTTPLogKeys(keyMsg)
m.ui.mu.RLock()
@@ -505,7 +505,7 @@ func TestHandleHTTPLogKeys_ClearFilters(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press 'c'
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")}
keyMsg := tea.KeyPressMsg{Text: "c"}
m.handleHTTPLogKeys(keyMsg)
m.ui.mu.RLock()
@@ -869,7 +869,7 @@ func TestModel_Update_ViewModeRouting(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Send a key message - should not panic
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
_, _ = m.Update(keyMsg)
})
}
@@ -925,7 +925,7 @@ func TestHandleRemoveWizardKeys_EscInConfirmingCancels(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
_, cmd := m.handleRemoveWizardKeys(keyMsg)
// No removal command must be dispatched.
@@ -962,7 +962,7 @@ func TestHandleRemoveWizardKeys_EscNotConfirmingExits(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
_, cmd := m.handleRemoveWizardKeys(keyMsg)
// Should return tea.ClearScreen command on full exit.
@@ -992,7 +992,7 @@ func TestHandleRemoveWizardKeys_EnterOnYesStillConfirms(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleRemoveWizardKeys(keyMsg)
assert.NotNil(t, cmd, "Enter on Yes must still dispatch removeForwardsCmd")
@@ -1017,7 +1017,7 @@ func TestHandleAddWizardKeys_HToggleHTTPLog(t *testing.T) {
require.False(t, m.ui.addWizard.httpLog, "httpLog should default to false")
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("h")}
keyMsg := tea.KeyPressMsg{Text: "h"}
m.handleAddWizardKeys(keyMsg)
assert.True(t, m.ui.addWizard.httpLog, "first 'h' should enable httpLog")
@@ -1042,7 +1042,7 @@ func TestHandleAddWizardKeys_HOnAliasFocusIsTextInput(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("h")}
keyMsg := tea.KeyPressMsg{Text: "h"}
m.handleAddWizardKeys(keyMsg)
assert.False(t, m.ui.addWizard.httpLog, "httpLog must NOT toggle when alias has focus")
@@ -1067,7 +1067,7 @@ func TestEditPrefill_PreservesHTTPLog(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")}
keyMsg := tea.KeyPressMsg{Text: "e"}
m.handleMainViewKeys(keyMsg)
require.NotNil(t, m.ui.addWizard, "wizard should be active after 'e'")
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"fmt"
"time"
tea "github.com/charmbracelet/bubbletea"
tea "charm.land/bubbletea/v2"
"github.com/lukaszraczylo/kportal/internal/benchmark"
"github.com/lukaszraczylo/kportal/internal/config"
"github.com/lukaszraczylo/kportal/internal/k8s"
+9 -9
View File
@@ -9,7 +9,7 @@ import (
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
tea "charm.land/bubbletea/v2"
"github.com/lukaszraczylo/kportal/internal/config"
"github.com/lukaszraczylo/kportal/internal/k8s"
)
@@ -31,7 +31,7 @@ func isFilterableStep(step AddWizardStep) bool {
}
// handleMainViewKeys handles keyboard input in the main view
func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m model) handleMainViewKeys(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
// If delete confirmation is showing, handle it separately
if m.ui.deleteConfirming {
return m.handleDeleteConfirmation(msg)
@@ -53,7 +53,7 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "pgdown", "ctrl+d":
m.ui.moveSelection(10)
case " ", "enter":
case "space", "enter":
m.ui.toggleSelected()
case "n": // Enter add wizard
@@ -294,7 +294,7 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
// handleDeleteConfirmation handles keyboard input for delete confirmation dialog
func (m model) handleDeleteConfirmation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m model) handleDeleteConfirmation(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
switch msg.String() {
@@ -336,7 +336,7 @@ func (m model) handleDeleteConfirmation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
// handleAddWizardKeys handles keyboard input in the add wizard
func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m model) handleAddWizardKeys(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
defer m.ui.mu.Unlock()
@@ -781,7 +781,7 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
}
// handleRemoveWizardKeys handles keyboard input in the remove wizard
func (m model) handleRemoveWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m model) handleRemoveWizardKeys(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
defer m.ui.mu.Unlock()
@@ -822,7 +822,7 @@ func (m model) handleRemoveWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "pgdown", "ctrl+d":
wizard.moveCursor(10)
case " ":
case "space":
if !wizard.confirming {
wizard.toggleSelection()
}
@@ -1051,7 +1051,7 @@ func (m model) handleForwardsRemoved(msg ForwardsRemovedMsg) (tea.Model, tea.Cmd
}
// handleBenchmarkKeys handles keyboard input in the benchmark view
func (m model) handleBenchmarkKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m model) handleBenchmarkKeys(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
defer m.ui.mu.Unlock()
@@ -1192,7 +1192,7 @@ func (m model) applyBenchmarkTextInput() {
}
// handleHTTPLogKeys handles keyboard input in the HTTP log view
func (m model) handleHTTPLogKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m model) handleHTTPLogKeys(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
defer m.ui.mu.Unlock()
+85 -85
View File
@@ -4,7 +4,7 @@ import (
"os"
"testing"
tea "github.com/charmbracelet/bubbletea"
tea "charm.land/bubbletea/v2"
"github.com/lukaszraczylo/kportal/internal/config"
"github.com/lukaszraczylo/kportal/internal/k8s"
"github.com/stretchr/testify/assert"
@@ -44,7 +44,7 @@ func TestHandleMainViewKeys_PageUpDown(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyPgUp}
keyMsg := tea.KeyPressMsg{Code: tea.KeyPgUp}
m.handleMainViewKeys(keyMsg)
ui.mu.RLock()
assert.Equal(t, 0, ui.selectedIndex)
@@ -54,7 +54,7 @@ func TestHandleMainViewKeys_PageUpDown(t *testing.T) {
ui.mu.Lock()
ui.selectedIndex = 0
ui.mu.Unlock()
keyMsg = tea.KeyMsg{Type: tea.KeyPgDown}
keyMsg = tea.KeyPressMsg{Code: tea.KeyPgDown}
m.handleMainViewKeys(keyMsg)
ui.mu.RLock()
assert.Equal(t, 10, ui.selectedIndex)
@@ -76,7 +76,7 @@ func TestHandleMainViewKeys_NewWizard_WithDiscovery(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}
keyMsg := tea.KeyPressMsg{Text: "n"}
_, cmd := m.handleMainViewKeys(keyMsg)
ui.mu.RLock()
@@ -99,7 +99,7 @@ func TestHandleMainViewKeys_NewWizard_AlreadyActive(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}
keyMsg := tea.KeyPressMsg{Text: "n"}
m.handleMainViewKeys(keyMsg)
// Should still be the same wizard (not replaced).
@@ -116,7 +116,7 @@ func TestHandleMainViewKeys_HttpLog_NoSubscriber(t *testing.T) {
ui.AddForward("id-1", fwd)
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")}
keyMsg := tea.KeyPressMsg{Text: "l"}
m.handleMainViewKeys(keyMsg)
ui.mu.RLock()
@@ -133,7 +133,7 @@ func TestHandleMainViewKeys_HttpLog_WithSubscriber(t *testing.T) {
ui.AddForward("id-1", fwd)
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")}
keyMsg := tea.KeyPressMsg{Text: "l"}
m.handleMainViewKeys(keyMsg)
// Subscription should be established.
@@ -151,7 +151,7 @@ func TestHandleMainViewKeys_Benchmark(t *testing.T) {
ui.AddForward("id-1", fwd)
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b")}
keyMsg := tea.KeyPressMsg{Text: "b"}
m.handleMainViewKeys(keyMsg)
ui.mu.RLock()
@@ -170,7 +170,7 @@ func TestHandleMainViewKeys_Benchmark_BlocksWhenActive(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
prevState := ui.benchmarkState
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b")}
keyMsg := tea.KeyPressMsg{Text: "b"}
m.handleMainViewKeys(keyMsg)
// Should not have replaced the benchmark state.
@@ -184,7 +184,7 @@ func TestHandleMainViewKeys_Benchmark_BlocksWhenActive(t *testing.T) {
func TestHandleAddWizardKeys_CtrlC(t *testing.T) {
m := newModelWithWizard(StepSelectContext)
keyMsg := tea.KeyMsg{Type: tea.KeyCtrlC}
keyMsg := tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}
_, cmd := m.handleAddWizardKeys(keyMsg)
assert.NotNil(t, cmd) // tea.ClearScreen
@@ -197,7 +197,7 @@ func TestHandleAddWizardKeys_CtrlC(t *testing.T) {
func TestHandleAddWizardKeys_Esc_FirstStep_Cancels(t *testing.T) {
m := newModelWithWizard(StepSelectContext)
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
m.handleAddWizardKeys(keyMsg)
m.ui.mu.RLock()
@@ -209,7 +209,7 @@ func TestHandleAddWizardKeys_Esc_FirstStep_Cancels(t *testing.T) {
func TestHandleAddWizardKeys_Esc_MiddleStep_GoesBack(t *testing.T) {
m := newModelWithWizard(StepSelectNamespace)
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
m.handleAddWizardKeys(keyMsg)
m.ui.mu.RLock()
@@ -221,7 +221,7 @@ func TestHandleAddWizardKeys_Esc_ClearsSearchFilter_InsteadOfBack(t *testing.T)
m := newModelWithWizard(StepSelectContext)
m.ui.addWizard.searchFilter = "my"
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
m.handleAddWizardKeys(keyMsg)
m.ui.mu.RLock()
@@ -235,7 +235,7 @@ func TestHandleAddWizardKeys_Esc_EditMode_AlwaysCancels(t *testing.T) {
m := newModelWithWizard(StepEnterRemotePort)
m.ui.addWizard.isEditing = true
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
m.handleAddWizardKeys(keyMsg)
m.ui.mu.RLock()
@@ -250,11 +250,11 @@ func TestHandleAddWizardKeys_Navigation(t *testing.T) {
m := newModelWithWizard(StepSelectContext)
m.ui.addWizard.contexts = []string{"ctx-a", "ctx-b", "ctx-c"}
keyMsg := tea.KeyMsg{Type: tea.KeyDown}
keyMsg := tea.KeyPressMsg{Code: tea.KeyDown}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, 1, m.ui.addWizard.cursor)
keyMsg = tea.KeyMsg{Type: tea.KeyUp}
keyMsg = tea.KeyPressMsg{Code: tea.KeyUp}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, 0, m.ui.addWizard.cursor)
}
@@ -268,11 +268,11 @@ func TestHandleAddWizardKeys_PageNavigation(t *testing.T) {
m.ui.addWizard.contexts = contexts
m.ui.addWizard.cursor = 15
keyMsg := tea.KeyMsg{Type: tea.KeyPgUp}
keyMsg := tea.KeyPressMsg{Code: tea.KeyPgUp}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, 5, m.ui.addWizard.cursor)
keyMsg = tea.KeyMsg{Type: tea.KeyPgDown}
keyMsg = tea.KeyPressMsg{Code: tea.KeyPgDown}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, 15, m.ui.addWizard.cursor)
}
@@ -283,7 +283,7 @@ func TestHandleAddWizardKeys_ConfirmationStep_UpFocusAlias(t *testing.T) {
m := newModelWithWizard(StepConfirmation)
m.ui.addWizard.confirmationFocus = FocusButtons
keyMsg := tea.KeyMsg{Type: tea.KeyUp}
keyMsg := tea.KeyPressMsg{Code: tea.KeyUp}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, FocusAlias, m.ui.addWizard.confirmationFocus)
}
@@ -292,7 +292,7 @@ func TestHandleAddWizardKeys_ConfirmationStep_DownFocusButtons(t *testing.T) {
m := newModelWithWizard(StepConfirmation)
m.ui.addWizard.confirmationFocus = FocusAlias
keyMsg := tea.KeyMsg{Type: tea.KeyDown}
keyMsg := tea.KeyPressMsg{Code: tea.KeyDown}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, FocusButtons, m.ui.addWizard.confirmationFocus)
}
@@ -301,7 +301,7 @@ func TestHandleAddWizardKeys_Tab_TogglesFocus(t *testing.T) {
m := newModelWithWizard(StepConfirmation)
m.ui.addWizard.confirmationFocus = FocusAlias
keyMsg := tea.KeyMsg{Type: tea.KeyTab}
keyMsg := tea.KeyPressMsg{Code: tea.KeyTab}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, FocusButtons, m.ui.addWizard.confirmationFocus)
@@ -316,7 +316,7 @@ func TestHandleAddWizardKeys_Backspace_TextMode(t *testing.T) {
m.ui.addWizard.inputMode = InputModeText
m.ui.addWizard.textInput = "808"
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace}
keyMsg := tea.KeyPressMsg{Code: tea.KeyBackspace}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, "80", m.ui.addWizard.textInput)
}
@@ -325,7 +325,7 @@ func TestHandleAddWizardKeys_Backspace_SearchFilter(t *testing.T) {
m := newModelWithWizard(StepSelectContext)
m.ui.addWizard.searchFilter = "my"
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace}
keyMsg := tea.KeyPressMsg{Code: tea.KeyBackspace}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, "m", m.ui.addWizard.searchFilter)
}
@@ -334,7 +334,7 @@ func TestHandleAddWizardKeys_Backspace_EmptyFilter_NoOp(t *testing.T) {
m := newModelWithWizard(StepSelectContext)
m.ui.addWizard.searchFilter = ""
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace}
keyMsg := tea.KeyPressMsg{Code: tea.KeyBackspace}
// Must not panic.
m.handleAddWizardKeys(keyMsg)
}
@@ -345,7 +345,7 @@ func TestHandleAddWizardKeys_TypeCharAddsToSearchFilter(t *testing.T) {
m := newModelWithWizard(StepSelectContext)
// Context step is filterable in list mode.
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("p")}
keyMsg := tea.KeyPressMsg{Text: "p"}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, "p", m.ui.addWizard.searchFilter)
}
@@ -356,7 +356,7 @@ func TestHandleAddWizardKeys_TypeCharInTextMode(t *testing.T) {
m := newModelWithWizard(StepEnterRemotePort)
m.ui.addWizard.inputMode = InputModeText
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("8")}
keyMsg := tea.KeyPressMsg{Text: "8"}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, "8", m.ui.addWizard.textInput)
}
@@ -377,7 +377,7 @@ func TestHandleAddWizardEnter_SelectContext(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
ui.mu.RLock()
@@ -393,7 +393,7 @@ func TestHandleAddWizardEnter_SelectContext_Loading_NoOp(t *testing.T) {
m := newModelWithWizard(StepSelectContext)
m.ui.addWizard.loading = true
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
assert.Nil(t, cmd)
}
@@ -405,7 +405,7 @@ func TestHandleAddWizardEnter_SelectNamespace(t *testing.T) {
m.ui.addWizard.namespaces = []string{"default", "kube-system"}
m.ui.addWizard.cursor = 1 // kube-system
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepSelectResourceType, m.ui.addWizard.step)
@@ -428,7 +428,7 @@ func TestHandleAddWizardEnter_SelectResourceType_Service(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
ui.mu.RLock()
@@ -454,7 +454,7 @@ func TestHandleAddWizardEnter_SelectResourceType_PodPrefix(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
ui.mu.RLock()
@@ -477,7 +477,7 @@ func TestHandleAddWizardEnter_EnterResource_PodPrefix(t *testing.T) {
Containers: []k8s.ContainerInfo{{Name: "main", Ports: []k8s.PortInfo{{Port: 8080}}}},
}}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterRemotePort, m.ui.addWizard.step)
@@ -490,7 +490,7 @@ func TestHandleAddWizardEnter_EnterResource_PodPrefix_EmptyInput_NoOp(t *testing
m.ui.addWizard.inputMode = InputModeText
m.ui.addWizard.textInput = ""
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterResource, m.ui.addWizard.step) // should not advance
@@ -505,7 +505,7 @@ func TestHandleAddWizardEnter_EnterResource_PodSelector(t *testing.T) {
m.ui.addWizard.textInput = "app=my"
m.ui.addWizard.matchingPods = []k8s.PodInfo{{Name: "my-pod"}}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterRemotePort, m.ui.addWizard.step)
@@ -518,7 +518,7 @@ func TestHandleAddWizardEnter_EnterResource_PodSelector_NoMatchingPods_NoOp(t *t
m.ui.addWizard.textInput = "app=my"
m.ui.addWizard.matchingPods = nil
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterResource, m.ui.addWizard.step)
@@ -535,7 +535,7 @@ func TestHandleAddWizardEnter_EnterResource_Service(t *testing.T) {
}
m.ui.addWizard.cursor = 0
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterRemotePort, m.ui.addWizard.step)
@@ -549,7 +549,7 @@ func TestHandleAddWizardEnter_RemotePort_TextMode_ValidPort(t *testing.T) {
m.ui.addWizard.inputMode = InputModeText
m.ui.addWizard.textInput = "8080"
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterLocalPort, m.ui.addWizard.step)
@@ -561,7 +561,7 @@ func TestHandleAddWizardEnter_RemotePort_TextMode_InvalidPort(t *testing.T) {
m.ui.addWizard.inputMode = InputModeText
m.ui.addWizard.textInput = "invalid"
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterRemotePort, m.ui.addWizard.step)
@@ -577,7 +577,7 @@ func TestHandleAddWizardEnter_RemotePort_ListMode_SelectPort(t *testing.T) {
}
m.ui.addWizard.cursor = 1 // 8080 → TargetPort 9090
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterLocalPort, m.ui.addWizard.step)
@@ -590,7 +590,7 @@ func TestHandleAddWizardEnter_RemotePort_ListMode_ManualEntry(t *testing.T) {
m.ui.addWizard.detectedPorts = []k8s.PortInfo{{Port: 80}}
m.ui.addWizard.cursor = 1 // "Manual entry" option
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, InputModeText, m.ui.addWizard.inputMode)
@@ -612,7 +612,7 @@ func TestHandleAddWizardEnter_LocalPort_ValidPort(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
// loading set, cmd generated.
@@ -627,7 +627,7 @@ func TestHandleAddWizardEnter_LocalPort_InvalidPort(t *testing.T) {
m := newModelWithWizard(StepEnterLocalPort)
m.ui.addWizard.textInput = "0" // invalid (must be > 0)
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.NotNil(t, m.ui.addWizard.error)
@@ -639,7 +639,7 @@ func TestHandleAddWizardEnter_Confirmation_FocusAlias_MoveToButtons(t *testing.T
m := newModelWithWizard(StepConfirmation)
m.ui.addWizard.confirmationFocus = FocusAlias
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, FocusButtons, m.ui.addWizard.confirmationFocus)
@@ -650,7 +650,7 @@ func TestHandleAddWizardEnter_Confirmation_CancelButton(t *testing.T) {
m.ui.addWizard.confirmationFocus = FocusButtons
m.ui.addWizard.cursor = 1 // Cancel
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
assert.NotNil(t, cmd) // tea.ClearScreen
@@ -667,7 +667,7 @@ func TestHandleAddWizardEnter_Confirmation_PortNotAvailable_Error(t *testing.T)
m.ui.addWizard.portAvailable = false
m.ui.addWizard.localPort = 8080
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.NotNil(t, m.ui.addWizard.error)
@@ -697,7 +697,7 @@ func TestHandleAddWizardEnter_Confirmation_Save_NewForward(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
require.NotNil(t, cmd)
@@ -731,7 +731,7 @@ func TestHandleAddWizardEnter_Confirmation_Save_NewForward_WithHTTPLog(t *testin
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
require.NotNil(t, cmd)
msg := cmd()
@@ -767,7 +767,7 @@ func TestHandleAddWizardEnter_Confirmation_Update_EditMode(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
require.NotNil(t, cmd)
msg := cmd()
@@ -790,7 +790,7 @@ func TestHandleAddWizardEnter_Success_AddAnother(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
// A cmd (loadContextsCmd) must be returned.
@@ -801,7 +801,7 @@ func TestHandleAddWizardEnter_Success_ReturnToMain(t *testing.T) {
m := newModelWithWizard(StepSuccess)
m.ui.addWizard.cursor = 1 // "Return to main"
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
assert.NotNil(t, cmd)
@@ -828,7 +828,7 @@ func TestHandleAddWizardKeys_SelectorValidation_OnChar(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Typing a char in PodSelector step should return a validation cmd.
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("-")}
keyMsg := tea.KeyPressMsg{Text: "-"}
_, cmd := m.handleAddWizardKeys(keyMsg)
// A cmd is returned (validateSelectorCmd); we don't invoke it as it needs a real cluster.
@@ -849,7 +849,7 @@ func TestHandleRemoveWizardKeys_SpaceToggle(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeySpace}
keyMsg := tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}
m.handleRemoveWizardKeys(keyMsg)
ui.mu.RLock()
@@ -874,7 +874,7 @@ func TestHandleRemoveWizardKeys_SelectAll(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}
keyMsg := tea.KeyPressMsg{Text: "a"}
m.handleRemoveWizardKeys(keyMsg)
ui.mu.RLock()
@@ -895,7 +895,7 @@ func TestHandleRemoveWizardKeys_SelectNone(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}
keyMsg := tea.KeyPressMsg{Text: "n"}
m.handleRemoveWizardKeys(keyMsg)
ui.mu.RLock()
@@ -915,7 +915,7 @@ func TestHandleRemoveWizardKeys_CtrlC_ExitsAlways(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyCtrlC}
keyMsg := tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}
m.handleRemoveWizardKeys(keyMsg)
ui.mu.RLock()
@@ -935,7 +935,7 @@ func TestHandleRemoveWizardKeys_Enter_NothingSelected_NoOp(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleRemoveWizardKeys(keyMsg)
assert.Nil(t, cmd)
@@ -955,7 +955,7 @@ func TestHandleRemoveWizardKeys_Enter_ShowConfirmation(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleRemoveWizardKeys(keyMsg)
ui.mu.RLock()
@@ -976,7 +976,7 @@ func TestHandleRemoveWizardKeys_Enter_ConfirmNo(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleRemoveWizardKeys(keyMsg)
assert.Nil(t, cmd)
@@ -997,13 +997,13 @@ func TestHandleRemoveWizardKeys_Navigation_InSelection(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyDown}
keyMsg := tea.KeyPressMsg{Code: tea.KeyDown}
m.handleRemoveWizardKeys(keyMsg)
ui.mu.RLock()
assert.Equal(t, 1, ui.removeWizard.cursor)
ui.mu.RUnlock()
keyMsg = tea.KeyMsg{Type: tea.KeyUp}
keyMsg = tea.KeyPressMsg{Code: tea.KeyUp}
m.handleRemoveWizardKeys(keyMsg)
ui.mu.RLock()
assert.Equal(t, 0, ui.removeWizard.cursor)
@@ -1017,7 +1017,7 @@ func TestHandleBenchmarkKeys_Tab(t *testing.T) {
m.ui.benchmarkState.step = BenchmarkStepConfig
m.ui.benchmarkState.cursor = 0
keyMsg := tea.KeyMsg{Type: tea.KeyTab}
keyMsg := tea.KeyPressMsg{Code: tea.KeyTab}
m.handleBenchmarkKeys(keyMsg)
assert.Equal(t, 1, m.ui.benchmarkState.cursor)
@@ -1028,7 +1028,7 @@ func TestHandleBenchmarkKeys_Tab_Wraps(t *testing.T) {
m.ui.benchmarkState.step = BenchmarkStepConfig
m.ui.benchmarkState.cursor = 3
keyMsg := tea.KeyMsg{Type: tea.KeyTab}
keyMsg := tea.KeyPressMsg{Code: tea.KeyTab}
m.handleBenchmarkKeys(keyMsg)
assert.Equal(t, 0, m.ui.benchmarkState.cursor)
@@ -1040,7 +1040,7 @@ func TestHandleBenchmarkKeys_Backspace(t *testing.T) {
m.ui.benchmarkState.cursor = 0
m.ui.benchmarkState.textInput = "/api"
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace}
keyMsg := tea.KeyPressMsg{Code: tea.KeyBackspace}
m.handleBenchmarkKeys(keyMsg)
assert.Equal(t, "/ap", m.ui.benchmarkState.textInput)
@@ -1053,7 +1053,7 @@ func TestHandleBenchmarkKeys_TypeChar_UpdatesField(t *testing.T) {
m.ui.benchmarkState.cursor = 1 // Method field
m.ui.benchmarkState.textInput = "GE"
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("T")}
keyMsg := tea.KeyPressMsg{Text: "T"}
m.handleBenchmarkKeys(keyMsg)
assert.Equal(t, "GET", m.ui.benchmarkState.method)
@@ -1063,7 +1063,7 @@ func TestHandleBenchmarkKeys_Enter_ResultsStep_ReturnsToMain(t *testing.T) {
m := newModelWithBenchmark()
m.ui.benchmarkState.step = BenchmarkStepResults
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleBenchmarkKeys(keyMsg)
assert.NotNil(t, cmd) // tea.ClearScreen
@@ -1077,7 +1077,7 @@ func TestHandleBenchmarkKeys_Enter_ConfigStep_StartsRunning(t *testing.T) {
m := newModelWithBenchmark()
m.ui.benchmarkState.step = BenchmarkStepConfig
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleBenchmarkKeys(keyMsg)
require.NotNil(t, cmd)
@@ -1157,7 +1157,7 @@ func TestHandleHTTPLogKeys_Detail_Esc(t *testing.T) {
m.ui.httpLogState.showingDetail = true
m.ui.httpLogState.entries = []HTTPLogEntry{{Method: "GET", Path: "/test"}}
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
m.handleHTTPLogKeys(keyMsg)
assert.False(t, m.ui.httpLogState.showingDetail)
@@ -1169,11 +1169,11 @@ func TestHandleHTTPLogKeys_Detail_UpDown(t *testing.T) {
m.ui.httpLogState.detailScroll = 5
m.ui.httpLogState.entries = []HTTPLogEntry{{Method: "GET"}}
keyMsg := tea.KeyMsg{Type: tea.KeyUp}
keyMsg := tea.KeyPressMsg{Code: tea.KeyUp}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 4, m.ui.httpLogState.detailScroll)
keyMsg = tea.KeyMsg{Type: tea.KeyDown}
keyMsg = tea.KeyPressMsg{Code: tea.KeyDown}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 5, m.ui.httpLogState.detailScroll)
}
@@ -1184,11 +1184,11 @@ func TestHandleHTTPLogKeys_Detail_PgUpDown(t *testing.T) {
m.ui.httpLogState.detailScroll = 25
m.ui.httpLogState.entries = []HTTPLogEntry{{Method: "GET"}}
keyMsg := tea.KeyMsg{Type: tea.KeyPgUp}
keyMsg := tea.KeyPressMsg{Code: tea.KeyPgUp}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 5, m.ui.httpLogState.detailScroll)
keyMsg = tea.KeyMsg{Type: tea.KeyPgDown}
keyMsg = tea.KeyPressMsg{Code: tea.KeyPgDown}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 25, m.ui.httpLogState.detailScroll)
}
@@ -1199,7 +1199,7 @@ func TestHandleHTTPLogKeys_Detail_GoToTop(t *testing.T) {
m.ui.httpLogState.detailScroll = 99
m.ui.httpLogState.entries = []HTTPLogEntry{{Method: "GET"}}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("g")}
keyMsg := tea.KeyPressMsg{Text: "g"}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 0, m.ui.httpLogState.detailScroll)
@@ -1213,7 +1213,7 @@ func TestHandleHTTPLogKeys_Enter_ShowDetail(t *testing.T) {
m.ui.httpLogState.entries = []HTTPLogEntry{{Method: "GET", Path: "/test", StatusCode: 200}}
m.ui.httpLogState.cursor = 0
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleHTTPLogKeys(keyMsg)
assert.True(t, m.ui.httpLogState.showingDetail)
@@ -1227,11 +1227,11 @@ func TestHandleHTTPLogKeys_Navigate_UpDown(t *testing.T) {
}
m.ui.httpLogState.cursor = 1
keyMsg := tea.KeyMsg{Type: tea.KeyUp}
keyMsg := tea.KeyPressMsg{Code: tea.KeyUp}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 0, m.ui.httpLogState.cursor)
keyMsg = tea.KeyMsg{Type: tea.KeyDown}
keyMsg = tea.KeyPressMsg{Code: tea.KeyDown}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 1, m.ui.httpLogState.cursor)
}
@@ -1243,7 +1243,7 @@ func TestHandleHTTPLogKeys_Down_AtBottom_EnablesAutoScroll(t *testing.T) {
m.ui.httpLogState.cursor = 0
m.ui.httpLogState.autoScroll = false
keyMsg := tea.KeyMsg{Type: tea.KeyDown}
keyMsg := tea.KeyPressMsg{Code: tea.KeyDown}
m.handleHTTPLogKeys(keyMsg)
assert.True(t, m.ui.httpLogState.autoScroll)
@@ -1257,7 +1257,7 @@ func TestHandleHTTPLogKeys_GoToTop_G(t *testing.T) {
}
m.ui.httpLogState.cursor = 2
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("g")}
keyMsg := tea.KeyPressMsg{Text: "g"}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 0, m.ui.httpLogState.cursor)
@@ -1272,7 +1272,7 @@ func TestHandleHTTPLogKeys_GoToBottom_CapitalG(t *testing.T) {
}
m.ui.httpLogState.cursor = 0
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("G")}
keyMsg := tea.KeyPressMsg{Text: "G"}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 2, m.ui.httpLogState.cursor)
@@ -1283,7 +1283,7 @@ func TestHandleHTTPLogKeys_ToggleAutoScroll(t *testing.T) {
m := newModelWithHTTPLog()
m.ui.httpLogState.autoScroll = false
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}
keyMsg := tea.KeyPressMsg{Text: "a"}
m.handleHTTPLogKeys(keyMsg)
assert.True(t, m.ui.httpLogState.autoScroll)
@@ -1301,12 +1301,12 @@ func TestHandleHTTPLogKeys_PgUpDown_List(t *testing.T) {
m.ui.httpLogState.entries = entries
m.ui.httpLogState.cursor = 25
keyMsg := tea.KeyMsg{Type: tea.KeyPgUp}
keyMsg := tea.KeyPressMsg{Code: tea.KeyPgUp}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 5, m.ui.httpLogState.cursor)
assert.False(t, m.ui.httpLogState.autoScroll)
keyMsg = tea.KeyMsg{Type: tea.KeyPgDown}
keyMsg = tea.KeyPressMsg{Code: tea.KeyPgDown}
m.handleHTTPLogKeys(keyMsg)
// PgDown +20 from 5 = 25; index 25 is in range (30 items, indices 0-29).
assert.Equal(t, 25, m.ui.httpLogState.cursor)
@@ -1319,7 +1319,7 @@ func TestHandleHTTPLogKeys_FilterActive_Typing(t *testing.T) {
m.ui.httpLogState.filterActive = true
m.ui.httpLogState.filterText = "te"
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("s")}
keyMsg := tea.KeyPressMsg{Text: "s"}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, "tes", m.ui.httpLogState.filterText)
@@ -1330,7 +1330,7 @@ func TestHandleHTTPLogKeys_FilterActive_Backspace(t *testing.T) {
m.ui.httpLogState.filterActive = true
m.ui.httpLogState.filterText = "tes"
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace}
keyMsg := tea.KeyPressMsg{Code: tea.KeyBackspace}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, "te", m.ui.httpLogState.filterText)
@@ -1341,7 +1341,7 @@ func TestHandleHTTPLogKeys_FilterActive_EscClearsFilter(t *testing.T) {
m.ui.httpLogState.filterActive = true
m.ui.httpLogState.filterText = "test"
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
m.handleHTTPLogKeys(keyMsg)
assert.False(t, m.ui.httpLogState.filterActive)
@@ -1353,7 +1353,7 @@ func TestHandleHTTPLogKeys_FilterActive_EnterConfirms(t *testing.T) {
m.ui.httpLogState.filterActive = true
m.ui.httpLogState.filterText = "api"
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleHTTPLogKeys(keyMsg)
assert.False(t, m.ui.httpLogState.filterActive)
+4 -4
View File
@@ -4,7 +4,7 @@ import (
"errors"
"testing"
tea "github.com/charmbracelet/bubbletea"
tea "charm.land/bubbletea/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -12,7 +12,7 @@ import (
func typeKeys(t *testing.T, m model, text string) {
t.Helper()
for _, r := range text {
m.handleAddWizardKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
m.handleAddWizardKeys(tea.KeyPressMsg{Text: string(r)})
}
}
@@ -87,7 +87,7 @@ func TestManualNamespace_BackFromLaterStepRestoresTextInput(t *testing.T) {
m := newModelWithWizard(StepSelectResourceType)
m.ui.addWizard.namespaceManual = true
m.handleAddWizardKeys(tea.KeyMsg{Type: tea.KeyEsc})
m.handleAddWizardKeys(tea.KeyPressMsg{Code: tea.KeyEscape})
w := m.ui.addWizard
assert.Equal(t, StepSelectNamespace, w.step)
@@ -98,7 +98,7 @@ func TestKeysJK_StillNavigateInListMode(t *testing.T) {
m := newModelWithWizard(StepSelectResourceType)
m.ui.addWizard.inputMode = InputModeList
m.handleAddWizardKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
m.handleAddWizardKeys(tea.KeyPressMsg{Text: string('j')})
assert.Equal(t, 1, m.ui.addWizard.cursor)
}
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
)
// Color palette for wizards
+9 -9
View File
@@ -987,7 +987,7 @@ func TestRenderDeleteConfirmation_NoSelected(t *testing.T) {
func TestModelView_MainViewDefault(t *testing.T) {
m := newTestModel()
result := m.View()
result := m.View().Content
assert.NotEmpty(t, result)
}
@@ -1001,7 +1001,7 @@ func TestModelView_DeleteConfirmationOverlay(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
result := m.View()
result := m.View().Content
assert.Contains(t, result, "Delete Port Forward")
}
@@ -1013,7 +1013,7 @@ func TestModelView_AddWizardOverlay(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
result := m.View()
result := m.View().Content
assert.NotEmpty(t, result)
}
@@ -1028,7 +1028,7 @@ func TestModelView_RemoveWizardOverlay(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
result := m.View()
result := m.View().Content
assert.Contains(t, result, "alpha")
}
@@ -1040,7 +1040,7 @@ func TestModelView_BenchmarkOverlay(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
result := m.View()
result := m.View().Content
assert.NotEmpty(t, result)
}
@@ -1052,7 +1052,7 @@ func TestModelView_HTTPLogFullScreen(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
result := m.View()
result := m.View().Content
assert.Contains(t, result, "HTTP Traffic Log")
}
@@ -1061,7 +1061,7 @@ func TestModelView_ZeroTermSize(t *testing.T) {
m.termWidth = 0
m.termHeight = 0
// Must not panic even with zero term dims.
result := m.View()
result := m.View().Content
assert.NotEmpty(t, result)
}
@@ -1406,8 +1406,8 @@ func TestGetStatusIconAndText(t *testing.T) {
func TestDefaultMainViewColors(t *testing.T) {
colors := defaultMainViewColors()
assert.NotEmpty(t, string(colors.header))
assert.NotEmpty(t, string(colors.active))
assert.NotNil(t, colors.header)
assert.NotNil(t, colors.active)
}
func TestMainViewKeyBindings(t *testing.T) {