Skip to content
Merged
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: 1 addition & 3 deletions .github/workflows/electron.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
name: Electron Build

on:
push:
tags:
- 'v*'
workflow_dispatch: # manual trigger only

permissions:
contents: write
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,24 @@ compose-preview

# Or specify a path:
compose-preview /path/to/android/project

# Open HD web preview in browser:
compose-preview --web

# Web preview on a custom port (default: 9999):
compose-preview --web --port 8080

# List all previews as JSON (for scripting/CI):
compose-preview --list

# Run a specific preview on a connected device:
compose-preview --run SplashScreenPreview

# Take a screenshot of a preview (saves to preview.png):
compose-preview --screenshot SplashScreenPreview

# Custom output file and delay (default: 3s):
compose-preview --screenshot SplashScreenPreview --output splash.png --delay 5
```

### Layout
Expand Down
221 changes: 214 additions & 7 deletions cmd/compose-preview/main.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
package main

import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"

tea "github.com/charmbracelet/bubbletea"

"github.com/ignaciotcrespo/compose-preview-cli/internal/adb"
"github.com/ignaciotcrespo/compose-preview-cli/internal/gradle"
"github.com/ignaciotcrespo/compose-preview-cli/internal/scanner"
"github.com/ignaciotcrespo/compose-preview-cli/internal/server"
Expand All @@ -24,12 +29,46 @@ func main() {
return
}

// Check for --web flag
// Check for flags
webMode := false
listMode := false
runPreview := ""
screenshotPreview := ""
screenshotOutput := "preview.png"
screenshotDelay := 3
webPort := 9999
args := []string{}
for _, arg := range os.Args[1:] {
for i := 1; i < len(os.Args); i++ {
arg := os.Args[i]
if arg == "--web" || arg == "-w" {
webMode = true
} else if arg == "--list" || arg == "-l" {
listMode = true
} else if (arg == "--run" || arg == "-r") && i+1 < len(os.Args) {
i++
runPreview = os.Args[i]
} else if arg == "--screenshot" && i+1 < len(os.Args) {
i++
screenshotPreview = os.Args[i]
} else if arg == "--output" && i+1 < len(os.Args) {
i++
screenshotOutput = os.Args[i]
} else if arg == "--delay" && i+1 < len(os.Args) {
i++
d, err := strconv.Atoi(os.Args[i])
if err != nil {
fmt.Fprintf(os.Stderr, "Error: invalid delay %q\n", os.Args[i])
os.Exit(1)
}
screenshotDelay = d
} else if (arg == "--port" || arg == "-p") && i+1 < len(os.Args) {
i++
p, err := strconv.Atoi(os.Args[i])
if err != nil {
fmt.Fprintf(os.Stderr, "Error: invalid port %q\n", os.Args[i])
os.Exit(1)
}
webPort = p
} else {
args = append(args, arg)
}
Expand All @@ -55,7 +94,22 @@ func main() {
}

if webMode {
runWebMode(root)
runWebMode(root, webPort)
return
}

if listMode {
runListMode(root)
return
}

if runPreview != "" {
runPreviewMode(root, runPreview)
return
}

if screenshotPreview != "" {
runScreenshotMode(root, screenshotPreview, screenshotOutput, screenshotDelay)
return
}

Expand All @@ -78,7 +132,7 @@ func main() {
}
}

func runWebMode(root string) {
func runWebMode(root string, port int) {
// In web mode, start the server and open the browser.
// The server spawns the TUI in a PTY internally.
goBinary, err := os.Executable()
Expand All @@ -87,14 +141,14 @@ func runWebMode(root string) {
os.Exit(1)
}

srv := server.New(9999, goBinary, root)
port, err := srv.Start()
srv := server.New(port, goBinary, root)
actualPort, err := srv.Start()
if err != nil {
fmt.Fprintf(os.Stderr, "Error starting server: %v\n", err)
os.Exit(1)
}

url := fmt.Sprintf("http://localhost:%d", port)
url := fmt.Sprintf("http://localhost:%d", actualPort)
fmt.Fprintf(os.Stderr, "Compose Preview running at %s\n", url)

// Open browser
Expand All @@ -111,3 +165,156 @@ func runWebMode(root string) {
fmt.Fprintf(os.Stderr, "Press Ctrl+C to stop\n")
select {}
}

func runListMode(root string) {
result := scanner.Scan(root)

type previewEntry struct {
Module string `json:"module"`
Function string `json:"function"`
FQN string `json:"fqn"`
File string `json:"file"`
Line int `json:"line"`
Name string `json:"name,omitempty"`
}

var entries []previewEntry
for _, p := range result.AllPreviews {
rel, _ := filepath.Rel(root, p.FilePath)
entries = append(entries, previewEntry{
Module: p.Module,
Function: p.FunctionName,
FQN: p.FQN,
File: rel,
Line: p.LineNumber,
Name: p.PreviewName,
})
}

enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
enc.Encode(entries)
}

// resolvedPreview holds everything needed to launch a preview on a device.
type resolvedPreview struct {
preview scanner.PreviewFunc
serial string
packages []string
}

// resolvePreview scans the project, finds the matching preview, device, and installed app.
func resolvePreview(root, query string) resolvedPreview {
result := scanner.Scan(root)
if len(result.AllPreviews) == 0 {
fmt.Fprintf(os.Stderr, "No @Preview composables found.\n")
os.Exit(1)
}

var match *scanner.PreviewFunc
var partialMatches []scanner.PreviewFunc
queryLower := strings.ToLower(query)
for i, p := range result.AllPreviews {
if p.FQN == query || p.FunctionName == query {
match = &result.AllPreviews[i]
break
}
if strings.Contains(strings.ToLower(p.FunctionName), queryLower) {
partialMatches = append(partialMatches, p)
}
}
if match == nil && len(partialMatches) == 1 {
match = &partialMatches[0]
}
if match == nil {
if len(partialMatches) > 1 {
fmt.Fprintf(os.Stderr, "Multiple previews match %q:\n", query)
for _, p := range partialMatches {
fmt.Fprintf(os.Stderr, " %s (%s)\n", p.FunctionName, p.FQN)
}
} else {
fmt.Fprintf(os.Stderr, "No preview found matching %q\n", query)
}
os.Exit(1)
}

devices, err := adb.DetectDevices()
if err != nil || len(devices) == 0 {
fmt.Fprintf(os.Stderr, "Error: no device connected\n")
os.Exit(1)
}
serial := devices[0].Serial

appId, _ := findAppApplicationId(result.Modules, root)
if appId == "" {
fmt.Fprintf(os.Stderr, "Error: could not detect applicationId — check build.gradle.kts\n")
os.Exit(1)
}

packages := adb.FindInstalledPackage(serial, appId)
if len(packages) == 0 {
fmt.Fprintf(os.Stderr, "Error: app not installed on %s\n", serial)
fmt.Fprintf(os.Stderr, "Install it first:\n")
fmt.Fprintf(os.Stderr, " compose-preview (TUI, press 'i')\n")
fmt.Fprintf(os.Stderr, " ./gradlew installDebug (manual)\n")
os.Exit(1)
}

return resolvedPreview{preview: *match, serial: serial, packages: packages}
}

// launchPreview launches the preview on the device, trying all installed variants.
func launchPreview(r resolvedPreview) {
fmt.Fprintf(os.Stderr, "Launching %s on %s...\n", r.preview.FunctionName, r.serial)
for _, pkg := range r.packages {
if err := adb.LaunchPreview(r.serial, pkg, r.preview.FQN); err == nil {
fmt.Fprintf(os.Stderr, "Launched: %s (%s)\n", r.preview.FunctionName, pkg)
return
}
}
fmt.Fprintf(os.Stderr, "Error: failed to launch preview with any installed variant\n")
os.Exit(1)
}

func runPreviewMode(root, query string) {
r := resolvePreview(root, query)
launchPreview(r)
}

func runScreenshotMode(root, query, output string, delay int) {
r := resolvePreview(root, query)
launchPreview(r)

fmt.Fprintf(os.Stderr, "Waiting %ds for preview to render...\n", delay)
time.Sleep(time.Duration(delay) * time.Second)

png, err := adb.CaptureScreenshot(r.serial)
if err != nil {
fmt.Fprintf(os.Stderr, "Error capturing screenshot: %v\n", err)
os.Exit(1)
}

if err := os.WriteFile(output, png, 0644); err != nil {
fmt.Fprintf(os.Stderr, "Error writing %s: %v\n", output, err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "Screenshot saved to %s\n", output)
}

func findAppApplicationId(modules []scanner.Module, projectRoot string) (string, string) {
for _, name := range []string{":composeApp", ":app"} {
for _, mod := range modules {
if mod.Name == name {
if id := gradle.FindApplicationId(mod.Path); id != "" {
return id, mod.Path
}
}
}
}
for _, mod := range modules {
if id := gradle.FindApplicationId(mod.Path); id != "" {
return id, mod.Path
}
}
return "", ""
}
2 changes: 1 addition & 1 deletion internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ type Model struct {
// Prompt
prompt prompt.Prompt

// Electron mode: hide screenshot panel, Electron shows it
// Web mode: hide screenshot panel, browser shows HD preview
electronMode bool

// Panel regions for mouse click detection
Expand Down
2 changes: 1 addition & 1 deletion internal/ui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func (m Model) View() string {

var leftW, midW, rightW int
if m.electronMode {
// 2-panel layout: Electron shows the screenshot
// 2-panel layout: browser shows the HD screenshot
leftW = m.width / 4
midW = m.width - leftW - 4
rightW = 0
Expand Down
Loading