Skip to content
Closed
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
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed
- TUI loading screen: content is now horizontally centered inside the
panel (titles, stepper, hints all aligned to the panel midline) rather
than indented with a fixed two-space rail that left the layout flush
against the left border. The pull phase additionally shows a
multi-line layer log — completed layers stay visible (dimmed, full
bar) above the in-flight layer (accent, live progress) so a 12-layer
pull reads as a running history instead of one perpetually-restarting
line. The log caps at five rows to keep the panel within the terminal.
- TUI loading screen: now shows an `elapsed M:SS` timer below the
per-phase detail and a `Pull ── Export ── Parse` stepper rail, so a
multi-GB pull is visibly making progress instead of looking stuck.
Cache hits hold a `✓ <ref> — loaded from cache` line for ~300 ms with
a `ready in 0.3s` readout so warm runs are visibly cached rather than
blinking past. Per-phase inline glyphs (`↓` pull, `▣` export, `≡`
parse, `✓` cache) label the active step.
- TUI error screen: rendered inside a bordered panel matching the
loading screen, with a recovery hint when one is obvious — e.g. when
the Docker daemon is unreachable, the panel now suggests passing a
saved archive path instead. The same hint is appended to the CLI's
daemon-down message and to "no container engine found". Panel
contents (title, error body, hint, exit line) center on the panel
midline rather than sitting flush against the left border.
- TUI file viewer: while a file is extracting, the panel shows the
target path (mid-truncated for long paths), the elapsed time, and an
`Press Esc to close` hint instead of a bare spinner. Binary and empty
file states now lead with a glyph (`◧` / `◯`) and a consistent close
hint.
- TUI status bar: `?` help hint is pinned to the rightmost slot in
every focus mode and collapses to its key alone when the rest of the
hint cluster would otherwise overflow. Transient status messages now
colour-code by outcome — green for success (`Copied!`, `Saved:`,
`Jumped →`), red for errors.
- README Quick Start: one-line nudge pointing new users at `?` for
TUI keybindings.

## [v1.5.0] - 2026-06-24

