fix(ui): keep the saved sidebar width when a narrower sidebar clamps it

The workspace context panels and Favorites share one stored
`sidebar-width` but clamp it to different limits. Loading wrote the
clamped value back, so visiting Settings (max 400) cut a categories
panel widened to 520 down to 400; the saved width could only shrink.

Load now clamps for rendering only. Legacy alias keys migrate their raw
value, an unparseable value falls back to the default without a write,
and only a drag that changes the width persists — a click on the handle
no longer saves the clamped width either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-08 18:34:46 +02:00
1 parent 6c8f5028c7
commit 6edec0fc76
5 files changed
+324 -30

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
type: fix
area: ui
---
A sidebar you widen now keeps its width after you visit a page with a narrower sidebar, such as Settings. Opening that page no longer saves its smaller width over yours.
@@ -0,0 +1,86 @@
import { Locator, Page } from '@playwright/test';
import {
addXtreamPortal,
closeElectronApp,
expect,
launchElectronApp,
openSettings,
openWorkspaceSection,
resetMockServers,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
/**
* The workspace context panels share one stored width but clamp it to their
* own limits. Opening a narrower panel used to save its clamped width, so a
* categories panel widened past Settings' 400px maximum came back at 400.
*/
const SHARED_WIDTH_KEY = 'sidebar-width';
/** The CSS width the directive sets; the panels are content-box. */
function renderedWidth(panel: Locator): Promise<number> {
return panel.evaluate((el) => parseFloat(getComputedStyle(el).width));
}
function storedWidth(page: Page): Promise<string | null> {
return page.evaluate((key) => localStorage.getItem(key), SHARED_WIDTH_KEY);
}
async function dragPanelTo(
page: Page,
panel: Locator,
width: number
): Promise<void> {
const handle = panel.locator(':scope > .resize-handle');
const box = await handle.boundingBox();
if (!box) throw new Error('The panel has no resize handle');
const startX = Math.round(box.x + box.width / 2);
const y = Math.round(box.y + box.height / 2);
// The directive starts a drag from offsetWidth (border box included).
const delta = width - (await panel.evaluate((el) => el.offsetWidth));
await page.mouse.move(startX, y);
await page.mouse.down();
await page.mouse.move(startX + delta, y, { steps: 10 });
await page.mouse.up();
}
test.describe('Electron shared sidebar width', () => {
test('@persistence @electron keeps a widened categories panel after Settings clamps it', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
try {
const page = app.mainWindow;
await page.setViewportSize({ width: 1440, height: 900 });
await addXtreamPortal(page);
await openWorkspaceSection(page, 'Movies');
await waitForXtreamWorkspaceReady(page);
const categories = page.locator('aside.context-panel--route');
await expect(categories).toBeVisible();
const categoriesUrl = page.url();
await dragPanelTo(page, categories, 520);
await expect.poll(() => renderedWidth(categories)).toBe(520);
await expect.poll(() => storedWidth(page)).toBe('520');
await openSettings(page);
const settings = page.locator('aside.context-panel--settings');
await expect(settings).toBeVisible();
await expect.poll(() => renderedWidth(settings)).toBe(400);
expect(await storedWidth(page)).toBe('520');
await page.goBack();
await page.waitForURL(categoriesUrl);
await expect(categories).toBeVisible();
await expect.poll(() => renderedWidth(categories)).toBe(520);
expect(await storedWidth(page)).toBe('520');
} finally {
await closeElectronApp(app);
}
});
});
@@ -212,6 +212,14 @@ directive-generated `.resize-handle` sets this centrally in `resizable.scss`.
The shared live-layout sidebar reserves 8 px at its right edge so the inward
half of the 12 px resize handle cannot cover the channel scrollbar.
## Resizable Sidebar Widths
Workspace context panels and Favorites share one `sidebar-width` key (its
legacy aliases map onto it) but have different limits. `ResizableDirective`
renders the stored width clamped to the host's own limits and never writes
that clamped value back: only a drag that changes the width persists, so a
narrow sidebar such as Settings cannot shrink the width a wider one saved.
## Keyboard Scrolling and Channel Focus
`ChannelScrollFocusDirective` belongs on the actual channel scroll owner,
@@ -0,0 +1,181 @@
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ResizableDirective } from './resizable.directive';
@Component({
imports: [ResizableDirective],
template: `
<aside
appResizable
[minWidth]="minWidth"
[maxWidth]="maxWidth"
[defaultWidth]="defaultWidth"
[storageKey]="storageKey"
[handlePosition]="handlePosition"
></aside>
`,
})
class Host {
minWidth = 200;
maxWidth = 600;
defaultWidth = 300;
storageKey = 'sidebar-width';
handlePosition: 'right' | 'left' = 'right';
}
describe('ResizableDirective', () => {
const fixtures: ComponentFixture<Host>[] = [];
function render(host: Partial<Host> = {}): HTMLElement {
const fixture = TestBed.createComponent(Host);
Object.assign(fixture.componentInstance, host);
fixture.detectChanges();
fixtures.push(fixture);
const aside: HTMLElement = fixture.nativeElement.querySelector('aside');
// jsdom has no layout; a drag starts from the rendered width.
Object.defineProperty(aside, 'offsetWidth', {
configurable: true,
get: () => parseInt(aside.style.width, 10) || 0,
});
return aside;
}
function drag(aside: HTMLElement, fromX: number, toX: number): void {
const handle = aside.querySelector('.resize-handle') as HTMLElement;
handle.dispatchEvent(
new MouseEvent('mousedown', { clientX: fromX, bubbles: true })
);
if (toX !== fromX) {
document.dispatchEvent(
new MouseEvent('mousemove', { clientX: toX, bubbles: true })
);
}
document.dispatchEvent(
new MouseEvent('mouseup', { clientX: toX, bubbles: true })
);
}
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
fixtures.splice(0).forEach((fixture) => fixture.destroy());
localStorage.clear();
});
it('clamps a shared width for rendering without writing it back', () => {
localStorage.setItem('sidebar-width', '520');
const settings = render({ minWidth: 200, maxWidth: 400 });
expect(settings.style.width).toBe('400px');
expect(localStorage.getItem('sidebar-width')).toBe('520');
const categories = render({ minWidth: 240, maxWidth: 560 });
expect(categories.style.width).toBe('520px');
expect(localStorage.getItem('sidebar-width')).toBe('520');
});
it('does not raise a shared width a wider minimum clamps up', () => {
localStorage.setItem('sidebar-width', '210');
const favorites = render({ minWidth: 250, maxWidth: 600 });
expect(favorites.style.width).toBe('250px');
expect(localStorage.getItem('sidebar-width')).toBe('210');
});
it('reads an alias key from the shared width without writing it', () => {
localStorage.setItem('sidebar-width', '520');
const panel = render({
maxWidth: 400,
storageKey: 'workspace-settings-panel-width',
});
expect(panel.style.width).toBe('400px');
expect(localStorage.getItem('sidebar-width')).toBe('520');
expect(
localStorage.getItem('workspace-settings-panel-width')
).toBeNull();
});
it('migrates the raw legacy width, not the clamped one', () => {
localStorage.setItem('downloads-sidebar-width', '520');
const downloads = render({
maxWidth: 400,
storageKey: 'downloads-sidebar-width',
});
expect(downloads.style.width).toBe('400px');
expect(localStorage.getItem('sidebar-width')).toBe('520');
expect(localStorage.getItem('downloads-sidebar-width')).toBeNull();
});
it('persists the width a user drags to', () => {
localStorage.setItem('sidebar-width', '300');
const aside = render({ maxWidth: 560 });
drag(aside, 100, 180);
expect(aside.style.width).toBe('380px');
expect(localStorage.getItem('sidebar-width')).toBe('380');
});
it('persists a left-handle drag on a key of its own', () => {
localStorage.setItem('live-channels-sidebar-width', '400');
const aside = render({
storageKey: 'live-channels-sidebar-width',
handlePosition: 'left',
});
drag(aside, 400, 350);
expect(aside.style.width).toBe('450px');
expect(localStorage.getItem('live-channels-sidebar-width')).toBe('450');
expect(localStorage.getItem('sidebar-width')).toBeNull();
});
it('does not persist the clamped width when the handle is only clicked', () => {
localStorage.setItem('sidebar-width', '520');
const settings = render({ maxWidth: 400 });
drag(settings, 100, 100);
expect(settings.style.width).toBe('400px');
expect(localStorage.getItem('sidebar-width')).toBe('520');
});
it('falls back to the default without writing an unparseable width', () => {
localStorage.setItem('sidebar-width', 'wide');
const aside = render({ defaultWidth: 290 });
expect(aside.style.width).toBe('290px');
expect(localStorage.getItem('sidebar-width')).toBe('wide');
});
it('keeps an unparseable legacy width instead of migrating it', () => {
localStorage.setItem('downloads-sidebar-width', 'wide');
const aside = render({
defaultWidth: 290,
storageKey: 'downloads-sidebar-width',
});
expect(aside.style.width).toBe('290px');
expect(localStorage.getItem('sidebar-width')).toBeNull();
expect(localStorage.getItem('downloads-sidebar-width')).toBe('wide');
});
it('renders the default without writing when nothing is stored', () => {
const aside = render({ defaultWidth: 290 });
expect(aside.style.width).toBe('290px');
expect(localStorage.getItem('sidebar-width')).toBeNull();
});
});
@@ -23,6 +23,11 @@ const SIDEBAR_WIDTH_KEY_ALIASES = new Set([
'iptvnator_video_sidebar_width',
]);
function parseStoredWidth(value: string): number | null {
const width = parseInt(value, 10);
return Number.isNaN(width) ? null : width;
}
/**
* ResizableDirective - Makes an element horizontally resizable by dragging its edge.
*
@@ -72,6 +77,7 @@ export class ResizableDirective implements OnInit, AfterViewInit, OnDestroy {
private isResizing = signal(false);
private startX = 0;
private startWidth = 0;
private widthBeforeResize = 0;
private currentWidth = signal(0);
// Event listener references for cleanup
@@ -164,39 +170,41 @@ export class ResizableDirective implements OnInit, AfterViewInit, OnDestroy {
this.renderer.appendChild(this.el.nativeElement, handle);
}
/**
* Sidebars sharing a key have different limits, so the stored width is
* clamped for rendering only: writing it back would hand every other
* sidebar the narrowest (or widest) limit. Only a user resize persists.
*/
private loadPersistedWidth(): void {
const storedWidth = this.readStoredWidth();
this.setWidthImmediate(
storedWidth === null
? this.defaultWidth()
: this.clampWidth(storedWidth)
);
}
/** The unclamped stored width, migrating a legacy key's raw value. */
private readStoredWidth(): number | null {
const { key, legacyKeys } = this.resolveStorageKey();
let widthToApply = this.defaultWidth();
if (key) {
let stored = localStorage.getItem(key);
let sourceLegacyKey: string | null = null;
if (!stored) {
for (const legacyKey of legacyKeys) {
const legacyValue = localStorage.getItem(legacyKey);
if (legacyValue) {
stored = legacyValue;
sourceLegacyKey = legacyKey;
break;
}
}
}
if (stored) {
const width = parseInt(stored, 10);
if (!isNaN(width)) {
widthToApply = this.clampWidth(width);
localStorage.setItem(key, widthToApply.toString());
if (sourceLegacyKey && sourceLegacyKey !== key) {
localStorage.removeItem(sourceLegacyKey);
}
}
}
const stored = localStorage.getItem(key);
if (stored) {
return parseStoredWidth(stored);
}
// Apply width immediately without transition to avoid flash
this.setWidthImmediate(widthToApply);
for (const legacyKey of legacyKeys) {
const legacyValue = localStorage.getItem(legacyKey);
if (!legacyValue) continue;
const width = parseStoredWidth(legacyValue);
if (width !== null) {
localStorage.setItem(key, legacyValue);
localStorage.removeItem(legacyKey);
}
return width;
}
return null;
}
/** Set width immediately without CSS transition */
@@ -246,6 +254,7 @@ export class ResizableDirective implements OnInit, AfterViewInit, OnDestroy {
this.isResizing.set(true);
this.startX = clientX;
this.startWidth = this.el.nativeElement.offsetWidth;
this.widthBeforeResize = this.currentWidth();
this.resizeStart.emit();
}
@@ -284,7 +293,11 @@ export class ResizableDirective implements OnInit, AfterViewInit, OnDestroy {
this.isResizing.set(false);
this.removeGlobalListeners();
this.persistWidth();
// A click on the handle is not a resize: persisting would save the
// width this sidebar clamped instead of the shared one.
if (this.currentWidth() !== this.widthBeforeResize) {
this.persistWidth();
}
this.resizeEnd.emit(this.currentWidth());
}