From 91b0f843942d263b78c096c39e54852e02e3842c Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Fri, 23 Jan 2026 15:33:09 -0800 Subject: [PATCH] feat(open): implement interactive TUI file browser Add the `blob open` command for interactively exploring blob archives. Features: - Split-view layout with file tree (left) and content preview (right) - Lazy loading via HTTP range requests for fast navigation - Syntax highlighting for source code files - Hex dump display for binary files - File size guard to prevent loading files >512KB into memory - Status bar showing selected file metadata (size, modification time) - Copy files from archive to local filesystem - Keyboard navigation (arrows, Tab, Enter, c for copy, q to quit) Components: - internal/tui/open: Main TUI model with Bubble Tea - internal/tui/components/filetree: File tree navigation - internal/tui/components/preview: Content preview with hex/text modes - internal/tui/components/statusbar: Status bar with file metadata - internal/tui/components/copydialog: File copy dialog - internal/tui/detect: Binary file detection Co-Authored-By: Claude Opus 4.5 --- .go-version | 1 + cmd/open.go | 98 ++++ go.mod | 2 + go.sum | 4 + .../tui/components/copydialog/copydialog.go | 137 ++++++ internal/tui/components/filetree/filetree.go | 301 ++++++++++++ internal/tui/components/preview/hex.go | 65 +++ internal/tui/components/preview/hex_test.go | 119 +++++ internal/tui/components/preview/highlight.go | 129 +++++ internal/tui/components/preview/preview.go | 335 +++++++++++++ .../tui/components/statusbar/statusbar.go | 290 +++++++++++ internal/tui/detect/binary.go | 61 +++ internal/tui/detect/binary_test.go | 103 ++++ internal/tui/open/keys.go | 78 +++ internal/tui/open/messages.go | 40 ++ internal/tui/open/model.go | 82 ++++ internal/tui/open/styles.go | 47 ++ internal/tui/open/update.go | 451 ++++++++++++++++++ internal/tui/open/view.go | 164 +++++++ 19 files changed, 2507 insertions(+) create mode 100644 .go-version create mode 100644 cmd/open.go create mode 100644 internal/tui/components/copydialog/copydialog.go create mode 100644 internal/tui/components/filetree/filetree.go create mode 100644 internal/tui/components/preview/hex.go create mode 100644 internal/tui/components/preview/hex_test.go create mode 100644 internal/tui/components/preview/highlight.go create mode 100644 internal/tui/components/preview/preview.go create mode 100644 internal/tui/components/statusbar/statusbar.go create mode 100644 internal/tui/detect/binary.go create mode 100644 internal/tui/detect/binary_test.go create mode 100644 internal/tui/open/keys.go create mode 100644 internal/tui/open/messages.go create mode 100644 internal/tui/open/model.go create mode 100644 internal/tui/open/styles.go create mode 100644 internal/tui/open/update.go create mode 100644 internal/tui/open/view.go diff --git a/.go-version b/.go-version new file mode 100644 index 0000000..b45fe31 --- /dev/null +++ b/.go-version @@ -0,0 +1 @@ +1.25.5 diff --git a/cmd/open.go b/cmd/open.go new file mode 100644 index 0000000..5d06b3c --- /dev/null +++ b/cmd/open.go @@ -0,0 +1,98 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/meigma/blob" + blobcore "github.com/meigma/blob/core" + "github.com/spf13/cobra" + + internalcfg "github.com/meigma/blob-cli/internal/config" + "github.com/meigma/blob-cli/internal/tui/open" +) + +var openCmd = &cobra.Command{ + Use: "open ", + Short: "Open an interactive file browser for a blob archive", + Long: `Open an interactive TUI to explore blob archive contents. + +Features a split-view layout with file tree on the left and content +preview on the right. Files load on-demand via HTTP range requests +for fast navigation. + +Navigation: + Arrow keys Navigate file list / scroll preview + Tab Switch focus between tree and preview + Enter/Right Enter directory or preview file + Left Go to parent directory + c Copy selected file (prompts for path) + q/Esc Quit`, + Example: ` blob open ghcr.io/acme/configs:v1.0.0 + blob open myalias`, + Args: cobra.ExactArgs(1), + RunE: runOpen, +} + +func init() { + rootCmd.AddCommand(openCmd) +} + +func runOpen(cmd *cobra.Command, args []string) error { + // 1. Get config from context + cfg := internalcfg.FromContext(cmd.Context()) + if cfg == nil { + return errors.New("configuration not loaded") + } + + // 2. Parse arguments + inputRef := args[0] + + // 3. Resolve alias + resolvedRef := cfg.ResolveAlias(inputRef) + + // 4. Create client + client, err := newClient(cfg) + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + // 5. Create loader function for async archive loading + ctx := cmd.Context() + loader := makeArchiveLoader(ctx, client, resolvedRef) + + // 6. Create and run the TUI (starts with loading screen) + model := open.New(resolvedRef, loader) + p := tea.NewProgram( + model, + tea.WithAltScreen(), + tea.WithMouseCellMotion(), + ) + + if _, err := p.Run(); err != nil { + return fmt.Errorf("running TUI: %w", err) + } + + return nil +} + +// makeArchiveLoader creates a LoadFunc that fetches the archive from the registry. +func makeArchiveLoader(ctx context.Context, client *blob.Client, ref string) open.LoadFunc { + return func() (*blob.IndexView, *blob.Archive, error) { + // Pull archive (lazy - does NOT download data blob) + archive, err := client.Pull(ctx, ref) + if err != nil { + return nil, nil, fmt.Errorf("accessing archive %s: %w", ref, err) + } + + // Create index view from the archive's index data + index, err := blobcore.NewIndexView(archive.IndexData()) + if err != nil { + return nil, nil, fmt.Errorf("parsing index: %w", err) + } + + return index, archive, nil + } +} diff --git a/go.mod b/go.mod index 85f1b52..385837d 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect + github.com/alecthomas/chroma/v2 v2.23.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect @@ -47,6 +48,7 @@ require ( github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect github.com/distribution/reference v0.6.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect github.com/docker/docker v28.5.2+incompatible // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect diff --git a/go.sum b/go.sum index a16fd47..e4feb00 100644 --- a/go.sum +++ b/go.sum @@ -38,6 +38,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= +github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= +github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= @@ -150,6 +152,8 @@ github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 h1:lxmTCgmHE1G github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= 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 v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= diff --git a/internal/tui/components/copydialog/copydialog.go b/internal/tui/components/copydialog/copydialog.go new file mode 100644 index 0000000..86f9724 --- /dev/null +++ b/internal/tui/components/copydialog/copydialog.go @@ -0,0 +1,137 @@ +// Package copydialog provides a modal dialog for copying files. +package copydialog + +import ( + "path/filepath" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// Model represents the copy dialog component state. +type Model struct { + input textinput.Model + sourcePath string + visible bool + width int + height int +} + +// New creates a new copy dialog component. +func New() Model { + ti := textinput.New() + ti.Placeholder = "destination path" + ti.CharLimit = 256 + ti.Width = 40 + + return Model{ + input: ti, + } +} + +// Show displays the dialog for copying a file. +func (m *Model) Show(sourcePath string) { + m.sourcePath = sourcePath + m.visible = true + + // Set default destination to current directory with source filename + baseName := filepath.Base(sourcePath) + m.input.SetValue(baseName) + m.input.Focus() + m.input.CursorEnd() +} + +// Hide hides the dialog. +func (m *Model) Hide() { + m.visible = false + m.input.Blur() +} + +// Visible returns whether the dialog is visible. +func (m *Model) Visible() bool { + return m.visible +} + +// SourcePath returns the source file path. +func (m *Model) SourcePath() string { + return m.sourcePath +} + +// Destination returns the entered destination path. +func (m *Model) Destination() string { + return m.input.Value() +} + +// SetSize updates the dialog dimensions. +func (m *Model) SetSize(width, height int) { + m.width = width + m.height = height + // Adjust input width based on dialog size + inputWidth := min(width-10, 60) + inputWidth = max(inputWidth, 20) + m.input.Width = inputWidth +} + +// Init initializes the component. +// +//nolint:gocritic // hugeParam: value receiver required by tea.Model interface +func (m Model) Init() tea.Cmd { + return textinput.Blink +} + +// Update handles messages. +// +//nolint:gocritic // hugeParam: value receiver required by tea.Model interface +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + if !m.visible { + return m, nil + } + + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + return m, cmd +} + +// View renders the component. +// +//nolint:gocritic // hugeParam: value receiver required by tea.Model interface +func (m Model) View() string { + if !m.visible { + return "" + } + + dialogWidth := 50 + if m.width > 0 && m.width < dialogWidth+4 { + dialogWidth = m.width - 4 + } + + borderStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("62")). + Padding(1, 2). + Width(dialogWidth) + + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("229")) + + labelStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")) + + hintStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")) + + content := lipgloss.JoinVertical(lipgloss.Left, + titleStyle.Render("Copy File"), + "", + labelStyle.Render("Source: "+m.sourcePath), + "", + labelStyle.Render("Destination:"), + m.input.View(), + "", + hintStyle.Render("Enter: confirm Esc: cancel"), + ) + + return borderStyle.Render(content) +} diff --git a/internal/tui/components/filetree/filetree.go b/internal/tui/components/filetree/filetree.go new file mode 100644 index 0000000..c437fd9 --- /dev/null +++ b/internal/tui/components/filetree/filetree.go @@ -0,0 +1,301 @@ +// Package filetree provides a file browser component for the TUI. +package filetree + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/meigma/blob" + + "github.com/meigma/blob-cli/internal/archive" +) + +// Model represents the file tree component state. +type Model struct { + index *blob.IndexView + currentDir string + entries []*archive.DirEntry + cursor int + offset int // scroll offset + width int + height int + focused bool + history []historyEntry // navigation history for Back +} + +// historyEntry stores state for navigation history. +type historyEntry struct { + dir string + cursor int + offset int +} + +// New creates a new file tree component. +func New(index *blob.IndexView) Model { + m := Model{ + index: index, + history: make([]historyEntry, 0), + } + m.loadDir("") + return m +} + +// SetSize updates the component dimensions. +func (m *Model) SetSize(width, height int) { + m.width = width + m.height = height + m.adjustScroll() +} + +// SetFocused sets the focus state. +func (m *Model) SetFocused(focused bool) { + m.focused = focused +} + +// Focused returns whether the component is focused. +func (m *Model) Focused() bool { + return m.focused +} + +// Selected returns the currently selected entry, or nil if none. +func (m *Model) Selected() *archive.DirEntry { + if len(m.entries) == 0 { + return nil + } + return m.entries[m.cursor] +} + +// CurrentDir returns the current directory path. +func (m *Model) CurrentDir() string { + return m.currentDir +} + +// EntryCount returns the number of entries in the current directory. +func (m *Model) EntryCount() int { + return len(m.entries) +} + +// CursorUp moves the cursor up one item. +func (m *Model) CursorUp() { + if m.cursor > 0 { + m.cursor-- + m.adjustScroll() + } +} + +// CursorDown moves the cursor down one item. +func (m *Model) CursorDown() { + if m.cursor < len(m.entries)-1 { + m.cursor++ + m.adjustScroll() + } +} + +// Enter enters the selected directory or returns the selected file. +// Returns true if a directory was entered, false if a file was selected. +func (m *Model) Enter() bool { + selected := m.Selected() + if selected == nil { + return false + } + + if selected.IsDir { + // Save current state to history + m.history = append(m.history, historyEntry{ + dir: m.currentDir, + cursor: m.cursor, + offset: m.offset, + }) + m.loadDir(selected.Path) + return true + } + return false +} + +// Back goes to the parent directory. +// Returns true if navigation occurred. +func (m *Model) Back() bool { + // First try history + if len(m.history) > 0 { + h := m.history[len(m.history)-1] + m.history = m.history[:len(m.history)-1] + m.loadDir(h.dir) + m.cursor = h.cursor + m.offset = h.offset + m.adjustScroll() + return true + } + + // Otherwise try parent directory + if m.currentDir == "" { + return false + } + + parent := parentPath(m.currentDir) + m.loadDir(parent) + return true +} + +// loadDir loads entries for a directory. +func (m *Model) loadDir(dir string) { + m.currentDir = dir + m.cursor = 0 + m.offset = 0 + + entries, err := archive.ListDir(m.index, dir) + if err != nil { + m.entries = nil + return + } + + // Sort directories first + archive.SortDirsFirst(entries) + m.entries = entries +} + +// adjustScroll ensures the cursor is visible within the viewport. +func (m *Model) adjustScroll() { + visibleLines := m.visibleLines() + if visibleLines <= 0 { + return + } + + // Scroll up if cursor is above viewport + if m.cursor < m.offset { + m.offset = m.cursor + } + + // Scroll down if cursor is below viewport + if m.cursor >= m.offset+visibleLines { + m.offset = m.cursor - visibleLines + 1 + } +} + +// visibleLines returns the number of visible lines in the viewport. +func (m *Model) visibleLines() int { + // Account for: 2 border lines + 1 header + 1 separator + return m.height - 6 +} + +// parentPath returns the parent directory path. +func parentPath(p string) string { + if p == "" { + return "" + } + idx := strings.LastIndex(p, "/") + if idx == -1 { + return "" + } + return p[:idx] +} + +// Init initializes the component. +// +//nolint:gocritic // hugeParam: value receiver required by tea.Model interface +func (m Model) Init() tea.Cmd { + return nil +} + +// Update handles messages. +// +//nolint:gocritic // hugeParam: value receiver required by tea.Model interface +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + return m, nil +} + +// viewStyles holds the styles used for rendering. +type viewStyles struct { + header lipgloss.Style + selected lipgloss.Style + normal lipgloss.Style + dir lipgloss.Style + box lipgloss.Style +} + +// newViewStyles creates styles based on focus state. +func newViewStyles(focused bool, width, height int) viewStyles { + borderColor := lipgloss.Color("240") + if focused { + borderColor = lipgloss.Color("62") + } + + return viewStyles{ + header: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("252")). + Padding(0, 1), + selected: lipgloss.NewStyle(). + Foreground(lipgloss.Color("229")). + Bold(true), + normal: lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")), + dir: lipgloss.NewStyle(). + Foreground(lipgloss.Color("75")), + box: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + Width(width - 2). + Height(height - 2), + } +} + +// formatEntry formats a single entry for display. +func (m *Model) formatEntry(entry *archive.DirEntry, index int, styles *viewStyles) string { + name := entry.Name + if entry.IsDir { + name += "/" + } + + var line string + switch { + case index == m.cursor && m.focused: + line = styles.selected.Render("> " + name) + case index == m.cursor: + line = styles.normal.Render("> " + name) + case entry.IsDir: + line = styles.dir.Render(" " + name) + default: + line = styles.normal.Render(" " + name) + } + + // Truncate if too wide + maxWidth := m.width - 6 + if len(line) > maxWidth && maxWidth > 3 { + line = line[:maxWidth-3] + "..." + } + + return line +} + +// View renders the component. +// +//nolint:gocritic // hugeParam: value receiver required by tea.Model interface +func (m Model) View() string { + pathDisplay := "/" + m.currentDir + if m.currentDir == "" { + pathDisplay = "/" + } + + styles := newViewStyles(m.focused, m.width, m.height) + + // Build entry list + var lines []string + visibleLines := m.visibleLines() + for i := m.offset; i < len(m.entries) && i < m.offset+visibleLines; i++ { + lines = append(lines, m.formatEntry(m.entries[i], i, &styles)) + } + + // Pad with empty lines if needed + for len(lines) < visibleLines { + lines = append(lines, "") + } + + content := lipgloss.JoinVertical(lipgloss.Left, + styles.header.Render(pathDisplay), + strings.Repeat("─", m.width-4), + strings.Join(lines, "\n"), + ) + + return styles.box.Render(content) +} diff --git a/internal/tui/components/preview/hex.go b/internal/tui/components/preview/hex.go new file mode 100644 index 0000000..cca307d --- /dev/null +++ b/internal/tui/components/preview/hex.go @@ -0,0 +1,65 @@ +package preview + +import ( + "fmt" + "strings" +) + +// bytesPerLine is the number of bytes displayed per line in hex dump. +const bytesPerLine = 16 + +// FormatHex formats binary content as a hex dump. +// Format: offset hex-bytes |ascii| +// Example: 00000000 48 65 6c 6c 6f 20 57 6f 72 6c 64 21 0a 00 00 00 |Hello World!....| +func FormatHex(content []byte) string { + if len(content) == 0 { + return "(empty)" + } + + var sb strings.Builder + for offset := 0; offset < len(content); offset += bytesPerLine { + end := min(offset+bytesPerLine, len(content)) + line := content[offset:end] + sb.WriteString(formatHexLine(offset, line)) + sb.WriteByte('\n') + } + return sb.String() +} + +// formatHexLine formats a single line of hex dump. +func formatHexLine(offset int, line []byte) string { + var sb strings.Builder + + // Offset (8 hex digits) + sb.WriteString(fmt.Sprintf("%08x ", offset)) + + // Hex bytes with spacing + for i := range bytesPerLine { + if i < len(line) { + sb.WriteString(fmt.Sprintf("%02x ", line[i])) + } else { + sb.WriteString(" ") + } + // Extra space after 8 bytes for readability + if i == 7 { + sb.WriteByte(' ') + } + } + + // ASCII representation + sb.WriteString(" |") + for _, b := range line { + if b >= 32 && b < 127 { + sb.WriteByte(b) + } else { + sb.WriteByte('.') + } + } + // Pad ASCII section to align closing pipe + for range bytesPerLine - len(line) { + sb.WriteByte(' ') + } + sb.WriteByte('|') + + return sb.String() +} diff --git a/internal/tui/components/preview/hex_test.go b/internal/tui/components/preview/hex_test.go new file mode 100644 index 0000000..2b52503 --- /dev/null +++ b/internal/tui/components/preview/hex_test.go @@ -0,0 +1,119 @@ +package preview + +import ( + "strings" + "testing" +) + +func TestFormatHex(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content []byte + contains []string + notEmpty bool + }{ + { + name: "empty content", + content: []byte{}, + contains: []string{"(empty)"}, + }, + { + name: "hello world", + content: []byte("Hello World!"), + contains: []string{ + "00000000", // offset + "48 65 6c 6c 6f 20", // "Hello " in hex + "|Hello World!", // ASCII representation + }, + notEmpty: true, + }, + { + name: "binary content with nulls", + content: []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05}, + contains: []string{ + "00000000", + "00 01 02 03 04 05", + "|......", // non-printable shown as dots (with padding after) + }, + notEmpty: true, + }, + { + name: "printable and non-printable mix", + content: []byte("AB\x00CD\x7FEF"), + contains: []string{ + "41 42 00 43 44 7f 45 46", // hex bytes + "|AB.CD.EF", // ASCII with dots for non-printable + }, + notEmpty: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := FormatHex(tt.content) + + for _, want := range tt.contains { + if !strings.Contains(got, want) { + t.Errorf("FormatHex() missing %q in output:\n%s", want, got) + } + } + + if tt.notEmpty && got == "" { + t.Error("FormatHex() returned empty string for non-empty content") + } + }) + } +} + +func TestFormatHex_MultiLine(t *testing.T) { + t.Parallel() + + // 32 bytes = 2 lines of output + content := make([]byte, 32) + for i := range content { + content[i] = byte(i) + } + + got := FormatHex(content) + lines := strings.Split(strings.TrimSpace(got), "\n") + + if len(lines) != 2 { + t.Errorf("FormatHex() got %d lines, want 2", len(lines)) + } + + // First line should start with offset 00000000 + if !strings.HasPrefix(lines[0], "00000000") { + t.Errorf("first line should start with 00000000, got: %s", lines[0]) + } + + // Second line should start with offset 00000010 (16 in hex) + if !strings.HasPrefix(lines[1], "00000010") { + t.Errorf("second line should start with 00000010, got: %s", lines[1]) + } +} + +func TestFormatHex_PartialLine(t *testing.T) { + t.Parallel() + + // 20 bytes = 1 full line + 4 bytes partial + content := make([]byte, 20) + for i := range content { + content[i] = byte('A' + i) + } + + got := FormatHex(content) + lines := strings.Split(strings.TrimSpace(got), "\n") + + if len(lines) != 2 { + t.Errorf("FormatHex() got %d lines, want 2", len(lines)) + } + + // Second line should have padding for missing bytes + // The ASCII section should still have the closing pipe aligned + if !strings.Contains(lines[1], "|") { + t.Error("partial line should contain ASCII section with pipes") + } +} diff --git a/internal/tui/components/preview/highlight.go b/internal/tui/components/preview/highlight.go new file mode 100644 index 0000000..e106fe9 --- /dev/null +++ b/internal/tui/components/preview/highlight.go @@ -0,0 +1,129 @@ +package preview + +import ( + "bytes" + "path/filepath" + "strings" + + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/formatters" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/alecthomas/chroma/v2/styles" +) + +// defaultStyle is the syntax highlighting style to use. +// "monokai" works well on dark terminals; alternatives: "dracula", "native", "vim" +const defaultStyle = "monokai" + +// defaultFormatter is the terminal formatter to use. +// "terminal256" provides good color support on most terminals. +const defaultFormatter = "terminal256" + +// Highlight applies syntax highlighting to code based on the filename. +// Returns the original content if highlighting fails or no lexer is found. +func Highlight(filename string, content []byte) string { + text := string(content) + + // Try to find a lexer for this file + lexer := lexers.Match(filename) + if lexer == nil { + // Try to detect from content + lexer = lexers.Analyse(text) //nolint:misspell // Chroma API uses British spelling + } + if lexer == nil { + // No syntax highlighting available + return text + } + + // Coalesce runs of identical token types for cleaner output + lexer = chroma.Coalesce(lexer) + + // Get style and formatter + style := styles.Get(defaultStyle) + if style == nil { + style = styles.Fallback + } + + formatter := formatters.Get(defaultFormatter) + if formatter == nil { + formatter = formatters.Fallback + } + + // Tokenize the content + iterator, err := lexer.Tokenise(nil, text) + if err != nil { + return text + } + + // Format to a buffer + var buf bytes.Buffer + if err := formatter.Format(&buf, style, iterator); err != nil { + return text + } + + return buf.String() +} + +// CanHighlight returns true if syntax highlighting is available for the given filename. +func CanHighlight(filename string) bool { + return lexers.Match(filename) != nil +} + +// GetLanguage returns the detected language name for a filename, or empty string if unknown. +func GetLanguage(filename string) string { + lexer := lexers.Match(filename) + if lexer == nil { + return "" + } + config := lexer.Config() + if config == nil { + return "" + } + return config.Name +} + +// commonHighlightExtensions is a quick lookup for files we know can be highlighted. +// This avoids calling lexers.Match for common cases. +var commonHighlightExtensions = map[string]bool{ + ".go": true, + ".py": true, + ".js": true, + ".ts": true, + ".jsx": true, + ".tsx": true, + ".java": true, + ".c": true, + ".cpp": true, + ".h": true, + ".hpp": true, + ".rs": true, + ".rb": true, + ".php": true, + ".sh": true, + ".bash": true, + ".zsh": true, + ".yaml": true, + ".yml": true, + ".json": true, + ".xml": true, + ".html": true, + ".css": true, + ".scss": true, + ".md": true, + ".sql": true, + ".toml": true, + ".ini": true, + ".conf": true, + ".swift": true, + ".kt": true, + ".scala": true, + ".lua": true, + ".vim": true, + ".zig": true, +} + +// IsLikelyHighlightable does a quick check if a file extension is commonly highlighted. +func IsLikelyHighlightable(filename string) bool { + ext := strings.ToLower(filepath.Ext(filename)) + return commonHighlightExtensions[ext] +} diff --git a/internal/tui/components/preview/preview.go b/internal/tui/components/preview/preview.go new file mode 100644 index 0000000..d15787f --- /dev/null +++ b/internal/tui/components/preview/preview.go @@ -0,0 +1,335 @@ +// Package preview provides a content preview component for the TUI. +package preview + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// State represents the current state of the preview. +type State int + +const ( + StateNone State = iota // No file selected + StateLoading // Loading file content + StateText // Displaying text content + StateBinary // Displaying hex dump + StateError // Error loading file + StateDir // Directory selected (no preview) + StateTooLarge // File too large for preview +) + +// MaxPreviewBytes is the maximum size of file content to preview. +// Files larger than this will show a "too large" message instead of loading. +const MaxPreviewBytes = 512 * 1024 // 512KB + +// Model represents the preview component state. +type Model struct { + viewport viewport.Model + state State + path string + language string // Detected language for syntax highlighting + errMsg string + width int + height int + focused bool + ready bool +} + +// New creates a new preview component. +func New() Model { + return Model{ + state: StateNone, + } +} + +// SetSize updates the component dimensions. +func (m *Model) SetSize(width, height int) { + m.width = width + m.height = height + // Viewport height needs to account for: 2 border + 1 header + 1 separator + vpWidth := width - 4 // Account for border padding + vpHeight := max(height-6, 1) + if !m.ready { + m.viewport = viewport.New(vpWidth, vpHeight) + m.ready = true + } else { + m.viewport.Width = vpWidth + m.viewport.Height = vpHeight + } +} + +// SetFocused sets the focus state. +func (m *Model) SetFocused(focused bool) { + m.focused = focused +} + +// Focused returns whether the component is focused. +func (m *Model) Focused() bool { + return m.focused +} + +// SetLoading shows the loading state for a path. +func (m *Model) SetLoading(path string) { + m.state = StateLoading + m.path = path + m.language = "" + m.errMsg = "" + if m.ready { + m.viewport.SetContent(fmt.Sprintf("Loading %s...", path)) + m.viewport.GotoTop() + } +} + +// SetContent sets the content to display. +func (m *Model) SetContent(path string, content []byte, isBinary bool) { + m.path = path + m.errMsg = "" + + if isBinary { + m.state = StateBinary + // Truncate for hex display + displayContent := content + truncated := false + if len(displayContent) > MaxPreviewBytes { + displayContent = displayContent[:MaxPreviewBytes] + truncated = true + } + hexContent := FormatHex(displayContent) + if truncated { + hexContent += fmt.Sprintf("\n\n... (truncated, showing first %d bytes)", MaxPreviewBytes) + } + if m.ready { + m.viewport.SetContent(hexContent) + m.viewport.GotoTop() + } + } else { + m.state = StateText + displayContent := content + truncated := false + if len(content) > MaxPreviewBytes { + displayContent = content[:MaxPreviewBytes] + truncated = true + } + + // Apply syntax highlighting if available + var text string + m.language = GetLanguage(path) + if m.language != "" { + text = Highlight(path, displayContent) + } else { + text = string(displayContent) + } + + if truncated { + text += fmt.Sprintf("\n\n... (truncated, showing first %d bytes)", MaxPreviewBytes) + } + + if m.ready { + // Wrap text to viewport width + wrapped := m.wrapText(text) + m.viewport.SetContent(wrapped) + m.viewport.GotoTop() + } + } +} + +// wrapText wraps text to fit the viewport width. +func (m *Model) wrapText(text string) string { + if m.viewport.Width <= 0 { + return text + } + return lipgloss.NewStyle().Width(m.viewport.Width).Render(text) +} + +// SetError shows an error message. +func (m *Model) SetError(path string, err error) { + m.state = StateError + m.path = path + m.language = "" + m.errMsg = err.Error() + if m.ready { + m.viewport.SetContent(fmt.Sprintf("Error loading %s:\n\n%s", path, err.Error())) + m.viewport.GotoTop() + } +} + +// SetDir shows directory selected state. +func (m *Model) SetDir(path string) { + m.state = StateDir + m.path = path + m.language = "" + m.errMsg = "" + if m.ready { + m.viewport.SetContent(fmt.Sprintf("Directory: %s\n\nPress Enter to browse contents", path)) + m.viewport.GotoTop() + } +} + +// SetTooLarge shows the file-too-large state. +func (m *Model) SetTooLarge(path string, size uint64) { + m.state = StateTooLarge + m.path = path + m.language = "" + m.errMsg = "" + if m.ready { + content := fmt.Sprintf( + "File too large for preview\n\n"+ + "Path: %s\n"+ + "Size: %s\n"+ + "Limit: %s\n\n"+ + "Press 'c' to copy this file to local filesystem", + path, + formatBytes(size), + formatBytes(MaxPreviewBytes), + ) + m.viewport.SetContent(content) + m.viewport.GotoTop() + } +} + +// formatBytes formats a byte count in human-readable form. +func formatBytes(b uint64) string { + const unit = 1024 + if b < unit { + return fmt.Sprintf("%d B", b) + } + div, exp := uint64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp]) +} + +// SetNone clears the preview. +func (m *Model) SetNone() { + m.state = StateNone + m.path = "" + m.language = "" + m.errMsg = "" + if m.ready { + m.viewport.SetContent("No file selected") + m.viewport.GotoTop() + } +} + +// Path returns the current path being previewed. +func (m *Model) Path() string { + return m.path +} + +// State returns the current state. +func (m *Model) State() State { + return m.state +} + +// ScrollPercent returns the scroll position as a percentage. +func (m *Model) ScrollPercent() float64 { + return m.viewport.ScrollPercent() +} + +// Init initializes the component. +// +//nolint:gocritic // hugeParam: value receiver required by tea.Model interface +func (m Model) Init() tea.Cmd { + return nil +} + +// Update handles messages. +// +//nolint:gocritic // hugeParam: value receiver required by tea.Model interface +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + if !m.ready { + return m, nil + } + + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +// View renders the component. +// +//nolint:gocritic // hugeParam: value receiver required by tea.Model interface +func (m Model) View() string { + if !m.ready { + return "Initializing..." + } + + // Build header with scroll indicator for scrollable content + var header string + switch m.state { + case StateNone: + header = "Preview" + case StateLoading: + header = "Loading: " + m.path + case StateText: + header = m.buildHeader("Text", m.path) + case StateBinary: + header = m.buildHeader("Binary", m.path) + case StateError: + header = "Error: " + m.path + case StateDir: + header = "Directory: " + m.path + case StateTooLarge: + header = "Too Large: " + m.path + } + + // Style based on focus + borderColor := lipgloss.Color("240") + if m.focused { + borderColor = lipgloss.Color("62") + } + + headerStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("252")). + Padding(0, 1) + + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + Width(m.width - 2). + Height(m.height - 2) + + content := lipgloss.JoinVertical(lipgloss.Left, + headerStyle.Render(header), + strings.Repeat("─", m.width-4), + m.viewport.View(), + ) + + return boxStyle.Render(content) +} + +// buildHeader creates a header with optional language and scroll indicator. +// +//nolint:gocritic // hugeParam: consistent with tea.Model pattern +func (m Model) buildHeader(prefix, path string) string { + base := prefix + ": " + path + + // Add detected language if available + if m.language != "" { + base += " (" + m.language + ")" + } + + // Only show scroll info if content is scrollable + if m.viewport.TotalLineCount() <= m.viewport.Height { + return base + } + + // Show scroll percentage + percent := int(m.viewport.ScrollPercent() * 100) + scrollInfo := fmt.Sprintf(" [%d%%]", percent) + + // Add hint when focused + if m.focused { + scrollInfo += " (↑↓ to scroll)" + } + + return base + scrollInfo +} diff --git a/internal/tui/components/statusbar/statusbar.go b/internal/tui/components/statusbar/statusbar.go new file mode 100644 index 0000000..b56efae --- /dev/null +++ b/internal/tui/components/statusbar/statusbar.go @@ -0,0 +1,290 @@ +// Package statusbar provides a status bar component for the TUI. +package statusbar + +import ( + "fmt" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// errorDuration is how long error messages are shown. +const errorDuration = 5 * time.Second + +// Model represents the status bar component state. +type Model struct { + ref string + path string + entryCount int + message string + isError bool + messageExp time.Time // when message expires + width int + + // Selected file metadata + selectedName string + selectedSize uint64 + selectedTime time.Time + selectedIsDir bool + hasSelection bool +} + +// New creates a new status bar component. +func New(ref string) Model { + return Model{ + ref: ref, + } +} + +// SetRef updates the archive reference. +func (m *Model) SetRef(ref string) { + m.ref = ref +} + +// SetPath updates the current path display. +func (m *Model) SetPath(path string) { + m.path = path +} + +// SetEntryCount updates the entry count display. +func (m *Model) SetEntryCount(count int) { + m.entryCount = count +} + +// SetMessage sets a transient message. +func (m *Model) SetMessage(msg string) { + m.message = msg + m.isError = false + m.messageExp = time.Now().Add(errorDuration) +} + +// SetError sets a transient error message. +func (m *Model) SetError(err error) { + m.message = err.Error() + m.isError = true + m.messageExp = time.Now().Add(errorDuration) +} + +// SetWidth updates the status bar width. +func (m *Model) SetWidth(width int) { + m.width = width +} + +// SetSelectedFile updates the selected file metadata display. +func (m *Model) SetSelectedFile(name string, size uint64, modTime time.Time, isDir bool) { + m.selectedName = name + m.selectedSize = size + m.selectedTime = modTime + m.selectedIsDir = isDir + m.hasSelection = true +} + +// ClearSelection clears the selected file metadata. +func (m *Model) ClearSelection() { + m.hasSelection = false + m.selectedName = "" +} + +// formatSelectionInfo formats the selected file/directory metadata. +// +//nolint:gocritic // hugeParam: consistent with tea.Model pattern +func (m Model) formatSelectionInfo(style lipgloss.Style) string { + if m.selectedIsDir { + return style.Render("directory") + } + + // Format: "4.2 KB · Jan 15 10:30" or "4.2 KB · 2d ago" + size := formatBytes(m.selectedSize) + timeStr := formatTime(m.selectedTime) + + if timeStr == "" { + return style.Render(size) + } + return style.Render(size + " · " + timeStr) +} + +// ClearMessage clears any transient message if expired. +func (m *Model) ClearMessage() { + if m.message != "" && time.Now().After(m.messageExp) { + m.message = "" + m.isError = false + } +} + +// ClearMessageMsg is sent to clear expired messages. +type ClearMessageMsg struct{} + +// ScheduleClear returns a command to clear the message after the error duration. +// +//nolint:gocritic // hugeParam: value receiver matches tea.Cmd pattern +func (m Model) ScheduleClear() tea.Cmd { + return tea.Tick(errorDuration, func(t time.Time) tea.Msg { + return ClearMessageMsg{} + }) +} + +// Init initializes the component. +// +//nolint:gocritic // hugeParam: value receiver required by tea.Model interface +func (m Model) Init() tea.Cmd { + return nil +} + +// Update handles messages. +// +//nolint:gocritic // hugeParam: value receiver required by tea.Model interface +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + if _, ok := msg.(ClearMessageMsg); ok { + m.ClearMessage() + } + return m, nil +} + +// View renders the component. +// +//nolint:gocritic // hugeParam: value receiver required by tea.Model interface +func (m Model) View() string { + barStyle := lipgloss.NewStyle(). + Background(lipgloss.Color("236")). + Foreground(lipgloss.Color("252")). + Width(m.width) + + refStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("75")). + Bold(true) + + pathStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")) + + countStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")) + + errorStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). + Bold(true) + + msgStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("229")) + + helpStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")) + + // Build left section: ref and path + left := refStyle.Render(m.ref) + if m.path != "" { + left += " " + pathStyle.Render(m.path) + } + + // Build middle section: message, file metadata, or entry count + var middle string + if m.message != "" && time.Now().Before(m.messageExp) { + if m.isError { + middle = errorStyle.Render(m.message) + } else { + middle = msgStyle.Render(m.message) + } + } else if m.hasSelection { + middle = m.formatSelectionInfo(countStyle) + } else if m.entryCount > 0 { + middle = countStyle.Render(fmt.Sprintf("%d items", m.entryCount)) + } + + // Build right section: help hints + right := helpStyle.Render("q:quit c:copy Tab:focus ?:help") + + // Calculate spacing + leftLen := lipgloss.Width(left) + middleLen := lipgloss.Width(middle) + rightLen := lipgloss.Width(right) + + // Distribute space + totalContent := leftLen + middleLen + rightLen + if totalContent >= m.width { + // Content too long, just show left and right + gap := max(m.width-leftLen-rightLen, 0) + return barStyle.Render(left + spaces(gap) + right) + } + + // Center the middle, with left and right at edges + gapLeft := max((m.width-middleLen)/2-leftLen, 1) + gapRight := max(m.width-leftLen-gapLeft-middleLen-rightLen, 1) + + return barStyle.Render(left + spaces(gapLeft) + middle + spaces(gapRight) + right) +} + +// spaces returns n space characters. +func spaces(n int) string { + if n <= 0 { + return "" + } + b := make([]byte, n) + for i := range b { + b[i] = ' ' + } + return string(b) +} + +// formatBytes formats a byte count in human-readable form. +func formatBytes(b uint64) string { + const unit = 1024 + if b < unit { + return fmt.Sprintf("%d B", b) + } + div, exp := uint64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp]) +} + +// formatTime formats a time as relative (if recent) or absolute. +// Shows relative time for times within 7 days, absolute otherwise. +func formatTime(t time.Time) string { + if t.IsZero() { + return "" + } + + now := time.Now() + diff := now.Sub(t) + + // Use relative time for recent times (within 7 days) + if diff >= 0 && diff < 7*24*time.Hour { + return formatRelativeTime(diff) + } + + // Use absolute time for older or future times + // Same year: "Jan 15 10:30" + // Different year: "Jan 15 2023" + if t.Year() == now.Year() { + return t.Format("Jan 2 15:04") + } + return t.Format("Jan 2 2006") +} + +// formatRelativeTime formats a duration as a relative time string. +func formatRelativeTime(d time.Duration) string { + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + mins := int(d.Minutes()) + if mins == 1 { + return "1m ago" + } + return fmt.Sprintf("%dm ago", mins) + case d < 24*time.Hour: + hours := int(d.Hours()) + if hours == 1 { + return "1h ago" + } + return fmt.Sprintf("%dh ago", hours) + default: + days := int(d.Hours() / 24) + if days == 1 { + return "1d ago" + } + return fmt.Sprintf("%dd ago", days) + } +} diff --git a/internal/tui/detect/binary.go b/internal/tui/detect/binary.go new file mode 100644 index 0000000..0f25af3 --- /dev/null +++ b/internal/tui/detect/binary.go @@ -0,0 +1,61 @@ +// Package detect provides utilities for detecting file content types. +package detect + +import ( + "slices" + "unicode/utf8" +) + +// maxScanSize is the maximum number of bytes to scan for binary detection. +const maxScanSize = 8 * 1024 // 8KB + +// IsBinary detects if content is binary based on several heuristics: +// - Presence of null bytes +// - Invalid UTF-8 sequences +// - High ratio of control characters +// +// Returns true if the content appears to be binary. +func IsBinary(content []byte) bool { + if len(content) == 0 { + return false + } + + // Only scan the first maxScanSize bytes + sample := content[:min(len(content), maxScanSize)] + + // Check for null bytes - strong indicator of binary + if slices.Contains(sample, 0) { + return true + } + + // Check if content is valid UTF-8 + if !utf8.Valid(sample) { + return true + } + + // Count control characters (except common ones like tab, newline, carriage return) + controlCount := 0 + for _, b := range sample { + if isControlChar(b) { + controlCount++ + } + } + + // If more than 10% of characters are control characters, treat as binary + threshold := len(sample) / 10 + return controlCount > threshold +} + +// isControlChar returns true if the byte is a control character +// that typically indicates binary content. +// Excludes common text control characters: tab (9), newline (10), carriage return (13). +func isControlChar(b byte) bool { + // Control characters are 0x00-0x1F and 0x7F + if b < 32 && b != 9 && b != 10 && b != 13 { + return true + } + if b == 0x7F { + return true + } + return false +} diff --git a/internal/tui/detect/binary_test.go b/internal/tui/detect/binary_test.go new file mode 100644 index 0000000..b3e3b3a --- /dev/null +++ b/internal/tui/detect/binary_test.go @@ -0,0 +1,103 @@ +package detect + +import ( + "testing" +) + +func TestIsBinary(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content []byte + wantBin bool + }{ + { + name: "empty content", + content: []byte{}, + wantBin: false, + }, + { + name: "plain text", + content: []byte("Hello, World!\n"), + wantBin: false, + }, + { + name: "text with tabs and newlines", + content: []byte("Line 1\tColumn\nLine 2\tColumn\r\n"), + wantBin: false, + }, + { + name: "JSON content", + content: []byte(`{"key": "value", "number": 42}`), + wantBin: false, + }, + { + name: "null byte makes it binary", + content: []byte("Hello\x00World"), + wantBin: true, + }, + { + name: "binary with null at start", + content: []byte{0x00, 0x01, 0x02, 0x03}, + wantBin: true, + }, + { + name: "PNG header", + content: []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, + wantBin: true, + }, + { + name: "invalid UTF-8", + content: []byte{0xFF, 0xFE, 0x00, 0x01}, + wantBin: true, + }, + { + name: "many control characters", + content: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x0E, 0x0F}, + wantBin: true, + }, + { + name: "valid UTF-8 with unicode", + content: []byte("Hello 世界 🌍"), + wantBin: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := IsBinary(tt.content) + if got != tt.wantBin { + t.Errorf("IsBinary() = %v, want %v", got, tt.wantBin) + } + }) + } +} + +func TestIsBinary_LargeContent(t *testing.T) { + t.Parallel() + + // Create content larger than maxScanSize (8KB) + largeText := make([]byte, 16*1024) + for i := range largeText { + largeText[i] = 'a' + } + + // Should not be binary (all 'a' characters) + if IsBinary(largeText) { + t.Error("large text content should not be detected as binary") + } + + // Put a null byte after the scan window - should NOT be detected + largeText[10*1024] = 0x00 + if IsBinary(largeText) { + t.Error("null byte after scan window should not trigger binary detection") + } + + // Put a null byte within the scan window - should be detected + largeText[4*1024] = 0x00 + if !IsBinary(largeText) { + t.Error("null byte within scan window should trigger binary detection") + } +} diff --git a/internal/tui/open/keys.go b/internal/tui/open/keys.go new file mode 100644 index 0000000..9c3d64f --- /dev/null +++ b/internal/tui/open/keys.go @@ -0,0 +1,78 @@ +package open + +import "github.com/charmbracelet/bubbles/key" + +// keyMap defines the key bindings for the TUI. +type keyMap struct { + Up key.Binding + Down key.Binding + Left key.Binding + Right key.Binding + Enter key.Binding + Tab key.Binding + Copy key.Binding + Quit key.Binding + Escape key.Binding + Help key.Binding +} + +// keys is the default key mapping. +var keys = keyMap{ + Up: key.NewBinding( + key.WithKeys("up"), + key.WithHelp("↑", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down"), + key.WithHelp("↓", "down"), + ), + Left: key.NewBinding( + key.WithKeys("left", "backspace"), + key.WithHelp("←/⌫", "parent dir"), + ), + Right: key.NewBinding( + key.WithKeys("right"), + key.WithHelp("→", "enter/preview"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "enter/confirm"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "switch focus"), + ), + Copy: key.NewBinding( + key.WithKeys("c"), + key.WithHelp("c", "copy file"), + ), + Quit: key.NewBinding( + key.WithKeys("q"), + key.WithHelp("q", "quit"), + ), + Escape: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel/quit"), + ), + Help: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "help"), + ), +} + +// ShortHelp returns key bindings for the short help view. +// +//nolint:gocritic // hugeParam: value receiver required by help.KeyMap interface +func (k keyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Up, k.Down, k.Left, k.Right, k.Tab, k.Copy, k.Quit} +} + +// FullHelp returns key bindings for the full help view. +// +//nolint:gocritic // hugeParam: value receiver required by help.KeyMap interface +func (k keyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Up, k.Down, k.Left, k.Right}, + {k.Tab, k.Copy, k.Quit, k.Help}, + } +} diff --git a/internal/tui/open/messages.go b/internal/tui/open/messages.go new file mode 100644 index 0000000..74d003b --- /dev/null +++ b/internal/tui/open/messages.go @@ -0,0 +1,40 @@ +package open + +import "github.com/meigma/blob" + +// ArchiveLoadedMsg is sent when the archive has been loaded successfully. +type ArchiveLoadedMsg struct { + Index *blob.IndexView + Archive *blob.Archive +} + +// ArchiveErrorMsg is sent when loading the archive fails. +type ArchiveErrorMsg struct { + Err error +} + +// FileContentMsg is sent when file content has been loaded. +type FileContentMsg struct { + Path string + Content []byte + IsBinary bool +} + +// FileErrorMsg is sent when loading a file fails. +type FileErrorMsg struct { + Path string + Err error +} + +// CopyCompleteMsg is sent when a file copy completes successfully. +type CopyCompleteMsg struct { + SourcePath string + DestPath string +} + +// CopyErrorMsg is sent when a file copy fails. +type CopyErrorMsg struct { + SourcePath string + DestPath string + Err error +} diff --git a/internal/tui/open/model.go b/internal/tui/open/model.go new file mode 100644 index 0000000..6dfb888 --- /dev/null +++ b/internal/tui/open/model.go @@ -0,0 +1,82 @@ +// Package open provides the TUI for the blob open command. +package open + +import ( + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/lipgloss" + "github.com/meigma/blob" + + "github.com/meigma/blob-cli/internal/tui/components/copydialog" + "github.com/meigma/blob-cli/internal/tui/components/filetree" + "github.com/meigma/blob-cli/internal/tui/components/preview" + "github.com/meigma/blob-cli/internal/tui/components/statusbar" +) + +// state represents the current TUI state. +type state int + +const ( + stateLoading state = iota + stateReady + stateError +) + +// focus indicates which pane has focus. +type focus int + +const ( + focusTree focus = iota + focusPreview +) + +// LoadFunc is a function that loads the archive data. +// It's called asynchronously in Init(). +type LoadFunc func() (*blob.IndexView, *blob.Archive, error) + +// Model is the main TUI model for blob open. +type Model struct { + // Loading state + state state + loader LoadFunc + loadErr error + spinner spinner.Model + + // Archive data (set after loading) + ref string + index *blob.IndexView + archive *blob.Archive + + // Components (initialized after loading) + tree filetree.Model + preview preview.Model + copyDialog copydialog.Model + statusBar statusbar.Model + help help.Model + + // State + focus focus + showHelp bool + styles Styles + + // Dimensions + width int + height int + ready bool +} + +// New creates a new TUI model in loading state. +// The loader function will be called asynchronously to fetch the archive. +func New(ref string, loader LoadFunc) Model { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + + return Model{ + state: stateLoading, + ref: ref, + loader: loader, + spinner: s, + styles: DefaultStyles(), + } +} diff --git a/internal/tui/open/styles.go b/internal/tui/open/styles.go new file mode 100644 index 0000000..b08d845 --- /dev/null +++ b/internal/tui/open/styles.go @@ -0,0 +1,47 @@ +package open + +import "github.com/charmbracelet/lipgloss" + +// Styles holds the TUI styles. +type Styles struct { + // Focus indicator colors + FocusedBorder lipgloss.Color + UnfocusedBorder lipgloss.Color + + // Text styles + Title lipgloss.Style + Selected lipgloss.Style + Normal lipgloss.Style + Dir lipgloss.Style + Error lipgloss.Style + Hint lipgloss.Style +} + +// DefaultStyles returns the default style configuration. +func DefaultStyles() Styles { + return Styles{ + FocusedBorder: lipgloss.Color("62"), + UnfocusedBorder: lipgloss.Color("240"), + + Title: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("252")), + + Selected: lipgloss.NewStyle(). + Foreground(lipgloss.Color("229")). + Bold(true), + + Normal: lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")), + + Dir: lipgloss.NewStyle(). + Foreground(lipgloss.Color("75")), + + Error: lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). + Bold(true), + + Hint: lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")), + } +} diff --git a/internal/tui/open/update.go b/internal/tui/open/update.go new file mode 100644 index 0000000..2f4349d --- /dev/null +++ b/internal/tui/open/update.go @@ -0,0 +1,451 @@ +package open + +import ( + "os" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + + "github.com/meigma/blob-cli/internal/tui/components/copydialog" + "github.com/meigma/blob-cli/internal/tui/components/filetree" + "github.com/meigma/blob-cli/internal/tui/components/preview" + "github.com/meigma/blob-cli/internal/tui/components/statusbar" + "github.com/meigma/blob-cli/internal/tui/detect" +) + +// Init initializes the model. +// +//nolint:gocritic // hugeParam: value receiver required by tea.Model interface +func (m Model) Init() tea.Cmd { + // Start spinner and kick off archive loading + return tea.Batch( + m.spinner.Tick, + m.loadArchive(), + ) +} + +// loadArchive returns a command that loads the archive asynchronously. +// +//nolint:gocritic // hugeParam: consistent with tea.Model pattern +func (m Model) loadArchive() tea.Cmd { + loader := m.loader + return func() tea.Msg { + index, archive, err := loader() + if err != nil { + return ArchiveErrorMsg{Err: err} + } + return ArchiveLoadedMsg{Index: index, Archive: archive} + } +} + +// Update handles messages and updates the model. +// +//nolint:gocritic // hugeParam: value receiver required by tea.Model interface +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Handle global messages first + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + if m.state == stateReady { + return m.handleResize(msg) + } + return m, nil + + case tea.KeyMsg: + // Allow quitting with 'q' in any state + if key.Matches(msg, keys.Quit) { + return m, tea.Quit + } + // Escape is handled per-state (may close dialogs/help first) + } + + // Route to state-specific handler + switch m.state { + case stateLoading: + return m.updateLoading(msg) + case stateError: + return m.updateError(msg) + case stateReady: + return m.updateReady(msg) + } + + return m, nil +} + +// updateLoading handles messages during the loading state. +// +//nolint:gocritic // hugeParam: consistent with tea.Model pattern +func (m Model) updateLoading(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if key.Matches(msg, keys.Escape) { + return m, tea.Quit + } + + case ArchiveLoadedMsg: + // Initialize components now that we have the archive + m.state = stateReady + m.index = msg.Index + m.archive = msg.Archive + m.tree = filetree.New(msg.Index) + m.preview = preview.New() + m.copyDialog = copydialog.New() + m.statusBar = statusbar.New(m.ref) + m.help = help.New() + + // Set initial focus + m.tree.SetFocused(true) + m.preview.SetFocused(false) + + // Apply dimensions if we already received a WindowSizeMsg + var cmds []tea.Cmd + if m.width > 0 && m.height > 0 { + resized, cmd := m.handleResize(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + if resizedModel, ok := resized.(Model); ok { + m = resizedModel + } + if cmd != nil { + cmds = append(cmds, cmd) + } + } + + // Initialize child components + cmds = append(cmds, + m.tree.Init(), + m.preview.Init(), + m.copyDialog.Init(), + m.statusBar.Init(), + ) + + // Load initial preview and selection status for the first selected item + m.updateSelectionStatus() + if cmd := m.loadSelectedPreview(); cmd != nil { + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) + + case ArchiveErrorMsg: + m.state = stateError + m.loadErr = msg.Err + return m, nil + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } + + return m, nil +} + +// updateError handles messages during the error state. +// +//nolint:gocritic // hugeParam: consistent with tea.Model pattern +func (m Model) updateError(msg tea.Msg) (tea.Model, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + if key.Matches(keyMsg, keys.Escape) { + return m, tea.Quit + } + } + return m, nil +} + +// updateReady handles messages when the archive is loaded. +// +//nolint:gocritic // hugeParam: consistent with tea.Model pattern +func (m Model) updateReady(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + // Handle copy dialog if visible + if m.copyDialog.Visible() { + return m.handleCopyDialogKeys(msg) + } + return m.handleKeys(msg) + + case FileContentMsg: + m.preview.SetContent(msg.Path, msg.Content, msg.IsBinary) + return m, nil + + case FileErrorMsg: + m.preview.SetError(msg.Path, msg.Err) + m.statusBar.SetError(msg.Err) + return m, m.statusBar.ScheduleClear() + + case CopyCompleteMsg: + m.copyDialog.Hide() + m.statusBar.SetMessage("Copied to " + msg.DestPath) + return m, m.statusBar.ScheduleClear() + + case CopyErrorMsg: + m.copyDialog.Hide() + m.statusBar.SetError(msg.Err) + return m, m.statusBar.ScheduleClear() + + case statusbar.ClearMessageMsg: + m.statusBar, _ = m.statusBar.Update(msg) + return m, nil + } + + // Forward messages to copy dialog if visible + if m.copyDialog.Visible() { + var cmd tea.Cmd + m.copyDialog, cmd = m.copyDialog.Update(msg) + cmds = append(cmds, cmd) + } + + // Forward to focused component + if m.focus == focusPreview { + var cmd tea.Cmd + m.preview, cmd = m.preview.Update(msg) + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +// handleResize handles window resize events when in ready state. +// +//nolint:gocritic // hugeParam: consistent with tea.Model pattern +func (m Model) handleResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { + m.width = msg.Width + m.height = msg.Height + m.ready = true + + // Calculate layout (40% tree, 60% preview) + treeWidth := m.width * 40 / 100 + previewWidth := m.width - treeWidth + + // Height: full height minus status bar (1 line) + contentHeight := m.height - 1 + + m.tree.SetSize(treeWidth, contentHeight) + m.preview.SetSize(previewWidth, contentHeight) + m.copyDialog.SetSize(m.width, m.height) + m.statusBar.SetWidth(m.width) + + // Update status bar with entry count + m.statusBar.SetPath(m.tree.CurrentDir()) + m.statusBar.SetEntryCount(m.tree.EntryCount()) + + return m, nil +} + +// handleKeys handles key presses in normal mode. +// +//nolint:gocritic // hugeParam: consistent with tea.Model pattern +func (m Model) handleKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Global keys that work in any focus state + switch { + case key.Matches(msg, keys.Quit): + return m, tea.Quit + + case key.Matches(msg, keys.Escape): + // If help is showing, close it; otherwise quit + if m.showHelp { + m.showHelp = false + return m, nil + } + return m, tea.Quit + + case key.Matches(msg, keys.Help): + m.showHelp = !m.showHelp + return m, nil + + case key.Matches(msg, keys.Tab): + return m.toggleFocus(), nil + + case key.Matches(msg, keys.Copy): + return m.startCopy() + } + + // Focus-specific handling + if m.focus == focusTree { + return m.handleTreeKeys(msg) + } + return m.handlePreviewKeys(msg) +} + +// handleTreeKeys handles key presses when the tree is focused. +// +//nolint:gocritic // hugeParam: consistent with tea.Model pattern +func (m Model) handleTreeKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, keys.Up): + m.tree.CursorUp() + m.updateSelectionStatus() + return m, m.loadSelectedPreview() + + case key.Matches(msg, keys.Down): + m.tree.CursorDown() + m.updateSelectionStatus() + return m, m.loadSelectedPreview() + + case key.Matches(msg, keys.Left): + if m.tree.Back() { + m.updateStatusBar() + m.updateSelectionStatus() + return m, m.loadSelectedPreview() + } + return m, nil + + case key.Matches(msg, keys.Right), key.Matches(msg, keys.Enter): + if m.tree.Enter() { + // Entered a directory + m.updateStatusBar() + m.updateSelectionStatus() + return m, m.loadSelectedPreview() + } + // Selected a file - load preview + m.updateSelectionStatus() + return m, m.loadSelectedPreview() + } + + return m, nil +} + +// handlePreviewKeys handles key presses when the preview is focused. +// Most keys are forwarded to the viewport for scrolling. +// +//nolint:gocritic // hugeParam: consistent with tea.Model pattern +func (m Model) handlePreviewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Forward all navigation keys to the viewport + // The viewport handles: up/down/j/k, page up/down, g/G (home/end), ctrl+u/d + var cmd tea.Cmd + m.preview, cmd = m.preview.Update(msg) + return m, cmd +} + +// handleCopyDialogKeys handles key presses in copy dialog mode. +// +//nolint:gocritic // hugeParam: consistent with tea.Model pattern +func (m Model) handleCopyDialogKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, keys.Escape): + m.copyDialog.Hide() + return m, nil + + case key.Matches(msg, keys.Enter): + return m.executeCopy() + } + + // Forward other keys to the text input + var cmd tea.Cmd + m.copyDialog, cmd = m.copyDialog.Update(msg) + return m, cmd +} + +// toggleFocus switches focus between tree and preview. +// +//nolint:gocritic // hugeParam: consistent with tea.Model pattern +func (m Model) toggleFocus() Model { + if m.focus == focusTree { + m.focus = focusPreview + m.tree.SetFocused(false) + m.preview.SetFocused(true) + } else { + m.focus = focusTree + m.tree.SetFocused(true) + m.preview.SetFocused(false) + } + return m +} + +// updateSelectionStatus updates the status bar with the currently selected file's metadata. +func (m *Model) updateSelectionStatus() { + selected := m.tree.Selected() + if selected == nil { + m.statusBar.ClearSelection() + return + } + m.statusBar.SetSelectedFile(selected.Name, selected.Size, selected.ModTime, selected.IsDir) +} + +// loadSelectedPreview loads the preview for the currently selected item. +// +//nolint:gocritic // hugeParam: consistent with tea.Model pattern +func (m Model) loadSelectedPreview() tea.Cmd { + selected := m.tree.Selected() + if selected == nil { + m.preview.SetNone() + return nil + } + + if selected.IsDir { + m.preview.SetDir(selected.Path) + return nil + } + + // Check file size before loading to prevent memory issues + if selected.Size > preview.MaxPreviewBytes { + m.preview.SetTooLarge(selected.Path, selected.Size) + return nil + } + + // Load file content asynchronously + m.preview.SetLoading(selected.Path) + path := selected.Path + archive := m.archive + + return func() tea.Msg { + content, err := archive.ReadFile(path) + if err != nil { + return FileErrorMsg{Path: path, Err: err} + } + isBinary := detect.IsBinary(content) + return FileContentMsg{Path: path, Content: content, IsBinary: isBinary} + } +} + +// updateStatusBar updates the status bar with current state. +func (m *Model) updateStatusBar() { + m.statusBar.SetPath(m.tree.CurrentDir()) + m.statusBar.SetEntryCount(m.tree.EntryCount()) +} + +// startCopy initiates the copy dialog for the selected file. +// +//nolint:gocritic // hugeParam: consistent with tea.Model pattern +func (m Model) startCopy() (tea.Model, tea.Cmd) { + selected := m.tree.Selected() + if selected == nil || selected.IsDir { + m.statusBar.SetMessage("Select a file to copy") + return m, m.statusBar.ScheduleClear() + } + + m.copyDialog.Show(selected.Path) + return m, nil +} + +// executeCopy performs the file copy operation. +// +//nolint:gocritic // hugeParam: consistent with tea.Model pattern +func (m Model) executeCopy() (tea.Model, tea.Cmd) { + sourcePath := m.copyDialog.SourcePath() + destPath := m.copyDialog.Destination() + + if destPath == "" { + m.statusBar.SetMessage("Destination path required") + return m, m.statusBar.ScheduleClear() + } + + archive := m.archive + + return m, func() tea.Msg { + content, err := archive.ReadFile(sourcePath) + if err != nil { + return CopyErrorMsg{SourcePath: sourcePath, DestPath: destPath, Err: err} + } + + if err := os.WriteFile(destPath, content, 0o600); err != nil { + return CopyErrorMsg{SourcePath: sourcePath, DestPath: destPath, Err: err} + } + + return CopyCompleteMsg{SourcePath: sourcePath, DestPath: destPath} + } +} diff --git a/internal/tui/open/view.go b/internal/tui/open/view.go new file mode 100644 index 0000000..3e5e1ef --- /dev/null +++ b/internal/tui/open/view.go @@ -0,0 +1,164 @@ +package open + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" +) + +// View renders the TUI. +// +//nolint:gocritic // hugeParam: value receiver required by tea.Model interface +func (m Model) View() string { + switch m.state { + case stateLoading: + return m.viewLoading() + case stateError: + return m.viewError() + case stateReady: + return m.viewReady() + } + return "" +} + +// viewLoading renders the loading screen. +// +//nolint:gocritic // hugeParam: consistent with tea.Model pattern +func (m Model) viewLoading() string { + spinnerStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("205")) + + refStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")). + Bold(true) + + message := fmt.Sprintf("%s Loading %s...", m.spinner.View(), refStyle.Render(m.ref)) + + // Center the message if we have dimensions + if m.width > 0 && m.height > 0 { + return lipgloss.Place( + m.width, m.height, + lipgloss.Center, lipgloss.Center, + spinnerStyle.Render(message), + ) + } + + return spinnerStyle.Render(message) +} + +// viewError renders the error screen. +// +//nolint:gocritic // hugeParam: consistent with tea.Model pattern +func (m Model) viewError() string { + errorStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). + Bold(true) + + hintStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")) + + message := lipgloss.JoinVertical(lipgloss.Center, + errorStyle.Render("Error loading archive"), + "", + m.loadErr.Error(), + "", + hintStyle.Render("Press q to quit"), + ) + + // Center the message if we have dimensions + if m.width > 0 && m.height > 0 { + return lipgloss.Place( + m.width, m.height, + lipgloss.Center, lipgloss.Center, + message, + ) + } + + return message +} + +// viewReady renders the main browser interface. +// +//nolint:gocritic // hugeParam: consistent with tea.Model pattern +func (m Model) viewReady() string { + if !m.ready { + return "Initializing..." + } + + // Build the main layout + treeView := m.tree.View() + previewView := m.preview.View() + + // Join tree and preview horizontally + mainContent := lipgloss.JoinHorizontal(lipgloss.Top, treeView, previewView) + + // Add status bar at the bottom + statusView := m.statusBar.View() + fullView := lipgloss.JoinVertical(lipgloss.Left, mainContent, statusView) + + // Overlay copy dialog if visible + if m.copyDialog.Visible() { + fullView = m.overlayDialog(fullView) + } + + // Overlay help if visible + if m.showHelp { + fullView = m.overlayHelp(fullView) + } + + return fullView +} + +// overlayDialog overlays the copy dialog centered on the screen. +// +//nolint:gocritic // hugeParam: consistent with tea.Model pattern +func (m Model) overlayDialog(_ string) string { + dialog := m.copyDialog.View() + + // Create the overlay by placing dialog on top of background + return lipgloss.Place( + m.width, m.height, + lipgloss.Center, lipgloss.Center, + dialog, + lipgloss.WithWhitespaceChars(" "), + lipgloss.WithWhitespaceForeground(lipgloss.Color("0")), + ) +} + +// overlayHelp overlays the help panel centered on the screen. +// +//nolint:gocritic // hugeParam: consistent with tea.Model pattern +func (m Model) overlayHelp(_ string) string { + // Build help content using the full help view + helpContent := m.help.View(keys) + + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("229")). + MarginBottom(1) + + hintStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")). + MarginTop(1) + + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("62")). + Padding(1, 2) + + content := lipgloss.JoinVertical(lipgloss.Left, + titleStyle.Render("Keyboard Shortcuts"), + helpContent, + hintStyle.Render("Press ? or Esc to close"), + ) + + dialog := boxStyle.Render(content) + + return lipgloss.Place( + m.width, m.height, + lipgloss.Center, lipgloss.Center, + dialog, + lipgloss.WithWhitespaceChars(" "), + lipgloss.WithWhitespaceForeground(lipgloss.Color("0")), + ) +}