diff --git a/.gitea/workflows/build-app-preview.yml b/.gitea/workflows/build-app-preview.yml index 303d89a..2795813 100644 --- a/.gitea/workflows/build-app-preview.yml +++ b/.gitea/workflows/build-app-preview.yml @@ -10,7 +10,8 @@ name: Build App (Preview) # unreachable set of bundles; it is now releases-only. # # The cost of the swap, stated plainly: one prerelease per PR commit that -# touches `app/**`. They are pruned by Cleanup Old Releases (see Lifecycle). +# touches `app/**` — so the workflow prunes its own, keeping the newest +# KEEP_PREVIEWS (see Lifecycle). # # ## Why not workflow artifacts # @@ -34,14 +35,22 @@ name: Build App (Preview) # # The `preview-` tag prefix is deliberate. `cleanup-releases.yml` keeps the most # recent `v..` releases and separately deletes every release -# whose tag does *not* start with `v[0-9]` — so previews are pruned by the -# cleanup that is already run, and never crowd the real release list. +# whose tag does *not* start with `v[0-9]` — so previews never crowd the real +# release list, and a manual cleanup sweeps any this workflow missed. +# +# But that cleanup is a manual, dry-run-by-default action, and one prerelease per +# pushed commit accumulates faster than anyone runs it. So the last job here +# prunes previous previews itself, keeping the newest few. Bundles are ~130 MB a +# release; the point of a preview is the build you are testing now. # # `sync-release.yml` is workflow_dispatch-only, so nothing here reaches GitHub. env: GITEA_URL: ${{ gitea.server_url }} REPO: ${{ gitea.repository }} + # How many preview releases survive a run, newest first — including the one + # just published. + KEEP_PREVIEWS: "2" on: # Every push to an open PR: this *is* the branch's build check — it compiles @@ -522,3 +531,48 @@ jobs: --data-binary "@$($file.FullName)" $uploadUri if ($LASTEXITCODE -ne 0) { throw "Upload of $name failed (curl exit $LASTEXITCODE)" } } + + # Keep the preview list short. Runs after the builds and only if all three + # succeeded: a half-published run must not be what evicts a good older build. + prune-previews: + runs-on: ubuntu-latest + needs: [create-release, build-linux, build-macos, build-windows] + steps: + - name: Delete all but the newest preview releases + env: + TOKEN: ${{ secrets.REGISTRY_TOKEN }} + KEEP_TAG: ${{ needs.create-release.outputs.tag }} + run: | + set -euo pipefail + curl -fsS -H "Authorization: token ${TOKEN}" \ + "${GITEA_URL}/api/v1/repos/${REPO}/releases?limit=50" > releases.json + + # Newest first by creation time, `preview-` only, and never the one + # this run just published — a clock skew must not delete it. + DOOMED=$(python3 - "${KEEP_PREVIEWS}" "${KEEP_TAG}" <<'PY' + import json, sys + keep, keep_tag = int(sys.argv[1]), sys.argv[2] + previews = [r for r in json.load(open("releases.json")) + if r["tag_name"].startswith("preview-")] + previews.sort(key=lambda r: r["created_at"], reverse=True) + for r in previews[keep:]: + if r["tag_name"] != keep_tag: + print(r["id"], r["tag_name"]) + PY + ) + + if [ -z "${DOOMED}" ]; then + echo "Nothing to prune (keeping ${KEEP_PREVIEWS})" + exit 0 + fi + + echo "${DOOMED}" | while read -r ID TAG; do + [ -z "${ID}" ] && continue + echo "Deleting ${TAG} (id ${ID})" + # Best effort: a preview someone deleted by hand mid-run is not a + # reason to fail a build that otherwise succeeded. + curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \ + "${GITEA_URL}/api/v1/repos/${REPO}/releases/${ID}" || true + curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \ + "${GITEA_URL}/api/v1/repos/${REPO}/tags/${TAG}" || true + done diff --git a/app/src-tauri/src/browser_view/commands.rs b/app/src-tauri/src/browser_view/commands.rs index 8770b2c..0b3a58e 100644 --- a/app/src-tauri/src/browser_view/commands.rs +++ b/app/src-tauri/src/browser_view/commands.rs @@ -189,8 +189,15 @@ pub async fn open_page_in_container_browser( return Err("Only http:// and https:// URLs can be opened in the browser.".to_string()); } let container_id = running_container(&state, &project_id, "opening a page").await?; + crate::commands::project_commands::emit_progress( + &app_handle, + &project_id, + "Checking the container for Playwright…", + ); let detection = crate::browser_view::detect::detect(&container_id).await?; let opened = page::open( + &app_handle, + &project_id, &container_id, &detection, trimmed, @@ -204,6 +211,11 @@ pub async fn open_page_in_container_browser( // Asking for a page *is* asking to watch it, so the viewer comes up too. let status = manager().status(&project_id).await; if status.state != BrowserViewState::Running { + crate::commands::project_commands::emit_progress( + &app_handle, + &project_id, + "Starting the viewer…", + ); manager() .start( project_id.clone(), @@ -228,6 +240,7 @@ pub async fn open_page_in_container_browser( } } + crate::commands::project_commands::emit_progress(&app_handle, &project_id, ""); Ok(opened) } diff --git a/app/src-tauri/src/browser_view/page.rs b/app/src-tauri/src/browser_view/page.rs index cb2be8c..0d82111 100644 --- a/app/src-tauri/src/browser_view/page.rs +++ b/app/src-tauri/src/browser_view/page.rs @@ -34,7 +34,9 @@ //! larger. This is what makes the pop-out usable as a responsive-design ruler. use serde::{Deserialize, Serialize}; +use tauri::AppHandle; +use crate::commands::project_commands::emit_progress; use crate::docker::exec::exec_oneshot_as; use super::detect::PlaywrightDetection; @@ -87,6 +89,8 @@ pub struct PageState { /// Replaces any page this opened before: one helper per container, because the /// pane shows one browser and a second would just compete for the pane. pub async fn open( + app: &AppHandle, + project_id: &str, container_id: &str, detection: &PlaywrightDetection, url: &str, @@ -112,6 +116,7 @@ pub async fn open( // browser's cookies and storage — which for the auth case means signing in // again to reach the second page, having just signed in on the first. if state(container_id).await.ready { + emit_progress(app, project_id, "Navigating the container's browser…"); set_viewport(container_id, viewport).await?; navigate(container_id, url).await?; if let Some(state) = wait_for_url(container_id, url).await { @@ -121,6 +126,10 @@ pub async fn open( } close(container_id).await; + // Cold start: a browser launch plus a page load, which is the several + // seconds the user would otherwise spend wondering whether the click + // registered. + emit_progress(app, project_id, "Launching a browser in the container…"); let config = serde_json::json!({ "core": core_dir, @@ -150,6 +159,7 @@ pub async fn open( .await .map_err(|e| format!("Could not start the browser helper: {}", e))?; + emit_progress(app, project_id, "Waiting for the page to load…"); wait_until_ready(container_id).await } diff --git a/app/src/components/projects/home/BrowserTab.tsx b/app/src/components/projects/home/BrowserTab.tsx index abafb64..0805733 100644 --- a/app/src/components/projects/home/BrowserTab.tsx +++ b/app/src/components/projects/home/BrowserTab.tsx @@ -377,6 +377,15 @@ export default function BrowserTab({ project, active }: Props) { )}
+ {progress && ( + + + {progress} + + )} {live && poppedOut === true && ( Keep on top diff --git a/app/src/components/projects/home/ProjectHome.tsx b/app/src/components/projects/home/ProjectHome.tsx index 3b60987..9245f4d 100644 --- a/app/src/components/projects/home/ProjectHome.tsx +++ b/app/src/components/projects/home/ProjectHome.tsx @@ -43,6 +43,16 @@ export default function ProjectHome({ projectId, active }: Props) { const { projects, remove } = useProjects(); const project = projects.find((p) => p.id === projectId); const [tab, setTab] = useState("overview"); + + // Somewhere else asked for this project on a particular sub-tab — currently + // "I opened a page in the container's browser, show me it". Consumed once, so + // it cannot fight the user's own clicking afterwards. + const pendingHomeTab = useAppState((s) => s.pendingHomeTab); + useEffect(() => { + if (pendingHomeTab?.projectId !== projectId) return; + setTab(pendingHomeTab.tab as ProjectHomeTabId); + useAppState.getState().clearPendingHomeTab(); + }, [pendingHomeTab, projectId]); const [confirmRemove, setConfirmRemove] = useState(false); const [confirmReset, setConfirmReset] = useState(false); const [showMigration, setShowMigration] = useState(false); diff --git a/app/src/components/terminal/TerminalView.tsx b/app/src/components/terminal/TerminalView.tsx index 7ec51a2..a151b76 100644 --- a/app/src/components/terminal/TerminalView.tsx +++ b/app/src/components/terminal/TerminalView.tsx @@ -554,6 +554,9 @@ export default function TerminalView({ sessionId, active }: Props) { return; } if (!projectId) return; + // Land on the pane that will show it, before the work starts: opening takes + // several seconds, and the progress line lives there. + useAppState.getState().openProjectHomeTab(projectId, "browser"); // A sign-in page is the one case where the *window* size matters least and // the layout matters most, so it gets the ordinary desktop viewport. // `true`: from a terminal there is no Browser pane on screen, so the page diff --git a/app/src/store/appState.ts b/app/src/store/appState.ts index fc5c99e..29a1f1c 100644 --- a/app/src/store/appState.ts +++ b/app/src/store/appState.ts @@ -71,6 +71,18 @@ interface AppState { tabOrder: string[]; activeTabKey: string | null; openProjectHome: (projectId: string) => void; + /** + * Open a project's home tab *on a particular sub-tab*. + * + * The sub-tab is local state inside `ProjectHome`, so this parks a request + * here for it to pick up: an action taken somewhere else entirely — opening a + * page in the container's browser from a terminal — has to be able to land + * the user on the pane that shows the result. + */ + openProjectHomeTab: (projectId: string, tab: string) => void; + /** Consumed once by `ProjectHome`, then cleared. */ + pendingHomeTab: { projectId: string; tab: string } | null; + clearPendingHomeTab: () => void; closeHomeTab: (projectId: string) => void; setActiveTabKey: (key: string) => void; cycleTab: (delta: number) => void; @@ -235,6 +247,20 @@ export const useAppState = create((set) => ({ ...activation(key), }; }), + openProjectHomeTab: (projectId, tab) => + set((state) => { + const key = homeTabKey(projectId); + return { + selectedProjectId: projectId, + tabOrder: state.tabOrder.includes(key) + ? state.tabOrder + : [...state.tabOrder, key], + pendingHomeTab: { projectId, tab }, + ...activation(key), + }; + }), + pendingHomeTab: null, + clearPendingHomeTab: () => set({ pendingHomeTab: null }), closeHomeTab: (projectId) => set((state) => { const key = homeTabKey(projectId);