This commit is contained in:
Kelorastus
2026-09-28 11:19:02 +02:00
parent 21d6e6bffb
commit 73a8f2c30c
35 changed files with 1615 additions and 86 deletions
+70
View File
@@ -62,6 +62,10 @@
return { label: "Restoring database", tone: "accent", pulse: true, meta: stage };
case "syncing":
return { label: "Syncing with the DHP", tone: "accent", pulse: true, meta: version(s?.installed) };
case "pulling_tiles":
return { label: "Taking map tiles", tone: "accent", pulse: true, meta: tileMeta };
case "removing":
return { label: "Removing", tone: "accent", pulse: true, meta: rp ? `${rp.index}/${rp.count}` : null };
case "update":
return { label: "Update required", tone: "warn", pulse: false, meta: `${s?.installed} → ${s?.available}` };
case "launch":
@@ -76,6 +80,29 @@
}
});
// What the DHP has baked, for the sets this PC holds, since it last looked. Zero means
// level with the site - which is worth saying nothing at all about.
// Chosen, and not all here. ODIAN runs without it, so this is said beside Launch rather
// than in place of it - but it IS said: the marketplace was the only place that knew.
const unfinished = $derived(s?.unfinished ?? null);
const unfinishedNames = $derived.by(() => {
const titles = unfinished?.titles ?? [];
if (titles.length === 0) return "";
const shown = titles.slice(0, 2).join(", ");
return (unfinished?.items ?? 0) > titles.length || titles.length > 2 ? `${shown}…` : shown;
});
const tiles = $derived(s?.tiles_waiting ?? null);
const tilesOffered = $derived(tiles !== null && tiles.tiles > 0);
const tilesCount = $derived(tiles === null ? "" : `${formatCount(tiles.tiles)}${tiles.more ? "+" : ""}`);
const rp = $derived(launcher.removeProgress);
const removeFraction = $derived(rp === null || rp.total === 0 ? null : Math.min(rp.done / rp.total, 1));
const tp = $derived(launcher.tileProgress);
const tileMeta = $derived(tp === null ? null : `${formatCount(tp.tiles)} tiles`);
const tileFraction = $derived(tp === null || tp.page_tiles === 0 ? null : tp.page_done / tp.page_tiles);
const showNotice = $derived(launcher.notice !== null && !(action === "error" && launcher.notice === s?.error));
</script>
@@ -182,6 +209,29 @@
<button class="btn-link" disabled={blocked} onclick={() => launcher.openSync()}>Sync</button>
</p>
{/if}
{#if unfinished}
<p class="secondary attention">
<Icon name="alert" size={14} />
<span>
<strong>{unfinished.items}</strong>
{unfinished.items === 1 ? "chosen map is" : "chosen maps are"} only partly downloaded
{#if unfinishedNames}<span class="dim">({unfinishedNames})</span>{/if}.
<button class="btn-link" disabled={blocked} onclick={() => launcher.finishMarket()}>Finish</button>
</span>
</p>
{/if}
{#if tilesOffered}
<!-- Ground another operator looked at while this PC was away. Online it is already
on screen, drawn from {site}; nothing keeps it, so unplugging loses a week of
other people's map. -->
<p class="secondary attention">
<Icon name="download" size={14} />
<span>
<strong>{tilesCount}</strong> new map tiles on {site} ({formatBytes(tiles?.bytes ?? 0)}).
<button class="btn-link" disabled={blocked} onclick={() => launcher.syncTiles()}>Take them</button>
</span>
</p>
{/if}
{:else if action === "installing"}
<div class="progress">
<div class="stage-line">
@@ -236,6 +286,26 @@
<ProgressBar value={null} label="Syncing with the DHP" />
</div>
<p class="secondary">Sending this PC's changes to {site} and taking in the other operators' work.</p>
{:else if action === "removing"}
<div class="progress">
<div class="stage-line">
<span class="stage-name">Removing {rp?.title ?? "what you unticked"}</span>
{#if removeFraction !== null}<span class="pct mono tabular">{Math.floor(removeFraction * 100)}%</span>{/if}
</div>
<ProgressBar value={removeFraction} label="Removing" />
{#if rp}<p class="message mono">{formatCount(rp.done)} / {formatCount(rp.total)} files</p>{/if}
</div>
<p class="secondary">Deleting a map is hundreds of thousands of files. Nothing is downloaded meanwhile.</p>
{:else if action === "pulling_tiles"}
<div class="progress">
<div class="stage-line">
<span class="stage-name">Taking map tiles from {site}</span>
{#if tp}<span class="pct mono tabular">{formatBytes(tp.bytes)}</span>{/if}
</div>
<ProgressBar value={tileFraction} label="Taking map tiles" />
</div>
<p class="secondary">Tiles the other operators baked while this PC was away. It can be stopped and resumed.</p>
<button class="btn wide" onclick={() => launcher.cancelInstall()}>Stop</button>
{:else if action === "stack"}
<StackList stack={launcher.stack} phase={s?.phase ?? "launching"} />
{#if s?.map_from_site === false}
+214 -19
View File
@@ -1,9 +1,10 @@
<script lang="ts">
import { untrack } from "svelte";
import Icon from "./Icon.svelte";
import { backend } from "../lib/backend";
import { launcher } from "../lib/launcher.svelte";
import { errorText, formatBytes } from "../lib/format";
import type { Market, MarketItem } from "../lib/types";
import { errorText, formatBytes, formatCount } from "../lib/format";
import type { Market, MarketCheck, MarketItem } from "../lib/types";
let dialog: HTMLDialogElement | undefined = $state();
let market = $state<Market | null>(null);
@@ -11,20 +12,55 @@
let loadError = $state<string | null>(null);
/** The optional items ticked here, by key: the draft the operator applies. */
let chosen = $state<string[]>([]);
/** What a count of the files on disk found, once it has been taken. */
let checks = $state<Record<string, MarketCheck> | null>(null);
let saved = $state(false);
let applyError = $state<string | null>(null);
/** Apply is waiting on a yes: it would delete something. */
let confirming = $state(false);
let cancelButton: HTMLButtonElement | undefined = $state();
const s = $derived(launcher.status);
const locked = $derived(launcher.locked);
/** Set while the panel describes a drive to install from, instead of this PC and its DHP. */
const drive = $derived(launcher.marketDrive);
const siteName = $derived(launcher.siteName);
const applying = $derived(launcher.busy === "market");
const busy = $derived(launcher.busy !== null);
const required = $derived(market?.items.filter((item) => item.required) ?? []);
const optional = $derived(market?.items.filter((item) => !item.required) ?? []);
const picked = (item: MarketItem) => chosen.includes(item.key);
const dirty = $derived(optional.some((item) => picked(item) !== item.selected));
/**
* What is REALLY there, once counted.
*
* An item's badge comes from the installer's own marker - its note that it finished. Any
* emptying of the folder afterwards leaves that note behind, and the marketplace goes on
* calling it installed. The count is a walk of hundreds of thousands of files, far too
* slow to open on, so the drawer opens on the marker and corrects itself when the count
* comes back.
*/
const shortfall = (item: MarketItem): MarketCheck | null => {
const check = checks?.[item.key];
return check && !check.whole ? check : null;
};
const counting = $derived(checks === null && market !== null && drive === null);
/** What the badge should say, marker and disk together. */
const presenceOf = (item: MarketItem) =>
item.presence === "installed" && shortfall(item) !== null ? "partial" : item.presence;
/**
* What this PC has settled on: what the operator chose, PLUS whatever is actually here.
*
* `selected` answers only "did the operator ask for this", and an item can be installed
* without ever having been asked for - required by the version that installed it (Dark,
* until Dark V2 took its place), taken off a drive, or never downloadable at all. Opening
* on `selected` alone shows those unticked though installed, which this panel states as
* "removed from this PC, frees N GB" and acts on at the next Apply. Nobody asked for that.
*
* It is the OPENING position, not a veto: unticking works exactly as before.
*/
const held = (item: MarketItem) => item.selected || item.presence === "installed";
const dirty = $derived(optional.some((item) => picked(item) !== held(item)));
const adds = $derived(optional.filter((item) => picked(item) && item.presence !== "installed"));
const removes = $derived(optional.filter((item) => !picked(item) && item.presence !== "absent"));
const installed = $derived((market?.installed ?? false) && drive === null);
@@ -49,6 +85,8 @@
const updateFirst = $derived(drive === null && installed && (s?.update_required ?? false));
/** A stopped download is work to do even when the choice did not change. */
const work = $derived(installed ? adds.length + removes.length > 0 : dirty);
/** Removed items no download can bring back: only an ODIAN drive carries them. */
const gone = $derived(removes.filter((item) => !item.on_dhp));
// A copy from a drive starts here, so a disk that cannot hold it stops the button too.
const blocked = $derived(locked || busy || offlineAdds || updateFirst || ((installed || drive !== null) && tooBig));
@@ -58,7 +96,9 @@
try {
const next = await launcher.loadMarket(drive ?? undefined);
market = next;
chosen = next.items.filter((item) => !item.required && item.selected).map((item) => item.key);
chosen = next.items.filter((item) => !item.required && held(item)).map((item) => item.key);
// Not awaited: the list is usable at once, and the badges settle when the count lands.
if (drive === null) void count();
} catch (e) {
loadError = errorText(e);
} finally {
@@ -66,20 +106,69 @@
}
}
/** Weigh what the markers claim. Slow, so it runs behind the open list. */
async function count() {
checks = null;
try {
const found = await backend.checkMarket();
checks = Object.fromEntries(found.map((check) => [check.key, check]));
} catch {
// A count that cannot be taken leaves the markers' word standing, which is what the
// marketplace showed before there was a count at all.
checks = {};
}
}
function revert() {
chosen = optional.filter((item) => item.selected).map((item) => item.key);
chosen = optional.filter((item) => held(item)).map((item) => item.key);
saved = false;
applyError = null;
confirming = false;
}
function toggle(item: MarketItem) {
chosen = picked(item) ? chosen.filter((key) => key !== item.key) : [...chosen, item.key];
saved = false;
applyError = null;
// The question was asked about a list that no longer holds.
confirming = false;
}
// The question replaces the button that asked it, so focus would fall to the document
// and Enter would do nothing at all. It lands on Cancel: the safe answer, and the one a
// keyboard reaches first.
$effect(() => {
if (confirming) cancelButton?.focus();
});
// Escape while the question is up answers "no", and nothing more. Left alone it closes
// the whole drawer - which looks exactly like the removal having been accepted, on the
// one screen where that guess costs 705 GB. Taken on the KEYDOWN rather than the dialog's
// `cancel` event: closing on Escape is that key's default action, and `cancel` does not
// bubble, so a handler on it is the less certain of the two.
$effect(() => {
const el = dialog;
if (!el) return;
const onKey = (event: KeyboardEvent) => {
if (event.key !== "Escape" || !confirming) return;
event.preventDefault();
event.stopPropagation();
confirming = false;
};
el.addEventListener("keydown", onKey, true);
return () => el.removeEventListener("keydown", onKey, true);
});
async function apply() {
if (blocked) return;
// Deleting is the one thing here that cannot be taken back by pressing the button
// again: a re-download is hours, and for an item the DHP does not carry there is no
// re-download at all. Adding needs no ceremony, so only a removal is asked about.
if (drive === null && installed && removes.length > 0 && !confirming) {
confirming = true;
return;
}
confirming = false;
// From a drive: save the choice, then copy. The progress shows in the main panel.
if (drive !== null) {
if (!(await launcher.setMarket([...chosen], drive))) {
@@ -107,17 +196,29 @@
/** True while the DHP would draw this item itself, so the PC lacking it shows nothing. */
const drawnBySite = (item: MarketItem) =>
item.presence === "absent" && drive === null && (s?.online ?? false);
item.presence === "absent" && drive === null && (s?.online ?? false) && item.on_dhp;
// Listed, but the DHP has not got it: the terrain source is 705 GB and comes on a drive.
// Only true in the DHP's own marketplace - a drive that carries it is offering it - and
// only while this PC does NOT hold it. Once it is here, where it came from is history:
// saying "not on the DHP" beside "Installed" reads as a fault instead of a fact.
const offDhp = (item: MarketItem) => !item.on_dhp && drive === null && item.presence !== "installed";
function presenceLabel(item: MarketItem): string {
if (item.presence === "installed") return "Installed";
if (item.presence === "partial") return "Partly downloaded";
const presence = presenceOf(item);
if (presence === "installed") return counting ? "Installed, checking…" : "Installed";
if (presence === "partial") return "Partly downloaded";
// "Not installed" would read as missing when nothing is missing: while the DHP
// answers it draws this itself. What this PC lacks is the copy that works offline.
return drawnBySite(item) ? "On the DHP" : "Not installed";
}
function presenceHelp(item: MarketItem): string | undefined {
const short = shortfall(item);
if (short) {
return `Its marker says it is installed, but only ${formatCount(short.found)} of ${formatCount(short.expected)} files are on this PC. Re-applying downloads the rest.`;
}
if (offDhp(item)) return "Your DHP does not carry this one. It is installed from an ODIAN drive.";
if (item.presence !== "absent" || drive !== null) return undefined;
return drawnBySite(item)
? "Your DHP draws this while you are connected. Download it to keep it when you are not."
@@ -249,7 +350,7 @@
type="checkbox"
class="check"
checked={picked(item)}
disabled={locked || applying}
disabled={locked || applying || offDhp(item)}
onchange={() => toggle(item)}
/>
<span class="body">
@@ -259,9 +360,12 @@
</span>
{#if item.description}<span class="desc">{item.description}</span>{/if}
<span class="meta">
<span class="pill {item.presence}" class:from-site={drawnBySite(item)} title={presenceHelp(item)}>
<span class="pill {presenceOf(item)}" class:from-site={drawnBySite(item)} title={presenceHelp(item)}>
{presenceLabel(item)}
</span>
{#if offDhp(item)}
<span class="pill off-dhp" title={presenceHelp(item)}>Not on the DHP</span>
{/if}
{#if note}<span class="change" class:loss={!picked(item)}>{note}</span>{/if}
</span>
</span>
@@ -287,6 +391,39 @@
{/if}
</div>
{#if confirming}
<section class="confirm" role="alert">
<h3 class="confirm-head">
<Icon name="alert" size={15} />
Remove {removes.length === 1 ? "this" : `these ${removes.length}`} from this PC?
</h3>
<ul class="confirm-list">
{#each removes as item (item.key)}
<li>
<span class="name">{item.title}</span>
<span class="size">{formatBytes(item.bytes)}</span>
{#if !item.on_dhp}<span class="pill off-dhp">Only from a drive</span>{/if}
</li>
{/each}
</ul>
<p class="confirm-note">
Their files are deleted from this PC, freeing {formatBytes(removeBytes)}.
{#if gone.length}
<strong>
{gone.length === 1 ? `${gone[0].title} is not on ${siteName}` : `${gone.length} of them are not on ${siteName}`}:
once removed, only an ODIAN drive can bring {gone.length === 1 ? "it" : "them"} back.
</strong>
{:else}
Taking {removes.length === 1 ? "it" : "them"} again later means downloading
{formatBytes(removeBytes)} from {siteName}.
{/if}
{#if adds.length}
&ensp;·&ensp;{adds.length === 1 ? "One item" : `${adds.length} items`} will be downloaded in the same pass.
{/if}
</p>
</section>
{/if}
<footer class="foot">
<span class="summary" role="status">
{#if applyError}
@@ -310,15 +447,28 @@
{/if}
{/if}
</span>
<button type="button" class="btn" disabled={!dirty || applying} onclick={revert}>Revert</button>
<button
type="button"
class="btn btn-accent"
disabled={blocked || (drive === null && !work) || (drive !== null && market === null)}
onclick={apply}
>
{#if applying}Applying…{:else if drive !== null}Install from drive{:else if installed}Apply changes{:else}Save choice{/if}
</button>
{#if confirming}
<button
type="button"
class="btn"
bind:this={cancelButton}
disabled={applying}
onclick={() => (confirming = false)}>Cancel</button
>
<button type="button" class="btn btn-danger" disabled={blocked} onclick={apply}>
{applying ? "Removing…" : `Remove ${removes.length === 1 ? "it" : "them"}`}
</button>
{:else}
<button type="button" class="btn" disabled={!dirty || applying} onclick={revert}>Revert</button>
<button
type="button"
class="btn btn-accent"
disabled={blocked || (drive === null && !work) || (drive !== null && market === null)}
onclick={apply}
>
{#if applying}Applying…{:else if drive !== null}Install from drive{:else if installed}Apply changes{:else}Save choice{/if}
</button>
{/if}
</footer>
</div>
</dialog>
@@ -552,6 +702,11 @@
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
color: var(--accent);
}
/* The DHP has not got this one. Not a fault either - it comes on a drive - so it is
stated flatly rather than coloured like something that went wrong. */
.pill.off-dhp {
border-style: dashed;
}
.change {
color: var(--accent-hover);
font-size: var(--text-xs);
@@ -579,6 +734,46 @@
font-weight: 400;
}
/* The question, shown where the answer is given: directly above the two buttons. */
.confirm {
padding: 14px 24px;
border-top: 1px solid color-mix(in srgb, var(--danger) 35%, transparent);
background: color-mix(in srgb, var(--danger) 8%, transparent);
}
.confirm-head {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 8px;
color: var(--danger);
font-size: var(--text-sm);
}
.confirm-list {
margin: 0 0 8px;
padding: 0;
list-style: none;
display: grid;
gap: 4px;
}
.confirm-list li {
display: flex;
align-items: center;
gap: 8px;
font-size: var(--text-sm);
}
.confirm-list .size {
color: var(--muted);
font-family: var(--font-mono);
}
.confirm-note {
margin: 0;
color: var(--muted);
font-size: var(--text-xs);
line-height: 1.5;
}
.confirm-note strong {
color: var(--danger);
}
.foot {
display: flex;
align-items: center;