diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a1ab0d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea +*.iml +vscode +.DS_Store \ No newline at end of file diff --git a/tui/README.md b/tui/README.md new file mode 100644 index 0000000..ff0c27b --- /dev/null +++ b/tui/README.md @@ -0,0 +1,57 @@ +# OCM TUI + +An interactive terminal UI for the [Open Component Model](https://ocm.software). + +## Features + +- **Explore** -- Browse component versions, resources, sources, references, and signatures in an interactive tree view with a detail pane. Download resources directly to disk. +- **Transfer** -- Step-by-step wizard to transfer component versions between repositories with option selection and transformation graph review. +- **Command Palette** -- Press `:` from anywhere to quickly switch between views, return to the menu, or quit. + +## Getting Started + +```bash +go build -o ocm-tui ./cmd +./ocm-tui +``` + +Requires an interactive terminal. The TUI loads OCM configuration and credentials from the same locations as the `ocm` CLI (`~/.ocm/config`, `~/.ocmconfig`, `$OCM_CONFIG`, Docker config). + +## Key Bindings + +| Key | Action | +|-----|--------| +| `j` / `k` / arrows | Navigate | +| `enter` | Select / expand | +| `esc` | Back (collapse node, previous wizard step, or exit view) | +| `tab` | Switch pane (tree / detail) | +| `:` | Open command palette | +| `ctrl+c` | Quit | + +### Explorer + +| Key | Action | +|-----|--------| +| `d` | Download selected resource (modal with progress) | +| `t` | Transfer selected component version (opens transfer wizard with source pre-filled) | + +### Transfer + +| Key | Action | +|-----|--------| +| `space` / `enter` | Toggle option / proceed | +| `esc` | Go back one step | + +## Configuration + +The TUI reads OCM configuration from these locations (in order): + +1. `$OCM_CONFIG` environment variable +2. `$HOME/.config/.ocm/config` or `$HOME/.config/.ocmconfig` +3. `$HOME/.ocm/config` or `$HOME/.ocmconfig` + +Plugins are loaded from `$HOME/.ocm/plugins`. + +## License + +Apache-2.0 diff --git a/tui/app.go b/tui/app.go new file mode 100644 index 0000000..df24fd8 --- /dev/null +++ b/tui/app.go @@ -0,0 +1,244 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "ext.ocm.software/tui/internal/components" + "ext.ocm.software/tui/internal/components/menu" + "ext.ocm.software/tui/internal/components/palette" + "ext.ocm.software/tui/internal/explorer" + "ext.ocm.software/tui/internal/theme" +) + +// Config holds the menu items for the TUI application. +type Config struct { + MenuItems []MenuItem +} + +// App is the root bubbletea model. +type App struct { + config Config + keys KeyMap + + // Menu + menu menu.Menu + + // Active view (nil when showing menu) + activeView View + activeMenuIndex int + + // Command palette + palette palette.Model + + // Layout + width int + height int + ready bool +} + +// NewApp creates the root TUI application model. +func NewApp(cfg Config) App { + // Build menu items. + var labels []string + for _, item := range cfg.MenuItems { + labels = append(labels, item.Label) + } + + // Build palette entries. + var entries []palette.Entry + for i, item := range cfg.MenuItems { + entries = append(entries, palette.Entry{Label: item.Label, ID: string(rune('0' + i))}) + } + entries = append(entries, palette.Entry{Label: "Menu (go back)", ID: "menu"}) + entries = append(entries, palette.Entry{Label: "Quit", ID: "quit"}) + + return App{ + config: cfg, + keys: DefaultKeyMap(), + menu: menu.New(labels...), + palette: palette.New(entries), + activeMenuIndex: -1, + } +} + +func (a App) Init() tea.Cmd { + return nil +} + +func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Window resize. + if wsm, ok := msg.(tea.WindowSizeMsg); ok { + a.width = wsm.Width + a.height = wsm.Height + a.ready = true + if a.activeView != nil { + a.activeView.Resize(wsm.Width, wsm.Height-2) + } + return a, nil + } + + // ctrl+c always quits. + if km, ok := msg.(tea.KeyMsg); ok && km.String() == "ctrl+c" { + return a, tea.Quit + } + + // Palette messages (selection results). + switch msg := msg.(type) { + case palette.SelectedMsg: + return a.handlePaletteSelection(msg.Entry) + case palette.ClosedMsg: + return a, nil + } + + // Palette input handling. + if a.palette.IsOpen() { + cmd := a.palette.Update(msg) + return a, cmd + } + + // ":" opens command palette. + if km, ok := msg.(tea.KeyMsg); ok && key.Matches(km, a.keys.Command) { + cursorAt := 0 + if a.activeMenuIndex >= 0 { + cursorAt = a.activeMenuIndex + } + cmd := a.palette.Open(cursorAt) + return a, cmd + } + + // Active view: esc -> OnBackPressed. + if a.activeView != nil { + if km, ok := msg.(tea.KeyMsg); ok && km.Type == tea.KeyEsc { + if a.activeView.OnBackPressed() == BackExit { + a.activeView = nil + a.activeMenuIndex = -1 + return a, nil + } + return a, nil + } + + // Cross-view: explorer -> transfer. + if trMsg, ok := msg.(explorer.TransferRequestMsg); ok { + return a.handleTransferRequest(trMsg) + } + + cmd := a.activeView.Update(msg) + return a, cmd + } + + // Menu mode. + if km, ok := msg.(tea.KeyMsg); ok && km.String() == "q" { + return a, tea.Quit + } + + return a.updateMenu(msg) +} + +func (a App) handlePaletteSelection(entry palette.Entry) (tea.Model, tea.Cmd) { + switch entry.ID { + case "quit": + return a, tea.Quit + case "menu": + a.activeView = nil + a.activeMenuIndex = -1 + return a, nil + default: + // Menu item by index. + for i, item := range a.config.MenuItems { + if entry.Label == item.Label { + a.activeView = item.NewView(a.width, a.height-2) + a.activeMenuIndex = i + cmd := a.activeView.Init() + return a, cmd + } + } + } + return a, nil +} + +func (a App) handleTransferRequest(msg explorer.TransferRequestMsg) (tea.Model, tea.Cmd) { + // Build the full source reference: "repo//component:version" + source := fmt.Sprintf("%s:%s", msg.Component, msg.Version) + if msg.Reference != "" { + if idx := strings.Index(msg.Reference, "//"); idx >= 0 { + repoPrefix := msg.Reference[:idx] + source = fmt.Sprintf("%s//%s:%s", repoPrefix, msg.Component, msg.Version) + } + } + + for i, item := range a.config.MenuItems { + if strings.Contains(item.Label, "Transfer") { + view := item.NewView(a.width, a.height-2) + // Pre-fill source and skip to target step. + if tv, ok := view.(interface{ SetSource(string) }); ok { + tv.SetSource(source) + } + a.activeView = view + a.activeMenuIndex = i + cmd := a.activeView.Init() + return a, cmd + } + } + return a, nil +} + +func (a App) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return a, nil + } + + if a.menu.Update(keyMsg) { + idx := a.menu.Selected() + if idx < len(a.config.MenuItems) { + item := a.config.MenuItems[idx] + a.activeView = item.NewView(a.width, a.height-2) + a.activeMenuIndex = idx + cmd := a.activeView.Init() + return a, cmd + } + } + + return a, nil +} + +func (a App) View() string { + if !a.ready { + return "Initializing..." + } + + if a.palette.IsOpen() { + return a.palette.View(a.width, a.height) + } + + if a.activeView != nil { + layout := components.Layout{ + Title: "ocm tui", + StatusInfo: a.activeView.StatusInfo(), + Content: a.activeView.Render(), + Hotkeys: a.activeView.Hotkeys(), + Width: a.width, + Height: a.height, + } + return layout.Render() + } + + return a.viewMenu() +} + +func (a App) viewMenu() string { + t := theme.Current() + + var sections []string + sections = append(sections, t.Title.MarginBottom(2).Render("OCM TUI")) + sections = append(sections, a.menu.View()) + sections = append(sections, t.Help.MarginTop(2).Render("j/k: navigate enter: select :: command palette q: quit")) + + content := lipgloss.JoinVertical(lipgloss.Left, sections...) + return lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, content) +} diff --git a/tui/cmd/main.go b/tui/cmd/main.go new file mode 100644 index 0000000..2e5650a --- /dev/null +++ b/tui/cmd/main.go @@ -0,0 +1,86 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "runtime/debug" + "time" + + tea "github.com/charmbracelet/bubbletea" + "golang.org/x/term" + + tui "ext.ocm.software/tui" + "ext.ocm.software/tui/internal/explorer" + ocmboot "ext.ocm.software/tui/internal/ocm" + "ext.ocm.software/tui/internal/transfer" +) + +func main() { + if !term.IsTerminal(int(os.Stdout.Fd())) { + fmt.Fprintln(os.Stderr, "ocm-tui requires an interactive terminal") + os.Exit(1) + } + + f, err := tea.LogToFile("tui-debug.log", "ocm-tui") + if err != nil { + log.Fatalf("could not open log file: %v", err) + } + defer f.Close() + + defer func() { + if r := recover(); r != nil { + stack := string(debug.Stack()) + log.Printf("PANIC: %v\n%s", r, stack) + fmt.Fprintf(os.Stderr, "\nocm-tui crashed. Details logged to tui-debug.log\n\nPanic: %v\n\n%s\n", r, stack) + os.Exit(1) + } + }() + + ctx := context.Background() + rt, err := ocmboot.Bootstrap(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to initialize OCM runtime: %v\n", err) + os.Exit(1) + } + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := rt.Shutdown(shutdownCtx); err != nil { + log.Printf("plugin shutdown error: %v", err) + } + }() + + fetcherFactory := ocmboot.NewFetcherFactory(rt) + downloader := ocmboot.NewResourceDownloader(rt) + transferExec := ocmboot.NewTransferExecutor(rt) + + cfg := tui.Config{ + MenuItems: []tui.MenuItem{ + { + Label: "Explore components", + NewView: func(w, h int) tui.View { + return explorer.NewView(explorer.Config{ + FetcherFactory: fetcherFactory, + Downloader: downloader, + }, w, h) + }, + }, + { + Label: "Transfer component versions", + NewView: func(w, h int) tui.View { + return transfer.NewView(transfer.Config{ + Executor: transferExec, + }, w, h) + }, + }, + }, + } + + p := tea.NewProgram(tui.NewApp(cfg), tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} diff --git a/tui/doc.go b/tui/doc.go new file mode 100644 index 0000000..1a323d3 --- /dev/null +++ b/tui/doc.go @@ -0,0 +1,3 @@ +// Package tui provides an interactive terminal user interface for exploring +// OCM component versions using the Bubble Tea framework. +package tui diff --git a/tui/docs/architecture.md b/tui/docs/architecture.md new file mode 100644 index 0000000..96675f6 --- /dev/null +++ b/tui/docs/architecture.md @@ -0,0 +1,136 @@ +# Architecture + +## Overview + +The TUI is built with [Bubble Tea](https://github.com/charmbracelet/bubbletea) and follows a component-based architecture. Each screen is a self-contained **View** that composes from shared **Components** and references a centralized **Theme**. + +``` +cmd/main.go Entry point: bootstraps OCM runtime, wires views + +app.go Root Bubble Tea model: menu, view lifecycle, command palette +view.go View interface + MenuItem +keymap.go Global key bindings +fetch/ Public interface definitions + +internal/ + theme/ Centralized styling + components/ Reusable UI primitives + explorer/ Component explorer view + transfer/ Transfer wizard view + ocm/ OCM runtime bootstrap and wiring +``` + +All implementation details live under `internal/`. The public surface is minimal: the root `tui` package (`App`, `Config`, `MenuItem`, `View`) and the `fetch` package (interfaces). + +## View Interface + +Every top-level screen implements the `View` interface defined in `view.go`: + +```go +type View interface { + Init() tea.Cmd + Update(msg tea.Msg) tea.Cmd + Render() string + StatusInfo() string + Hotkeys() []components.Hotkey + OnBackPressed() int + Resize(width, height int) +} +``` + +Views use **pointer receivers** so `Update` mutates in place. This avoids a circular dependency: view packages satisfy the interface via structural typing without importing the root `tui` package. + +The root `app.go` stores the active view as a `View` interface value and delegates all messages to it. Views are registered as `MenuItem` closures -- the app never imports view packages directly. + +## Navigation Model + +Navigation follows a simple pattern inspired by Android's back stack: + +- **`esc`** is intercepted by the app and routed to `OnBackPressed()`. Each view decides what "back" means: + - Explorer: collapse the current tree node, or exit if nothing to collapse + - Transfer: go back one wizard step, or exit at the first step +- **`:`** opens the command palette from anywhere, allowing the user to switch views, go to the menu, or quit +- **`ctrl+c`** always quits + +Views never need to handle quit/back logic themselves beyond implementing `OnBackPressed`. + +## Component Library + +Reusable components live in `internal/components/`. They are stateless renderers or thin stateful wrappers that reference `theme.Current()` for all styling. + +| Component | Package | Used By | +|-----------|---------|---------| +| **Prompt** | `internal/components/input` | Explorer reference input, transfer source/target | +| **Modal** | `internal/components/modal` | Download progress dialog | +| **Menu** | `internal/components/menu` | Main menu, command palette entries | +| **SplitPane** | `internal/components/splitpane` | Explorer tree+detail layout | +| **Palette** | `internal/components/palette` | Command palette overlay | +| **Spinner** | `internal/components/progress` | Download progress animation | +| **Tree** | `internal/components/tree` | Reusable tree navigation model | +| **Layout** | `internal/components/layout` | Standard frame: title bar + content + hotkey footer | +| **StatusBar** | `internal/components/statusbar` | Top bar with title, hint, and status info | + +### Hotkey Footer + +Views declare their context-sensitive key bindings via `Hotkeys()`. The `Layout` component renders them automatically in the footer. The `:: command` hint is always appended. + +## Theme System + +All colors and styles are defined in `internal/theme/default.go` as a `Theme` struct. Components call `theme.Current()` to get the active theme. No hardcoded `lipgloss.AdaptiveColor` values appear in view code. + +To add a new theme, create a function returning `*Theme` and call `theme.SetTheme()` before starting the app. + +## Per-View File Layout + +Each view package follows this pattern: + +| File | Responsibility | +|------|----------------| +| `view.go` | Model struct, constructors, View interface methods | +| `browse.go` | Main mode: update logic, rendering, key handling | +| `prompt.go` | Input screen (if the view starts with one) | +| Additional files | Per sub-mode (e.g. `download.go`, `steps.go`, `render.go`) | +| `keymap.go` | Key binding definitions | +| `tree.go` | Tree node builders (if applicable) | + +No file exceeds ~400 lines. + +## Fetch Interfaces + +`fetch/fetch.go` defines the public interfaces that decouple the TUI from OCM internals: + +| Interface | Purpose | +|-----------|---------| +| `ComponentFetcher` | List versions, get descriptors | +| `ResourceDownloader` | Download a resource to disk | +| `FetcherFactory` | Create a fetcher from a reference string | +| `TransferExecutor` | Build and execute transfer graphs | + +Implementations live in `internal/ocm/wiring.go`. + +## OCM Runtime Bootstrap + +`internal/ocm/bootstrap.go` initializes the OCM runtime using only public bindings APIs (no CLI internal imports): + +1. **Config**: Loads from `~/.ocm/config`, `~/.ocmconfig`, or `$OCM_CONFIG` +2. **Plugin Manager**: Creates manager, discovers plugins from `~/.ocm/plugins` +3. **Builtins**: Registers OCI repository, resource, digest, blob transformer, and credential plugins +4. **Credential Graph**: Builds from config using the registered credential repository plugins + +`internal/ocm/wiring.go` creates the fetch interface implementations from the runtime, including a `repoResolver` that satisfies the transfer system's resolver interface. + +## Adding a New View + +1. Create a package under `internal/` (e.g. `internal/verify/`) +2. Define a `Config` struct for dependencies +3. Define a `Model` struct with the view state +4. Implement the `View` interface methods on `*Model` +5. Create a `NewView(cfg Config, width, height int) *Model` constructor +6. Compose UI from `internal/components/` primitives, reference `theme.Current()` +7. Register as a `MenuItem` in `cmd/main.go` + +## Cross-View Communication + +The only cross-view interaction is explorer-to-transfer (press `t` to transfer the selected component). This uses a `TransferRequestMsg` that the app intercepts. The app creates a new transfer view and calls `SetSource()` to pre-fill the source reference. + +This is handled as a pragmatic exception rather than a general mechanism. diff --git a/tui/fetch/fetch.go b/tui/fetch/fetch.go new file mode 100644 index 0000000..ed813a1 --- /dev/null +++ b/tui/fetch/fetch.go @@ -0,0 +1,58 @@ +// Package fetch defines the interfaces for retrieving OCM component data. +// Implementations are provided by the CLI layer which has access to the +// plugin manager and credential graph. +package fetch + +import ( + "context" + + descriptor "ocm.software/open-component-model/bindings/go/descriptor/runtime" + transformv1alpha1 "ocm.software/open-component-model/bindings/go/transform/spec/v1alpha1" +) + +// ComponentFetcher abstracts component version retrieval for the TUI. +type ComponentFetcher interface { + // ListVersions returns all available versions for a component. + ListVersions(ctx context.Context, component string) ([]string, error) + // GetDescriptor returns the full descriptor for a component version. + GetDescriptor(ctx context.Context, component, version string) (*descriptor.Descriptor, error) +} + +// ResourceDownloader abstracts downloading a single resource to disk. +type ResourceDownloader interface { + // DownloadResource downloads a resource from the given component version to outputDir. + // The reference is the original repository reference string (e.g. "ghcr.io/org/repo"). + // It returns the path of the downloaded file/directory. + DownloadResource(ctx context.Context, reference, component, version string, resource *descriptor.Resource, outputDir string) (string, error) +} + +// FetcherFactory creates a ComponentFetcher from a parsed component reference. +// The reference string is the raw user input (e.g. "ghcr.io/org/repo//component:version"). +// It returns the fetcher, the parsed component name, the parsed version (may be empty), +// and any error. +type FetcherFactory func(ctx context.Context, reference string) (fetcher ComponentFetcher, component string, version string, err error) + +// TransferOptions holds the user-selected options for a transfer operation. +type TransferOptions struct { + Recursive bool + CopyResources bool + UploadAs string // "default", "localBlob", "ociArtifact" +} + +// TransferExecutor abstracts the transfer workflow for the TUI. +type TransferExecutor interface { + // BuildGraph builds a transformation graph definition for review. + BuildGraph(ctx context.Context, source, target string, opts TransferOptions) (*transformv1alpha1.TransformationGraphDefinition, error) + // Execute runs a previously built transformation graph. + Execute(ctx context.Context, tgd *transformv1alpha1.TransformationGraphDefinition, progress chan<- TransferProgress) error +} + +// TransferProgress reports the progress of a transfer execution. +type TransferProgress struct { + Step string + Total int + Current int + Done bool + Err error + IsLog bool // true if this is a slog message rather than a progress event +} diff --git a/tui/go.mod b/tui/go.mod new file mode 100644 index 0000000..0d86dbf --- /dev/null +++ b/tui/go.mod @@ -0,0 +1,158 @@ +module ext.ocm.software/tui + +go 1.26.1 + +require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + golang.org/x/term v0.42.0 + gopkg.in/yaml.v3 v3.0.1 + ocm.software/open-component-model/bindings/go/blob v0.0.12 + ocm.software/open-component-model/bindings/go/configuration v0.0.12 + ocm.software/open-component-model/bindings/go/credentials v0.0.9 + ocm.software/open-component-model/bindings/go/descriptor/runtime v0.0.0-20260421090214-21aeadb8fa7b + ocm.software/open-component-model/bindings/go/descriptor/v2 v2.0.3-alpha2 + ocm.software/open-component-model/bindings/go/oci v0.0.39 + ocm.software/open-component-model/bindings/go/plugin v0.0.14 + ocm.software/open-component-model/bindings/go/repository v0.0.8 + ocm.software/open-component-model/bindings/go/runtime v0.0.7 + ocm.software/open-component-model/bindings/go/transfer v0.0.0-20260407102747-5b24debae3cf + ocm.software/open-component-model/bindings/go/transform v0.0.0-20260422070750-479394eef5a7 +) + +require ( + cel.dev/expr v0.25.1 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/MakeNowJust/heredoc v1.0.0 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/ProtonMail/go-crypto v1.4.1 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/buger/jsonparser v1.1.2 // indirect + github.com/chai2010/gettext-go v1.0.3 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect + github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect + github.com/extism/go-sdk v1.7.1 // indirect + github.com/fluxcd/cli-utils v0.37.2-flux.1 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-errors/errors v1.5.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.22.5 // indirect + github.com/go-openapi/jsonreference v0.21.5 // indirect + github.com/go-openapi/swag v0.25.5 // indirect + github.com/go-openapi/swag/cmdutils v0.25.5 // indirect + github.com/go-openapi/swag/conv v0.25.5 // indirect + github.com/go-openapi/swag/fileutils v0.25.5 // indirect + github.com/go-openapi/swag/jsonname v0.25.5 // indirect + github.com/go-openapi/swag/jsonutils v0.25.5 // indirect + github.com/go-openapi/swag/loading v0.25.5 // indirect + github.com/go-openapi/swag/mangling v0.25.5 // indirect + github.com/go-openapi/swag/netutils v0.25.5 // indirect + github.com/go-openapi/swag/stringutils v0.25.5 // indirect + github.com/go-openapi/swag/typeutils v0.25.5 // indirect + github.com/go-openapi/swag/yamlutils v0.25.5 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/gofrs/flock v0.13.0 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/cel-go v0.27.0 // indirect + github.com/google/gnostic-models v0.7.1 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect + github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mailru/easyjson v0.9.2 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.21 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/moby/term v0.5.2 // 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/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // 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/nlepage/go-tarfs v1.2.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/peterbourgon/diskv v2.0.1+incompatible // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect + github.com/tetratelabs/wazero v1.11.0 // indirect + github.com/veqryn/slog-context v0.9.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/xlab/treeprint v1.2.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/time v0.15.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + helm.sh/helm/v4 v4.1.4 // indirect + k8s.io/api v0.35.3 // indirect + k8s.io/apiextensions-apiserver v0.35.3 // indirect + k8s.io/apimachinery v0.35.3 // indirect + k8s.io/cli-runtime v0.35.3 // indirect + k8s.io/client-go v0.35.3 // indirect + k8s.io/component-base v0.35.3 // indirect + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // indirect + k8s.io/kubectl v0.35.3 // indirect + k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 // indirect + ocm.software/open-component-model/bindings/go/cel v0.0.0-20260421090214-21aeadb8fa7b // indirect + ocm.software/open-component-model/bindings/go/constructor v0.0.7 // indirect + ocm.software/open-component-model/bindings/go/ctf v0.4.0 // indirect + ocm.software/open-component-model/bindings/go/dag v0.0.6 // indirect + ocm.software/open-component-model/bindings/go/descriptor/normalisation v0.0.0-20260421090214-21aeadb8fa7b // indirect + ocm.software/open-component-model/bindings/go/helm v0.0.0-20260407102747-5b24debae3cf // indirect + ocm.software/open-component-model/bindings/go/signing v0.0.0-20260421090214-21aeadb8fa7b // indirect + oras.land/oras-go/v2 v2.6.0 // indirect + sigs.k8s.io/controller-runtime v0.23.3 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/kustomize/api v0.21.1 // indirect + sigs.k8s.io/kustomize/kyaml v0.21.1 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/tui/go.sum b/tui/go.sum new file mode 100644 index 0000000..292a518 --- /dev/null +++ b/tui/go.sum @@ -0,0 +1,469 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= +github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +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/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70= +github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= +github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chai2010/gettext-go v1.0.3 h1:9liNh8t+u26xl5ddmWLmsOsdNLwkdRTg5AG+JnTiM80= +github.com/chai2010/gettext-go v1.0.3/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +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.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +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/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +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/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/distribution/distribution/v3 v3.0.0 h1:q4R8wemdRQDClzoNNStftB2ZAfqOiN6UX90KJc4HjyM= +github.com/distribution/distribution/v3 v3.0.0/go.mod h1:tRNuFoZsUdyRVegq8xGNeds4KLjwLCRin/tTo6i1DhU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo= +github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M= +github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8= +github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= +github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= +github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= +github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY= +github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q= +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/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4= +github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc= +github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw= +github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fluxcd/cli-utils v0.37.2-flux.1 h1:tQ588ghtRN+E+kHq415FddfqA9v4brn/1WWgrP6rQR0= +github.com/fluxcd/cli-utils v0.37.2-flux.1/go.mod h1:LcWSu1NYET8d8U7O326RhEm5JkQXCMK6ITu4G1CT02c= +github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0= +github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= +github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= +github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= +github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= +github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= +github.com/go-openapi/swag v0.25.5 h1:pNkwbUEeGwMtcgxDr+2GBPAk4kT+kJ+AaB+TMKAg+TU= +github.com/go-openapi/swag v0.25.5/go.mod h1:B3RT6l8q7X803JRxa2e59tHOiZlX1t8viplOcs9CwTA= +github.com/go-openapi/swag/cmdutils v0.25.5 h1:yh5hHrpgsw4NwM9KAEtaDTXILYzdXh/I8Whhx9hKj7c= +github.com/go-openapi/swag/cmdutils v0.25.5/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g= +github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k= +github.com/go-openapi/swag/fileutils v0.25.5 h1:B6JTdOcs2c0dBIs9HnkyTW+5gC+8NIhVBUwERkFhMWk= +github.com/go-openapi/swag/fileutils v0.25.5/go.mod h1:V3cT9UdMQIaH4WiTrUc9EPtVA4txS0TOmRURmhGF4kc= +github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= +github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= +github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo= +github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo= +github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU= +github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g= +github.com/go-openapi/swag/mangling v0.25.5 h1:hyrnvbQRS7vKePQPHHDso+k6CGn5ZBs5232UqWZmJZw= +github.com/go-openapi/swag/mangling v0.25.5/go.mod h1:6hadXM/o312N/h98RwByLg088U61TPGiltQn71Iw0NY= +github.com/go-openapi/swag/netutils v0.25.5 h1:LZq2Xc2QI8+7838elRAaPCeqJnHODfSyOa7ZGfxDKlU= +github.com/go-openapi/swag/netutils v0.25.5/go.mod h1:lHbtmj4m57APG/8H7ZcMMSWzNqIQcu0RFiXrPUara14= +github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M= +github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII= +github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E= +github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc= +github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ= +github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM= +github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= +github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= +github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= +github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= +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= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= +github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= +github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/hashicorp/golang-lru/arc/v2 v2.0.5 h1:l2zaLDubNhW4XO3LnliVj0GXO3+/CGNJAg1dcN2Fpfw= +github.com/hashicorp/golang-lru/arc/v2 v2.0.5/go.mod h1:ny6zBSQZi2JxIeYcv7kt2sH2PXJtirBN7RDhRpxPkxU= +github.com/hashicorp/golang-lru/v2 v2.0.5 h1:wW7h1TG88eUIJ2i69gaE3uNVtEPIagzhGvHgwfx2Vm4= +github.com/hashicorp/golang-lru/v2 v2.0.5/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f h1:Fnl4pzx8SR7k7JuzyW8lEtSFH6EQ8xgcypgIn8pcGIE= +github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M= +github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= +github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= +github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +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/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= +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/nlepage/go-tarfs v1.2.1 h1:o37+JPA+ajllGKSPfy5+YpsNHDjZnAoyfvf5GsUa+Ks= +github.com/nlepage/go-tarfs v1.2.1/go.mod h1:rno18mpMy9aEH1IiJVftFsqPyIpwqSUiAOpJYjlV2NA= +github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= +github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= +github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= +github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 h1:EaDatTxkdHG+U3Bk4EUr+DZ7fOGwTfezUiUJMaIcaho= +github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5/go.mod h1:fyalQWdtzDBECAQFBJuQe5bzQ02jGd5Qcbgb97Flm7U= +github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 h1:EfpWLLCyXw8PSM2/XNJLjI3Pb27yVE+gIAfeqp8LUCc= +github.com/redis/go-redis/extra/redisotel/v9 v9.0.5/go.mod h1:WZjPDy7VNzn77AAfnAfVjZNvfJTYfPetfZk5yoSTLaQ= +github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= +github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q= +github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk= +github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= +github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= +github.com/veqryn/slog-context v0.9.0 h1:VNXHBWufRGfKiumi7cYoh7p2iElquZ4v8AnAumFOhEI= +github.com/veqryn/slog-context v0.9.0/go.mod h1:l953waOLsWW6hArZeJDGGKZYLrsOIPBeJ/QQnOA8RU0= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +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/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= +github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/bridges/prometheus v0.65.0 h1:I/7S/yWobR3QHFLqHsJ8QOndoiFsj1VgHpQiq43KlUI= +go.opentelemetry.io/contrib/bridges/prometheus v0.65.0/go.mod h1:jPF6gn3y1E+nozCAEQj3c6NZ8KY+tvAgSVfvoOJUFac= +go.opentelemetry.io/contrib/exporters/autoexport v0.65.0 h1:2gApdml7SznX9szEKFjKjM4qGcGSvAybYLBY319XG3g= +go.opentelemetry.io/contrib/exporters/autoexport v0.65.0/go.mod h1:0QqAGlbHXhmPYACG3n5hNzO5DnEqqtg4VcK5pr22RI0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= +go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= +go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0 h1:ZVg+kCXxd9LtAaQNKBxAvJ5NpMf7LpvEr4MIZqb0TMQ= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0/go.mod h1:hh0tMeZ75CCXrHd9OXRYxTlCAdxcXioWHFIpYw2rZu8= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0 h1:NOyNnS19BF2SUDApbOKbDtWZ0IK7b8FJ2uAGdIWOGb0= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0/go.mod h1:VL6EgVikRLcJa9ftukrHu/ZkkhFBSo1lzvdBC9CF1ss= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 h1:9y5sHvAxWzft1WQ4BwqcvA+IFVUJ1Ya75mSAUnFEVwE= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0/go.mod h1:eQqT90eR3X5Dbs1g9YSM30RavwLF725Ris5/XSXWvqE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40= +go.opentelemetry.io/otel/exporters/prometheus v0.62.0 h1:krvC4JMfIOVdEuNPTtQ0ZjCiXrybhv+uOHMfHRmnvVo= +go.opentelemetry.io/otel/exporters/prometheus v0.62.0/go.mod h1:fgOE6FM/swEnsVQCqCnbOfRV4tOnWPg7bVeo4izBuhQ= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.16.0 h1:ivlbaajBWJqhcCPniDqDJmRwj4lc6sRT+dCAVKNmxlQ= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.16.0/go.mod h1:u/G56dEKDDwXNCVLsbSrllB2o8pbtFLUC4HpR66r2dc= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8= +go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4= +go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes= +go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= +go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= +go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= +go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI= +go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= +go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +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.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY= +golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +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/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0= +google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +helm.sh/helm/v4 v4.1.4 h1:zwTrNkalG4f7SYigRSdQnYrTj0QEz1qzetzAlYoDVSo= +helm.sh/helm/v4 v4.1.4/go.mod h1:5dSo8rRgn3OTkDAc/k0Ipw5/Q+BlqKIKZwa0XwSiINI= +k8s.io/api v0.35.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ= +k8s.io/api v0.35.3/go.mod h1:9Y9tkBcFwKNq2sxwZTQh1Njh9qHl81D0As56tu42GA4= +k8s.io/apiextensions-apiserver v0.35.3 h1:2fQUhEO7P17sijylbdwt0nBdXP0TvHrHj0KeqHD8FiU= +k8s.io/apiextensions-apiserver v0.35.3/go.mod h1:tK4Kz58ykRpwAEkXUb634HD1ZAegEElktz/B3jgETd8= +k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8= +k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/cli-runtime v0.35.3 h1:UZq4ipNimtzBmhN7PPKbfAdqo8quK0H0UdGl6qAQnqI= +k8s.io/cli-runtime v0.35.3/go.mod h1:O7MUmCqcKSd5xI+O5X7/pRkB5l0O2NIhOdUVwbHLXu4= +k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg= +k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c= +k8s.io/component-base v0.35.3 h1:mbKbzoIMy7JDWS/wqZobYW1JDVRn/RKRaoMQHP9c4P0= +k8s.io/component-base v0.35.3/go.mod h1:IZ8LEG30kPN4Et5NeC7vjNv5aU73ku5MS15iZyvyMYk= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 h1:HhDfevmPS+OalTjQRKbTHppRIz01AWi8s45TMXStgYY= +k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/kubectl v0.35.3 h1:1KqSYXk/sodU7VeDvK6atX2kAGUZd2QTeR5K7Hb9r9w= +k8s.io/kubectl v0.35.3/go.mod h1:GPHxZqRe+u/i3gTBoVQHeIyq2NilfNPj9hDWeuN3x5s= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +ocm.software/open-component-model/bindings/go/blob v0.0.12 h1:5YOFDxERzF6w+rPWYuvnb5iVf3xgWJo9wLkzIPe+FmU= +ocm.software/open-component-model/bindings/go/blob v0.0.12/go.mod h1:YswKfR/kxhdHHK7bN98S2O73ZZlPLsHfHO2Bjsr6Zww= +ocm.software/open-component-model/bindings/go/cel v0.0.0-20260421090214-21aeadb8fa7b h1:iiMfbJ70bEKD2ha6G7WjbnVcZ1fZV/W/Avy+az8/SYI= +ocm.software/open-component-model/bindings/go/cel v0.0.0-20260421090214-21aeadb8fa7b/go.mod h1:GHZfvK+e1mgGf3V15w3ZgTVDxuFSqxxOoSRqIMUtNSA= +ocm.software/open-component-model/bindings/go/configuration v0.0.12 h1:SXrHUR70gsW+qZI5zbCtem6Rh5DvSFA19GDEd8CNtis= +ocm.software/open-component-model/bindings/go/configuration v0.0.12/go.mod h1:0sMurs1+cI/ohkuNSipDPPRb5ycSSTHDZoFG92aDtlE= +ocm.software/open-component-model/bindings/go/constructor v0.0.7 h1:w1VB//QLrbC6r+lB8FHqcu4dK43wQD0MgULTsxFc0hA= +ocm.software/open-component-model/bindings/go/constructor v0.0.7/go.mod h1:Aont6PV4Tm4ZU6ESOZdnnhJ+YV7LBeKM98+PSaMX+wA= +ocm.software/open-component-model/bindings/go/credentials v0.0.9 h1:sYKfVKs+CrVL2EuKYNevCjXq1cdF0DsLnWyEjamjDdY= +ocm.software/open-component-model/bindings/go/credentials v0.0.9/go.mod h1:JFQDqqZv17uJLQSZOKa9siuLTK4zMA3Bzk4amU9ZALU= +ocm.software/open-component-model/bindings/go/ctf v0.4.0 h1:E2kDGJk/ZR2wMK6fk3yFr2Uv6AhfLdMmvdvQ7Y64/2s= +ocm.software/open-component-model/bindings/go/ctf v0.4.0/go.mod h1:XaVTQK/STJ64pq8vClsT+onD0kEs7P+Wzsq1k2tp9h4= +ocm.software/open-component-model/bindings/go/dag v0.0.6 h1:To76QJAmFD88C101oB/HgYvtomp8mm0270ewDLcVncw= +ocm.software/open-component-model/bindings/go/dag v0.0.6/go.mod h1:mQbO95zYvX59VXNJGer4+wGsKY0BVI4FKwlR5BlPugM= +ocm.software/open-component-model/bindings/go/descriptor/normalisation v0.0.0-20260421090214-21aeadb8fa7b h1:E/a/BhDcay7PI93U/E5vJTP6rPlMYfEQH8rUFKSVqzU= +ocm.software/open-component-model/bindings/go/descriptor/normalisation v0.0.0-20260421090214-21aeadb8fa7b/go.mod h1:wYRaHgOT446ed7ZRhF7nSclDVPXeV/MP167CRCQPGoU= +ocm.software/open-component-model/bindings/go/descriptor/runtime v0.0.0-20260421090214-21aeadb8fa7b h1:rjea6JlLpZwjXjiGXrhcBCYU+IB8mrXUkRCvS2yu7ic= +ocm.software/open-component-model/bindings/go/descriptor/runtime v0.0.0-20260421090214-21aeadb8fa7b/go.mod h1:gB2z/xXiBqridb+jfkKgukfCP8y6+EhNylCNAK3iVWY= +ocm.software/open-component-model/bindings/go/descriptor/v2 v2.0.3-alpha2 h1:loFXVXXXgCaBlpzs7gPizIopzyB3+SXIWHou8Xlp3Mc= +ocm.software/open-component-model/bindings/go/descriptor/v2 v2.0.3-alpha2/go.mod h1:FTKrAPkR0865aUlDBrzmmHTdFxuy7AbqQZrgG6pB9YY= +ocm.software/open-component-model/bindings/go/helm v0.0.0-20260407102747-5b24debae3cf h1:Ng+pgeZE3pW8+RvYt8xZz00SZtejQiBU/rEn0ngbBsE= +ocm.software/open-component-model/bindings/go/helm v0.0.0-20260407102747-5b24debae3cf/go.mod h1:Vd/AkYQYKXhaBnuIxO71w4gy1pC4B9eF4B0A7WIl8DE= +ocm.software/open-component-model/bindings/go/oci v0.0.39 h1:kDpneFeFRtzyHpUARM2yZN8NdHbabj6ymx6GnAahL8c= +ocm.software/open-component-model/bindings/go/oci v0.0.39/go.mod h1:6Hl1wqCwXJP4xoNu6o9Ypq4emDEiyymWCgqpr6juhvU= +ocm.software/open-component-model/bindings/go/plugin v0.0.14 h1:PJg5pYB26ji9xKhKjSmdhajw6XBs97wNhmoo0TdkS1o= +ocm.software/open-component-model/bindings/go/plugin v0.0.14/go.mod h1:nXAFNN9qb4RpVbkjK55RVgWwGBgQZpfIIQw9kNrXlNE= +ocm.software/open-component-model/bindings/go/repository v0.0.8 h1:TUwsQSOkPMvKAU3ciK7Tr2UgewDag640X+OD97zI2oE= +ocm.software/open-component-model/bindings/go/repository v0.0.8/go.mod h1:2TCCOD2lV/druBsW3/YBRnk8uOqUJCPefk1s7MyULb8= +ocm.software/open-component-model/bindings/go/runtime v0.0.7 h1:/4RDxVVubr6IC+ZKNAseDl4CoTwweIx0LYmCiNgkQYU= +ocm.software/open-component-model/bindings/go/runtime v0.0.7/go.mod h1:hbas6Al4VECYKFk51SLIi8zdzhFgZ2WvFpPljatbJHo= +ocm.software/open-component-model/bindings/go/signing v0.0.0-20260421090214-21aeadb8fa7b h1:RNuO8hpPh3gHxTCjix7yyvf6+BbXuxez/Mvmx7bD0j0= +ocm.software/open-component-model/bindings/go/signing v0.0.0-20260421090214-21aeadb8fa7b/go.mod h1:xwt8gJgKEjxfPwAGkQqOKP4NPYohHSlPePNRm2ED2pU= +ocm.software/open-component-model/bindings/go/transfer v0.0.0-20260407102747-5b24debae3cf h1:ftRU8yv2ZVb4Ob4P6sg5j4DDGQriDCnh+QcEEx3I+TI= +ocm.software/open-component-model/bindings/go/transfer v0.0.0-20260407102747-5b24debae3cf/go.mod h1:RH++tt/jUPDbBohVwQSHoIx/HC9WOh6gbW0tmU5Iwac= +ocm.software/open-component-model/bindings/go/transform v0.0.0-20260422070750-479394eef5a7 h1:0BsvR/g/96sb2KhuhwWk/wJSkMdVn+0t6S/tMWv+I74= +ocm.software/open-component-model/bindings/go/transform v0.0.0-20260422070750-479394eef5a7/go.mod h1:JkTD4EWLgRRG+tJ/TFyXXDUsJOwKDUKMzj9W0itlLDU= +oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= +oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= +sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= +sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/kustomize/api v0.21.1 h1:lzqbzvz2CSvsjIUZUBNFKtIMsEw7hVLJp0JeSIVmuJs= +sigs.k8s.io/kustomize/api v0.21.1/go.mod h1:f3wkKByTrgpgltLgySCntrYoq5d3q7aaxveSagwTlwI= +sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7fI= +sigs.k8s.io/kustomize/kyaml v0.21.1/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/tui/internal/components/input/prompt.go b/tui/internal/components/input/prompt.go new file mode 100644 index 0000000..d257b53 --- /dev/null +++ b/tui/internal/components/input/prompt.go @@ -0,0 +1,107 @@ +// Package input provides reusable text input components. +package input + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "ext.ocm.software/tui/internal/theme" +) + +// Prompt is a centered text input with title, subtitle, error display, and help text. +type Prompt struct { + Title string + Subtitle string + Input textinput.Model + Err error + Loading bool + LoadingMsg string + Help string + Width int + Height int +} + +// New creates a new prompt with sensible defaults. +func New(placeholder string, width int) Prompt { + ti := textinput.New() + ti.Placeholder = placeholder + ti.CharLimit = 512 + ti.Width = 80 + if width > 4 && ti.Width > width-4 { + ti.Width = width - 4 + } + ti.Focus() + + return Prompt{ + Input: ti, + Width: width, + } +} + +// Update forwards messages to the text input. +func (p *Prompt) Update(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + p.Input, cmd = p.Input.Update(msg) + return cmd +} + +// Value returns the trimmed input value. +func (p Prompt) Value() string { + return p.Input.Value() +} + +// Focus activates the input for typing. +func (p *Prompt) Focus() tea.Cmd { + p.Input.Focus() + return textinput.Blink +} + +// Blur deactivates the input. +func (p *Prompt) Blur() { + p.Input.Blur() +} + +// Resize updates the prompt dimensions. +func (p *Prompt) Resize(width, height int) { + p.Width = width + p.Height = height + if p.Input.Width > width-4 { + p.Input.Width = width - 4 + } +} + +// View renders the prompt. +func (p Prompt) View() string { + t := theme.Current() + + var sections []string + if p.Title != "" { + sections = append(sections, t.Title.MarginBottom(1).Render(p.Title)) + } + if p.Subtitle != "" { + sections = append(sections, t.Subtitle.MarginBottom(1).Render(p.Subtitle)) + } + sections = append(sections, p.Input.View()) + + if p.Loading { + msg := "Loading..." + if p.LoadingMsg != "" { + msg = p.LoadingMsg + } + sections = append(sections, t.Subtitle.Render(msg)) + } + + if p.Err != nil { + sections = append(sections, t.ErrorText.MarginTop(1).Render(fmt.Sprintf("Error: %v", p.Err))) + } + + if p.Help != "" { + sections = append(sections, t.Help.MarginTop(1).Render(p.Help)) + } + + content := lipgloss.JoinVertical(lipgloss.Left, sections...) + return lipgloss.Place(p.Width, p.Height, lipgloss.Center, lipgloss.Center, content) +} diff --git a/tui/internal/components/layout.go b/tui/internal/components/layout.go new file mode 100644 index 0000000..fde9d20 --- /dev/null +++ b/tui/internal/components/layout.go @@ -0,0 +1,77 @@ +package components + +import ( + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/lipgloss" + + "ext.ocm.software/tui/internal/theme" +) + +// Hotkey describes a key binding to show in the footer. +type Hotkey struct { + Key key.Binding + Show bool +} + +// NewHotkey creates a visible hotkey. +func NewHotkey(k key.Binding) Hotkey { + return Hotkey{Key: k, Show: true} +} + +// Layout renders a standard view layout with title bar, content, and footer. +type Layout struct { + Title string + StatusInfo string + Content string + Hotkeys []Hotkey + Width int + Height int +} + +// ContentHeight returns the available height for the content area, +// accounting for the title bar (1 line), separator (1 line), and footer (1 line). +func (l Layout) ContentHeight() int { + h := l.Height - 3 + if h < 1 { + h = 1 + } + return h +} + +// Render produces the full layout string. +func (l Layout) Render() string { + t := theme.Current() + + titleBar := StatusBar(l.Width, l.Title, l.StatusInfo) + separator := t.Separator(l.Width) + + contentStyle := lipgloss.NewStyle(). + Width(l.Width). + Height(l.ContentHeight()) + content := contentStyle.Render(l.Content) + + footer := l.renderFooter() + + return lipgloss.JoinVertical(lipgloss.Left, titleBar, separator, content, footer) +} + +func (l Layout) renderFooter() string { + t := theme.Current() + + var parts []string + for _, hk := range l.Hotkeys { + if !hk.Show { + continue + } + help := hk.Key.Help() + if help.Key == "" { + continue + } + parts = append(parts, help.Key+": "+help.Desc) + } + parts = append(parts, ":: command") + + return t.Help.Render(strings.Join(parts, " ")) +} diff --git a/tui/internal/components/menu/menu.go b/tui/internal/components/menu/menu.go new file mode 100644 index 0000000..a801cf0 --- /dev/null +++ b/tui/internal/components/menu/menu.go @@ -0,0 +1,76 @@ +// Package menu provides a cursor-navigable menu list component. +package menu + +import ( + tea "github.com/charmbracelet/bubbletea" + + "ext.ocm.software/tui/internal/theme" +) + +// Menu is a vertical selectable list with cursor navigation. +type Menu struct { + Items []string + Cursor int +} + +// New creates a menu with the given items. +func New(items ...string) Menu { + return Menu{Items: items} +} + +// Update handles j/k/up/down navigation and enter selection. +// Returns true if an item was selected (enter pressed). +func (m *Menu) Update(msg tea.KeyMsg) bool { + switch msg.Type { + case tea.KeyUp: + if m.Cursor > 0 { + m.Cursor-- + } + case tea.KeyDown: + if m.Cursor < len(m.Items)-1 { + m.Cursor++ + } + case tea.KeyEnter: + return true + } + + switch msg.String() { + case "j": + if m.Cursor < len(m.Items)-1 { + m.Cursor++ + } + case "k": + if m.Cursor > 0 { + m.Cursor-- + } + } + + return false +} + +// Selected returns the currently highlighted index. +func (m Menu) Selected() int { + return m.Cursor +} + +// View renders the menu list. +func (m Menu) View() string { + t := theme.Current() + + var lines string + for i, item := range m.Items { + cursor := " " + if i == m.Cursor { + cursor = "> " + } + line := cursor + item + if i == m.Cursor { + line = t.Selected.Render(line) + } + if i > 0 { + lines += "\n" + } + lines += line + } + return lines +} diff --git a/tui/internal/components/modal/modal.go b/tui/internal/components/modal/modal.go new file mode 100644 index 0000000..b507b15 --- /dev/null +++ b/tui/internal/components/modal/modal.go @@ -0,0 +1,43 @@ +// Package modal provides a centered modal dialog component. +package modal + +import ( + "github.com/charmbracelet/lipgloss" + + "ext.ocm.software/tui/internal/theme" +) + +// Modal renders a centered bordered popup. +type Modal struct { + Title string + Content string + Footer string + Width int // inner width, 0 = auto (50) +} + +// Render places the modal centered in the given dimensions. +func (m Modal) Render(width, height int) string { + t := theme.Current() + + innerWidth := m.Width + if innerWidth == 0 { + innerWidth = 50 + } + + border := t.ModalBorder.Width(innerWidth) + + var sections []string + if m.Title != "" { + sections = append(sections, t.Title.MarginBottom(1).Render(m.Title)) + } + if m.Content != "" { + sections = append(sections, m.Content) + } + if m.Footer != "" { + sections = append(sections, "") + sections = append(sections, t.DimText.Render(m.Footer)) + } + + popup := border.Render(lipgloss.JoinVertical(lipgloss.Left, sections...)) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, popup) +} diff --git a/tui/internal/components/palette/palette.go b/tui/internal/components/palette/palette.go new file mode 100644 index 0000000..4d7f8ac --- /dev/null +++ b/tui/internal/components/palette/palette.go @@ -0,0 +1,164 @@ +// Package palette provides a filterable command palette overlay. +package palette + +import ( + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "ext.ocm.software/tui/internal/theme" +) + +// Entry is a single item in the command palette. +type Entry struct { + Label string + ID string // opaque identifier for the caller +} + +// SelectedMsg is sent when the user selects an entry. +type SelectedMsg struct { + Entry Entry +} + +// ClosedMsg is sent when the user closes the palette without selecting. +type ClosedMsg struct{} + +// Model is the command palette state. +type Model struct { + entries []Entry + input textinput.Model + cursor int + open bool +} + +// New creates a new command palette. +func New(entries []Entry) Model { + ti := textinput.New() + ti.Placeholder = "type to filter..." + ti.CharLimit = 64 + ti.Width = 40 + ti.Prompt = ": " + + return Model{ + entries: entries, + input: ti, + } +} + +// Open activates the palette with the cursor at the given index. +func (m *Model) Open(cursorAt int) tea.Cmd { + m.open = true + m.cursor = cursorAt + m.input.SetValue("") + m.input.Focus() + return textinput.Blink +} + +// IsOpen returns whether the palette is visible. +func (m Model) IsOpen() bool { + return m.open +} + +// Filtered returns entries matching the current filter text. +func (m Model) Filtered() []Entry { + filter := strings.ToLower(strings.TrimSpace(m.input.Value())) + if filter == "" { + return m.entries + } + var result []Entry + for _, e := range m.entries { + if strings.Contains(strings.ToLower(e.Label), filter) { + result = append(result, e) + } + } + return result +} + +// Update handles palette input. Returns a tea.Cmd (may contain SelectedMsg or ClosedMsg). +func (m *Model) Update(msg tea.Msg) tea.Cmd { + km, ok := msg.(tea.KeyMsg) + if !ok { + return nil + } + + filtered := m.Filtered() + + switch km.Type { + case tea.KeyEsc: + m.open = false + m.input.Blur() + return func() tea.Msg { return ClosedMsg{} } + + case tea.KeyEnter: + if len(filtered) > 0 && m.cursor < len(filtered) { + entry := filtered[m.cursor] + m.open = false + m.input.Blur() + return func() tea.Msg { return SelectedMsg{Entry: entry} } + } + m.open = false + m.input.Blur() + return func() tea.Msg { return ClosedMsg{} } + + case tea.KeyUp: + if m.cursor > 0 { + m.cursor-- + } + return nil + + case tea.KeyDown: + if m.cursor < len(filtered)-1 { + m.cursor++ + } + return nil + } + + // Forward to text input for filtering. + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + m.cursor = 0 // reset cursor when filter changes + return cmd +} + +// View renders the palette as a centered popup. +func (m Model) View(width, height int) string { + t := theme.Current() + filtered := m.Filtered() + + border := t.ModalBorder.Width(50) + + var sections []string + sections = append(sections, t.Title.MarginBottom(1).Render("Command Palette")) + sections = append(sections, m.input.View()) + sections = append(sections, "") + + maxShow := 8 + if len(filtered) < maxShow { + maxShow = len(filtered) + } + for i := 0; i < maxShow; i++ { + entry := filtered[i] + cursor := " " + if i == m.cursor { + cursor = "> " + } + line := cursor + entry.Label + if i == m.cursor { + line = t.Selected.Render(line) + } else { + line = t.DimText.Render(line) + } + sections = append(sections, line) + } + if len(filtered) > maxShow { + sections = append(sections, t.DimText.Render(" ...")) + } + + sections = append(sections, "") + sections = append(sections, t.DimText.Render("enter: select esc: close type to filter")) + + popup := border.Render(lipgloss.JoinVertical(lipgloss.Left, sections...)) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, popup) +} diff --git a/tui/internal/components/progress/spinner.go b/tui/internal/components/progress/spinner.go new file mode 100644 index 0000000..0a5bd68 --- /dev/null +++ b/tui/internal/components/progress/spinner.go @@ -0,0 +1,26 @@ +// Package progress provides progress indicator components. +package progress + +import ( + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +// Frames are the spinner animation characters. +var Frames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +// TickMsg drives the spinner animation. +type TickMsg struct{} + +// Tick returns a command that fires a TickMsg after 100ms. +func Tick() tea.Cmd { + return tea.Tick(100*time.Millisecond, func(_ time.Time) tea.Msg { + return TickMsg{} + }) +} + +// Frame returns the spinner character for the given frame index. +func Frame(index int) string { + return Frames[index%len(Frames)] +} diff --git a/tui/internal/components/splitpane/splitpane.go b/tui/internal/components/splitpane/splitpane.go new file mode 100644 index 0000000..a52c7d0 --- /dev/null +++ b/tui/internal/components/splitpane/splitpane.go @@ -0,0 +1,52 @@ +// Package splitpane provides a two-pane horizontal layout component. +package splitpane + +import ( + "github.com/charmbracelet/lipgloss" + + "ext.ocm.software/tui/internal/theme" +) + +// SplitPane renders a horizontal split layout with a divider. +type SplitPane struct { + Left string + Right string + Ratio float64 // left pane ratio (0.5 = 50/50) + Width int + Height int +} + +// New creates a split pane with 50/50 ratio. +func New(width, height int) SplitPane { + return SplitPane{Ratio: 0.5, Width: width, Height: height} +} + +// LeftWidth returns the left pane width. +func (s SplitPane) LeftWidth() int { + return int(float64(s.Width) * s.Ratio) +} + +// RightWidth returns the right pane width (minus divider). +func (s SplitPane) RightWidth() int { + return s.Width - s.LeftWidth() - 1 // 1 for divider +} + +// Render produces the split layout. +func (s SplitPane) Render() string { + t := theme.Current() + + leftView := lipgloss.NewStyle(). + Width(s.LeftWidth()). + Height(s.Height). + Render(s.Left) + + rightView := lipgloss.NewStyle(). + Width(s.RightWidth()). + Height(s.Height). + PaddingLeft(1). + Render(s.Right) + + divider := t.Divider.Render("│") + + return lipgloss.JoinHorizontal(lipgloss.Top, leftView, divider, rightView) +} diff --git a/tui/internal/components/statusbar.go b/tui/internal/components/statusbar.go new file mode 100644 index 0000000..af4209c --- /dev/null +++ b/tui/internal/components/statusbar.go @@ -0,0 +1,27 @@ +package components + +import ( + "github.com/charmbracelet/lipgloss" + + "ext.ocm.software/tui/internal/theme" +) + +// StatusBar renders a top status bar with title, global hint, and reference info. +func StatusBar(width int, title, reference string) string { + t := theme.Current() + + left := t.StatusTitle.Render(title) + hint := t.StatusHint.Render(":: command palette") + right := t.StatusRef.Render(reference) + + gap := width - lipgloss.Width(left) - lipgloss.Width(hint) - lipgloss.Width(right) + if gap < 0 { + gap = 0 + } + + bar := lipgloss.NewStyle(). + Width(width). + Render(left + hint + lipgloss.NewStyle().Width(gap).Render("") + right) + + return bar +} diff --git a/tui/internal/components/tree/keymap.go b/tui/internal/components/tree/keymap.go new file mode 100644 index 0000000..9c2fa3b --- /dev/null +++ b/tui/internal/components/tree/keymap.go @@ -0,0 +1,43 @@ +package tree + +import "github.com/charmbracelet/bubbles/key" + +// KeyMap defines the key bindings for tree navigation. +type KeyMap struct { + Up key.Binding + Down key.Binding + Expand key.Binding + Collapse key.Binding + PageUp key.Binding + PageDown key.Binding +} + +// DefaultKeyMap returns the default tree navigation key bindings. +func DefaultKeyMap() KeyMap { + return KeyMap{ + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("k/up", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("j/down", "down"), + ), + Expand: key.NewBinding( + key.WithKeys("enter", "right", "l"), + key.WithHelp("enter", "expand"), + ), + Collapse: key.NewBinding( + key.WithKeys("esc", "left", "h"), + key.WithHelp("esc", "collapse"), + ), + PageUp: key.NewBinding( + key.WithKeys("pgup", "ctrl+u"), + key.WithHelp("pgup", "page up"), + ), + PageDown: key.NewBinding( + key.WithKeys("pgdown", "ctrl+d"), + key.WithHelp("pgdown", "page down"), + ), + } +} diff --git a/tui/internal/components/tree/model.go b/tui/internal/components/tree/model.go new file mode 100644 index 0000000..d7e81e2 --- /dev/null +++ b/tui/internal/components/tree/model.go @@ -0,0 +1,169 @@ +package tree + +import ( + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + + "ext.ocm.software/tui/internal/theme" +) + +// NodeExpandedMsg is sent when a node is expanded by the user. +// The domain model should handle this to trigger data fetching if needed. +type NodeExpandedMsg struct{ Node *Node } + +// CursorChangedMsg is sent when the cursor moves to a different node. +type CursorChangedMsg struct{ Node *Node } + +// Model handles tree navigation state. Domain views embed this and +// delegate key handling to it. +type Model struct { + Keys KeyMap + Roots []*Node + Cursor int + Visible []*Node +} + +// New creates a new tree model. +func New() Model { + return Model{ + Keys: DefaultKeyMap(), + } +} + +// SetRoots replaces the tree roots and recomputes visible nodes. +func (m *Model) SetRoots(roots []*Node) { + m.Roots = roots + m.Visible = Flatten(m.Roots) + if m.Cursor >= len(m.Visible) { + m.Cursor = len(m.Visible) - 1 + } + if m.Cursor < 0 { + m.Cursor = 0 + } +} + +// Rebuild recomputes visible nodes from the current roots. +func (m *Model) Rebuild() { + m.Visible = Flatten(m.Roots) + if m.Cursor >= len(m.Visible) { + m.Cursor = len(m.Visible) - 1 + } + if m.Cursor < 0 { + m.Cursor = 0 + } +} + +// Selected returns the currently selected node, or nil if the tree is empty. +func (m Model) Selected() *Node { + if len(m.Visible) == 0 || m.Cursor >= len(m.Visible) { + return nil + } + return m.Visible[m.Cursor] +} + +// Update handles tree navigation keys. Returns true if the key was handled. +func (m *Model) Update(msg tea.KeyMsg) (tea.Cmd, bool) { + switch { + case key.Matches(msg, m.Keys.Up): + if m.Cursor > 0 { + m.Cursor-- + return cursorChanged(m), true + } + return nil, true + + case key.Matches(msg, m.Keys.Down): + if m.Cursor < len(m.Visible)-1 { + m.Cursor++ + return cursorChanged(m), true + } + return nil, true + + case key.Matches(msg, m.Keys.PageUp): + m.Cursor -= 10 + if m.Cursor < 0 { + m.Cursor = 0 + } + return cursorChanged(m), true + + case key.Matches(msg, m.Keys.PageDown): + m.Cursor += 10 + if m.Cursor >= len(m.Visible) { + m.Cursor = len(m.Visible) - 1 + } + if m.Cursor < 0 { + m.Cursor = 0 + } + return cursorChanged(m), true + + case key.Matches(msg, m.Keys.Expand): + if len(m.Visible) > 0 { + node := m.Visible[m.Cursor] + if node.Expandable && !node.Expanded { + node.Expanded = true + m.Visible = Flatten(m.Roots) + return func() tea.Msg { return NodeExpandedMsg{Node: node} }, true + } + } + return nil, true + + case key.Matches(msg, m.Keys.Collapse): + if len(m.Visible) > 0 { + node := m.Visible[m.Cursor] + if node.Expandable && node.Expanded { + node.Expanded = false + m.Visible = Flatten(m.Roots) + return cursorChanged(m), true + } + } + return nil, true + } + + return nil, false +} + +func cursorChanged(m *Model) tea.Cmd { + node := m.Selected() + if node == nil { + return nil + } + return func() tea.Msg { return CursorChangedMsg{Node: node} } +} + +// Render renders the tree pane with the given height. +func (m Model) Render(height int, focused bool) string { + if len(m.Visible) == 0 { + return "No items." + } + + var lines []string + + scrollOffset := 0 + if m.Cursor >= height { + scrollOffset = m.Cursor - height + 1 + } + + end := scrollOffset + height + if end > len(m.Visible) { + end = len(m.Visible) + } + + for i := scrollOffset; i < end; i++ { + node := m.Visible[i] + line := RenderNode(node, i == m.Cursor) + + if i == m.Cursor { + t := theme.Current() + if focused { + line = t.Cursor.Render(line) + } else { + line = t.CursorDim.Render(line) + } + } + + lines = append(lines, line) + } + + return strings.Join(lines, "\n") +} diff --git a/tui/internal/components/tree/node.go b/tui/internal/components/tree/node.go new file mode 100644 index 0000000..a702ac3 --- /dev/null +++ b/tui/internal/components/tree/node.go @@ -0,0 +1,68 @@ +// Package tree provides a reusable tree component for TUI views. +package tree + +import ( + "strings" +) + +// Node represents a single item in a tree. Domain packages set Data +// to hold their specific payload (e.g. *descriptor.Resource). +type Node struct { + Label string + Depth int + Expanded bool + Loading bool + Expandable bool + Children []*Node + Data any +} + +// Flatten returns a flat slice of visible nodes (respecting expand/collapse). +func Flatten(roots []*Node) []*Node { + var result []*Node + for _, root := range roots { + flatten(root, &result) + } + return result +} + +func flatten(n *Node, result *[]*Node) { + *result = append(*result, n) + if n.Expanded { + for _, child := range n.Children { + flatten(child, result) + } + } +} + +// ContainsNode returns true if target is a descendant of parent. +func ContainsNode(parent, target *Node) bool { + for _, child := range parent.Children { + if child == target { + return true + } + if ContainsNode(child, target) { + return true + } + } + return false +} + +// RenderNode produces a single-line string for a tree node. +func RenderNode(n *Node, _ bool) string { + indent := strings.Repeat(" ", n.Depth) + + var prefix string + switch { + case n.Loading: + prefix = "~ " + case n.Expandable && n.Expanded: + prefix = "v " + case n.Expandable: + prefix = "> " + default: + prefix = " " + } + + return indent + prefix + n.Label +} diff --git a/tui/internal/explorer/browse.go b/tui/internal/explorer/browse.go new file mode 100644 index 0000000..ea8e481 --- /dev/null +++ b/tui/internal/explorer/browse.go @@ -0,0 +1,307 @@ +package explorer + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + + "ext.ocm.software/tui/internal/components/progress" + "ext.ocm.software/tui/internal/components/splitpane" + "ext.ocm.software/tui/internal/theme" +) + +// updateBrowse handles messages in browse mode. +func (m *Model) updateBrowse(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.Resize(msg.Width, msg.Height) + return nil + + case versionsMsg: + m.loading = false + node := &Node{Kind: NodeComponent, Label: msg.component, Depth: 0} + for _, v := range msg.versions { + node.Children = append(node.Children, &Node{Kind: NodeVersion, Label: v, Depth: 1}) + } + node.Expanded = true + m.roots = []*Node{node} + m.visible = Flatten(m.roots) + m.updateDetail() + return nil + + case descriptorMsg: + m.loading = false + found := false + for _, root := range m.roots { + for _, child := range root.Children { + if child.Kind == NodeVersion && child.Label == msg.version { + child.Descriptor = msg.descriptor + child.Children = BuildVersionNodes(msg.descriptor, child.Depth+1)[0].Children + child.Expanded = true + child.Loading = false + found = true + break + } + } + } + if !found && msg.descriptor != nil { + node := &Node{Kind: NodeComponent, Label: msg.descriptor.Component.Name, Depth: 0, Expanded: true} + versionNodes := BuildVersionNodes(msg.descriptor, 1) + versionNodes[0].Expanded = true + node.Children = versionNodes + m.roots = []*Node{node} + } + m.visible = Flatten(m.roots) + m.updateDetail() + return nil + + case progress.TickMsg: + if m.downloading { + m.spinnerFrame = (m.spinnerFrame + 1) % len(progress.Frames) + m.downloadStatus = fmt.Sprintf("%s Downloading %s...", progress.Frame(m.spinnerFrame), m.downloadResName) + return progress.Tick() + } + return nil + + case downloadDoneMsg: + m.downloading = false + m.downloadStatus = fmt.Sprintf("Downloaded %s to:\n%s", msg.resourceName, msg.outputPath) + return nil + + case errMsg: + m.loading = false + if m.downloading { + m.downloading = false + m.downloadStatus = fmt.Sprintf("Download failed: %v", msg.err) + } + m.err = msg.err + return nil + + case tea.KeyMsg: + if m.downloading { + return nil + } + if m.downloadStatus != "" { + m.downloadStatus = "" + return nil + } + return m.handleKey(msg) + } + + if !m.focusTree { + var cmd tea.Cmd + m.detail, cmd = m.detail.Update(msg) + return cmd + } + + return nil +} + +func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { + switch { + case key.Matches(msg, m.keys.Up): + if m.focusTree && m.cursor > 0 { + m.cursor-- + m.updateDetail() + } + + case key.Matches(msg, m.keys.Down): + if m.focusTree && m.cursor < len(m.visible)-1 { + m.cursor++ + m.updateDetail() + } + + case key.Matches(msg, m.keys.PageUp): + if m.focusTree { + m.cursor -= 10 + if m.cursor < 0 { + m.cursor = 0 + } + m.updateDetail() + } + + case key.Matches(msg, m.keys.PageDown): + if m.focusTree { + m.cursor += 10 + if m.cursor >= len(m.visible) { + m.cursor = len(m.visible) - 1 + } + if m.cursor < 0 { + m.cursor = 0 + } + m.updateDetail() + } + + case key.Matches(msg, m.keys.Expand): + if m.focusTree && len(m.visible) > 0 { + node := m.visible[m.cursor] + if node.IsExpandable() && !node.Expanded { + node.Expanded = true + if node.Kind == NodeVersion && len(node.Children) == 0 && node.Descriptor == nil { + node.Loading = true + m.visible = Flatten(m.roots) + component := m.findComponentName(node) + return m.fetchDescriptor(component, node.Label) + } + if node.Kind == NodeReference && len(node.Children) == 0 && node.Reference != nil { + node.Loading = true + m.visible = Flatten(m.roots) + return m.fetchDescriptor(node.Reference.Component, node.Reference.Version) + } + m.visible = Flatten(m.roots) + m.updateDetail() + } + } + + case key.Matches(msg, m.keys.Collapse): + if m.focusTree && len(m.visible) > 0 { + node := m.visible[m.cursor] + if node.IsExpandable() && node.Expanded { + node.Expanded = false + m.visible = Flatten(m.roots) + m.updateDetail() + } + } + + case key.Matches(msg, m.keys.Download): + return m.startDownload() + + case key.Matches(msg, m.keys.Transfer): + if m.focusTree && len(m.visible) > 0 { + node := m.visible[m.cursor] + component, version := m.findTransferContext(node) + if component != "" && version != "" { + ref := m.reference + return func() tea.Msg { + return TransferRequestMsg{Reference: ref, Component: component, Version: version} + } + } + } + } + + return nil +} + +// renderBrowse renders the browse mode split pane view. +func (m *Model) renderBrowse() string { + if !m.ready { + return "Initializing..." + } + + sp := splitpane.New(m.width, m.height) + sp.Left = m.renderTree(m.height) + sp.Right = m.detail.View() + + base := sp.Render() + + if m.downloadStatus != "" { + return m.renderDownloadModal() + } + + return base +} + +func (m *Model) renderTree(height int) string { + if m.loading && len(m.visible) == 0 { + return "Loading..." + } + if m.err != nil && len(m.visible) == 0 { + return fmt.Sprintf("Error: %v", m.err) + } + + t := theme.Current() + var lines []string + + scrollOffset := 0 + if m.cursor >= height { + scrollOffset = m.cursor - height + 1 + } + end := scrollOffset + height + if end > len(m.visible) { + end = len(m.visible) + } + + for i := scrollOffset; i < end; i++ { + node := m.visible[i] + line := RenderNode(node, i == m.cursor) + if i == m.cursor { + if m.focusTree { + line = t.Cursor.Render(line) + } else { + line = t.CursorDim.Render(line) + } + } + lines = append(lines, line) + } + + return strings.Join(lines, "\n") +} + +// --- Tree context helpers --- + +func (m *Model) findComponentName(node *Node) string { + for _, root := range m.roots { + if root.Kind == NodeComponent { + for _, child := range root.Children { + if child == node { + return root.Label + } + } + if containsNode(root, node) { + return root.Label + } + } + } + return m.initialComponent +} + +func containsNode(parent, target *Node) bool { + for _, child := range parent.Children { + if child == target { + return true + } + if containsNode(child, target) { + return true + } + } + return false +} + +func (m *Model) findTransferContext(node *Node) (component, version string) { + if node.Kind == NodeVersion { + return m.findComponentName(node), node.Label + } + for _, root := range m.roots { + if root.Kind != NodeComponent { + continue + } + for _, vNode := range root.Children { + if vNode.Kind != NodeVersion { + continue + } + if vNode == node || containsNode(vNode, node) { + return root.Label, vNode.Label + } + } + } + return "", "" +} + +func (m *Model) findResourceContext(target *Node) (component, version string) { + for _, root := range m.roots { + if root.Kind != NodeComponent { + continue + } + for _, vNode := range root.Children { + if vNode.Kind != NodeVersion { + continue + } + if containsNode(vNode, target) { + return root.Label, vNode.Label + } + } + } + return "", "" +} diff --git a/tui/internal/explorer/detail.go b/tui/internal/explorer/detail.go new file mode 100644 index 0000000..5ac705a --- /dev/null +++ b/tui/internal/explorer/detail.go @@ -0,0 +1,156 @@ +package explorer + +import ( + "fmt" + "strings" + + descriptor "ocm.software/open-component-model/bindings/go/descriptor/runtime" +) + +// NodeDetail returns a formatted string with details about the selected node. +func NodeDetail(n *Node) string { + if n == nil { + return "Select a node to view details." + } + + switch n.Kind { + case NodeComponent: + return fmt.Sprintf("Component: %s", n.Label) + + case NodeVersion: + return versionDetail(n) + + case NodeResource: + return resourceDetail(n.Resource) + + case NodeSource: + return sourceDetail(n.Source) + + case NodeReference: + return referenceDetail(n.Reference) + + case NodeSignature: + return signatureDetail(n.Signature) + + case NodeResourceGroup, NodeSourceGroup, NodeReferenceGroup, + NodeSignatureGroup, NodeLabelGroup: + return fmt.Sprintf("%s\n\nPress enter to expand.", n.Label) + + case NodeLabel: + return fmt.Sprintf("Label: %s", n.Label) + + default: + return n.Label + } +} + +func versionDetail(n *Node) string { + if n.Descriptor == nil { + return fmt.Sprintf("Version: %s", n.Label) + } + desc := n.Descriptor + var b strings.Builder + b.WriteString(fmt.Sprintf("Name: %s\n", desc.Component.Name)) + b.WriteString(fmt.Sprintf("Version: %s\n", desc.Component.Version)) + if desc.Component.Provider.Name != "" { + b.WriteString(fmt.Sprintf("Provider: %s\n", desc.Component.Provider.Name)) + } + if desc.Component.CreationTime != "" { + b.WriteString(fmt.Sprintf("Created: %s\n", desc.Component.CreationTime)) + } + if desc.Meta.Version != "" { + b.WriteString(fmt.Sprintf("Schema: %s\n", desc.Meta.Version)) + } + b.WriteString(fmt.Sprintf("\nResources: %d\n", len(desc.Component.Resources))) + b.WriteString(fmt.Sprintf("Sources: %d\n", len(desc.Component.Sources))) + b.WriteString(fmt.Sprintf("References: %d\n", len(desc.Component.References))) + b.WriteString(fmt.Sprintf("Signatures: %d\n", len(desc.Signatures))) + if len(desc.Component.Labels) > 0 { + b.WriteString(fmt.Sprintf("Labels: %d\n", len(desc.Component.Labels))) + } + return b.String() +} + +func resourceDetail(res *descriptor.Resource) string { + if res == nil { + return "" + } + var b strings.Builder + b.WriteString(fmt.Sprintf("Name: %s\n", res.Name)) + b.WriteString(fmt.Sprintf("Version: %s\n", res.Version)) + b.WriteString(fmt.Sprintf("Type: %s\n", res.Type)) + b.WriteString(fmt.Sprintf("Relation: %s\n", res.Relation)) + if res.Digest != nil { + b.WriteString(fmt.Sprintf("\nDigest:\n")) + b.WriteString(fmt.Sprintf(" Algorithm: %s\n", res.Digest.HashAlgorithm)) + b.WriteString(fmt.Sprintf(" Normalisation: %s\n", res.Digest.NormalisationAlgorithm)) + b.WriteString(fmt.Sprintf(" Value: %s\n", res.Digest.Value)) + } + if len(res.Labels) > 0 { + b.WriteString(fmt.Sprintf("\nLabels:\n")) + for _, l := range res.Labels { + b.WriteString(fmt.Sprintf(" %s: %s\n", l.Name, string(l.Value))) + } + } + return b.String() +} + +func sourceDetail(src *descriptor.Source) string { + if src == nil { + return "" + } + var b strings.Builder + b.WriteString(fmt.Sprintf("Name: %s\n", src.Name)) + b.WriteString(fmt.Sprintf("Version: %s\n", src.Version)) + b.WriteString(fmt.Sprintf("Type: %s\n", src.Type)) + if len(src.Labels) > 0 { + b.WriteString(fmt.Sprintf("\nLabels:\n")) + for _, l := range src.Labels { + b.WriteString(fmt.Sprintf(" %s: %s\n", l.Name, string(l.Value))) + } + } + return b.String() +} + +func referenceDetail(ref *descriptor.Reference) string { + if ref == nil { + return "" + } + var b strings.Builder + b.WriteString(fmt.Sprintf("Name: %s\n", ref.Name)) + b.WriteString(fmt.Sprintf("Component: %s\n", ref.Component)) + b.WriteString(fmt.Sprintf("Version: %s\n", ref.Version)) + if ref.Digest.Value != "" { + b.WriteString(fmt.Sprintf("\nDigest:\n")) + b.WriteString(fmt.Sprintf(" Algorithm: %s\n", ref.Digest.HashAlgorithm)) + b.WriteString(fmt.Sprintf(" Normalisation: %s\n", ref.Digest.NormalisationAlgorithm)) + b.WriteString(fmt.Sprintf(" Value: %s\n", ref.Digest.Value)) + } + if len(ref.Labels) > 0 { + b.WriteString(fmt.Sprintf("\nLabels:\n")) + for _, l := range ref.Labels { + b.WriteString(fmt.Sprintf(" %s: %s\n", l.Name, string(l.Value))) + } + } + return b.String() +} + +func signatureDetail(sig *descriptor.Signature) string { + if sig == nil { + return "" + } + var b strings.Builder + b.WriteString(fmt.Sprintf("Name: %s\n", sig.Name)) + b.WriteString(fmt.Sprintf("Algorithm: %s\n", sig.Signature.Algorithm)) + if sig.Signature.MediaType != "" { + b.WriteString(fmt.Sprintf("MediaType: %s\n", sig.Signature.MediaType)) + } + if sig.Signature.Issuer != "" { + b.WriteString(fmt.Sprintf("Issuer: %s\n", sig.Signature.Issuer)) + } + b.WriteString(fmt.Sprintf("\nDigest:\n")) + b.WriteString(fmt.Sprintf(" Algorithm: %s\n", sig.Digest.HashAlgorithm)) + b.WriteString(fmt.Sprintf(" Normalisation: %s\n", sig.Digest.NormalisationAlgorithm)) + b.WriteString(fmt.Sprintf(" Value: %s\n", sig.Digest.Value)) + return b.String() +} diff --git a/tui/internal/explorer/download.go b/tui/internal/explorer/download.go new file mode 100644 index 0000000..8676036 --- /dev/null +++ b/tui/internal/explorer/download.go @@ -0,0 +1,88 @@ +package explorer + +import ( + "context" + "fmt" + "path/filepath" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + descriptor "ocm.software/open-component-model/bindings/go/descriptor/runtime" + "ext.ocm.software/tui/internal/components/progress" + "ext.ocm.software/tui/internal/theme" +) + +// downloadDoneMsg is sent when a download completes. +type downloadDoneMsg struct { + resourceName string + outputPath string +} + +// startDownload initiates a resource download if a resource node is selected. +func (m *Model) startDownload() tea.Cmd { + if !m.focusTree || m.downloader == nil || m.downloading || len(m.visible) == 0 { + return nil + } + node := m.visible[m.cursor] + if node.Kind != NodeResource || node.Resource == nil { + return nil + } + component, version := m.findResourceContext(node) + if component == "" || version == "" { + return nil + } + + m.downloading = true + m.downloadResName = node.Resource.Name + m.spinnerFrame = 0 + m.downloadStatus = fmt.Sprintf("%s Downloading %s...", progress.Frame(0), node.Resource.Name) + + return tea.Batch( + m.doDownload(component, version, node.Resource), + progress.Tick(), + ) +} + +func (m *Model) doDownload(component, version string, res *descriptor.Resource) tea.Cmd { + downloader := m.downloader + ref := m.reference + return func() tea.Msg { + outputPath, err := downloader.DownloadResource(context.Background(), ref, component, version, res, ".") + if err != nil { + return errMsg{fmt.Errorf("downloading resource %s: %w", res.Name, err)} + } + if abs, err := filepath.Abs(outputPath); err == nil { + outputPath = abs + } + return downloadDoneMsg{resourceName: res.Name, outputPath: outputPath} + } +} + +// renderDownloadModal renders the download progress/result as a centered modal. +func (m *Model) renderDownloadModal() string { + t := theme.Current() + + border := t.ModalBorder.Padding(1, 2).Width(60) + + var sections []string + + if m.downloading { + sections = append(sections, t.Title.MarginBottom(1).Render("Download in Progress")) + sections = append(sections, m.downloadStatus) + } else if m.err != nil { + sections = append(sections, t.Title.MarginBottom(1).Render("Download Failed")) + sections = append(sections, t.ErrorText.Render(m.downloadStatus)) + sections = append(sections, "") + sections = append(sections, t.DimText.Render("press any key to dismiss")) + } else { + sections = append(sections, t.SuccessText.Render("Download Complete")) + sections = append(sections, "") + sections = append(sections, m.downloadStatus) + sections = append(sections, "") + sections = append(sections, t.DimText.Render("press any key to dismiss")) + } + + popup := border.Render(lipgloss.JoinVertical(lipgloss.Left, sections...)) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, popup) +} diff --git a/tui/internal/explorer/keymap.go b/tui/internal/explorer/keymap.go new file mode 100644 index 0000000..ae56a80 --- /dev/null +++ b/tui/internal/explorer/keymap.go @@ -0,0 +1,53 @@ +package explorer + +import "github.com/charmbracelet/bubbles/key" + +// KeyMap defines the key bindings for the explorer view. +type KeyMap struct { + Up key.Binding + Down key.Binding + Expand key.Binding + Collapse key.Binding + PageUp key.Binding + PageDown key.Binding + Download key.Binding + Transfer key.Binding +} + +// DefaultKeyMap returns the default explorer key bindings. +func DefaultKeyMap() KeyMap { + return KeyMap{ + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("k/up", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("j/down", "down"), + ), + Expand: key.NewBinding( + key.WithKeys("enter", "right", "l"), + key.WithHelp("enter", "expand"), + ), + Collapse: key.NewBinding( + key.WithKeys("esc", "left", "h"), + key.WithHelp("esc", "collapse"), + ), + PageUp: key.NewBinding( + key.WithKeys("pgup", "ctrl+u"), + key.WithHelp("pgup", "page up"), + ), + PageDown: key.NewBinding( + key.WithKeys("pgdown", "ctrl+d"), + key.WithHelp("pgdown", "page down"), + ), + Download: key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "download resource"), + ), + Transfer: key.NewBinding( + key.WithKeys("t"), + key.WithHelp("t", "transfer"), + ), + } +} diff --git a/tui/internal/explorer/prompt.go b/tui/internal/explorer/prompt.go new file mode 100644 index 0000000..8d4e521 --- /dev/null +++ b/tui/internal/explorer/prompt.go @@ -0,0 +1,108 @@ +package explorer + +import ( + "context" + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "ext.ocm.software/tui/internal/components/input" + "ext.ocm.software/tui/fetch" +) + +// fetcherReadyMsg is sent when the FetcherFactory succeeds. +type fetcherReadyMsg struct { + fetcher fetch.ComponentFetcher + component string + version string + reference string +} + +// fetcherErrMsg is sent when the FetcherFactory fails. +type fetcherErrMsg struct{ err error } + +// updatePrompt handles messages in prompt mode. +func (m *Model) updatePrompt(msg tea.Msg) tea.Cmd { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.Type { + case tea.KeyEnter: + ref := strings.TrimSpace(m.input.Value()) + if ref == "" { + return nil + } + factory := m.config.FetcherFactory + if factory == nil { + m.inputErr = fmt.Errorf("no repository connection configured") + return nil + } + m.loading = true + m.inputErr = nil + m.input.Blur() + return func() tea.Msg { + fetcher, component, version, err := factory(context.Background(), ref) + if err != nil { + return fetcherErrMsg{fmt.Errorf("connecting to %s: %w", ref, err)} + } + return fetcherReadyMsg{fetcher: fetcher, component: component, version: version, reference: ref} + } + case tea.KeyEsc: + return nil // OnBackPressed handles this + } + } + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + if m.input.Width > msg.Width-4 { + m.input.Width = msg.Width - 4 + } + return nil + + case fetcherReadyMsg: + m.loading = false + m.reference = msg.reference + m.fetcher = msg.fetcher + m.initialComponent = msg.component + m.initialVersion = msg.version + m.mode = modeBrowse + m.Resize(m.width, m.height) + var initCmd tea.Cmd + if msg.component != "" { + if msg.version != "" { + initCmd = m.fetchDescriptor(msg.component, msg.version) + } else { + initCmd = m.fetchVersions(msg.component) + } + } + return initCmd + + case fetcherErrMsg: + m.loading = false + m.inputErr = msg.err + m.input.Focus() + return textinput.Blink + } + + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + return cmd +} + +// renderPrompt renders the reference input screen using the shared prompt component. +func (m *Model) renderPrompt() string { + p := input.Prompt{ + Title: "Explore Components", + Subtitle: "Enter a component reference:", + Input: m.input, + Err: m.inputErr, + Loading: m.loading, + LoadingMsg: "Connecting...", + Help: "enter: connect esc: back", + Width: m.width, + Height: m.height, + } + return p.View() +} diff --git a/tui/internal/explorer/tree.go b/tui/internal/explorer/tree.go new file mode 100644 index 0000000..29261d8 --- /dev/null +++ b/tui/internal/explorer/tree.go @@ -0,0 +1,238 @@ +package explorer + +import ( + "fmt" + "slices" + "strings" + + descriptor "ocm.software/open-component-model/bindings/go/descriptor/runtime" + "ocm.software/open-component-model/bindings/go/runtime" +) + +// NodeKind identifies what type of tree node we're looking at. +type NodeKind int + +const ( + NodeComponent NodeKind = iota + NodeVersion + NodeResourceGroup + NodeResource + NodeSourceGroup + NodeSource + NodeReferenceGroup + NodeReference + NodeSignatureGroup + NodeSignature + NodeLabelGroup + NodeLabel +) + +// Node represents a single item in the tree. +type Node struct { + Kind NodeKind + Label string + Depth int + Expanded bool + Loading bool + Children []*Node + + // Data associated with this node (set for leaf/detail nodes). + Descriptor *descriptor.Descriptor + Resource *descriptor.Resource + Source *descriptor.Source + Reference *descriptor.Reference + Signature *descriptor.Signature +} + +// IsExpandable returns true if this node can have children. +func (n *Node) IsExpandable() bool { + switch n.Kind { + case NodeComponent, NodeVersion, NodeResourceGroup, NodeSourceGroup, + NodeReferenceGroup, NodeSignatureGroup, NodeLabelGroup, NodeReference: + return true + default: + return false + } +} + +// Toggle flips the expanded state of an expandable node. +func (n *Node) Toggle() { + if n.IsExpandable() { + n.Expanded = !n.Expanded + } +} + +// Flatten returns a flat slice of visible nodes (respecting expand/collapse). +func Flatten(roots []*Node) []*Node { + var result []*Node + for _, root := range roots { + flatten(root, &result) + } + return result +} + +func flatten(n *Node, result *[]*Node) { + *result = append(*result, n) + if n.Expanded { + for _, child := range n.Children { + flatten(child, result) + } + } +} + +// BuildVersionNodes creates children for a component node from a descriptor. +func BuildVersionNodes(desc *descriptor.Descriptor, depth int) []*Node { + versionNode := &Node{ + Kind: NodeVersion, + Label: desc.Component.Version, + Depth: depth, + Expanded: false, + Descriptor: desc, + } + + var groups []*Node + + if len(desc.Component.Resources) > 0 { + resGroup := &Node{ + Kind: NodeResourceGroup, + Label: fmt.Sprintf("Resources (%d)", len(desc.Component.Resources)), + Depth: depth + 1, + } + for i := range desc.Component.Resources { + res := &desc.Component.Resources[i] + resGroup.Children = append(resGroup.Children, &Node{ + Kind: NodeResource, + Label: elementLabel(res.Name, res.Type, res.ExtraIdentity), + Depth: depth + 2, + Resource: res, + }) + } + groups = append(groups, resGroup) + } + + if len(desc.Component.Sources) > 0 { + srcGroup := &Node{ + Kind: NodeSourceGroup, + Label: fmt.Sprintf("Sources (%d)", len(desc.Component.Sources)), + Depth: depth + 1, + } + for i := range desc.Component.Sources { + src := &desc.Component.Sources[i] + srcGroup.Children = append(srcGroup.Children, &Node{ + Kind: NodeSource, + Label: elementLabel(src.Name, src.Type, src.ExtraIdentity), + Depth: depth + 2, + Source: src, + }) + } + groups = append(groups, srcGroup) + } + + if len(desc.Component.References) > 0 { + refGroup := &Node{ + Kind: NodeReferenceGroup, + Label: fmt.Sprintf("References (%d)", len(desc.Component.References)), + Depth: depth + 1, + } + for i := range desc.Component.References { + ref := &desc.Component.References[i] + refGroup.Children = append(refGroup.Children, &Node{ + Kind: NodeReference, + Label: fmt.Sprintf("%s -> %s:%s", ref.Name, ref.Component, ref.Version), + Depth: depth + 2, + Reference: ref, + }) + } + groups = append(groups, refGroup) + } + + if len(desc.Signatures) > 0 { + sigGroup := &Node{ + Kind: NodeSignatureGroup, + Label: fmt.Sprintf("Signatures (%d)", len(desc.Signatures)), + Depth: depth + 1, + } + for i := range desc.Signatures { + sig := &desc.Signatures[i] + label := sig.Name + if sig.Signature.Algorithm != "" { + label += fmt.Sprintf(" [%s]", sig.Signature.Algorithm) + } + sigGroup.Children = append(sigGroup.Children, &Node{ + Kind: NodeSignature, + Label: label, + Depth: depth + 2, + Signature: sig, + }) + } + groups = append(groups, sigGroup) + } + + if len(desc.Component.Labels) > 0 { + lblGroup := &Node{ + Kind: NodeLabelGroup, + Label: fmt.Sprintf("Labels (%d)", len(desc.Component.Labels)), + Depth: depth + 1, + } + for _, lbl := range desc.Component.Labels { + lblGroup.Children = append(lblGroup.Children, &Node{ + Kind: NodeLabel, + Label: fmt.Sprintf("%s = %s", lbl.Name, string(lbl.Value)), + Depth: depth + 2, + }) + } + groups = append(groups, lblGroup) + } + + versionNode.Children = groups + return []*Node{versionNode} +} + +// elementLabel builds a tree label like "ocmcli [executable] (os=linux, arch=amd64)". +func elementLabel(name, typ string, extraIdentity runtime.Identity) string { + label := fmt.Sprintf("%s [%s]", name, typ) + if len(extraIdentity) == 0 { + return label + } + keys := make([]string, 0, len(extraIdentity)) + for k := range extraIdentity { + if k == "name" || k == "version" { + continue + } + keys = append(keys, k) + } + slices.Sort(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s=%s", k, extraIdentity[k])) + } + if len(parts) > 0 { + label += " (" + strings.Join(parts, ", ") + ")" + } + return label +} + +// RenderNode produces a single-line string representation of a tree node. +func RenderNode(n *Node, isCursor bool) string { + indent := strings.Repeat(" ", n.Depth) + + var prefix string + switch { + case n.Loading: + prefix = "~ " + case n.IsExpandable() && n.Expanded: + prefix = "v " + case n.IsExpandable(): + prefix = "> " + default: + prefix = " " + } + + line := indent + prefix + n.Label + + if isCursor { + line = indent + prefix + n.Label + } + + return line +} diff --git a/tui/internal/explorer/view.go b/tui/internal/explorer/view.go new file mode 100644 index 0000000..1911406 --- /dev/null +++ b/tui/internal/explorer/view.go @@ -0,0 +1,272 @@ +package explorer + +import ( + "context" + "fmt" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + + descriptor "ocm.software/open-component-model/bindings/go/descriptor/runtime" + "ext.ocm.software/tui/internal/components" + "ext.ocm.software/tui/internal/components/progress" + "ext.ocm.software/tui/fetch" +) + +// --- Messages --- + +type versionsMsg struct { + component string + versions []string +} + +type descriptorMsg struct { + component string + version string + descriptor *descriptor.Descriptor +} + +// TransferRequestMsg is emitted when the user requests a transfer from the explorer. +type TransferRequestMsg struct { + Reference string // full reference including repo (e.g. "ghcr.io/org/repo//comp:ver") + Component string + Version string +} + +type errMsg struct{ err error } + +func (e errMsg) Error() string { return e.err.Error() } + +// --- Mode --- + +type explorerMode int + +const ( + modePrompt explorerMode = iota + modeBrowse +) + +// Config holds the dependencies for the explorer view. +type Config struct { + FetcherFactory fetch.FetcherFactory + Downloader fetch.ResourceDownloader +} + +// Model is the explorer view state. +type Model struct { + config Config + mode explorerMode + + // Prompt + input textinput.Model + inputErr error + + // Browse + fetcher fetch.ComponentFetcher + downloader fetch.ResourceDownloader + keys KeyMap + reference string + + // Tree + roots []*Node + cursor int + visible []*Node + + // Detail pane + detail viewport.Model + + // Layout + width int + height int + treeWidth int + focusTree bool + ready bool + + // Status + err error + loading bool + + // Download + downloading bool + downloadStatus string + downloadResName string + spinnerFrame int + + // Initial load + initialComponent string + initialVersion string +} + +// NewView creates a new explorer view starting at the prompt. +func NewView(cfg Config, width, height int) *Model { + ti := textinput.New() + ti.Placeholder = "ghcr.io/open-component-model/ocm//ocm.software/ocmcli:0.23.0" + ti.CharLimit = 512 + ti.Width = 80 + if width > 4 && ti.Width > width-4 { + ti.Width = width - 4 + } + ti.Focus() + + m := &Model{ + config: cfg, + mode: modePrompt, + input: ti, + keys: DefaultKeyMap(), + focusTree: true, + width: width, + height: height, + } + if cfg.Downloader != nil { + m.downloader = cfg.Downloader + } + return m +} + +// --- View interface implementation --- + +func (m *Model) Init() tea.Cmd { + if m.mode == modePrompt { + return textinput.Blink + } + if m.initialComponent == "" { + return nil + } + if m.initialVersion != "" { + return m.fetchDescriptor(m.initialComponent, m.initialVersion) + } + return m.fetchVersions(m.initialComponent) +} + +func (m *Model) Update(msg tea.Msg) tea.Cmd { + switch m.mode { + case modePrompt: + return m.updatePrompt(msg) + case modeBrowse: + return m.updateBrowse(msg) + } + return nil +} + +func (m *Model) Render() string { + switch m.mode { + case modePrompt: + return m.renderPrompt() + case modeBrowse: + return m.renderBrowse() + } + return "" +} + +func (m *Model) StatusInfo() string { + if m.mode == modePrompt { + return "enter component reference" + } + if m.downloading { + return fmt.Sprintf("%s downloading...", progress.Frame(m.spinnerFrame)) + } + return m.reference +} + +func (m *Model) Hotkeys() []components.Hotkey { + if m.mode == modePrompt { + return []components.Hotkey{ + components.NewHotkey(key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "connect"))), + components.NewHotkey(key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back"))), + } + } + if m.downloadStatus != "" { + if m.downloading { + return nil + } + return []components.Hotkey{ + components.NewHotkey(key.NewBinding(key.WithKeys("any"), key.WithHelp("any key", "dismiss"))), + } + } + hotkeys := []components.Hotkey{ + components.NewHotkey(m.keys.Up), + components.NewHotkey(m.keys.Expand), + components.NewHotkey(m.keys.Collapse), + components.NewHotkey(key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "switch pane"))), + } + if m.downloader != nil { + hotkeys = append(hotkeys, components.NewHotkey(m.keys.Download)) + } + hotkeys = append(hotkeys, components.NewHotkey(m.keys.Transfer)) + return hotkeys +} + +func (m *Model) OnBackPressed() int { + if m.mode == modePrompt { + return 1 // BackExit + } + if m.focusTree && len(m.visible) > 0 && m.cursor < len(m.visible) { + node := m.visible[m.cursor] + if node.IsExpandable() && node.Expanded { + node.Expanded = false + m.visible = Flatten(m.roots) + m.updateDetail() + return 0 // BackConsumed + } + } + return 1 // BackExit +} + +func (m *Model) Resize(width, height int) { + m.width = width + m.height = height + m.treeWidth = width / 2 + if m.input.Width > width-4 { + m.input.Width = width - 4 + } + detailWidth := width - m.treeWidth - 1 + contentHeight := height - 3 + if !m.ready { + m.detail = viewport.New(detailWidth, contentHeight) + m.ready = true + } else { + m.detail.Width = detailWidth + m.detail.Height = contentHeight + } +} + +// ToggleFocus switches focus between tree and detail panes. +func (m *Model) ToggleFocus() { + m.focusTree = !m.focusTree +} + +func (m *Model) updateDetail() { + if len(m.visible) == 0 || m.cursor >= len(m.visible) { + m.detail.SetContent("No items.") + return + } + node := m.visible[m.cursor] + m.detail.SetContent(NodeDetail(node)) + m.detail.GotoTop() +} + +// --- Fetch commands --- + +func (m *Model) fetchVersions(component string) tea.Cmd { + fetcher := m.fetcher + return func() tea.Msg { + versions, err := fetcher.ListVersions(context.Background(), component) + if err != nil { + return errMsg{fmt.Errorf("listing versions for %s: %w", component, err)} + } + return versionsMsg{component: component, versions: versions} + } +} + +func (m *Model) fetchDescriptor(component, version string) tea.Cmd { + fetcher := m.fetcher + return func() tea.Msg { + desc, err := fetcher.GetDescriptor(context.Background(), component, version) + if err != nil { + return errMsg{fmt.Errorf("fetching %s:%s: %w", component, version, err)} + } + return descriptorMsg{component: component, version: version, descriptor: desc} + } +} diff --git a/tui/internal/ocm/bootstrap.go b/tui/internal/ocm/bootstrap.go new file mode 100644 index 0000000..5bbf146 --- /dev/null +++ b/tui/internal/ocm/bootstrap.go @@ -0,0 +1,157 @@ +// Package ocm bootstraps the OCM runtime for the standalone TUI binary. +// It uses only the public bindings API, not CLI internals. +package ocm + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "time" + + genericv1 "ocm.software/open-component-model/bindings/go/configuration/generic/v1/spec" + "ocm.software/open-component-model/bindings/go/credentials" + credentialsRuntime "ocm.software/open-component-model/bindings/go/credentials/spec/config/runtime" + "ocm.software/open-component-model/bindings/go/plugin/manager" + "ocm.software/open-component-model/bindings/go/runtime" +) + +// Runtime holds the bootstrapped OCM runtime components. +type Runtime struct { + Config *genericv1.Config + PluginManager *manager.PluginManager + CredentialGraph credentials.Resolver +} + +// Bootstrap initializes the OCM runtime. +func Bootstrap(ctx context.Context) (*Runtime, error) { + // 1. Load OCM config. + cfg, err := loadOCMConfig() + if err != nil { + slog.Debug("could not load OCM config, using defaults", slog.String("error", err.Error())) + cfg = &genericv1.Config{} + } + + // 2. Create plugin manager and discover plugins. + pm := manager.NewPluginManager(ctx) + + pluginDir := defaultPluginDir() + if pluginDir != "" { + if err := pm.RegisterPlugins(ctx, pluginDir, + manager.WithIdleTimeout(time.Hour), + ); err != nil && !errors.Is(err, manager.ErrNoPluginsFound) { + return nil, fmt.Errorf("registering plugins from %s: %w", pluginDir, err) + } + } + + // Register builtin OCI support. + if err := registerBuiltins(pm); err != nil { + return nil, fmt.Errorf("registering builtins: %w", err) + } + + // 3. Build credential graph. + graph, err := buildCredentialGraph(ctx, cfg, pm) + if err != nil { + return nil, fmt.Errorf("building credential graph: %w", err) + } + + return &Runtime{ + Config: cfg, + PluginManager: pm, + CredentialGraph: graph, + }, nil +} + +// Shutdown cleanly stops the plugin manager. +func (r *Runtime) Shutdown(ctx context.Context) error { + if r.PluginManager != nil { + return r.PluginManager.Shutdown(ctx) + } + return nil +} + +func buildCredentialGraph(ctx context.Context, cfg *genericv1.Config, pm *manager.PluginManager) (credentials.Resolver, error) { + opts := credentials.Options{ + RepositoryPluginProvider: pm.CredentialRepositoryRegistry, + CredentialPluginProvider: credentials.GetCredentialPluginFn( + func(_ context.Context, typed runtime.Typed) (credentials.CredentialPlugin, error) { + return nil, fmt.Errorf("no credential plugin for type %s", typed) + }, + ), + CredentialRepositoryTypeScheme: pm.CredentialRepositoryRegistry.RepositoryScheme(), + } + + credCfg, err := credentialsRuntime.LookupCredentialConfig(cfg) + if err != nil || credCfg == nil { + credCfg = &credentialsRuntime.Config{} + } + + return credentials.ToGraph(ctx, credCfg, opts) +} + +func defaultPluginDir() string { + if home, err := os.UserHomeDir(); err == nil { + dir := filepath.Join(home, ".ocm", "plugins") + if fi, err := os.Stat(dir); err == nil && fi.IsDir() { + return dir + } + } + return "" +} + +// --- Config loading --- + +func loadOCMConfig() (*genericv1.Config, error) { + paths := configPaths() + if len(paths) == 0 { + return nil, fmt.Errorf("no OCM config found") + } + var cfgs []*genericv1.Config + for _, path := range paths { + cfg, err := loadConfigFile(path) + if err != nil { + continue + } + slog.Debug("loaded OCM config", slog.String("path", path)) + cfgs = append(cfgs, cfg) + } + return genericv1.FlatMap(cfgs...), nil +} + +func loadConfigFile(path string) (*genericv1.Config, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + var cfg genericv1.Config + if err := genericv1.Scheme.Decode(f, &cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +func configPaths() []string { + var paths []string + if env := os.Getenv("OCM_CONFIG"); env != "" { + if _, err := os.Stat(env); err == nil { + paths = append(paths, env) + } + } + if home, err := os.UserHomeDir(); err == nil { + for _, name := range []string{".ocm/config", ".ocmconfig"} { + for _, base := range []string{ + filepath.Join(home, ".config"), + home, + } { + c := filepath.Join(base, name) + if _, err := os.Stat(c); err == nil { + paths = append(paths, c) + } + } + } + } + return paths +} diff --git a/tui/internal/ocm/builtins.go b/tui/internal/ocm/builtins.go new file mode 100644 index 0000000..c7047c9 --- /dev/null +++ b/tui/internal/ocm/builtins.go @@ -0,0 +1,48 @@ +package ocm + +import ( + "errors" + "log/slog" + + ocicredentials "ocm.software/open-component-model/bindings/go/oci/credentials" + "ocm.software/open-component-model/bindings/go/oci/repository/provider" + ocires "ocm.software/open-component-model/bindings/go/oci/repository/resource" + credidentityv1 "ocm.software/open-component-model/bindings/go/oci/spec/credentials/identity/v1" + "ocm.software/open-component-model/bindings/go/oci/transformer" + "ocm.software/open-component-model/bindings/go/plugin/manager" + "ocm.software/open-component-model/bindings/go/runtime" + + // Import the credential spec packages for their init() side effects. + // This registers DockerConfig/v1 in the credential repository scheme. + _ "ocm.software/open-component-model/bindings/go/oci/spec/credentials" + _ "ocm.software/open-component-model/bindings/go/oci/spec/credentials/v1" +) + +const userAgent = "ocm-tui" + +// registerBuiltins mirrors what the CLI's builtin.Register does, +// registering the OCI plugins needed for browsing, downloading, +// and credential resolution. +func registerBuiltins(pm *manager.PluginManager) error { + // 1. OCI credential repository (same as cli/internal/plugin/builtin/credentials/oci) + if err := pm.CredentialRepositoryRegistry.RegisterInternalCredentialRepositoryPlugin( + &ocicredentials.OCICredentialRepository{}, + []runtime.Type{credidentityv1.Type}, + ); err != nil { + return err + } + + // 2. OCI component version repository + resource plugin (same as cli/internal/plugin/builtin/oci) + cvRepoProvider := provider.NewComponentVersionRepositoryProvider( + provider.WithUserAgent(userAgent), + ) + resPlugin := ocires.NewResourceRepository(nil, ocires.WithUserAgent(userAgent)) + blobTransformer := transformer.New(slog.Default()) + + return errors.Join( + pm.ComponentVersionRepositoryRegistry.RegisterInternalComponentVersionRepositoryPlugin(cvRepoProvider), + pm.ResourcePluginRegistry.RegisterInternalResourcePlugin(resPlugin), + pm.DigestProcessorRegistry.RegisterInternalDigestProcessorPlugin(resPlugin), + pm.BlobTransformerRegistry.RegisterInternalBlobTransformerPlugin(blobTransformer), + ) +} diff --git a/tui/internal/ocm/wiring.go b/tui/internal/ocm/wiring.go new file mode 100644 index 0000000..6fe6aeb --- /dev/null +++ b/tui/internal/ocm/wiring.go @@ -0,0 +1,301 @@ +package ocm + +import ( + "context" + "errors" + "fmt" + "log/slog" + + "ocm.software/open-component-model/bindings/go/blob" + "ocm.software/open-component-model/bindings/go/repository/component/resolvers" + "ocm.software/open-component-model/bindings/go/blob/filesystem" + "ocm.software/open-component-model/bindings/go/credentials" + descriptor "ocm.software/open-component-model/bindings/go/descriptor/runtime" + v2 "ocm.software/open-component-model/bindings/go/descriptor/v2" + "ocm.software/open-component-model/bindings/go/oci/compref" + "ocm.software/open-component-model/bindings/go/plugin/manager" + "ocm.software/open-component-model/bindings/go/repository" + "ocm.software/open-component-model/bindings/go/runtime" + "ocm.software/open-component-model/bindings/go/transfer" + graphRuntime "ocm.software/open-component-model/bindings/go/transform/graph/runtime" + transformv1alpha1 "ocm.software/open-component-model/bindings/go/transform/spec/v1alpha1" + + "ext.ocm.software/tui/fetch" +) + +// NewFetcherFactory creates a FetcherFactory from the runtime. +func NewFetcherFactory(rt *Runtime) fetch.FetcherFactory { + return func(ctx context.Context, reference string) (fetch.ComponentFetcher, string, string, error) { + ref, err := compref.Parse(reference, compref.IgnoreSemverCompatibility()) + if err != nil { + return nil, "", "", fmt.Errorf("invalid reference %q: %w", reference, err) + } + + repo, err := getRepo(ctx, rt, ref) + if err != nil { + return nil, "", "", err + } + + return &componentFetcher{repo: repo}, ref.Component, ref.Version, nil + } +} + +type componentFetcher struct { + repo repository.ComponentVersionRepository +} + +func (f *componentFetcher) ListVersions(ctx context.Context, component string) ([]string, error) { + return f.repo.ListComponentVersions(ctx, component) +} + +func (f *componentFetcher) GetDescriptor(ctx context.Context, component, version string) (*descriptor.Descriptor, error) { + return f.repo.GetComponentVersion(ctx, component, version) +} + +// NewResourceDownloader creates a ResourceDownloader from the runtime. +func NewResourceDownloader(rt *Runtime) fetch.ResourceDownloader { + return &resourceDownloader{rt: rt} +} + +type resourceDownloader struct { + rt *Runtime +} + +func (d *resourceDownloader) DownloadResource(ctx context.Context, reference, component, version string, res *descriptor.Resource, outputDir string) (string, error) { + ref, err := compref.Parse(reference, compref.IgnoreSemverCompatibility()) + if err != nil { + return "", fmt.Errorf("parsing reference: %w", err) + } + ref.Component = component + ref.Version = version + + repo, err := getRepo(ctx, d.rt, ref) + if err != nil { + return "", err + } + + identity := res.ToIdentity() + data, err := downloadResourceData(ctx, d.rt.PluginManager, d.rt.CredentialGraph, component, version, repo, res, identity) + if err != nil { + return "", err + } + + outputPath := fmt.Sprintf("%s/%s", outputDir, identity.String()) + outputDir2 := outputPath + _ = outputDir2 + if err := filesystem.CopyBlobToOSPath(data, outputPath); err != nil { + return "", fmt.Errorf("saving resource: %w", err) + } + return outputPath, nil +} + +// downloadResourceData handles both local blob and remote plugin resources. +func downloadResourceData(ctx context.Context, pm *manager.PluginManager, credGraph credentials.Resolver, + component, version string, repo repository.ComponentVersionRepository, + res *descriptor.Resource, identity runtime.Identity, +) (blob.ReadOnlyBlob, error) { + access := res.GetAccess() + + // Check if local blob access. + if isLocal(access) { + data, _, err := repo.GetLocalResource(ctx, component, version, identity) + return data, err + } + + // Remote: use resource plugin. + plugin, err := pm.ResourcePluginRegistry.GetResourcePlugin(ctx, access) + if err != nil { + return nil, fmt.Errorf("getting resource plugin for %q: %w", access.GetType(), err) + } + + var creds map[string]string + if credIdentity, err := plugin.GetResourceCredentialConsumerIdentity(ctx, res); err == nil { + if creds, err = credGraph.Resolve(ctx, credIdentity); err != nil && !errors.Is(err, credentials.ErrNotFound) { + return nil, fmt.Errorf("resolving credentials: %w", err) + } + } + + return plugin.DownloadResource(ctx, res, creds) +} + +func isLocal(access runtime.Typed) bool { + if access == nil { + return false + } + var local v2.LocalBlob + return v2.Scheme.Convert(access, &local) == nil +} + +// NewTransferExecutor creates a TransferExecutor from the runtime. +func NewTransferExecutor(rt *Runtime) fetch.TransferExecutor { + return &transferExecutor{rt: rt} +} + +type transferExecutor struct { + rt *Runtime +} + +func (t *transferExecutor) BuildGraph(ctx context.Context, source, target string, opts fetch.TransferOptions) (*transformv1alpha1.TransformationGraphDefinition, error) { + fromSpec, err := compref.Parse(source) + if err != nil { + return nil, fmt.Errorf("invalid source: %w", err) + } + + sourceRepo, err := getRepo(ctx, t.rt, fromSpec) + if err != nil { + return nil, err + } + + toSpec, err := compref.ParseRepository(target) + if err != nil { + return nil, fmt.Errorf("invalid target: %w", err) + } + + copyMode := transfer.CopyModeLocalBlobResources + if opts.CopyResources { + copyMode = transfer.CopyModeAllResources + } + uploadType := transfer.UploadAsDefault + switch opts.UploadAs { + case "localBlob": + uploadType = transfer.UploadAsLocalBlob + case "ociArtifact": + uploadType = transfer.UploadAsOciArtifact + } + + resolver := &repoResolver{rt: t.rt, repo: sourceRepo, spec: fromSpec.Repository} + return transfer.BuildGraphDefinition(ctx, + transfer.WithTransfer( + transfer.Component(fromSpec.Component, fromSpec.Version), + transfer.ToRepositorySpec(toSpec), + transfer.FromResolver(resolver), + ), + transfer.WithRecursive(opts.Recursive), + transfer.WithCopyMode(copyMode), + transfer.WithUploadType(uploadType), + ) +} + +func (t *transferExecutor) Execute(ctx context.Context, tgd *transformv1alpha1.TransformationGraphDefinition, progressCh chan<- fetch.TransferProgress) error { + b := transfer.NewDefaultBuilder( + t.rt.PluginManager.ComponentVersionRepositoryRegistry, + t.rt.PluginManager.ResourcePluginRegistry, + t.rt.CredentialGraph, + ) + + eventCh := make(chan graphRuntime.ProgressEvent, 16) + graph, err := b.WithEvents(eventCh).BuildAndCheck(tgd) + if err != nil { + close(progressCh) + return fmt.Errorf("building transformation graph: %w", err) + } + + nodeCount := graph.NodeCount() + fwd := &slogForwarder{fallback: slog.Default().Handler(), ch: progressCh} + slog.SetDefault(slog.New(fwd)) + + eventsDone := make(chan struct{}) + go func() { + defer close(eventsDone) + completed := 0 + for event := range graph.Events() { + if event.State == graphRuntime.Completed || event.State == graphRuntime.Failed { + completed++ + } + name := event.State.String() + if event.Transformation != nil { + name = fmt.Sprintf("%s [%s]: %s", event.Transformation.ID, event.Transformation.Type.Name, event.State.String()) + } + progressCh <- fetch.TransferProgress{Step: name, Total: nodeCount, Current: completed} + } + }() + + processErr := graph.Process(ctx) + <-eventsDone + slog.SetDefault(slog.New(fwd.fallback)) + fwd.stop() + close(progressCh) + + if processErr != nil { + return fmt.Errorf("transfer failed: %w", processErr) + } + return nil +} + +// --- Resolver --- + +// repoResolver implements resolvers.ComponentVersionRepositoryResolver +// by reusing a single repository connection. +type repoResolver struct { + rt *Runtime + repo repository.ComponentVersionRepository + spec runtime.Typed +} + +var _ resolvers.ComponentVersionRepositoryResolver = (*repoResolver)(nil) + +func (r *repoResolver) GetComponentVersionRepositoryForComponent(ctx context.Context, component, version string) (repository.ComponentVersionRepository, error) { + return r.repo, nil +} + +func (r *repoResolver) GetComponentVersionRepositoryForSpecification(ctx context.Context, specification runtime.Typed) (repository.ComponentVersionRepository, error) { + return getRepo(ctx, r.rt, &compref.Ref{Repository: specification}) +} + +func (r *repoResolver) GetRepositorySpecificationForComponent(ctx context.Context, component, version string) (runtime.Typed, error) { + return r.spec, nil +} + +// --- Helpers --- + +func getRepo(ctx context.Context, rt *Runtime, ref *compref.Ref) (repository.ComponentVersionRepository, error) { + repoSpec := ref.Repository + if repoSpec == nil { + return nil, fmt.Errorf("no repository in reference") + } + + // Resolve credentials for the repository. + var creds map[string]string + identity, err := rt.PluginManager.ComponentVersionRepositoryRegistry.GetComponentVersionRepositoryCredentialConsumerIdentity(ctx, repoSpec) + if err == nil && rt.CredentialGraph != nil { + if creds, err = rt.CredentialGraph.Resolve(ctx, identity); err != nil && !errors.Is(err, credentials.ErrNotFound) { + return nil, fmt.Errorf("resolving repository credentials: %w", err) + } + } + + repo, err := rt.PluginManager.ComponentVersionRepositoryRegistry.GetComponentVersionRepository(ctx, repoSpec, creds) + if err != nil { + return nil, fmt.Errorf("connecting to repository: %w", err) + } + + return repo, nil +} + +// --- slog forwarder --- + +type slogForwarder struct { + fallback slog.Handler + ch chan<- fetch.TransferProgress + stopped bool +} + +func (h *slogForwarder) stop() { h.stopped = true } +func (h *slogForwarder) Enabled(_ context.Context, _ slog.Level) bool { return !h.stopped } +func (h *slogForwarder) WithAttrs(attrs []slog.Attr) slog.Handler { + return &slogForwarder{fallback: h.fallback.WithAttrs(attrs), ch: h.ch, stopped: h.stopped} +} +func (h *slogForwarder) WithGroup(name string) slog.Handler { + return &slogForwarder{fallback: h.fallback.WithGroup(name), ch: h.ch, stopped: h.stopped} +} +func (h *slogForwarder) Handle(_ context.Context, r slog.Record) error { + if h.stopped { + return nil + } + msg := r.Message + r.Attrs(func(a slog.Attr) bool { msg += fmt.Sprintf(" %s=%v", a.Key, a.Value); return true }) + select { + case h.ch <- fetch.TransferProgress{Step: msg, IsLog: true}: + default: + } + return nil +} diff --git a/tui/internal/theme/default.go b/tui/internal/theme/default.go new file mode 100644 index 0000000..7dd79bc --- /dev/null +++ b/tui/internal/theme/default.go @@ -0,0 +1,82 @@ +package theme + +import "github.com/charmbracelet/lipgloss" + +// Default returns the default OCM TUI theme. +func Default() *Theme { + primary := lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"} + subtle := lipgloss.AdaptiveColor{Light: "#999999", Dark: "#666666"} + text := lipgloss.AdaptiveColor{Light: "#333333", Dark: "#DDDDDD"} + errColor := lipgloss.AdaptiveColor{Light: "#FF0000", Dark: "#FF4444"} + success := lipgloss.AdaptiveColor{Light: "#00AA00", Dark: "#44FF44"} + warning := lipgloss.AdaptiveColor{Light: "#CCAA00", Dark: "#FFCC44"} + running := lipgloss.AdaptiveColor{Light: "#0077CC", Dark: "#55AAFF"} + + return &Theme{ + // Colors + Primary: primary, + Subtle: subtle, + Text: text, + Error: errColor, + Success: success, + Warning: warning, + Running: running, + + // Text styles + Title: lipgloss.NewStyle(). + Bold(true). + Foreground(primary), + + Subtitle: lipgloss.NewStyle(). + Foreground(subtle), + + Help: lipgloss.NewStyle(). + Foreground(subtle), + + ErrorText: lipgloss.NewStyle(). + Foreground(errColor), + + SuccessText: lipgloss.NewStyle(). + Foreground(success), + + RunningText: lipgloss.NewStyle(). + Foreground(running), + + DimText: lipgloss.NewStyle(). + Foreground(subtle), + + // Interactive + Cursor: lipgloss.NewStyle(). + Bold(true). + Reverse(true), + + CursorDim: lipgloss.NewStyle(). + Bold(true), + + Selected: lipgloss.NewStyle(). + Bold(true). + Foreground(primary), + + // Layout + ModalBorder: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(primary). + Padding(0, 1), + + Divider: lipgloss.NewStyle(). + Foreground(subtle), + + // Status bar + StatusTitle: lipgloss.NewStyle(). + Bold(true). + Foreground(primary). + PaddingRight(1), + + StatusHint: lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#BBBBBB", Dark: "#555555"}). + PaddingRight(1), + + StatusRef: lipgloss.NewStyle(). + Foreground(subtle), + } +} diff --git a/tui/internal/theme/theme.go b/tui/internal/theme/theme.go new file mode 100644 index 0000000..562b90d --- /dev/null +++ b/tui/internal/theme/theme.go @@ -0,0 +1,69 @@ +// Package theme provides centralized styling for the TUI. +// All components reference theme.Current() instead of hardcoding colors. +package theme + +import "github.com/charmbracelet/lipgloss" + +// Theme defines all colors and pre-built styles for the TUI. +type Theme struct { + // Base colors + Primary lipgloss.AdaptiveColor + Subtle lipgloss.AdaptiveColor + Text lipgloss.AdaptiveColor + Error lipgloss.AdaptiveColor + Success lipgloss.AdaptiveColor + Warning lipgloss.AdaptiveColor + Running lipgloss.AdaptiveColor + + // Pre-built text styles + Title lipgloss.Style + Subtitle lipgloss.Style + Help lipgloss.Style + ErrorText lipgloss.Style + SuccessText lipgloss.Style + RunningText lipgloss.Style + DimText lipgloss.Style + + // Interactive styles + Cursor lipgloss.Style // focused cursor line (bold + reverse) + CursorDim lipgloss.Style // unfocused cursor line (bold only) + Selected lipgloss.Style // selected/highlighted item + + // Layout styles + ModalBorder lipgloss.Style + Divider lipgloss.Style // vertical pane divider character style + + // Status bar styles + StatusTitle lipgloss.Style + StatusHint lipgloss.Style + StatusRef lipgloss.Style +} + +// Separator renders a horizontal separator line of the given width. +func (t Theme) Separator(width int) string { + return t.DimText.Render(repeat("─", width)) +} + +func repeat(s string, n int) string { + if n <= 0 { + return "" + } + b := make([]byte, 0, n*len(s)) + for i := 0; i < n; i++ { + b = append(b, s...) + } + return string(b) +} + +// current holds the active theme. Changed via SetTheme. +var current = Default() + +// Current returns the active theme. +func Current() *Theme { + return current +} + +// SetTheme replaces the active theme. +func SetTheme(t *Theme) { + current = t +} diff --git a/tui/internal/transfer/keymap.go b/tui/internal/transfer/keymap.go new file mode 100644 index 0000000..59a40ee --- /dev/null +++ b/tui/internal/transfer/keymap.go @@ -0,0 +1,28 @@ +package transfer + +import "github.com/charmbracelet/bubbles/key" + +// KeyMap defines the key bindings for the transfer wizard. +type KeyMap struct { + Up key.Binding + Down key.Binding + Submit key.Binding +} + +// DefaultKeyMap returns the default transfer wizard key bindings. +func DefaultKeyMap() KeyMap { + return KeyMap{ + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("k", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("j", "down"), + ), + Submit: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "confirm"), + ), + } +} diff --git a/tui/internal/transfer/render.go b/tui/internal/transfer/render.go new file mode 100644 index 0000000..4dc7e3c --- /dev/null +++ b/tui/internal/transfer/render.go @@ -0,0 +1,185 @@ +package transfer + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/lipgloss" + + "ext.ocm.software/tui/internal/components/input" + "ext.ocm.software/tui/internal/theme" +) + +func (m *Model) render() string { + switch m.step { + case stepSource: + return m.renderInput("Step 1/4: Source Component", "Enter the source component reference:", m.sourceInput) + case stepTarget: + return m.renderInput("Step 2/4: Target Repository", "Enter the target repository:", m.targetInput) + case stepOptions: + return m.renderOptions() + case stepReview: + return m.renderReview() + case stepExecuting: + return m.renderExecuting() + case stepDone: + return m.renderDone() + } + return "" +} + +func (m *Model) renderInput(title, subtitle string, ti textinput.Model) string { + p := input.Prompt{ + Title: title, + Subtitle: subtitle, + Input: ti, + Err: m.err, + Help: "enter: next esc: back", + Width: m.width, + Height: m.height, + } + return p.View() +} + +func (m *Model) renderOptions() string { + t := theme.Current() + + var sections []string + sections = append(sections, t.Title.MarginBottom(1).Render("Step 3/4: Transfer Options")) + sections = append(sections, "") + + type row struct { + label string + value string + toggle bool + checked bool + action bool + } + options := []row{ + {"Recursive", "", true, m.recursive, false}, + {"Copy all resources", "", true, m.copyResources, false}, + {"Upload as", uploadAsLabels[m.uploadAs], false, false, false}, + {"Build graph >>>", "", false, false, true}, + } + + for i, opt := range options { + cursor := " " + if i == m.optionCursor { + cursor = "> " + } + + var line string + switch { + case opt.action: + line = fmt.Sprintf("%s%s", cursor, opt.label) + case opt.toggle: + check := "[ ]" + if opt.checked { + check = "[x]" + } + line = fmt.Sprintf("%s%s %s", cursor, check, opt.label) + default: + line = fmt.Sprintf("%s %s: %s", cursor, opt.label, opt.value) + } + + if i == m.optionCursor { + line = t.Selected.Render(line) + } + sections = append(sections, line) + } + + sections = append(sections, "") + if m.err != nil { + sections = append(sections, t.ErrorText.MarginTop(1).Render(fmt.Sprintf("Error: %v", m.err))) + } + + content := lipgloss.JoinVertical(lipgloss.Left, sections...) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, content) +} + +func (m *Model) renderReview() string { + t := theme.Current() + + var sections []string + sections = append(sections, t.Title.MarginBottom(1).Render("Step 4/4: Review Transformation Graph")) + + count := 0 + if m.tgd != nil { + count = len(m.tgd.Transformations) + } + sections = append(sections, fmt.Sprintf("%d transformations will be executed:", count)) + sections = append(sections, "") + sections = append(sections, m.reviewView.View()) + + return lipgloss.JoinVertical(lipgloss.Left, sections...) +} + +func (m *Model) renderExecuting() string { + t := theme.Current() + + var sections []string + + progressText := "" + if m.progressTotal > 0 { + progressText = fmt.Sprintf(" (%d/%d)", m.progressCurrent, m.progressTotal) + } + sections = append(sections, t.Title.Render("Transferring..."+progressText)) + sections = append(sections, "") + + maxVisible := m.height - 5 + if maxVisible < 1 { + maxVisible = 1 + } + start := 0 + if len(m.progressLog) > maxVisible { + start = len(m.progressLog) - maxVisible + } + for _, entry := range m.progressLog[start:] { + sections = append(sections, " "+styleLogEntry(t, entry)) + } + + return lipgloss.JoinVertical(lipgloss.Left, sections...) +} + +func (m *Model) renderDone() string { + t := theme.Current() + + var sections []string + + if m.execErr != nil { + sections = append(sections, t.ErrorText.Bold(true).Render("Transfer failed")) + sections = append(sections, fmt.Sprintf("\n%v", m.execErr)) + } else { + sections = append(sections, t.SuccessText.Bold(true).Render("Transfer completed successfully")) + } + sections = append(sections, "") + + maxVisible := m.height - 6 + if maxVisible < 1 { + maxVisible = 1 + } + start := 0 + if len(m.progressLog) > maxVisible { + start = len(m.progressLog) - maxVisible + } + for _, entry := range m.progressLog[start:] { + sections = append(sections, " "+styleLogEntry(t, entry)) + } + + content := lipgloss.JoinVertical(lipgloss.Left, sections...) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, content) +} + +func styleLogEntry(t *theme.Theme, entry string) string { + switch { + case strings.Contains(entry, "completed"): + return t.SuccessText.Render(entry) + case strings.Contains(entry, "failed"): + return t.ErrorText.Render(entry) + case strings.Contains(entry, "running"): + return t.RunningText.Render(entry) + default: + return t.DimText.Render(entry) + } +} diff --git a/tui/internal/transfer/steps.go b/tui/internal/transfer/steps.go new file mode 100644 index 0000000..27a08d7 --- /dev/null +++ b/tui/internal/transfer/steps.go @@ -0,0 +1,138 @@ +package transfer + +import ( + "context" + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "ext.ocm.software/tui/fetch" +) + +func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { + switch m.step { + case stepSource: + return m.handleSourceKey(msg) + case stepTarget: + return m.handleTargetKey(msg) + case stepOptions: + return m.handleOptionsKey(msg) + case stepReview: + return m.handleReviewKey(msg) + case stepDone: + return nil + } + return nil +} + +func (m *Model) handleSourceKey(msg tea.KeyMsg) tea.Cmd { + switch msg.Type { + case tea.KeyEnter: + if strings.TrimSpace(m.sourceInput.Value()) == "" { + return nil + } + m.step = stepTarget + m.sourceInput.Blur() + m.targetInput.Focus() + return textinput.Blink + } + var cmd tea.Cmd + m.sourceInput, cmd = m.sourceInput.Update(msg) + return cmd +} + +func (m *Model) handleTargetKey(msg tea.KeyMsg) tea.Cmd { + switch msg.Type { + case tea.KeyEnter: + if strings.TrimSpace(m.targetInput.Value()) == "" { + return nil + } + m.step = stepOptions + m.targetInput.Blur() + return nil + } + var cmd tea.Cmd + m.targetInput, cmd = m.targetInput.Update(msg) + return cmd +} + +func (m *Model) handleOptionsKey(msg tea.KeyMsg) tea.Cmd { + switch { + case key.Matches(msg, m.keys.Up): + if m.optionCursor > 0 { + m.optionCursor-- + } + case key.Matches(msg, m.keys.Down): + if m.optionCursor < 3 { + m.optionCursor++ + } + case msg.Type == tea.KeySpace: + m.toggleOption() + case msg.Type == tea.KeyEnter: + if m.optionCursor == 3 { + m.err = nil + return m.buildGraph() + } + m.toggleOption() + } + return nil +} + +func (m *Model) toggleOption() { + switch m.optionCursor { + case 0: + m.recursive = !m.recursive + case 1: + m.copyResources = !m.copyResources + case 2: + m.uploadAs = (m.uploadAs + 1) % len(uploadAsLabels) + } +} + +func (m *Model) handleReviewKey(msg tea.KeyMsg) tea.Cmd { + if key.Matches(msg, m.keys.Submit) { + if m.executor == nil { + m.err = fmt.Errorf("no transfer executor configured") + return nil + } + m.step = stepExecuting + progressCh := make(chan fetch.TransferProgress, 16) + doneCh := make(chan error, 1) + m.progressCh = progressCh + m.doneCh = doneCh + tgd := m.tgd + executor := m.executor + go func() { + doneCh <- executor.Execute(context.Background(), tgd, progressCh) + }() + return waitForProgress(progressCh, doneCh) + } + var cmd tea.Cmd + m.reviewView, cmd = m.reviewView.Update(msg) + return cmd +} + +func (m *Model) buildGraph() tea.Cmd { + if m.executor == nil { + m.err = fmt.Errorf("no transfer executor configured") + return nil + } + source := strings.TrimSpace(m.sourceInput.Value()) + target := strings.TrimSpace(m.targetInput.Value()) + opts := fetch.TransferOptions{ + Recursive: m.recursive, + CopyResources: m.copyResources, + UploadAs: uploadAsLabels[m.uploadAs], + } + executor := m.executor + return func() tea.Msg { + tgd, err := executor.BuildGraph(context.Background(), source, target, opts) + if err != nil { + return graphErrMsg{err} + } + return graphBuiltMsg{tgd: tgd} + } +} diff --git a/tui/internal/transfer/view.go b/tui/internal/transfer/view.go new file mode 100644 index 0000000..bf50beb --- /dev/null +++ b/tui/internal/transfer/view.go @@ -0,0 +1,282 @@ +package transfer + +import ( + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "gopkg.in/yaml.v3" + + "ext.ocm.software/tui/internal/components" + "ext.ocm.software/tui/fetch" + transformv1alpha1 "ocm.software/open-component-model/bindings/go/transform/spec/v1alpha1" +) + +// Wizard steps +type step int + +const ( + stepSource step = iota + stepTarget + stepOptions + stepReview + stepExecuting + stepDone +) + +// Messages +type graphBuiltMsg struct { + tgd *transformv1alpha1.TransformationGraphDefinition +} +type graphErrMsg struct{ err error } +type transferProgressMsg struct { + progress fetch.TransferProgress +} +type transferDoneMsg struct{ err error } + +// Model is the transfer wizard state. +type Model struct { + executor fetch.TransferExecutor + keys KeyMap + step step + + // Inputs + sourceInput textinput.Model + targetInput textinput.Model + + // Options + recursive bool + copyResources bool + uploadAs int + optionCursor int + + // Review + tgd *transformv1alpha1.TransformationGraphDefinition + reviewView viewport.Model + + // Execution + progressCh <-chan fetch.TransferProgress + doneCh <-chan error + progressLog []string + progressCurrent int + progressTotal int + execErr error + + // Layout + width int + height int + ready bool + err error +} + +var uploadAsLabels = []string{"default", "localBlob", "ociArtifact"} + +// Config holds dependencies for the transfer view. +type Config struct { + Executor fetch.TransferExecutor +} + +// New creates a new transfer wizard model. +func New(executor fetch.TransferExecutor) Model { + src := textinput.New() + src.Placeholder = "ghcr.io/source-org/ocm//ocm.software/mycomponent:1.0.0" + src.CharLimit = 512 + src.Width = 80 + src.Focus() + + tgt := textinput.New() + tgt.Placeholder = "ghcr.io/target-org/ocm" + tgt.CharLimit = 512 + tgt.Width = 80 + + return Model{ + executor: executor, + keys: DefaultKeyMap(), + step: stepSource, + sourceInput: src, + targetInput: tgt, + } +} + +// NewView creates a new transfer view. +func NewView(cfg Config, width, height int) *Model { + m := New(cfg.Executor) + m.width = width + m.height = height + m.ready = true + return &m +} + +// --- View interface --- + +func (m *Model) Init() tea.Cmd { + return textinput.Blink +} + +func (m *Model) Update(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.ready = true + for _, inp := range []*textinput.Model{&m.sourceInput, &m.targetInput} { + if inp.Width > msg.Width-4 { + inp.Width = msg.Width - 4 + } + } + if m.step == stepReview { + m.reviewView.Width = msg.Width - 4 + m.reviewView.Height = msg.Height - 8 + } + return nil + + case graphBuiltMsg: + m.tgd = msg.tgd + m.step = stepReview + m.err = nil + rendered, _ := yaml.Marshal(msg.tgd) + m.reviewView = viewport.New(m.width-4, m.height-8) + m.reviewView.SetContent(string(rendered)) + return nil + + case graphErrMsg: + m.err = msg.err + m.step = stepOptions + return nil + + case transferProgressMsg: + if msg.progress.IsLog { + m.progressLog = append(m.progressLog, " "+msg.progress.Step) + } else { + m.progressLog = append(m.progressLog, msg.progress.Step) + m.progressCurrent = msg.progress.Current + m.progressTotal = msg.progress.Total + } + return waitForProgress(m.progressCh, m.doneCh) + + case transferDoneMsg: + m.step = stepDone + m.execErr = msg.err + return nil + + case tea.KeyMsg: + return m.handleKey(msg) + } + + // Forward to active input or viewport. + switch m.step { + case stepSource: + var cmd tea.Cmd + m.sourceInput, cmd = m.sourceInput.Update(msg) + return cmd + case stepTarget: + var cmd tea.Cmd + m.targetInput, cmd = m.targetInput.Update(msg) + return cmd + case stepReview: + var cmd tea.Cmd + m.reviewView, cmd = m.reviewView.Update(msg) + return cmd + } + + return nil +} + +func (m *Model) Render() string { + return m.render() +} + +func (m *Model) StatusInfo() string { + stepNames := []string{"source", "target", "options", "review", "executing", "done"} + if int(m.step) < len(stepNames) { + return "transfer: " + stepNames[m.step] + } + return "transfer" +} + +func (m *Model) Hotkeys() []components.Hotkey { + switch m.step { + case stepSource, stepTarget: + return []components.Hotkey{ + components.NewHotkey(key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "next"))), + components.NewHotkey(key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back"))), + } + case stepOptions: + return []components.Hotkey{ + components.NewHotkey(key.NewBinding(key.WithKeys("space"), key.WithHelp("space/enter", "toggle"))), + components.NewHotkey(m.keys.Up), + components.NewHotkey(m.keys.Down), + components.NewHotkey(key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back"))), + } + case stepReview: + return []components.Hotkey{ + components.NewHotkey(m.keys.Submit), + components.NewHotkey(key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back"))), + } + case stepDone: + return []components.Hotkey{ + components.NewHotkey(key.NewBinding(key.WithKeys("any"), key.WithHelp("any key", "continue"))), + } + default: + return nil + } +} + +func (m *Model) OnBackPressed() int { + switch m.step { + case stepSource: + return 1 + case stepTarget: + m.step = stepSource + m.targetInput.Blur() + m.sourceInput.Focus() + return 0 + case stepOptions: + m.step = stepTarget + m.targetInput.Focus() + return 0 + case stepReview: + m.step = stepOptions + return 0 + case stepDone: + return 1 + default: + return 1 + } +} + +func (m *Model) Resize(width, height int) { + m.width = width + m.height = height + m.ready = true +} + +// Step returns the current wizard step. +func (m Model) Step() step { return m.step } + +// IsDone returns true when the transfer is complete. +func (m Model) IsDone() bool { return m.step == stepDone } + +// SetSource pre-fills the source input and advances to the target step. +func (m *Model) SetSource(source string) { + m.sourceInput.SetValue(source) + m.sourceInput.Blur() + m.step = stepTarget + m.targetInput.Focus() +} + +func waitForProgress(progressCh <-chan fetch.TransferProgress, doneCh <-chan error) tea.Cmd { + return func() tea.Msg { + select { + case p, ok := <-progressCh: + if !ok { + return transferDoneMsg{err: <-doneCh} + } + return transferProgressMsg{progress: p} + case err := <-doneCh: + for range progressCh { + } + return transferDoneMsg{err: err} + } + } +} diff --git a/tui/keymap.go b/tui/keymap.go new file mode 100644 index 0000000..ce1c856 --- /dev/null +++ b/tui/keymap.go @@ -0,0 +1,33 @@ +package tui + +import "github.com/charmbracelet/bubbles/key" + +// KeyMap defines the global key bindings for the TUI. +type KeyMap struct { + Quit key.Binding + Command key.Binding + Help key.Binding + Tab key.Binding +} + +// DefaultKeyMap returns the default global key bindings. +func DefaultKeyMap() KeyMap { + return KeyMap{ + Quit: key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ), + Command: key.NewBinding( + key.WithKeys(":"), + key.WithHelp(":", "command"), + ), + Help: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "help"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "switch pane"), + ), + } +} diff --git a/tui/view.go b/tui/view.go new file mode 100644 index 0000000..dea3c9f --- /dev/null +++ b/tui/view.go @@ -0,0 +1,30 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" + + "ext.ocm.software/tui/internal/components" +) + +// Back action results — used by OnBackPressed. +const ( + BackConsumed = 0 + BackExit = 1 +) + +// View is the interface that each top-level TUI screen implements. +type View interface { + Init() tea.Cmd + Update(msg tea.Msg) tea.Cmd + Render() string + StatusInfo() string + Hotkeys() []components.Hotkey + OnBackPressed() int + Resize(width, height int) +} + +// MenuItem defines a menu entry in the root TUI. +type MenuItem struct { + Label string + NewView func(width, height int) View +}