Two gaps that were the same gap: the store list could only ever grow, and it could only grow from what the registry happened to offer. **Removing** uninstalls what the store installed, then deletes the store itself, in that order. The order is the whole of it: `state.json` is the only record of which payloads, icons and menu entries belong to a store, so deleting the home first would strip the one thing that knows — leaving files nothing could ever identify, least of all a later install of the same store into the same folder. The confirmation says how many titles will go, because that is the part nobody would otherwise expect. The token goes too; a credential for a store that is not here is a secret kept for nothing. The window names a *store*, never a path: the home is resolved against what a disk scan actually found before anything is deleted, and `removeHome` refuses anything else. That is the only guard between a bad argument and `rm -rf`, so it has a test. **Adding** moved to a + beside Refresh — both are actions on the whole store rather than on one of them, and the full-width button under the list read as a third store — and the picker now takes a catalog address as well as a listed one. A bare host is enough and the name comes from the address; nothing else about installing changes, which is why the typed path hands the same record to the same method instead of growing a second one. The picker also has a Cancel now: opening it with a store installed used to replace the grid with no way back. `make storetest` is new, and it earned itself immediately. Removal is the only code here that deletes a directory tree, which the smoke test cannot cover — it runs against the real machine and would have to delete a real store to prove anything. Two bugs on the first run: - `http://` was accepted and became a store called *http*. The trailing slashes were stripped before the scheme was checked, turning `http://` into `http:` and then into `https://http:`, whose hostname parses as "http". The URL is rebuilt from the parsed form now, which also settles the trailing slash in one place. - `STORE_ROOT` only *prepended* to the search path, so a "sandboxed" run still listed the real stores — despite the README saying "instead of the real one". Harmless while a sandbox could only add; not harmless now that it can delete. It replaces the search path. The self-test needed two changes, both of which are it working: the store row is a wrapper now, so clicking `.store-row` did nothing at all, and the footer icon check counted exactly two named controls when there are three. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
112 lines
4.5 KiB
TypeScript
112 lines
4.5 KiB
TypeScript
import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto'
|
|
import { createElement, requireElement, setHidden, setText } from '../dom/Dom'
|
|
import type { MessageBundle } from '../../shared/i18n/MessageBundle'
|
|
import type { GateCustom, GateLink, GatePresentation } from '../state/GatePresentation'
|
|
|
|
/**
|
|
* The screen shown instead of the grid when there is nothing to drive: no Python, no
|
|
* store yet, an engine too old, or a registry that cannot be reached.
|
|
*/
|
|
export class GateView {
|
|
private readonly section = requireElement('gate', HTMLElement)
|
|
private readonly title = requireElement('gate-title', HTMLElement)
|
|
private readonly body = requireElement('gate-body', HTMLElement)
|
|
private readonly choice = requireElement('gate-choice', HTMLElement)
|
|
private readonly choiceLabel = requireElement('gate-choice-label', HTMLElement)
|
|
private readonly select = requireElement('gate-select', HTMLSelectElement)
|
|
private readonly button = requireElement('gate-action', HTMLButtonElement)
|
|
private readonly cancel = requireElement('gate-cancel', HTMLButtonElement)
|
|
private readonly link = requireElement('gate-link', HTMLAnchorElement)
|
|
private readonly custom = requireElement('gate-custom', HTMLElement)
|
|
private readonly customLabel = requireElement('gate-custom-label', HTMLElement)
|
|
private readonly customUrl = requireElement('gate-custom-url', HTMLInputElement)
|
|
private readonly customAction = requireElement('gate-custom-action', HTMLButtonElement)
|
|
private readonly customHint = requireElement('gate-custom-hint', HTMLElement)
|
|
|
|
public constructor (private readonly onOpenUrl: (url: string) => void) {}
|
|
|
|
public show (presentation: GatePresentation, messages: MessageBundle): void {
|
|
setHidden(this.section, false)
|
|
setText(this.title, presentation.title)
|
|
setText(this.body, presentation.body)
|
|
this.renderChoices(presentation.choices ?? [], messages)
|
|
this.renderAction(presentation)
|
|
this.renderCancel(presentation)
|
|
this.renderCustom(presentation.custom ?? null)
|
|
this.renderLink(presentation.link ?? null)
|
|
}
|
|
|
|
public hide (): void {
|
|
setHidden(this.section, true)
|
|
}
|
|
|
|
/** Only shown when the registry offers more than one store; with a single one there is nothing to decide. */
|
|
private renderChoices (choices: readonly RegistryStoreDto[], messages: MessageBundle): void {
|
|
setHidden(this.choice, choices.length < 2)
|
|
if (choices.length < 2) return
|
|
setText(this.choiceLabel, messages.setupChoose)
|
|
this.select.replaceChildren(...choices.map((store: RegistryStoreDto, index: number): HTMLOptionElement => {
|
|
const option = createElement('option', undefined, store.name)
|
|
option.value = String(index)
|
|
return option
|
|
}))
|
|
}
|
|
|
|
private renderAction (presentation: GatePresentation): void {
|
|
const action = presentation.action
|
|
setHidden(this.button, action === undefined)
|
|
this.button.disabled = false
|
|
if (action === undefined) return
|
|
setText(this.button, action.label)
|
|
this.button.onclick = (): void => {
|
|
const choices = presentation.choices ?? []
|
|
const index = Number(this.select.value)
|
|
action.perform(choices[Number.isFinite(index) ? index : 0] ?? choices[0] ?? null)
|
|
}
|
|
}
|
|
|
|
private renderCancel (presentation: GatePresentation): void {
|
|
const cancel = presentation.cancel
|
|
setHidden(this.cancel, cancel === undefined)
|
|
if (cancel === undefined) return
|
|
setText(this.cancel, cancel.label)
|
|
this.cancel.onclick = (): void => { cancel.perform(null) }
|
|
}
|
|
|
|
/**
|
|
* The typed-address row.
|
|
*
|
|
* Enter submits as well as the button, because a single text field with a button
|
|
* beside it is a form, and a form that ignores Enter is a small daily annoyance.
|
|
*/
|
|
private renderCustom (custom: GateCustom | null): void {
|
|
setHidden(this.custom, custom === null)
|
|
if (custom === null) return
|
|
setText(this.customLabel, custom.label)
|
|
setText(this.customHint, custom.hint)
|
|
setText(this.customAction, custom.actionLabel)
|
|
|
|
const submit = (): void => {
|
|
const value = this.customUrl.value.trim()
|
|
if (value.length === 0) return
|
|
custom.perform(value)
|
|
}
|
|
this.customAction.onclick = submit
|
|
this.customUrl.onkeydown = (event: KeyboardEvent): void => {
|
|
if (event.key !== 'Enter') return
|
|
event.preventDefault()
|
|
submit()
|
|
}
|
|
}
|
|
|
|
private renderLink (link: GateLink | null): void {
|
|
setHidden(this.link, link === null)
|
|
if (link === null) return
|
|
setText(this.link, link.label)
|
|
this.link.onclick = (event: MouseEvent): void => {
|
|
event.preventDefault()
|
|
this.onOpenUrl(link.url)
|
|
}
|
|
}
|
|
}
|