Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.idea
*.iml
vscode
.DS_Store
57 changes: 57 additions & 0 deletions tui/README.md
Original file line number Diff line number Diff line change
@@ -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
244 changes: 244 additions & 0 deletions tui/app.go
Original file line number Diff line number Diff line change
@@ -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)
}
86 changes: 86 additions & 0 deletions tui/cmd/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 3 additions & 0 deletions tui/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Package tui provides an interactive terminal user interface for exploring
// OCM component versions using the Bubble Tea framework.
package tui
Loading