feat(sources): rebuild the Sources page on the shared source row

- Side list (UI-kit side list): title with the playlist count and an Add
  button, a Type group, and on desktop a Status group (Any / Responding /
  Not responding). The two filters combine and persist; each group counts
  within the other's choice. The chosen row uses one selected-row token
  (soft tint, outlined in light theme).
- Page: a 28px title with the visible count instead of the bordered
  header bar; Clean up and the sort menu are secondary buttons. Clean up
  shows how many sources do not respond. Sort adds Last updated, Most
  channels and (desktop) Not responding first, and keeps Name Z-A and
  Custom order.
- List: app-source-row with hover actions (Refresh, Edit, ⋯); a failing
  row keeps Retry in view. "No matches" offers Reset filters. Dragging in
  custom order no longer reuses positions of rows a filter hides.
- Delete with Undo: no confirmation; the row leaves at once and the delete
  runs when the toast closes (its close button commits at once). Deleting
  the active source switches to the next row. A large Xtream delete shows
  its row as "Removing…" with progress and Cancel, as before.
- The list's refresh flows moved into SourceListRefreshService; E2E and
  performance drivers follow the new markup and delete flow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 22:34:22 +02:00
1 parent 028a3db880
commit 401295c092
70 files changed
+3837 -2023

No files matched your search

