new console

This commit is contained in:
Kelorastus
2026-09-30 13:36:14 +02:00
parent feed0fa869
commit 3863809ae8
11 changed files with 513 additions and 14 deletions
+7 -1
View File
@@ -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>
+15 -4
View File
@@ -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 &amp; 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 &amp; 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>
+166
View File
@@ -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>
+2
View File
@@ -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),
+52
View File
@@ -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
View File
@@ -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> {
+12
View File
@@ -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. */