Multi-platform image support, an aggregated split-pane layer view in the
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ layerx nginx:latest
layerx ./build/app.tar
```

Inside the TUI, press `?` for keybindings.

Other platforms: see [Install](#install).

---
Expand Down
6 changes: 3 additions & 3 deletions cmd/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func friendlyCLIError(err error) string {
return "timed out"
}
if _, ok := errors.AsType[*image.ErrDaemonNotRunning](err); ok {
return "Docker daemon is not reachable. Is Docker running?"
return "Docker daemon is not reachable. Start Docker, or pass a saved archive path instead (no daemon needed)."
}
if e, ok := errors.AsType[*image.ErrImageNotFound](err); ok {
return fmt.Sprintf("image %q not found", e.Ref)
Expand All @@ -45,8 +45,8 @@ func friendlyCLIError(err error) string {
"(see `podman system connection list`)", e.Platform)
}
if e, ok := errors.AsType[*image.ErrNoEngineFound](err); ok {
return fmt.Sprintf("no container engine found; tried: %s "+
"(start Docker or Podman, or set DOCKER_HOST)",
return fmt.Sprintf("no container engine found; tried: %s. "+
"Start Docker or Podman, set DOCKER_HOST, or pass a saved archive path instead (no daemon needed).",
strings.Join(e.Tried, ", "))
}
if e, ok := errors.AsType[*image.ErrPlatformInvalid](err); ok {
Expand Down
67 changes: 60 additions & 7 deletions tui/fileview.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package tui
import (
"fmt"
"strings"
"time"

"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
Expand All @@ -18,6 +19,8 @@ type viewerParams struct {
width int
height int
loading bool
loadingPath string
elapsed time.Duration
spinnerFrame int
originLayer int
originCmd string
Expand All @@ -38,8 +41,20 @@ func renderFileView(p viewerParams) string {

if p.loading {
frame := spinnerFrames[p.spinnerFrame%len(spinnerFrames)]
msg := frame + " Extracting file…"
body := lipgloss.Place(contentWidth, contentHeight, lipgloss.Center, lipgloss.Center, msg)
extractGlyph := phaseGlyph(image.PhaseExporting)
dim := styleWithFg(statusDimColor)

var first string
if p.loadingPath != "" {
path := truncateMidPath(p.loadingPath, max(contentWidth-len(" Extracting ")-4, 8))
first = frame + " " + extractGlyph + " Extracting " + path
} else {
first = frame + " " + extractGlyph + " Extracting file…"
}
second := dim.Render("elapsed " + formatElapsed(p.elapsed))
third := dim.Render("Press Esc to close")
stack := first + "\n" + second + "\n" + third
body := lipgloss.Place(contentWidth, contentHeight, lipgloss.Center, lipgloss.Center, stack)
return renderPanel(body, "File Viewer", true, contentWidth, p.height, false, false)
}

Expand All @@ -57,18 +72,20 @@ func renderFileView(p viewerParams) string {
}

if p.content.Binary {
glyph := styleWithFg(removedColor).Render("◧")
msg := fmt.Sprintf("Binary file (%s) — cannot display", image.FormatBytes(p.content.Size))
hint := "Press Esc to return"
hint := "Press Esc to close"
body := lipgloss.Place(contentWidth, contentHeight, lipgloss.Center, lipgloss.Center,
styleWithFg(removedColor).Render(msg)+"\n\n"+styleWithFg(statusDimColor).Render(hint))
glyph+"\n\n"+styleWithFg(removedColor).Render(msg)+"\n\n"+styleWithFg(statusDimColor).Render(hint))
return renderPanel(body, title, true, contentWidth, p.height, false, false)
}

if len(p.content.Data) == 0 {
glyph := styleWithFg(unchangedColor).Render("◯")
msg := "Empty file (0 bytes)"
hint := "Press Esc to return"
hint := "Press Esc to close"
body := lipgloss.Place(contentWidth, contentHeight, lipgloss.Center, lipgloss.Center,
styleWithFg(unchangedColor).Render(msg)+"\n\n"+styleWithFg(statusDimColor).Render(hint))
glyph+"\n\n"+styleWithFg(unchangedColor).Render(msg)+"\n\n"+styleWithFg(statusDimColor).Render(hint))
return renderPanel(body, title, true, contentWidth, p.height, false, false)
}

Expand Down Expand Up @@ -171,7 +188,43 @@ func renderFileView(p viewerParams) string {
return renderPanel(sb.String(), title, true, contentWidth, p.height, hasAbove, hasBelow)
}

// overlayCursor paints a reverse-video block at display column `col` of a
// truncateMidPath shortens a path so it fits within width display
// columns, preserving the filename and top-level prefix and elliding
// the middle segments with "…". Falls back to a right-truncate when
// even the head+tail won't fit. Used by the file-viewer loading line
// where the full path is informative but cannot widen the panel.
func truncateMidPath(path string, width int) string {
if width <= 0 {
return ""
}
if lipgloss.Width(path) <= width {
return path
}
if width < 3 {
return ansi.Truncate(path, width, "")
}

parts := strings.Split(path, "/")
if len(parts) <= 2 {
return ansi.Truncate(path, width, "…")
}

// Always keep filename (last segment) and the leading "/segment" or
// segment so context is preserved. Build "<head>/…/<tail>" and grow
// the head while it fits.
tail := parts[len(parts)-1]
head := parts[0]
if head == "" && len(parts) > 1 {
head = "/" + parts[1]
}
candidate := head + "/…/" + tail
if lipgloss.Width(candidate) > width {
return ansi.Truncate(path, width, "…")
}
return candidate
}


// styled line. ansi.Cut is grapheme- and escape-aware, so slicing the line
// into [pre | cell | post] keeps chroma colors and search highlights intact.
// When the cursor is past the end of the rendered line (cursor on a short
Expand Down
78 changes: 78 additions & 0 deletions tui/fileview_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package tui
import (
"strings"
"testing"
"time"

"github.com/charmbracelet/x/ansi"
"github.com/deveshctl/layerx/image"
Expand Down Expand Up @@ -265,3 +266,80 @@ func TestRenderFileView_HOffset_PreservesChromaOutput(t *testing.T) {
require.LessOrEqual(t, ansi.StringWidth(ln), 80)
}
}

// --- file viewer loading state -----------------------------------------------

func TestRenderFileViewLoadingShowsPathElapsedAndCloseHint(t *testing.T) {
body := renderFileView(viewerParams{
loading: true,
loadingPath: "/etc/nginx/nginx.conf",
elapsed: 1500 * time.Millisecond,
width: 80,
height: 20,
})
assert.Contains(t, body, "Extracting", "loading panel mentions Extracting")
assert.Contains(t, body, "nginx.conf", "loading panel includes the filename")
assert.Contains(t, body, "0:01", "elapsed time rendered")
assert.Contains(t, body, "Press Esc to close", "close hint visible")
}

func TestRenderFileViewLoadingTruncatesLongPath(t *testing.T) {
long := "/very/deeply/nested/path/with/many/segments/here/that/exceeds/panel/width/file.conf"
body := renderFileView(viewerParams{
loading: true,
loadingPath: long,
elapsed: 0,
width: 40,
height: 20,
})
for ln := range strings.SplitSeq(body, "\n") {
require.LessOrEqual(t, ansi.StringWidth(ln), 40,
"loading panel must not exceed terminal width")
}
assert.Contains(t, body, "file.conf", "filename preserved even after mid-truncate")
}

// --- binary / empty file empty states ---------------------------------------

func TestRenderFileViewBinaryFileShowsGlyphAndCloseHint(t *testing.T) {
body := renderFileView(viewerParams{
content: &image.FileContent{
Path: "/bin/sh",
Binary: true,
Size: 1234,
},
width: 80,
height: 20,
})
assert.Contains(t, body, "◧", "binary empty state glyph rendered")
assert.Contains(t, body, "Binary file")
assert.Contains(t, body, "Press Esc to close")
}

func TestRenderFileViewEmptyFileShowsGlyphAndCloseHint(t *testing.T) {
body := renderFileView(viewerParams{
content: &image.FileContent{
Path: "/tmp/empty",
Data: []byte{},
},
width: 80,
height: 20,
})
assert.Contains(t, body, "◯", "empty file glyph rendered")
assert.Contains(t, body, "Empty file")
assert.Contains(t, body, "Press Esc to close")
}

// --- truncateMidPath ---------------------------------------------------------

func TestTruncateMidPath(t *testing.T) {
// Short path: returned verbatim.
assert.Equal(t, "/a/b", truncateMidPath("/a/b", 20))
// Long path: filename + leading segment preserved with mid-elision.
got := truncateMidPath("/very/deep/nested/path/here/file.conf", 25)
assert.Contains(t, got, "file.conf", "filename always kept")
assert.Contains(t, got, "…", "mid-truncate marker present")
// Degenerate path with no separators: tail-truncate fallback.
got2 := truncateMidPath("singleword.long.filename.txt", 10)
assert.LessOrEqual(t, len(got2), 12, "truncated within budget")
}
Loading