feat(ui): refresh scm views from live events
Some checks failed
Security Scan / security (push) Failing after 13s
Test / test (push) Successful in 2m20s

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-02 06:58:47 +00:00
parent 676130ab84
commit 32e65b8b43
8 changed files with 2095 additions and 26 deletions

View file

2007
pkg/api/ui/dist/core-scm.js vendored Normal file

File diff suppressed because it is too large Load diff

View file

@ -162,6 +162,10 @@ export class ScmInstalled extends LitElement {
}
}
async refresh() {
await this.loadInstalled();
}
private async handleUpdate(code: string) {
this.updating = new Set([...this.updating, code]);
try {

View file

@ -265,6 +265,11 @@ export class ScmManifest extends LitElement {
}
}
async refresh() {
this.verifyResult = null;
await this.loadManifest();
}
private async handleVerify() {
if (!this.verifyKey.trim()) return;
try {

View file

@ -219,6 +219,10 @@ export class ScmMarketplace extends LitElement {
}
}
async refresh() {
await this.loadModules();
}
private handleSearch(e: Event) {
this.searchQuery = (e.target as HTMLInputElement).value;
this.loadModules();

View file

@ -2,6 +2,7 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import type { PropertyValues } from 'lit';
import { connectScmEvents, type ScmEvent } from './shared/events.js';
// Side-effect imports to register child elements
@ -11,6 +12,9 @@ import './scm-manifest.js';
import './scm-registry.js';
type TabId = 'marketplace' | 'installed' | 'manifest' | 'registry';
type RefreshableElement = HTMLElement & {
refresh?: () => Promise<void> | void;
};
/**
* <core-scm-panel> Top-level HLCRF panel with tabs.
@ -154,18 +158,28 @@ export class ScmPanel extends LitElement {
}
}
disconnectedCallback() {
super.disconnectedCallback();
if (this.ws) {
this.ws.close();
this.ws = null;
updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
if (changedProperties.has('wsUrl') && this.isConnected) {
this.connectWs();
}
}
disconnectedCallback() {
super.disconnectedCallback();
this.disconnectWs();
}
private connectWs() {
this.disconnectWs();
if (!this.wsUrl) {
return;
}
this.ws = connectScmEvents(this.wsUrl, (event: ScmEvent) => {
this.lastEvent = event.channel ?? event.type ?? '';
this.requestUpdate();
this.refreshForEvent(event);
});
this.ws.onopen = () => {
this.wsConnected = true;
@ -175,27 +189,55 @@ export class ScmPanel extends LitElement {
};
}
private disconnectWs() {
if (!this.ws) {
return;
}
this.ws.close();
this.ws = null;
}
private handleTabClick(tab: TabId) {
this.activeTab = tab;
}
private handleRefresh() {
// Force re-render of active child by toggling a key
const content = this.shadowRoot?.querySelector('.content');
if (content) {
const child = content.firstElementChild;
if (child && 'loadModules' in child) {
(child as any).loadModules();
} else if (child && 'loadInstalled' in child) {
(child as any).loadInstalled();
} else if (child && 'loadManifest' in child) {
(child as any).loadManifest();
} else if (child && 'loadRegistry' in child) {
(child as any).loadRegistry();
}
private async handleRefresh() {
await this.refreshActiveTab();
}
private refreshForEvent(event: ScmEvent) {
const targets = this.tabsForChannel(event.channel ?? event.type ?? '');
if (targets.includes(this.activeTab)) {
void this.refreshActiveTab();
}
}
private tabsForChannel(channel: string): TabId[] {
if (channel.startsWith('scm.marketplace.')) {
return ['marketplace', 'installed'];
}
if (channel.startsWith('scm.installed.')) {
return ['installed'];
}
if (channel === 'scm.manifest.verified') {
return ['manifest'];
}
if (channel === 'scm.registry.changed') {
return ['registry'];
}
return [];
}
private async refreshActiveTab() {
const child = this.shadowRoot?.querySelector('.content > *') as RefreshableElement | null;
if (!child?.refresh) {
return;
}
await child.refresh();
}
private renderContent() {
switch (this.activeTab) {
case 'marketplace':

View file

@ -170,6 +170,10 @@ export class ScmRegistry extends LitElement {
}
}
async refresh() {
await this.loadRegistry();
}
render() {
if (this.loading) {
return html`<div class="loading">Loading registry\u2026</div>`;

View file

@ -12,9 +12,12 @@ export class ScmApi {
private async request<T>(path: string, opts?: RequestInit): Promise<T> {
const res = await fetch(`${this.base}${path}`, opts);
const json = await res.json();
if (!json.success) {
throw new Error(json.error?.message ?? 'Request failed');
const json = await res.json().catch(() => null);
if (!res.ok) {
throw new Error(json?.error?.message ?? `Request failed (${res.status})`);
}
if (!json?.success) {
throw new Error(json?.error?.message ?? 'Request failed');
}
return json.data as T;
}
@ -28,15 +31,15 @@ export class ScmApi {
}
marketplaceItem(code: string) {
return this.request<any>(`/marketplace/${code}`);
return this.request<any>(`/marketplace/${encodeURIComponent(code)}`);
}
install(code: string) {
return this.request<any>(`/marketplace/${code}/install`, { method: 'POST' });
return this.request<any>(`/marketplace/${encodeURIComponent(code)}/install`, { method: 'POST' });
}
remove(code: string) {
return this.request<any>(`/marketplace/${code}`, { method: 'DELETE' });
return this.request<any>(`/marketplace/${encodeURIComponent(code)}`, { method: 'DELETE' });
}
installed() {
@ -44,7 +47,7 @@ export class ScmApi {
}
updateInstalled(code: string) {
return this.request<any>(`/installed/${code}/update`, { method: 'POST' });
return this.request<any>(`/installed/${encodeURIComponent(code)}/update`, { method: 'POST' });
}
manifest() {