new console
This commit is contained in:
+7
-1
@@ -6,6 +6,7 @@
|
||||
import KeyArt from "./components/KeyArt.svelte";
|
||||
import MarketplaceDrawer from "./components/MarketplaceDrawer.svelte";
|
||||
import NewsPanel from "./components/NewsPanel.svelte";
|
||||
import ReleaseConsole from "./components/ReleaseConsole.svelte";
|
||||
import ReleasePanel from "./components/ReleasePanel.svelte";
|
||||
import SettingsDrawer from "./components/SettingsDrawer.svelte";
|
||||
import SyncPanel from "./components/SyncPanel.svelte";
|
||||
@@ -18,6 +19,9 @@
|
||||
// Dev mode drops the patch notes: they describe the INSTALLED version, which is not what
|
||||
// a dev run starts. With that column gone the art has the whole window to sit in.
|
||||
const dev = $derived(launcher.devOn);
|
||||
// A release takes that column back while it runs: its output is the one thing worth
|
||||
// reading there, and it is where a developer is already looking.
|
||||
const showConsole = $derived(dev && launcher.consoleOpen);
|
||||
|
||||
onMount(() => launcher.start());
|
||||
</script>
|
||||
@@ -26,9 +30,11 @@
|
||||
<KeyArt />
|
||||
<TitleBar onOpenSettings={() => (settingsOpen = true)} onOpenMarket={() => launcher.openMarket()} />
|
||||
|
||||
<main class="hub" class:dev>
|
||||
<main class="hub" class:dev={dev && !showConsole}>
|
||||
{#if !dev}
|
||||
<div class="news-slot"><NewsPanel /></div>
|
||||
{:else if showConsole}
|
||||
<div class="news-slot"><ReleaseConsole /></div>
|
||||
{/if}
|
||||
<div class="hero-slot"><Hero /></div>
|
||||
<div class="action-slot"><ActionPanel /></div>
|
||||
|
||||
@@ -118,6 +118,11 @@
|
||||
await launcher.loadRelease();
|
||||
}
|
||||
|
||||
// While one runs, this button is the way BACK to its output rather than a way to start a
|
||||
// second: closing the console otherwise left a running release with nowhere to be seen.
|
||||
const releasing = $derived(launcher.consoleRunning);
|
||||
const hasOutput = $derived(launcher.consoleLines.length > 0 && !launcher.consoleOpen);
|
||||
|
||||
async function browseCheckout() {
|
||||
const path = await launcher.pickFolder();
|
||||
if (path) await launcher.setDev({ dev_root: path });
|
||||
@@ -304,10 +309,16 @@
|
||||
</div>
|
||||
|
||||
<div class="dev-row">
|
||||
<button class="btn dev-release" disabled={blocked || !s?.online} onclick={openRelease}>
|
||||
Build & deploy…
|
||||
</button>
|
||||
{#if !s?.online}
|
||||
{#if releasing}
|
||||
<button class="btn dev-release" onclick={() => launcher.showConsole()}>Release running…</button>
|
||||
{:else}
|
||||
<button class="btn dev-release" disabled={blocked || !s?.online} onclick={openRelease}>
|
||||
Build & deploy…
|
||||
</button>
|
||||
{/if}
|
||||
{#if hasOutput && !releasing}
|
||||
<button class="btn-link" onclick={() => launcher.showConsole()}>last output</button>
|
||||
{:else if !s?.online}
|
||||
<span class="dev-note">needs the DHP</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<script lang="ts">
|
||||
import Icon from "./Icon.svelte";
|
||||
import { launcher } from "../lib/launcher.svelte";
|
||||
|
||||
let scroller: HTMLElement | undefined = $state();
|
||||
|
||||
const lines = $derived(launcher.consoleLines);
|
||||
const running = $derived(launcher.consoleRunning);
|
||||
const exit = $derived(launcher.consoleExit);
|
||||
|
||||
// Follow the output, but only while the reader is already at the bottom: scrolling back to
|
||||
// read an error must not be yanked away by the next line that arrives.
|
||||
let follow = $state(true);
|
||||
function onScroll() {
|
||||
const el = scroller;
|
||||
if (!el) return;
|
||||
follow = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
|
||||
}
|
||||
$effect(() => {
|
||||
void lines.length;
|
||||
if (follow && scroller) scroller.scrollTop = scroller.scrollHeight;
|
||||
});
|
||||
|
||||
// A step header is worth picking out of a wall of build output, as is a failure.
|
||||
function kind(line: string): string {
|
||||
if (/^\s*={3}/.test(line)) return "step";
|
||||
if (/^FAILED:|^\s*Erreur|error[: ]/i.test(line)) return "bad";
|
||||
if (/nothing to (release|deploy)|No pack cut|Release finished/i.test(line)) return "note";
|
||||
return "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="console glass" aria-label="Release output">
|
||||
<header class="head">
|
||||
<h2 class="eyebrow">Release</h2>
|
||||
<span class="state mono" class:live={running} class:bad={!running && exit !== null && exit !== 0}>
|
||||
{#if running}
|
||||
running
|
||||
{:else if exit === null}
|
||||
idle
|
||||
{:else if exit === 0}
|
||||
finished
|
||||
{:else}
|
||||
failed ({exit})
|
||||
{/if}
|
||||
</span>
|
||||
<span class="grow"></span>
|
||||
<span class="total mono">{lines.length}</span>
|
||||
<button class="icon-btn" aria-label="Close release output" onclick={() => launcher.closeConsole()}>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Both are deliberate: `log` is the role for streamed output, and it has to be
|
||||
focusable or a keyboard cannot scroll back through a failed build. -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<div bind:this={scroller} class="body mono selectable" onscroll={onScroll} tabindex="0" role="log">
|
||||
{#if lines.length}
|
||||
{#each lines as line, i (i)}
|
||||
<div class="line {kind(line)}">{line || " "}</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="empty">Waiting for the first line…</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !running && exit !== null}
|
||||
<footer class="foot">
|
||||
{exit === 0 ? "Closing this keeps the output until the next release." : "The full transcript is in state\\release\\release.log."}
|
||||
</footer>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.console {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--glass-strong);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 8px 8px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.total,
|
||||
.state {
|
||||
color: var(--muted);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* The dot earns its place: from across the desk it is the only thing that says whether a
|
||||
release is still going. */
|
||||
.state.live::before,
|
||||
.state.bad::before {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-right: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent, #4ade80);
|
||||
content: "";
|
||||
vertical-align: middle;
|
||||
}
|
||||
.state.live {
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.state.bad {
|
||||
color: var(--danger);
|
||||
}
|
||||
.state.bad::before {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
padding: 8px 0;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.line {
|
||||
padding: 0 16px;
|
||||
color: var(--ink-2);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.line.step {
|
||||
margin-top: 6px;
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.line.bad {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.line.note {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 8px 16px;
|
||||
color: var(--muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.foot {
|
||||
padding: 8px 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
</style>
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
SignInResult,
|
||||
StackEvent,
|
||||
Status,
|
||||
ReleaseConsoleTail,
|
||||
ReleasePlan,
|
||||
RemoveProgress,
|
||||
SyncConflict,
|
||||
@@ -73,6 +74,7 @@ function createTauriBackend(): Backend {
|
||||
onInstallProgress: (handler) => subscribe<InstallProgress>("install-progress", handler),
|
||||
releasePlan: () => invoke<ReleasePlan>("release_plan"),
|
||||
releaseRun: (password, withApi, withMcp) => invoke<void>("release_run", { password, withApi, withMcp }),
|
||||
releaseConsole: (since) => invoke<ReleaseConsoleTail>("release_console", { since }),
|
||||
onTileSyncProgress: (handler) => subscribe<TileSyncProgress>("tile-sync-progress", handler),
|
||||
onRemoveProgress: (handler) => subscribe<RemoveProgress>("remove-progress", handler),
|
||||
onStack: (handler) => subscribe<StackEvent>("stack", handler),
|
||||
|
||||
@@ -418,9 +418,61 @@ class Launcher {
|
||||
return this.run("release", async () => {
|
||||
await backend.releaseRun(password, withApi, withMcp);
|
||||
this.releaseOpen = false;
|
||||
// Straight into the console: the release is running with nowhere else to show itself.
|
||||
this.consoleLines = [];
|
||||
this.consoleTotal = 0;
|
||||
this.consoleExit = null;
|
||||
this.consoleRunning = true;
|
||||
this.consoleOpen = true;
|
||||
this.pollConsole();
|
||||
});
|
||||
}
|
||||
|
||||
// ---- the release console -------------------------------------------------------------
|
||||
/** Shown in the notes column in dev mode, where the patch notes sit otherwise. */
|
||||
consoleOpen = $state(false);
|
||||
consoleLines = $state<string[]>([]);
|
||||
consoleRunning = $state(false);
|
||||
consoleExit = $state<number | null>(null);
|
||||
/** How many lines have been taken, so each poll asks only for what is new. */
|
||||
private consoleTotal = 0;
|
||||
private polling = false;
|
||||
|
||||
/** Polled rather than pushed: reopening the panel then needs no replay, and a slow window
|
||||
* cannot fall behind a build that prints thousands of lines. */
|
||||
private async pollConsole() {
|
||||
if (this.polling) return;
|
||||
this.polling = true;
|
||||
try {
|
||||
for (;;) {
|
||||
const tail = await backend.releaseConsole(this.consoleTotal);
|
||||
if (tail.from > this.consoleTotal && this.consoleLines.length) {
|
||||
this.consoleLines.push(`... ${tail.from - this.consoleTotal} earlier lines dropped`);
|
||||
}
|
||||
if (tail.lines.length) this.consoleLines.push(...tail.lines);
|
||||
this.consoleTotal = tail.total;
|
||||
this.consoleRunning = tail.running;
|
||||
this.consoleExit = tail.exit;
|
||||
if (!tail.running) return;
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
}
|
||||
} catch {
|
||||
this.consoleRunning = false;
|
||||
} finally {
|
||||
this.polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reopen it after it was closed; a finished release keeps its output to be read. */
|
||||
showConsole() {
|
||||
this.consoleOpen = true;
|
||||
if (this.consoleRunning) this.pollConsole();
|
||||
}
|
||||
|
||||
closeConsole() {
|
||||
this.consoleOpen = false;
|
||||
}
|
||||
|
||||
/** Change part of the settings and save, for the dev controls on the hub. */
|
||||
setDev(change: Partial<Settings>) {
|
||||
const current = this.settings;
|
||||
|
||||
+34
-1
@@ -24,6 +24,7 @@ import {
|
||||
type MarketItem,
|
||||
type MockControls,
|
||||
type MockScenario,
|
||||
type ReleaseConsoleTail,
|
||||
type ReleasePlan,
|
||||
type Settings,
|
||||
type SignInResult,
|
||||
@@ -786,7 +787,39 @@ class MockBackend implements Backend {
|
||||
async releaseRun(password: string, withApi: boolean, withMcp: boolean): Promise<void> {
|
||||
await this.wait(400);
|
||||
if (!password.trim()) throw "The node's sudo password is needed to cut a pack and to roll the API.";
|
||||
this.log("info", `Release started in its own window (browser mock: nothing runs). API ${withApi}, MCP ${withMcp}`);
|
||||
// Nothing runs in the browser, so play a short script into the console instead: enough
|
||||
// to see the panel fill, scroll and finish.
|
||||
this.consoleLines = ["=== ODIAN pack 0.74.0 ===", "", "=== Preflight ==="];
|
||||
this.consoleDone = null;
|
||||
const rest = [
|
||||
"engine at 1255873...",
|
||||
"Nothing that ships has changed. No pack cut.",
|
||||
"pack: nothing to release",
|
||||
`=== Launcher 0.1.40 === (API ${withApi}, MCP ${withMcp})`,
|
||||
"npx tauri build ...",
|
||||
"Release finished.",
|
||||
];
|
||||
void (async () => {
|
||||
for (const line of rest) {
|
||||
await this.wait(700);
|
||||
this.consoleLines.push(line);
|
||||
}
|
||||
this.consoleDone = 0;
|
||||
})();
|
||||
}
|
||||
|
||||
private consoleLines: string[] = [];
|
||||
private consoleDone: number | null = 0;
|
||||
|
||||
async releaseConsole(since: number): Promise<ReleaseConsoleTail> {
|
||||
await this.wait(60);
|
||||
return {
|
||||
from: Math.min(since, this.consoleLines.length),
|
||||
lines: this.consoleLines.slice(since),
|
||||
total: this.consoleLines.length,
|
||||
running: this.consoleDone === null,
|
||||
exit: this.consoleDone,
|
||||
};
|
||||
}
|
||||
|
||||
async syncNow(): Promise<void> {
|
||||
|
||||
@@ -306,6 +306,17 @@ export interface ReleasePlan {
|
||||
blocked: string | null;
|
||||
}
|
||||
|
||||
/** A slice of the running release's output: what the window has not shown yet. */
|
||||
export interface ReleaseConsoleTail {
|
||||
/** Index of the first line returned; higher than asked for when older lines were trimmed. */
|
||||
from: number;
|
||||
lines: string[];
|
||||
total: number;
|
||||
running: boolean;
|
||||
/** Null while running; 0 means it finished, anything else means it failed. */
|
||||
exit: number | null;
|
||||
}
|
||||
|
||||
export const DEV_TARGETS = ["client", "web", "both"] as const;
|
||||
export type DevTarget = (typeof DEV_TARGETS)[number];
|
||||
|
||||
@@ -375,6 +386,7 @@ export interface Backend {
|
||||
syncTiles(): Promise<void>;
|
||||
releasePlan(): Promise<ReleasePlan>;
|
||||
releaseRun(password: string, withApi: boolean, withMcp: boolean): Promise<void>;
|
||||
releaseConsole(since: number): Promise<ReleaseConsoleTail>;
|
||||
/** What the operator has to decide, oldest first. */
|
||||
getSyncConflicts(): Promise<SyncConflict[]>;
|
||||
/** Records one decision; resolves with what is left to decide. */
|
||||
|
||||
Reference in New Issue
Block a user