Stores can be removed, and added from an address you type
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful

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>
This commit is contained in:
2026-08-19 14:54:17 +02:00
co-authored by Claude Opus 5
parent 255c588cbd
commit 6285d93790
27 changed files with 823 additions and 72 deletions
+44 -6
View File
@@ -8,6 +8,7 @@ import type { AppState } from '../state/AppStore'
export interface SideMenuViewCallbacks {
readonly onSelectStore: (home: string) => void
readonly onRemoveStore: (home: string, name: string) => void
readonly onAddStore: () => void
readonly onRefresh: () => void
readonly onSelectCategory: (filter: CategoryFilter) => void
@@ -57,7 +58,9 @@ export class SideMenuView {
public render (state: AppState): void {
setText(this.storesHead, state.messages.stores)
setText(this.categoriesHead, state.messages.categories)
setText(this.addStore, state.messages.addStore)
// Add is an icon now, beside Refresh: naming it is all the view does, and writing
// text into it would replace the glyph.
describeControl(this.addStore, state.messages.addStoreHint)
// Refresh and the language picker are icons: naming them is all the view does, and
// writing text into them would replace the glyph.
describeControl(this.refresh, state.messages.refresh)
@@ -84,15 +87,30 @@ export class SideMenuView {
.map((store: InstalledStoreDto): string => store.id))
this.storeList.replaceChildren(...state.stores.map((store: InstalledStoreDto): HTMLElement => {
const row = createElement('button', 'store-row')
// A row is a button *and* carries one; nesting them would be invalid markup and
// the inner click would reach the outer handler anyway. So the row is a wrapper
// with two buttons in it: switch, and remove.
const row = createElement('div', 'store-row')
if (store.home === activeHome) row.classList.add('is-active')
row.appendChild(createElement('span', 'store-row-name', store.name))
row.appendChild(createElement('span', 'store-row-id',
const open = createElement('button', 'store-row-open')
open.appendChild(createElement('span', 'store-row-name', store.name))
open.appendChild(createElement('span', 'store-row-id',
ambiguousIds.has(store.id) ? store.home : store.id))
row.title = store.home
row.addEventListener('click', (): void => {
open.title = store.home
open.addEventListener('click', (): void => {
if (store.home !== activeHome) this.callbacks.onSelectStore(store.home)
})
row.appendChild(open)
const remove = createElement('button', 'store-row-remove')
remove.appendChild(createTrashIcon())
describeControl(remove, state.messages.removeStore)
remove.addEventListener('click', (): void => {
this.callbacks.onRemoveStore(store.home, store.name)
})
row.appendChild(remove)
return row
}))
}
@@ -168,6 +186,26 @@ export class SideMenuView {
* `title` is the tooltip a mouse finds and `aria-label` is what a screen reader reads;
* an icon button needs both, and they are the same sentence.
*/
/**
* The remove glyph: a lid and a bin.
*
* Drawn rather than a character, for the same reason the other two icons are — a font
* that lacks the symbol shows a box, and this button has no text to fall back on.
*/
function createTrashIcon (): SVGSVGElement {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('class', 'icon icon-trash')
svg.setAttribute('viewBox', '0 0 16 16')
svg.setAttribute('aria-hidden', 'true')
svg.setAttribute('focusable', 'false')
for (const d of ['M3 4.5h10', 'M6.5 4.5V3h3v1.5', 'M4.5 4.5 5 13h6l.5-8.5', 'M6.8 7v3.5', 'M9.2 7v3.5']) {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('d', d)
svg.appendChild(path)
}
return svg
}
function describeControl (element: HTMLElement, name: string): void {
element.title = name
element.setAttribute('aria-label', name)