From d3a97defb1de7241dc54bf7ccfff32cd6883044b Mon Sep 17 00:00:00 2001 From: Ignacio Tomas Crespo Date: Mon, 16 Mar 2026 22:32:49 -0300 Subject: [PATCH 1/5] electron app with hd preview --- .github/workflows/electron.yml | 79 ++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/.github/workflows/electron.yml b/.github/workflows/electron.yml index 7af8565..983a58a 100644 --- a/.github/workflows/electron.yml +++ b/.github/workflows/electron.yml @@ -90,6 +90,7 @@ jobs: # Move binary to where electron-builder expects it - name: Place binary for bundling + shell: bash run: | mkdir -p electron/bin cp electron/compose-preview* electron/bin/ || true @@ -108,6 +109,10 @@ jobs: working-directory: electron run: npm install + - name: Install setuptools for node-gyp + if: runner.os != 'Windows' + run: pip3 install setuptools || true + - name: Rebuild native modules working-directory: electron run: npx electron-rebuild -f -w node-pty @@ -117,3 +122,77 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: npx electron-builder ${{ matrix.electron-args }} --publish always + + # Publish Homebrew cask for the Electron app (all platforms) + publish-cask: + needs: build-electron + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Wait for release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # electron-builder --publish always uploads to the GitHub Release + # Wait a bit for all assets to be available + sleep 30 + + - name: Generate Homebrew cask + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${GITHUB_REF_NAME#v}" + REPO="ignaciotcrespo/compose-preview-cli" + BASE_URL="https://github.com/${REPO}/releases/download/v${VERSION}" + + # Download release assets to compute SHA256 + # macOS arm64 + MAC_ARM64_URL="${BASE_URL}/Compose-Preview-${VERSION}-arm64.dmg" + MAC_ARM64_SHA=$(curl -sL "${MAC_ARM64_URL}" | sha256sum | cut -d' ' -f1) + + # macOS x64 + MAC_X64_URL="${BASE_URL}/Compose-Preview-${VERSION}.dmg" + MAC_X64_SHA=$(curl -sL "${MAC_X64_URL}" | sha256sum | cut -d' ' -f1) + + cat > cask.rb << CASK + cask "compose-preview-app" do + version "${VERSION}" + + if Hardware::CPU.arm? + url "${MAC_ARM64_URL}" + sha256 "${MAC_ARM64_SHA}" + else + url "${MAC_X64_URL}" + sha256 "${MAC_X64_SHA}" + end + + name "Compose Preview" + desc "Browse and run Jetpack Compose previews without Android Studio" + homepage "https://github.com/${REPO}" + + app "Compose Preview.app" + + zap trash: [ + "~/Library/Application Support/compose-preview-app", + "~/Library/Preferences/com.ignaciotcrespo.compose-preview.plist", + ] + end + CASK + + echo "Generated cask for v${VERSION}" + cat cask.rb + + - name: Push cask to homebrew-tap + env: + HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} + run: | + git clone https://x-access-token:${HOMEBREW_TAP_GITHUB_TOKEN}@github.com/ignaciotcrespo/homebrew-tap.git tap + mkdir -p tap/Casks + cp cask.rb tap/Casks/compose-preview-app.rb + cd tap + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add Casks/compose-preview-app.rb + git commit -m "Update compose-preview-app cask to ${GITHUB_REF_NAME}" || echo "No changes" + git push From 7c496a4a703810117d6bc9ab51930341efec1687 Mon Sep 17 00:00:00 2001 From: Ignacio Tomas Crespo Date: Fri, 17 Apr 2026 10:50:21 -0300 Subject: [PATCH 2/5] commit missing changes --- cmd/compose-preview/main.go | 57 +++- go.mod | 2 + go.sum | 4 + internal/server/server.go | 455 ++++++++++++++++++++++++++++++++ internal/ui/app.go | 36 +++ internal/ui/render.go | 6 +- internal/ui/screenshot/cache.go | 59 +++-- internal/ui/view.go | 9 +- 8 files changed, 600 insertions(+), 28 deletions(-) create mode 100644 internal/server/server.go diff --git a/cmd/compose-preview/main.go b/cmd/compose-preview/main.go index 6f086cf..bcc0745 100644 --- a/cmd/compose-preview/main.go +++ b/cmd/compose-preview/main.go @@ -3,12 +3,15 @@ package main import ( "fmt" "os" + "os/exec" "path/filepath" + "runtime" tea "github.com/charmbracelet/bubbletea" "github.com/ignaciotcrespo/compose-preview-cli/internal/gradle" "github.com/ignaciotcrespo/compose-preview-cli/internal/scanner" + "github.com/ignaciotcrespo/compose-preview-cli/internal/server" "github.com/ignaciotcrespo/compose-preview-cli/internal/ui" ) @@ -21,10 +24,21 @@ func main() { return } + // Check for --web flag + webMode := false + args := []string{} + for _, arg := range os.Args[1:] { + if arg == "--web" || arg == "-w" { + webMode = true + } else { + args = append(args, arg) + } + } + // Determine project root dir := "." - if len(os.Args) > 1 { - dir = os.Args[1] + if len(args) > 0 { + dir = args[0] } absDir, err := filepath.Abs(dir) @@ -40,6 +54,11 @@ func main() { os.Exit(1) } + if webMode { + runWebMode(root) + return + } + // Scan for previews fmt.Fprintf(os.Stderr, "Scanning %s for @Preview composables...\n", root) result := scanner.Scan(root) @@ -58,3 +77,37 @@ func main() { os.Exit(1) } } + +func runWebMode(root string) { + // In web mode, start the server and open the browser. + // The server spawns the TUI in a PTY internally. + goBinary, err := os.Executable() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + srv := server.New(9999, goBinary, root) + port, 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) + fmt.Fprintf(os.Stderr, "Compose Preview running at %s\n", url) + + // Open browser + switch runtime.GOOS { + case "darwin": + exec.Command("open", url).Start() + case "windows": + exec.Command("cmd", "/c", "start", url).Start() + default: + exec.Command("xdg-open", url).Start() + } + + // Keep running until Ctrl+C + fmt.Fprintf(os.Stderr, "Press Ctrl+C to stop\n") + select {} +} diff --git a/go.mod b/go.mod index dd96eed..9e2f7f4 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,9 @@ require ( github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/creack/pty v1.1.24 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect diff --git a/go.sum b/go.sum index a7ec128..f7e0ea1 100644 --- a/go.sum +++ b/go.sum @@ -22,8 +22,12 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/ignaciotcrespo/tui-framework v0.1.0 h1:UsXwJhrgQDcGhPDNgfGhyFusHbwBjJLRbDesRyk/sO0= github.com/ignaciotcrespo/tui-framework v0.1.0/go.mod h1:bJ+i82G5aBf3fvXzokbBNJymouv2rKVlP3JKkWP9ZCY= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..0ce7f23 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,455 @@ +// Package server provides a localhost HTTP server that embeds the TUI +// in a browser via xterm.js + WebSocket, with a live preview image panel. +package server + +import ( + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "sync" + "time" + + "github.com/creack/pty" + "github.com/gorilla/websocket" +) + +var sharedDir = filepath.Join(os.TempDir(), "compose-preview") + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// Server serves the full app (TUI + preview) in a browser. +type Server struct { + port int + goBinary string + projectDir string + ptmx *os.File + listener net.Listener + sseClients map[chan string]bool + mu sync.Mutex + lastJSON string +} + +// New creates a server. +func New(port int, goBinary, projectDir string) *Server { + return &Server{ + port: port, + goBinary: goBinary, + projectDir: projectDir, + sseClients: make(map[chan string]bool), + } +} + +// URL returns the server URL. +func (s *Server) URL() string { + return fmt.Sprintf("http://localhost:%d", s.port) +} + +// Start starts the HTTP server and PTY process. Returns the actual port. +func (s *Server) Start() (int, error) { + mux := http.NewServeMux() + mux.HandleFunc("/", s.handleIndex) + mux.HandleFunc("/ws", s.handleWebSocket) + mux.HandleFunc("/events", s.handleSSE) + mux.HandleFunc("/screenshot", s.handleScreenshot) + + listener, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", s.port)) + if err != nil { + listener, err = net.Listen("tcp", "localhost:0") + if err != nil { + return 0, err + } + } + s.port = listener.Addr().(*net.TCPAddr).Port + s.listener = listener + + go http.Serve(listener, mux) + go s.watchState() + + return s.port, nil +} + +// Stop shuts down the server and kills the PTY process. +func (s *Server) Stop() { + if s.listener != nil { + s.listener.Close() + s.listener = nil + } + if s.ptmx != nil { + s.ptmx.Close() + s.ptmx = nil + } +} + +// startPTY starts the Go TUI in a PTY. Called on first WebSocket connection. +func (s *Server) startPTY() (*os.File, error) { + if s.ptmx != nil { + return s.ptmx, nil + } + + cmd := exec.Command(s.goBinary, s.projectDir) + cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COMPOSE_PREVIEW_ELECTRON=1") + + ptmx, err := pty.Start(cmd) + if err != nil { + return nil, err + } + pty.Setsize(ptmx, &pty.Winsize{Rows: 40, Cols: 120}) + s.ptmx = ptmx + + // Clean up when process exits + go func() { + cmd.Wait() + s.ptmx = nil + }() + + return ptmx, nil +} + +func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + + ptmx, err := s.startPTY() + if err != nil { + conn.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf("Error: %v\r\n", err))) + return + } + + // PTY → WebSocket + go func() { + buf := make([]byte, 4096) + for { + n, err := ptmx.Read(buf) + if err != nil { + conn.WriteMessage(websocket.CloseMessage, nil) + return + } + conn.WriteMessage(websocket.BinaryMessage, buf[:n]) + } + }() + + // WebSocket → PTY + for { + _, msg, err := conn.ReadMessage() + if err != nil { + return + } + + // Check for resize message: \x01{"cols":N,"rows":N} + if len(msg) > 1 && msg[0] == 1 { + var size struct { + Cols uint16 `json:"cols"` + Rows uint16 `json:"rows"` + } + if json.Unmarshal(msg[1:], &size) == nil && size.Cols > 0 && size.Rows > 0 { + pty.Setsize(ptmx, &pty.Winsize{Rows: size.Rows, Cols: size.Cols}) + continue + } + } + + ptmx.Write(msg) + } +} + +// SSE for screenshot updates + +func (s *Server) watchState() { + stateFile := filepath.Join(sharedDir, "state.json") + var lastMod time.Time + for { + time.Sleep(200 * time.Millisecond) + info, err := os.Stat(stateFile) + if err != nil { + continue + } + if info.ModTime().After(lastMod) { + lastMod = info.ModTime() + data, _ := os.ReadFile(stateFile) + s.notifySSE(string(data)) + } + } +} + +func (s *Server) notifySSE(jsonData string) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastJSON = jsonData + for ch := range s.sseClients { + select { + case ch <- jsonData: + default: + } + } +} + +func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "SSE not supported", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + ch := make(chan string, 10) + s.mu.Lock() + s.sseClients[ch] = true + last := s.lastJSON + s.mu.Unlock() + + if last != "" { + fmt.Fprintf(w, "data: %s\n\n", last) + flusher.Flush() + } + + defer func() { + s.mu.Lock() + delete(s.sseClients, ch) + s.mu.Unlock() + }() + + for { + select { + case msg := <-ch: + fmt.Fprintf(w, "data: %s\n\n", msg) + flusher.Flush() + case <-r.Context().Done(): + return + } + } +} + +func (s *Server) handleScreenshot(w http.ResponseWriter, r *http.Request) { + data, err := os.ReadFile(filepath.Join(sharedDir, "current.png")) + if err != nil { + http.Error(w, "No screenshot", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Cache-Control", "no-cache") + w.Write(data) +} + +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + io.WriteString(w, indexHTML) +} + +var indexHTML = ` + + + + Compose Preview + + + + +
+
+
+
+
+
+ No preview selected + +
+
+
+ Select a preview and press
+ Enter to run & capture
+ or s to screenshot +
+ +
+
+ + + + + + +` diff --git a/internal/ui/app.go b/internal/ui/app.go index bd62774..f4ad458 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -16,6 +16,7 @@ import ( "github.com/ignaciotcrespo/compose-preview-cli/internal/gradle" "github.com/ignaciotcrespo/compose-preview-cli/internal/scanner" "github.com/ignaciotcrespo/compose-preview-cli/internal/types" + "github.com/ignaciotcrespo/compose-preview-cli/internal/server" "github.com/ignaciotcrespo/compose-preview-cli/internal/ui/imgrender" "github.com/ignaciotcrespo/compose-preview-cli/internal/ui/panel" "github.com/ignaciotcrespo/compose-preview-cli/internal/ui/prompt" @@ -83,6 +84,7 @@ type Model struct { modules []scanner.Module projectRoot string appId string // applicationId from the app module (for ADB launch) + webServer *server.Server // nil until started by 'w' // Device / Emulator deviceStatus string @@ -401,6 +403,40 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } + // "w" toggles web preview viewer + if key == "w" { + if m.webServer == nil { + // Start server + goBinary, _ := os.Executable() + m.webServer = server.New(9999, goBinary, m.projectRoot) + port, err := m.webServer.Start() + if err != nil { + m.errorMsg = fmt.Sprintf("Server error: %v", err) + m.webServer = nil + } else { + url := fmt.Sprintf("http://localhost:%d", port) + m.statusMsg = fmt.Sprintf("Web viewer: %s", url) + // Open browser + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("cmd", "/c", "start", url) + default: + cmd = exec.Command("xdg-open", url) + } + cmd.Start() + } + } else { + // Stop server + m.webServer.Stop() + m.webServer = nil + m.statusMsg = "Web viewer stopped" + } + return m, nil + } + // "s" captures screenshot of current preview if key == "s" { return m, m.captureScreenshot() diff --git a/internal/ui/render.go b/internal/ui/render.go index 4cddd4d..eff61e7 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -189,7 +189,11 @@ func (m Model) renderHelp() string { return helpStyle.Render(" type to filter · tab panels · esc clear · enter confirm") } - parts := []string{"enter run", "s screenshot", "o open image", "b build", "/ filter", "d device", "q quit"} + webLabel := "w web" + if m.webServer != nil { + webLabel = "w stop web" + } + parts := []string{"enter run", "s screenshot", webLabel, "b build", "/ filter", "d device", "q quit"} return helpStyle.Render(" " + strings.Join(parts, " · ")) } diff --git a/internal/ui/screenshot/cache.go b/internal/ui/screenshot/cache.go index 2c5d4f9..de8f4f5 100644 --- a/internal/ui/screenshot/cache.go +++ b/internal/ui/screenshot/cache.go @@ -1,21 +1,22 @@ -// Package screenshot manages a cache of device screenshots keyed by composable FQN. +// Package screenshot manages a disk-backed cache of device screenshots keyed by composable FQN. +// Multiple instances of compose-preview share the same cache on disk. package screenshot import ( + "crypto/sha256" "encoding/json" "fmt" "os" "path/filepath" - "sync" "time" ) -// SharedDir is the directory where screenshots are written for external viewers. +// SharedDir is the directory where screenshots are stored. var SharedDir = filepath.Join(os.TempDir(), "compose-preview") // Entry is a cached screenshot. type Entry struct { - PNGData []byte + PNGData []byte CapturedAt time.Time } @@ -32,45 +33,55 @@ func (e *Entry) Age() string { } } -// Cache stores screenshots keyed by composable FQN. +// Cache stores screenshots on disk keyed by FQN. type Cache struct { - mu sync.RWMutex - entries map[string]*Entry + dir string } -// NewCache creates a new screenshot cache. +// NewCache creates a new disk-backed screenshot cache. func NewCache() *Cache { - return &Cache{entries: make(map[string]*Entry)} + dir := filepath.Join(SharedDir, "screenshots") + os.MkdirAll(dir, 0755) + return &Cache{dir: dir} +} + +// fqnToFile converts a FQN to a safe filename. +func fqnToFile(fqn string) string { + h := sha256.Sum256([]byte(fqn)) + return fmt.Sprintf("%x.png", h[:8]) } // Get returns the cached screenshot for the given FQN, or nil if not found. func (c *Cache) Get(fqn string) *Entry { - c.mu.RLock() - defer c.mu.RUnlock() - return c.entries[fqn] + path := filepath.Join(c.dir, fqnToFile(fqn)) + data, err := os.ReadFile(path) + if err != nil { + return nil + } + info, err := os.Stat(path) + if err != nil { + return nil + } + return &Entry{ + PNGData: data, + CapturedAt: info.ModTime(), + } } -// Put stores a screenshot for the given FQN and writes it to the shared directory. +// Put stores a screenshot for the given FQN on disk and signals external viewers. func (c *Cache) Put(fqn string, pngData []byte) { - c.mu.Lock() - defer c.mu.Unlock() - c.entries[fqn] = &Entry{ - PNGData: pngData, - CapturedAt: time.Now(), - } - // Write to shared dir for external viewers (Electron) + path := filepath.Join(c.dir, fqnToFile(fqn)) + os.WriteFile(path, pngData, 0644) writeShared(fqn, pngData) } -// SignalSelection writes a state.json to notify external viewers of the current selection. -// Called when navigating previews (even without a new screenshot). +// SignalSelection writes state.json to notify external viewers of the current selection. func (c *Cache) SignalSelection(fqn string) { entry := c.Get(fqn) hasScreenshot := entry != nil age := "" if hasScreenshot { age = entry.Age() - // Also write the cached PNG so Electron can show it writeShared(fqn, entry.PNGData) } @@ -85,7 +96,7 @@ func (c *Cache) SignalSelection(fqn string) { os.WriteFile(filepath.Join(SharedDir, "state.json"), data, 0644) } -// SignalCapturing writes state.json with capturing=true to show loading state in Electron. +// SignalCapturing writes state.json with capturing=true. func (c *Cache) SignalCapturing(fqn string) { state := map[string]interface{}{ "fqn": fqn, diff --git a/internal/ui/view.go b/internal/ui/view.go index 79a6046..aec0c3c 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -40,7 +40,14 @@ func (m Model) View() string { } else if m.deviceStatus != "" { deviceInfo = statusBarStyle.Render(" · ") + detailValueStyle.Render(m.deviceStatus) + statusBarStyle.Render(" (d to change)") } - header := title + projectInfo + deviceInfo + webInfo := "" + if m.webServer != nil { + url := m.webServer.URL() + link := fmt.Sprintf("\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\", url, url) + webInfo = statusBarStyle.Render(" · ") + lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Render(link) + + statusBarStyle.Render(" (w to stop)") + } + header := title + projectInfo + deviceInfo + webInfo // Status bar line (always 1 line, shows latest status or error) statusLine := "" From a4ded93e680fb9366d477dbf8c62152ae88dd1e1 Mon Sep 17 00:00:00 2001 From: Ignacio Tomas Crespo Date: Fri, 17 Apr 2026 11:09:25 -0300 Subject: [PATCH 3/5] improve ui feedback --- internal/controller/keymap.go | 2 +- internal/ui/app.go | 91 +++++++++++++++++++++++++++-------- internal/ui/render.go | 2 +- internal/ui/view.go | 50 ++++++++++++++++++- 4 files changed, 122 insertions(+), 23 deletions(-) diff --git a/internal/controller/keymap.go b/internal/controller/keymap.go index c4a9400..cfd0708 100644 --- a/internal/controller/keymap.go +++ b/internal/controller/keymap.go @@ -78,7 +78,7 @@ func HandleKey(key string, state State, ctx KeyContext) KeyResult { } return kr - case "b": + case "i": kr.RunBuild = true return kr diff --git a/internal/ui/app.go b/internal/ui/app.go index f4ad458..da88d8b 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -53,6 +53,11 @@ type emulatorReadyMsg struct { device adb.Device } +// installTasksMsg is sent when gradle install task discovery completes. +type installTasksMsg struct { + tasks []string +} + // devicePickerItem is an entry in the device/emulator picker. type devicePickerItem struct { label string @@ -102,8 +107,10 @@ type Model struct { needsBuild bool buildWarning string // e.g. "sources changed since last build" appModulePath string // path to the app module (for APK staleness check) - installTasks []string // cached install tasks (e.g. installDevDebug, installAcceptDebug) - lastBuildTask string // remember last selected task + installTasks []string // cached install tasks (e.g. installDevDebug, installAcceptDebug) + lastBuildTask string // remember last selected task + showInstallPicker bool // modal is visible + installPickerSel int // cursor in the picker // Screenshot cache and rendered preview screenshotCache *screenshot.Cache @@ -231,6 +238,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case installTasksMsg: + m.installTasks = msg.tasks + if len(m.installTasks) == 0 { + m.showInstallPicker = false + m.errorMsg = "No install tasks found" + return m, nil + } + if len(m.installTasks) == 1 { + m.showInstallPicker = false + return m, m.runBuildTask(m.installTasks[0]) + } + // Tasks loaded — picker is already showing, it will now render the list + return m, nil + case buildCompleteMsg: m.building = false if msg.err != nil { @@ -354,6 +375,35 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } + // Install task picker modal is active + if m.showInstallPicker { + switch msg.String() { + case "up", "k": + if m.installPickerSel > 0 { + m.installPickerSel-- + } + return m, nil + case "down", "j": + if m.installPickerSel < len(m.installTasks)-1 { + m.installPickerSel++ + } + return m, nil + case "enter": + if m.installPickerSel < len(m.installTasks) { + task := m.installTasks[m.installPickerSel] + m.showInstallPicker = false + return m, m.runBuildTask(task) + } + return m, nil + case "esc", "q": + m.showInstallPicker = false + return m, nil + case "ctrl+c": + return m, tea.Quit + } + return m, nil + } + // Search bar is active — route keys to textinput if m.searchActive { switch msg.String() { @@ -513,8 +563,6 @@ func (m *Model) handlePromptResult(result *prompt.Result) tea.Cmd { case types.PromptFilter: m.state.Filter = result.Value m.state.PreviewSel = 0 - case types.PromptBuildVariant: - return m.runBuildTask(result.Value) } return nil } @@ -553,7 +601,7 @@ func (m *Model) launchPreview() tea.Cmd { if len(packages) == 0 { return adbLaunchMsg{ preview: p.FunctionName, - err: fmt.Errorf("app not installed — build and install first (press 'b')"), + err: fmt.Errorf("app not installed — build and install first (press 'i')"), } } @@ -616,27 +664,30 @@ func (m *Model) startBuild() tea.Cmd { } } - // Discover install tasks if not cached - if len(m.installTasks) == 0 { - m.statusMsg = "Querying build variants..." - m.errorMsg = "" - tasks := gradle.ListInstallTasks(gradlew, appModuleName) - m.installTasks = tasks - m.statusMsg = "" + // If we used a task before, run it again directly + if m.lastBuildTask != "" { + return m.runBuildTask(m.lastBuildTask) } - // If only one task, run it directly + // Show picker modal immediately + m.installPickerSel = 0 + m.showInstallPicker = true + m.errorMsg = "" + + // If tasks are already cached, check shortcuts if len(m.installTasks) == 1 { + m.showInstallPicker = false return m.runBuildTask(m.installTasks[0]) } - - // If we used a task before, run it again directly - if m.lastBuildTask != "" { - return m.runBuildTask(m.lastBuildTask) + if len(m.installTasks) > 1 { + return nil } - // Multiple tasks: show quick-select prompt - return m.prompt.StartWithOptions(types.PromptBuildVariant, "", m.installTasks) + // Discover install tasks asynchronously + return func() tea.Msg { + tasks := gradle.ListInstallTasks(gradlew, appModuleName) + return installTasksMsg{tasks: tasks} + } } func (m *Model) runBuildTask(task string) tea.Cmd { @@ -847,7 +898,7 @@ func (m Model) currentPreviewScreenshot(width, height int) (string, string) { return " No screenshot (s to capture)", "" } - rendered := imgrender.Render(entry.PNGData, width, height-1) // -1 for age line + rendered := imgrender.Render(entry.PNGData, width, height-2) // -1 age line, -1 hint line age := "cached " + entry.Age() return rendered, age } diff --git a/internal/ui/render.go b/internal/ui/render.go index eff61e7..0b6ea0f 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -193,7 +193,7 @@ func (m Model) renderHelp() string { if m.webServer != nil { webLabel = "w stop web" } - parts := []string{"enter run", "s screenshot", webLabel, "b build", "/ filter", "d device", "q quit"} + parts := []string{"enter run", "s screenshot", webLabel, "i install", "/ filter", "d device", "q quit"} return helpStyle.Render(" " + strings.Join(parts, " · ")) } diff --git a/internal/ui/view.go b/internal/ui/view.go index aec0c3c..a80e12a 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -58,7 +58,7 @@ func (m Model) View() string { } else if m.statusMsg != "" { statusLine = lipgloss.NewStyle().Foreground(lipgloss.Color("35")).Render(" ● " + m.statusMsg) } else if m.needsBuild { - statusLine = lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Render(" ⚠ " + m.buildWarning + " — press 'b' to rebuild") + statusLine = lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Render(" ⚠ " + m.buildWarning + " — press 'i' to install") } // Calculate panel dimensions @@ -108,6 +108,7 @@ func (m Model) View() string { if previewContent == "" { previewContent = statusBarStyle.Render(" No screenshot\n s to capture\n enter auto-captures") } + previewContent += "\n" + helpStyle.Render(" w for HD preview in browser") screenshotBox := panel.Box(3, screenshotTitle, previewContent, rightW, contentH, false) topPanels = lipgloss.JoinHorizontal(lipgloss.Top, modBox, prevBox, screenshotBox) } @@ -145,6 +146,12 @@ func (m Model) View() string { layout = m.overlayModal(layout, modal) } + // Overlay install task picker modal if active + if m.showInstallPicker { + modal := m.renderInstallPickerModal() + layout = m.overlayModal(layout, modal) + } + return layout } @@ -203,6 +210,47 @@ func (m Model) renderDevicePickerModal() string { panel.BoxOpts{Accent: selectedAccent}) } +// renderInstallPickerModal renders the install task selection modal. +func (m Model) renderInstallPickerModal() string { + var lines []string + + if len(m.installTasks) == 0 { + // Still loading + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Render(" ⏳ Querying gradle install tasks...")) + } else { + for i, task := range m.installTasks { + cursor := " " + style := normalItemStyle + if i == m.installPickerSel { + cursor = "▸ " + style = selectedItemStyle + } + lines = append(lines, cursor+style.Render(task)) + } + } + + content := "" + for _, l := range lines { + content += l + "\n" + } + + modalW := 40 + for _, task := range m.installTasks { + if len(task)+4 > modalW { + modalW = len(task) + 4 + } + } + if modalW > m.width-4 { + modalW = m.width - 4 + } + + modalH := len(lines) + help := helpStyle.Render(" ↑↓ navigate · enter select · esc cancel") + + return panel.Box(0, "Select Install Task", content+help, modalW, modalH+1, true, + panel.BoxOpts{Accent: selectedAccent}) +} + // overlayModal places a modal string centered on top of the base layout. func (m Model) overlayModal(base, modal string) string { baseLines := strings.Split(base, "\n") From 20e000a47a885c544401d2d2e9c56d2bb23bb281 Mon Sep 17 00:00:00 2001 From: Ignacio Tomas Crespo Date: Fri, 17 Apr 2026 11:12:08 -0300 Subject: [PATCH 4/5] update readme --- README.md | 124 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 75 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 76ec180..3faae49 100644 --- a/README.md +++ b/README.md @@ -17,35 +17,37 @@ You want to check a Compose preview. You open Android Studio. You wait 2 minutes ``` Compose Preview Browser — umobKMP · Pixel_6a / Filter: login -╭ 1 Modules ─────────────╮╭ 2 Previews (8) ──────────────────────────────────╮ -│ ▸ :composeApp (0) ││ ▸ LoginScreenEmptyDarkPreview (Login - Dark) │ -│ :feature:auth (8) ││ LoginScreenEmptyLightPreview (Login - Light) │ -│ :feature:booking (0) ││ LoginScreenFilledDarkPreview (Login - Filled) │ -│ :feature:home (0) ││ LoginScreenLoadingDarkPreview (Login - Loading)│ -│ :feature:map (0) ││ LoginScreenErrorDarkPreview (Login - Error) │ -│ :feature:settings (0) ││ LoginFormContentEmptyDarkPreview (LoginForm) │ -│ :shared:presentation ││ LoginFormContentFilledDarkPreview (LoginForm) │ -│ (0) ││ LoginFormContentLoadingDarkPreview (LoginForm) │ -│ ││ │ -│ ││ │ -│ ││ │ -╰─────────────────────────╯╰──────────────────────────────────────────────────╯ -╭ Details ────────────────────────────────────────────────────────────────────╮ -│ FQN: com.example.feature.auth.LoginScreenPreviewKt.LoginScreenEmptyDark.. │ -│ File: feature/auth/src/androidMain/.../preview/LoginScreenPreview.kt:42 │ -│ Params: showBackground=true, backgroundColor=0xFF111111 │ -╰─────────────────────────────────────────────────────────────────────────────╯ +╭ 1 Modules ──────────╮╭ 2 Previews (8) ──────────────────────╮╭ 3 Preview ─────────╮ +│ ▸ :composeApp (0) ││ ▸ LoginScreenEmptyDarkPreview ││ │ +│ :feature:auth (8) ││ LoginScreenEmptyLightPreview ││ ┌───────────┐ │ +│ :feature:booking ││ LoginScreenFilledDarkPreview ││ │ │ │ +│ (0) ││ LoginScreenLoadingDarkPreview ││ │ preview │ │ +│ :feature:home (0) ││ LoginScreenErrorDarkPreview ││ │ image │ │ +│ :feature:map (0) ││ LoginFormContentEmptyDarkPreview ││ │ │ │ +│ :feature:settings ││ LoginFormContentFilledDarkPreview ││ └───────────┘ │ +│ (0) ││ LoginFormContentLoadingDarkPreview ││ │ +│ ││ ││ w for HD preview │ +│ ││ ││ in browser │ +╰──────────────────────╯╰──────────────────────────────────────╯╰─────────────────────╯ +╭ Details ────────────────────────────────────────────────────────────────────────────╮ +│ FQN: com.example.feature.auth.LoginScreenPreviewKt.LoginScreenEmptyDarkPreview │ +│ File: feature/auth/src/androidMain/.../preview/LoginScreenPreview.kt:42 │ +│ Params: showBackground=true, backgroundColor=0xFF111111 │ +╰─────────────────────────────────────────────────────────────────────────────────────╯ ● Launched: LoginScreenEmptyDarkPreview (com.example.app.dev) - enter run · b build · / filter · q quit + enter run · s screenshot · w web · i install · / filter · d device · q quit ``` ## What it does - **Scan** — Discovers all `@Preview` composables across all Gradle modules automatically -- **Browse** — Navigate modules and previews in a two-panel TUI with keyboard and mouse +- **Browse** — Navigate modules and previews in a three-panel TUI with keyboard and mouse - **Search** — Live filter bar (`/`) matches preview names across all modules, counts update in real time - **Run** — Launch any preview on a connected device via ADB with `Enter` -- **Build** — Trigger Gradle install tasks (`b`) with automatic variant detection (dev, qa, accept, production) +- **Screenshot** — Capture a preview screenshot (`s`) displayed directly in the terminal +- **HD Web Preview** — Press `w` to open a local web viewer in your browser with full-quality preview rendering +- **Install** — Trigger Gradle install tasks (`i`) with automatic variant detection (dev, qa, accept, production) +- **Device / Emulator picker** — Press `d` to select a connected device or launch an AVD emulator - **Details** — See fully qualified name, file path, line number, and `@Preview` parameters - **Stale detection** — Warns when source files are newer than the installed APK @@ -81,28 +83,28 @@ compose-preview /path/to/android/project ### Layout ``` -┌─────────────────────────────────────────────────────────────────────┐ -│ Compose Preview Browser — · │ header -├─────────────────────────────────────────────────────────────────────┤ -│ / Press / to filter previews │ search bar -├────────────────────┬────────────────────────────────────────────────┤ -│ 1 Modules │ 2 Previews (88) │ -│ │ │ -│ ▸ :composeApp (2) │ ▸ AppAndroidPreview │ -│ :feature:auth │ AppPreview │ main panels -│ (30) │ │ -│ :feature:home │ │ -│ (11) │ │ -│ ... │ │ -├────────────────────┴────────────────────────────────────────────────┤ -│ Details │ -│ FQN: com.example.MainActivityKt.AppAndroidPreview │ details -│ File: composeApp/src/androidMain/.../MainActivity.kt:41 │ -├─────────────────────────────────────────────────────────────────────┤ -│ ⚠ sources changed since last build — press 'b' to rebuild │ status -├─────────────────────────────────────────────────────────────────────┤ -│ enter run · b build · / filter · q quit │ help -└─────────────────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────┐ +│ Compose Preview Browser — · (d to change) │ header +├──────────────────────────────────────────────────────────────────────────────┤ +│ / Press / to filter previews │ search bar +├──────────────┬──────────────────────────┬────────────────────────────────────┤ +│ 1 Modules │ 2 Previews (88) │ 3 Preview │ +│ │ │ │ +│ ▸ :app (2) │ ▸ AppAndroidPreview │ ┌────────────┐ │ +│ :feature: │ AppPreview │ │ preview │ │ +│ auth (30)│ │ │ screenshot│ screenshot panel│ +│ :feature: │ │ └────────────┘ │ +│ home (11)│ │ │ +│ ... │ │ w for HD preview in browser │ +├──────────────┴──────────────────────────┴────────────────────────────────────┤ +│ Details │ +│ FQN: com.example.MainActivityKt.AppAndroidPreview │ details +│ File: composeApp/src/androidMain/.../MainActivity.kt:41 │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ ⚠ sources changed since last build — press 'i' to install │ status +├──────────────────────────────────────────────────────────────────────────────┤ +│ enter run · s screenshot · w web · i install · / filter · d device · q quit │ help +└──────────────────────────────────────────────────────────────────────────────┘ ``` ### Key bindings @@ -111,10 +113,14 @@ compose-preview /path/to/android/project |-----|--------| | `/` | Focus search bar — type to filter previews live | | `Tab` | Exit search / switch between Modules and Previews panels | -| `Enter` | Run selected preview on device (or confirm search) | +| `Enter` | Run selected preview on device (auto-captures screenshot) | | `Esc` | Clear filter and exit search | | `j/k` or `↑/↓` | Navigate items in focused panel | -| `b` | Build & install debug APK (auto-detects build variants) | +| `s` | Capture screenshot of the selected preview | +| `w` | Toggle HD web preview viewer in browser | +| `i` | Install APK via Gradle (auto-detects build variants) | +| `d` | Open device / emulator picker | +| `R` | Refresh project scan | | `1` / `2` | Focus Modules / Previews panel directly | | `q` | Quit | | Mouse click | Select item in any panel | @@ -138,15 +144,35 @@ Press `/` to activate the search bar. As you type, previews are filtered across Module counts update to show only matching previews. Press `Tab` to move to the panels with the filter active, or `Esc` to clear it. -### Build variants +### Screenshots + +Press `s` to capture a screenshot of the selected preview. The screenshot is rendered directly in the terminal using half-block characters. Screenshots are cached — a dot marker (`◉`) next to a preview name indicates a cached screenshot. + +Running a preview with `Enter` also auto-captures a screenshot after a short delay. + +### HD Web Preview + +The terminal screenshot is low resolution. For full-quality rendering, press `w` to start a local web viewer. This opens your browser with an HD version of the preview, served from a local web server. Press `w` again to stop the server. + +### Device / Emulator picker + +Press `d` to open a modal listing connected devices and available AVD emulators. Select a device to target, or pick an emulator to launch it. + +### Install variants -When you press `b`, compose-preview queries Gradle for all available install tasks. If your project has multiple build variants (dev, qa, accept, production), you get a quick-select prompt: +When you press `i`, compose-preview queries Gradle for all available install tasks. If your project has multiple build variants (dev, qa, accept, production), a picker modal appears: ``` -Build variant: [D]evDebug [A]cceptDebug [Q]aDebug [P]roductionDebug +╭ Select Install Task ─────────────╮ +│ ▸ installDevDebug │ +│ installAcceptDebug │ +│ installQaDebug │ +│ installProductionDebug │ +│ ↑↓ navigate · enter select · esc │ +╰──────────────────────────────────╯ ``` -Press the highlighted letter to select. The choice is remembered for subsequent builds. +Select a task with `Enter`. The choice is remembered for subsequent installs. ## Requirements From fbb37b5154727f1e7b78c1e8495fdccc0d36009f Mon Sep 17 00:00:00 2001 From: Ignacio Tomas Crespo Date: Fri, 17 Apr 2026 11:23:37 -0300 Subject: [PATCH 5/5] fixed wrong command --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3faae49..fdbdd37 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ You want to check a Compose preview. You open Android Studio. You wait 2 minutes ```bash brew tap ignaciotcrespo/tap -brew install compose-preview +brew install compose-preview-cli ``` ### Go