Say what "open in container" is doing, and land on the pane doing it
Build App (Preview) / compute-version (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m40s
Build App (Preview) / build-linux (pull_request) Successful in 5m33s
Build App (Preview) / build-windows (pull_request) Successful in 5m40s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Build App (Preview) / compute-version (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m40s
Build App (Preview) / build-linux (pull_request) Successful in 5m33s
Build App (Preview) / build-windows (pull_request) Successful in 5m40s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Opening a page is a container probe, a browser launch, a page load and often a viewer start — several seconds during which the only feedback was the click itself. Worse from a terminal, where the result appears in a pane the user is not looking at. So: the backend emits progress on the existing `container-progress` channel at each step, the Browser tab renders that line whenever it is set — the progress belongs to the project, not to whoever pressed the button, which is what lets a terminal-initiated open report anywhere at all — and the terminal's "In container" now selects the project's Browser tab before starting, so the line has somewhere to appear. Selecting a sub-tab from outside needed a route: `ProjectHome` keeps it in local state, so `openProjectHomeTab` parks a request in the store and the pane consumes it once. Consumed once, so it cannot fight the user's own clicking afterwards. Preview releases now prune themselves to the newest KEEP_PREVIEWS (2), in a job that runs only if all three platforms published — a half-finished run must not evict a good older build. The cleanup workflow's manual sweep stays as the backstop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,8 @@ name: Build App (Preview)
|
|||||||
# unreachable set of bundles; it is now releases-only.
|
# unreachable set of bundles; it is now releases-only.
|
||||||
#
|
#
|
||||||
# The cost of the swap, stated plainly: one prerelease per PR commit that
|
# 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
|
# ## Why not workflow artifacts
|
||||||
#
|
#
|
||||||
@@ -34,14 +35,22 @@ name: Build App (Preview)
|
|||||||
#
|
#
|
||||||
# The `preview-` tag prefix is deliberate. `cleanup-releases.yml` keeps the most
|
# The `preview-` tag prefix is deliberate. `cleanup-releases.yml` keeps the most
|
||||||
# recent `v<major>.<minor>.<patch>` releases and separately deletes every release
|
# recent `v<major>.<minor>.<patch>` releases and separately deletes every release
|
||||||
# whose tag does *not* start with `v[0-9]` — so previews are pruned by the
|
# whose tag does *not* start with `v[0-9]` — so previews never crowd the real
|
||||||
# cleanup that is already run, and never crowd the real release list.
|
# 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.
|
# `sync-release.yml` is workflow_dispatch-only, so nothing here reaches GitHub.
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GITEA_URL: ${{ gitea.server_url }}
|
GITEA_URL: ${{ gitea.server_url }}
|
||||||
REPO: ${{ gitea.repository }}
|
REPO: ${{ gitea.repository }}
|
||||||
|
# How many preview releases survive a run, newest first — including the one
|
||||||
|
# just published.
|
||||||
|
KEEP_PREVIEWS: "2"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# Every push to an open PR: this *is* the branch's build check — it compiles
|
# 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
|
--data-binary "@$($file.FullName)" $uploadUri
|
||||||
if ($LASTEXITCODE -ne 0) { throw "Upload of $name failed (curl exit $LASTEXITCODE)" }
|
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
|
||||||
|
|||||||
@@ -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());
|
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?;
|
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 detection = crate::browser_view::detect::detect(&container_id).await?;
|
||||||
let opened = page::open(
|
let opened = page::open(
|
||||||
|
&app_handle,
|
||||||
|
&project_id,
|
||||||
&container_id,
|
&container_id,
|
||||||
&detection,
|
&detection,
|
||||||
trimmed,
|
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.
|
// Asking for a page *is* asking to watch it, so the viewer comes up too.
|
||||||
let status = manager().status(&project_id).await;
|
let status = manager().status(&project_id).await;
|
||||||
if status.state != BrowserViewState::Running {
|
if status.state != BrowserViewState::Running {
|
||||||
|
crate::commands::project_commands::emit_progress(
|
||||||
|
&app_handle,
|
||||||
|
&project_id,
|
||||||
|
"Starting the viewer…",
|
||||||
|
);
|
||||||
manager()
|
manager()
|
||||||
.start(
|
.start(
|
||||||
project_id.clone(),
|
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)
|
Ok(opened)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,9 @@
|
|||||||
//! larger. This is what makes the pop-out usable as a responsive-design ruler.
|
//! larger. This is what makes the pop-out usable as a responsive-design ruler.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::AppHandle;
|
||||||
|
|
||||||
|
use crate::commands::project_commands::emit_progress;
|
||||||
use crate::docker::exec::exec_oneshot_as;
|
use crate::docker::exec::exec_oneshot_as;
|
||||||
|
|
||||||
use super::detect::PlaywrightDetection;
|
use super::detect::PlaywrightDetection;
|
||||||
@@ -87,6 +89,8 @@ pub struct PageState {
|
|||||||
/// Replaces any page this opened before: one helper per container, because the
|
/// 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.
|
/// pane shows one browser and a second would just compete for the pane.
|
||||||
pub async fn open(
|
pub async fn open(
|
||||||
|
app: &AppHandle,
|
||||||
|
project_id: &str,
|
||||||
container_id: &str,
|
container_id: &str,
|
||||||
detection: &PlaywrightDetection,
|
detection: &PlaywrightDetection,
|
||||||
url: &str,
|
url: &str,
|
||||||
@@ -112,6 +116,7 @@ pub async fn open(
|
|||||||
// browser's cookies and storage — which for the auth case means signing in
|
// 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.
|
// again to reach the second page, having just signed in on the first.
|
||||||
if state(container_id).await.ready {
|
if state(container_id).await.ready {
|
||||||
|
emit_progress(app, project_id, "Navigating the container's browser…");
|
||||||
set_viewport(container_id, viewport).await?;
|
set_viewport(container_id, viewport).await?;
|
||||||
navigate(container_id, url).await?;
|
navigate(container_id, url).await?;
|
||||||
if let Some(state) = wait_for_url(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;
|
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!({
|
let config = serde_json::json!({
|
||||||
"core": core_dir,
|
"core": core_dir,
|
||||||
@@ -150,6 +159,7 @@ pub async fn open(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Could not start the browser helper: {}", e))?;
|
.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
|
wait_until_ready(container_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -377,6 +377,15 @@ export default function BrowserTab({ project, active }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
{progress && (
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)] min-w-0"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<StatusIndicator tone="busy" label="" />
|
||||||
|
<span className="truncate">{progress}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{live && poppedOut === true && (
|
{live && poppedOut === true && (
|
||||||
<span className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
<span className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
||||||
Keep on top
|
Keep on top
|
||||||
|
|||||||
@@ -43,6 +43,16 @@ export default function ProjectHome({ projectId, active }: Props) {
|
|||||||
const { projects, remove } = useProjects();
|
const { projects, remove } = useProjects();
|
||||||
const project = projects.find((p) => p.id === projectId);
|
const project = projects.find((p) => p.id === projectId);
|
||||||
const [tab, setTab] = useState<ProjectHomeTabId>("overview");
|
const [tab, setTab] = useState<ProjectHomeTabId>("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 [confirmRemove, setConfirmRemove] = useState(false);
|
||||||
const [confirmReset, setConfirmReset] = useState(false);
|
const [confirmReset, setConfirmReset] = useState(false);
|
||||||
const [showMigration, setShowMigration] = useState(false);
|
const [showMigration, setShowMigration] = useState(false);
|
||||||
|
|||||||
@@ -554,6 +554,9 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!projectId) 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
|
// 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.
|
// 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
|
// `true`: from a terminal there is no Browser pane on screen, so the page
|
||||||
|
|||||||
@@ -71,6 +71,18 @@ interface AppState {
|
|||||||
tabOrder: string[];
|
tabOrder: string[];
|
||||||
activeTabKey: string | null;
|
activeTabKey: string | null;
|
||||||
openProjectHome: (projectId: string) => void;
|
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;
|
closeHomeTab: (projectId: string) => void;
|
||||||
setActiveTabKey: (key: string) => void;
|
setActiveTabKey: (key: string) => void;
|
||||||
cycleTab: (delta: number) => void;
|
cycleTab: (delta: number) => void;
|
||||||
@@ -235,6 +247,20 @@ export const useAppState = create<AppState>((set) => ({
|
|||||||
...activation(key),
|
...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) =>
|
closeHomeTab: (projectId) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const key = homeTabKey(projectId);
|
const key = homeTabKey(projectId);
|
||||||
|
|||||||
Reference in New Issue
Block a user