@@ -590,7 +590,7 @@ test('launch options target the workspace source cards and the shared sentinel',
assert.equal(options.splashId, 'initial-splash');
assert.equal(options.routeFragment, '/workspace');
assert.match(options.cardSelector, /dashboard-recent-sources-rail-card/);
assert.match(options.cardSelector, /app-playlist-item/);
assert.match(options.cardSelector, /app-source-row/);
assert.deepEqual(options.settle, {
capMs: JOURNEY_SETTLE_CAP_MS,
quietMs: JOURNEY_SETTLE_QUIET_MS,
@@ -921,7 +921,7 @@ test('open-source options start at the portal card and end on the source route',
options.startClick?.selector ?? '',
/dashboard-recent-sources-rail-card/
);
assert.match(options.startClick?.selector ?? '', /app-playlist-item/);
assert.match(options.startClick?.selector ?? '', /app-source-row/);
assert.equal(options.routeFragment, '/workspace/xtreams/');
assert.match(options.cardSelector, /app-grid-list mat-card/);
assert.match(options.cardSelector, /channel-item/);
@@ -57,7 +57,7 @@ export const JOURNEY_PLAYBACK_START_SELECTOR =
export const JOURNEY_PLAYBACK_VIDEO_SELECTOR = 'app-web-player-view video';
/** The Xtream portal card on the dashboard or its row on /workspace/sources. */
export const JOURNEY_OPEN_SOURCE_START_SELECTOR =
'[data-test-id="dashboard-recent-sources-rail-card"], app-playlist-item';
'[data-test-id="dashboard-recent-sources-rail-card"], app-source-row';
/**
* Bridge method used for the sentinels. The preload emits the trace event
* before it forwards the call, and `SOURCE_HEALTH_CANCEL` only looks the id
@@ -862,7 +862,7 @@ export function createLaunchJourneyProbeOptions(
): JourneyRendererProbeOptions {
return {
cardSelector:
'[data-test-id="dashboard-recent-sources-rail-card"], app-playlist-item',
'[data-test-id="dashboard-recent-sources-rail-card"], app-source-row',
cdTickCounterKey: JOURNEY_CD_TICK_COUNTER_KEY,
...(idleWindowMs === null
? {}
@@ -262,7 +262,7 @@ async function runIteration(
await renderer.markOperationStart();
const row = sourceRowByTitle(app.mainWindow, SOURCE_TITLE).first();
await row.locator('.refresh-btn').click();
await row.locator('.source-row__refresh').click();
await renderer.waitForCancelClick();
await Promise.all([
renderer.waitForTerminal(),
@@ -301,7 +301,7 @@ async function installRendererProbe(
const epoch = (): number =>
performance.timeOrigin + performance.now();
const sourceRow = (): Element | undefined =>
Array.from(document.querySelectorAll('app-playlist-item')).find(
Array.from(document.querySelectorAll('app-source-row')).find(
(element) => element.textContent?.includes(title)
);
const state = {
@@ -152,11 +152,11 @@ export async function installXtreamRendererProbe(
);
const sourceRowAbsent = (): boolean =>
!Array.from(
document.querySelectorAll('app-playlist-item')
document.querySelectorAll('app-source-row')
).some(
(row) =>
row
.querySelector('.playlist-title')
.querySelector('.source-row__name')
?.textContent?.trim() === state.sourceTitle
);
const terminalDomGate = (): boolean => {
@@ -32,14 +32,14 @@ describe('Xtream scenario driver source wiring', () => {
"'#serverUrl'",
"'#username'",
"'#password'",
"'app-playlist-item'",
"'.refresh-btn'",
"'.delete-btn'",
"'app-workspace-sources app-source-row'",
"'.source-row__refresh'",
"'.source-row__more'",
"'Refresh playlist'",
"'Remove playlist'",
"'.busy-state__message'",
"'.action-spinner'",
"'.cancel-btn'",
"'.source-row__busy'",
"'mat-spinner'",
"'.source-row__cancel'",
"'.workspace-loading-overlay'",
]) {
assert.ok(ui.includes(selector), `missing ${selector}`);
@@ -157,15 +157,18 @@ describe('Xtream Playwright scenario driver', () => {
});
it('does not require a misleading portal-status badge before refresh or delete', async () => {
for (const [scenarioId, selector, confirmLabel] of [
for (const [scenarioId, selector, confirmClick] of [
[
XTREAM_SCENARIO_ID.REFRESH_LARGE,
'.refresh-btn',
'Refresh playlist',
'.source-row__refresh',
'name=Refresh playlist;exact=true',
],
[
XTREAM_SCENARIO_ID.DELETE_LARGE,
'.source-row__more',
'.source-removal-toast__close',
],
[XTREAM_SCENARIO_ID.DELETE_LARGE, '.delete-btn', 'Remove playlist'],
] as const) {
const confirmClick = `name=${confirmLabel};exact=true`;
const fake = new FakePage();
const prepared = await prepareXtreamScenario(
fake.asPage(),
@@ -68,28 +68,42 @@ export async function prepareXtreamSourceActionTrigger(
const row = xtreamSourceRow(page, sourceTitle);
await row.waitFor({ state: 'visible' });
for (const selector of [
'.busy-state__message',
'.action-spinner',
'.cancel-btn',
'.source-row__busy',
'mat-spinner',
'.source-row__cancel',
]) {
await row.locator(selector).waitFor({ state: 'detached' });
}
const actionSelector =
action === 'refresh' ? '.refresh-btn' : '.delete-btn';
const actionButton = row.locator(actionSelector);
await assertTriggerReady(actionButton, `xtream-source-${action}`);
await actionButton.click();
if (action === 'refresh') {
const refresh = row.locator('.source-row__refresh');
await assertTriggerReady(refresh, 'xtream-source-refresh');
await refresh.click();
const dialog = page.locator('mat-dialog-container').last();
await dialog.waitFor({ state: 'visible' });
const confirmation = dialog
.getByRole('button', { name: 'Refresh playlist', exact: true })
.last();
await assertTriggerReady(confirmation, 'xtream-source-refresh-confirm');
return confirmation;
}
const dialog = page.locator('mat-dialog-container').last();
await dialog.waitFor({ state: 'visible' });
const confirmation = dialog
.getByRole('button', {
name: action === 'refresh' ? 'Refresh playlist' : 'Remove playlist',
exact: true,
})
.last();
await assertTriggerReady(confirmation, `xtream-source-${action}-confirm`);
return confirmation;
// Delete leaves the list at once and offers Undo; the toast's close
// button commits it without waiting out the Undo window, so the
// measurement starts when the delete does.
const more = row.locator('.source-row__more');
await assertTriggerReady(more, 'xtream-source-delete-menu');
await more.click();
const remove = page
.locator('.cdk-overlay-pane [mat-menu-item]')
.filter({ hasText: 'Remove playlist' })
.first();
await assertTriggerReady(remove, 'xtream-source-delete');
await remove.click();
const commit = page.locator(
'mat-snack-bar-container.app-toast .source-removal-toast__close'
);
await assertTriggerReady(commit, 'xtream-source-delete-confirm');
return commit;
}
export async function waitForXtreamCatalogTerminal(page: Page): Promise<void> {
@@ -129,7 +143,10 @@ export async function waitForXtreamDeleteTerminal(
page: Page,
sourceTitle: string
): Promise<void> {
await xtreamSourceRow(page, sourceTitle).waitFor({ state: 'detached' });
const row = xtreamSourceRow(page, sourceTitle);
// The row returns as "Removing…" once the delete starts, then leaves.
await row.waitFor({ state: 'attached' });
await row.waitFor({ state: 'detached' });
}
async function selectXtreamMethod(dialog: Locator): Promise<void> {
@@ -157,9 +174,9 @@ async function selectXtreamMethod(dialog: Locator): Promise<void> {
function xtreamSourceRow(page: Page, sourceTitle: string): Locator {
const exactTitle = new RegExp(`^\\s*${escapeRegex(sourceTitle)}\\s*$`, 'u');
return page
.locator('app-playlist-item')
.locator('app-workspace-sources app-source-row')
.filter({
has: page.locator('.playlist-title', {
has: page.locator('.source-row__name', {
hasText: exactTitle,
}),
})
@@ -178,7 +178,7 @@ class SearchCompletionFakePage {
}
this.searchGateObserved =
/searchParams\.get\((['"])q\1\)/u.test(source) &&
source.includes('app-playlist-item') &&
source.includes('app-source-row') &&
source.includes('no-results-state');
if (this.searchGateObserved) {
this.searchResultsApplied = true;
@@ -154,14 +154,14 @@ export async function runXtreamBackgroundUiActions(
);
const resultRows = Array.from(
document.querySelectorAll(
'app-workspace-sources app-playlist-item'
'app-workspace-sources app-source-row'
)
);
const resultPainted =
resultRows.length > 0
? resultRows.every((row) =>
(
row.querySelector('.playlist-title')
row.querySelector('.source-row__name')
?.textContent ?? ''
)
.trim()