release: publish v2.0.20 selection and sidebar refinements

Complete zero-width sidebar collapse with synchronized bidirectional motion, add package-row selection and guarded double-click disclosure, render continuous selection edges across nested package boundaries, expand regression coverage, and update the public English changelog and package metadata for v2.0.20.
This commit is contained in:
Sucukdeluxe
2026-08-11 04:42:14 +02:00
parent 14090011e3
commit eda3a3f744
10 changed files with 274 additions and 25 deletions
+22
View File
@@ -2,6 +2,28 @@
All notable changes to Multi-Debrid Downloader are documented in this file.
## [2.0.20] - 2026-08-11
### Downloads and selection
- Added package selection through a normal row click while preserving Control, Command, and Shift selection behavior.
- Added package expansion and collapse through a double-click on unused row space while keeping the disclosure button available.
- Kept renaming, checkboxes, progress meters, links, form controls, and action buttons isolated from the row disclosure gesture.
- Replaced segmented selection markers with a continuous three-pixel success-colored edge across adjacent selected packages and files.
- Extended package selection markers across card separators so consecutive selected packages no longer show one-pixel gaps.
### Sidebar motion
- Removed the empty sidebar rail when the sidebar is collapsed.
- Added synchronized 520-millisecond width and panel transitions for smooth sidebar opening and closing.
- Kept the sidebar edge control visible and reachable while the sidebar content is fully hidden.
- Preserved the requested sidebar motion when Windows application animations are disabled.
### Reliability and testing
- Added regression coverage for zero-width sidebar collapse, synchronized sidebar motion, package row selection, double-click disclosure boundaries, and continuous selection markers.
- Added rendered-pixel verification for the updated interactions and continuous selection edge.
## [2.0.19] - 2026-08-11
### Interface fixes
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "real-debrid-downloader",
"version": "2.0.19",
"version": "2.0.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "real-debrid-downloader",
"version": "2.0.19",
"version": "2.0.20",
"license": "MIT",
"dependencies": {
"adm-zip": "0.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "real-debrid-downloader",
"version": "2.0.19",
"version": "2.0.20",
"description": "Desktop downloader",
"main": "build/main/main/main.js",
"author": "Sucukdeluxe",
+4 -2
View File
@@ -21,8 +21,10 @@ export function AppSidebar({
className={`md-shell-sidebar${collapsed ? " is-collapsed" : ""}${responsiveRail ? " is-responsive-rail" : ""}`}
data-ui-region="sidebar"
>
{children ? <div className="md-shell-sidebar-scroll">{children}</div> : null}
{status ? <div className="md-shell-sidebar-status" data-ui-region="sidebar-status">{status}</div> : null}
<div className="md-shell-sidebar-panel">
{children ? <div className="md-shell-sidebar-scroll">{children}</div> : null}
{status ? <div className="md-shell-sidebar-status" data-ui-region="sidebar-status">{status}</div> : null}
</div>
<button
aria-label={collapsed ? "Seitenleiste ausklappen" : "Seitenleiste einklappen"}
className="md-shell-sidebar-toggle"
+32 -10
View File
@@ -404,6 +404,7 @@
min-height: 0;
grid-template-columns: minmax(0, 1fr);
gap: 8px;
transition: grid-template-columns 520ms cubic-bezier(0.22, 0.76, 0.22, 1);
}
.md-shell.has-sidebar .md-shell-workspace {
@@ -411,10 +412,10 @@
}
.md-shell.has-collapsed-sidebar .md-shell-workspace {
grid-template-columns: 56px minmax(0, 1fr);
grid-template-columns: 0 minmax(0, 1fr);
}
.md-shell-sidebar,
.md-shell-sidebar-panel,
.md-shell-main {
position: relative;
min-width: 0;
@@ -425,8 +426,24 @@
}
.md-shell-sidebar {
position: relative;
z-index: 4;
min-width: 0;
min-height: 0;
}
.md-shell-sidebar-panel {
display: flex;
width: 270px;
height: 100%;
flex-direction: column;
opacity: 1;
transform: translate3d(0, 0, 0);
visibility: visible;
transition:
opacity 260ms ease,
transform 520ms cubic-bezier(0.22, 0.76, 0.22, 1),
visibility 0s linear 0s;
}
.md-shell-sidebar-scroll {
@@ -465,16 +482,12 @@
line-height: 16px;
}
.md-shell-sidebar.is-collapsed :where(.md-shell-sidebar-scroll, .md-shell-sidebar-status) {
overflow: hidden;
.md-shell-sidebar.is-collapsed .md-shell-sidebar-panel {
visibility: hidden;
opacity: 0;
transform: translateX(-8px);
}
.md-shell-sidebar-scroll,
.md-shell-sidebar-status {
transition: opacity 160ms ease, transform 160ms ease;
transform: translate3d(calc(-100% - 8px), 0, 0);
pointer-events: none;
transition-delay: 0s, 0s, 520ms;
}
.md-shell-sidebar-toggle {
@@ -507,6 +520,7 @@
}
.md-shell-sidebar:hover .md-shell-sidebar-toggle,
.md-shell-sidebar.is-collapsed .md-shell-sidebar-toggle,
.md-shell-sidebar-toggle:focus-visible {
opacity: 1;
}
@@ -907,6 +921,14 @@
transition-duration: 0.01ms !important;
}
.md-shell-workspace {
transition-duration: 520ms !important;
}
.md-shell-sidebar-panel {
transition-duration: 260ms, 520ms, 0s !important;
}
.md-shell-navigation::before {
transition-duration: 420ms, 420ms, 420ms, 120ms !important;
}
@@ -18,6 +18,7 @@ export type DownloadSortColumn = "name" | "size" | "hoster" | "progress";
const DOWNLOAD_SELECTION_COLUMN_WIDTH = "36px";
const DOWNLOAD_ACTION_COLUMN_WIDTH = "60px";
const PACKAGE_ROW_DISCLOSURE_EXCLUSION_SELECTOR = "button, input, select, textarea, a, [contenteditable='true'], .downloads-copyable, .downloads-meter";
const useRendererLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
type HosterLabel = ReturnType<typeof formatHosterLabel>;
@@ -40,6 +41,11 @@ function downloadGridTemplate(gridTemplate: string): string {
return `${DOWNLOAD_SELECTION_COLUMN_WIDTH} ${gridTemplate} ${DOWNLOAD_ACTION_COLUMN_WIDTH}`;
}
function isPackageRowDisclosureExcluded(target: EventTarget | null): boolean {
const closest = (target as { closest?: (selector: string) => Element | null } | null)?.closest;
return typeof closest === "function" && closest.call(target, PACKAGE_ROW_DISCLOSURE_EXCLUSION_SELECTOR) !== null;
}
export const downloadColumnDefinitions: Record<string, { label: string; width: string; sortable?: DownloadSortColumn }> = {
name: { label: "Name", width: "minmax(290px, 2.3fr)", sortable: "name" },
size: { label: "Geladen / Größe", width: "minmax(140px, 1.1fr)", sortable: "size" },
@@ -489,9 +495,14 @@ export function PackageCardContent({ row, selectedIds, editing, editingName, pac
role="row"
style={{ gridTemplateColumns: downloadGridTemplate(gridTemplate) }}
onClick={(event) => {
if (event.ctrlKey || event.metaKey || event.shiftKey) {
actions.onToggleSelection(entry.id, event.ctrlKey || event.metaKey, event.shiftKey);
}
if (event.detail > 1) return;
if (!event.ctrlKey && !event.metaKey && !event.shiftKey && selectedIds.size === 1 && selectedIds.has(entry.id)) return;
actions.onToggleSelection(entry.id, event.ctrlKey || event.metaKey, event.shiftKey);
}}
onDoubleClick={(event) => {
if (event.ctrlKey || event.metaKey || event.shiftKey || isPackageRowDisclosureExcluded(event.target)) return;
event.preventDefault();
actions.onTogglePackageCollapse(entry.id);
}}
onMouseDown={(event) => actions.onSelectionMouseDown(entry.id, event)}
onMouseEnter={() => actions.onSelectionMouseEnter(entry.id)}
+40 -1
View File
@@ -300,6 +300,11 @@
opacity: 0.58;
}
.downloads-package-card.is-selected,
.downloads-package-card:has(.downloads-package-items-inner > .downloads-item-row:last-child.is-selected) {
position: relative;
}
.downloads-package-items {
height: auto;
overflow: hidden;
@@ -332,6 +337,7 @@
color: var(--ui-text);
background: transparent;
border-color: var(--ui-border);
border-left: 3px solid transparent;
contain: layout paint style;
content-visibility: auto;
contain-intrinsic-size: 40px;
@@ -346,8 +352,41 @@
.downloads-item-row.is-selected,
.downloads-package-card.is-selected > .downloads-package-row {
position: relative;
background: color-mix(in srgb, var(--ui-success) 14%, var(--ui-canvas));
box-shadow: inset 3px 0 0 var(--ui-success);
border-left-color: var(--ui-success);
}
.downloads-item-row.is-selected::before {
content: "";
position: absolute;
z-index: 1;
inset: -1px auto -1px -3px;
width: 3px;
background: var(--ui-success);
pointer-events: none;
}
.downloads-package-card.is-selected::before {
content: "";
position: absolute;
z-index: 3;
inset: 0 auto auto 0;
width: 3px;
height: 41px;
background: var(--ui-success);
pointer-events: none;
}
.downloads-package-card:has(.downloads-package-items-inner > .downloads-item-row:last-child.is-selected)::after {
content: "";
position: absolute;
z-index: 3;
inset: auto auto -1px 0;
width: 3px;
height: 1px;
background: var(--ui-success);
pointer-events: none;
}
.downloads-package-row:hover,
+41
View File
@@ -115,6 +115,47 @@ describe("desktop shell", () => {
expect(html).not.toContain("data-ui-region=\"footer\"");
});
it("collapses a populated sidebar to zero width while preserving the edge toggle", () => {
const html = renderToStaticMarkup(
<AppShell
activeView="downloads"
onViewChange={() => {}}
sidebar={<div>Filter</div>}
sidebarStatus={<div>2 Downloads</div>}
headerActions={null}
toolbar={null}
footer={null}
contextInfo={null}
sidebarCollapsed
onSidebarCollapsedChange={() => {}}
>
<div>Downloads</div>
</AppShell>
);
const shellCss = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
expect(html).toContain("has-collapsed-sidebar");
expect(html).toContain("md-shell-sidebar-panel");
expect(html).toContain("Seitenleiste ausklappen");
expect(shellCss).toMatch(/\.md-shell\.has-collapsed-sidebar \.md-shell-workspace\s*\{[^}]*grid-template-columns:\s*0 minmax\(0,\s*1fr\);/s);
});
it("slides the complete sidebar panel in both directions with the workspace width", () => {
const shellCss = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
expect(shellCss).toMatch(/\.md-shell-workspace\s*\{[^}]*transition:\s*grid-template-columns 520ms cubic-bezier\(0\.22, 0\.76, 0\.22, 1\);/s);
expect(shellCss).toMatch(/\.md-shell-sidebar-panel\s*\{[^}]*transform:\s*translate3d\(0, 0, 0\);[^}]*transition:[^}]*transform 520ms cubic-bezier\(0\.22, 0\.76, 0\.22, 1\)/s);
expect(shellCss).toMatch(/\.md-shell-sidebar\.is-collapsed \.md-shell-sidebar-panel\s*\{[^}]*transform:\s*translate3d\(calc\(-100% - 8px\), 0, 0\);[^}]*pointer-events:\s*none;/s);
expect(shellCss).toMatch(/\.md-shell-sidebar:hover \.md-shell-sidebar-toggle,\s*\.md-shell-sidebar\.is-collapsed \.md-shell-sidebar-toggle,\s*\.md-shell-sidebar-toggle:focus-visible\s*\{[^}]*opacity:\s*1;/s);
});
it("keeps the explicitly requested sidebar motion active when Windows animations are disabled", () => {
const shellCss = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
expect(shellCss).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.md-shell-workspace\s*\{[^}]*transition-duration:\s*520ms !important;/s);
expect(shellCss).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.md-shell-sidebar-panel\s*\{[^}]*transition-duration:\s*260ms, 520ms, 0s !important;/s);
});
it("keeps every view sidebar on the same content axis", () => {
const shellCss = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8").replaceAll("\r\n", "\n");
+117 -5
View File
@@ -709,7 +709,11 @@ describe("downloads view", () => {
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
const source = readFileSync(new URL("../src/renderer/views/downloads/DownloadsTable.tsx", import.meta.url), "utf8");
expect(css).toMatch(/\.downloads-item-row\.is-selected,\s*\.downloads-package-card\.is-selected\s*>\s*\.downloads-package-row\s*\{[^}]*background:\s*color-mix\(in srgb, var\(--ui-success\) 14%, var\(--ui-canvas\)\);[^}]*box-shadow:\s*inset 3px 0 0 var\(--ui-success\);/s);
expect(css).toMatch(/\.downloads-item-row,\s*\.downloads-package-row\s*\{[^}]*border-left:\s*3px solid transparent;/s);
expect(css).toMatch(/\.downloads-item-row\.is-selected,\s*\.downloads-package-card\.is-selected\s*>\s*\.downloads-package-row\s*\{[^}]*background:\s*color-mix\(in srgb, var\(--ui-success\) 14%, var\(--ui-canvas\)\);[^}]*border-left-color:\s*var\(--ui-success\);/s);
expect(css).toMatch(/\.downloads-item-row\.is-selected::before\s*\{[^}]*content:\s*"";[^}]*position:\s*absolute;[^}]*inset:\s*-1px auto -1px -3px;[^}]*width:\s*3px;[^}]*background:\s*var\(--ui-success\);/s);
expect(css).toMatch(/\.downloads-package-card\.is-selected::before\s*\{[^}]*content:\s*"";[^}]*position:\s*absolute;[^}]*inset:\s*0 auto auto 0;[^}]*width:\s*3px;[^}]*height:\s*41px;[^}]*background:\s*var\(--ui-success\);/s);
expect(css).toMatch(/\.downloads-package-card:has\(\.downloads-package-items-inner\s*>\s*\.downloads-item-row:last-child\.is-selected\)::after\s*\{[^}]*content:\s*"";[^}]*position:\s*absolute;[^}]*inset:\s*auto auto -1px 0;[^}]*width:\s*3px;[^}]*height:\s*1px;[^}]*background:\s*var\(--ui-success\);/s);
expect(css).toMatch(/\.downloads-selection-cell\s+input\[type="checkbox"\]\s*\{[^}]*width:\s*18px;[^}]*height:\s*18px;/s);
expect(css).toMatch(/\.downloads-hoster-icon\s*\{[^}]*width:\s*18px;[^}]*height:\s*18px;[^}]*object-fit:\s*contain;/s);
expect(css).toMatch(/\.downloads-hoster-icon\[data-hoster="rapidgator"\]\s*\{[^}]*transform:\s*translateY\(-4px\) scale\(2\);/s);
@@ -996,12 +1000,39 @@ describe("download table row contracts", () => {
expect(calls).toEqual(["select"]);
});
it("only collapses a package through its visible disclosure button", () => {
it("selects a package on a plain row click", () => {
const calls: Array<[string, boolean, boolean]> = [];
const model = withRuntime(createInput());
const row = model.packageRows[0];
const component = PackageCardContent({
actions: createActions({
onToggleSelection: (id, ctrl, shift) => calls.push([id, ctrl, shift])
}),
columnOrder: model.columnOrder,
editing: false,
editingName: "",
gridTemplate: model.gridTemplate,
packageSpeedBps: 0,
row,
selectedIds: new Set<string>(),
selectedVersion: 1
});
const packageRow = findElement(component, (element) => element.props["data-download-row-id"] === row.package.id);
packageRow.props.onClick({ ctrlKey: false, metaKey: false, shiftKey: false });
expect(calls).toEqual([[row.package.id, false, false]]);
});
it("collapses a package through its disclosure button or a free-row double click", () => {
const calls: string[] = [];
const model = withRuntime(createInput());
const row = model.packageRows[0];
const component = PackageCardContent({
actions: createActions({ onTogglePackageCollapse: () => calls.push("collapse") }),
actions: createActions({
onTogglePackageCollapse: () => calls.push("collapse"),
onToggleSelection: () => calls.push("select")
}),
columnOrder: model.columnOrder,
editing: false,
editingName: "",
@@ -1014,14 +1045,95 @@ describe("download table row contracts", () => {
const packageRow = findElement(component, (element) => element.props["data-download-row-id"] === row.package.id);
const collapseButton = findElement(component, (element) => element.type === "button" && String(element.props.className || "").includes("downloads-collapse-button"));
packageRow.props.onClick({ ctrlKey: false, metaKey: false, shiftKey: false, target: {}, currentTarget: {} });
packageRow.props.onClick({ ctrlKey: false, detail: 1, metaKey: false, shiftKey: false, target: {}, currentTarget: {} });
packageRow.props.onClick({ ctrlKey: false, detail: 2, metaKey: false, shiftKey: false, target: {}, currentTarget: {} });
expect(packageRow.props.onDoubleClick).toBeTypeOf("function");
packageRow.props.onDoubleClick({
ctrlKey: false,
currentTarget: {},
metaKey: false,
preventDefault: () => calls.push("prevent"),
shiftKey: false,
target: { closest: () => null }
});
collapseButton.props.onClick({ stopPropagation: () => {} });
expect(calls).toEqual(["collapse"]);
expect(calls).toEqual(["select", "prevent", "collapse", "collapse"]);
expect(collapseButton.props["aria-expanded"]).toBe(true);
expect(collapseButton.props["aria-controls"]).toBe(`downloads-package-items-${row.package.id}`);
});
it("does not collapse a package when a row double click starts on interactive content", () => {
const calls: string[] = [];
const model = withRuntime(createInput());
const row = model.packageRows[0];
const component = PackageCardContent({
actions: createActions({
onStartPackageRename: () => calls.push("rename"),
onTogglePackageCollapse: () => calls.push("collapse"),
onToggleSelection: () => calls.push("select")
}),
columnOrder: model.columnOrder,
editing: false,
editingName: "",
gridTemplate: model.gridTemplate,
packageSpeedBps: 0,
row,
selectedIds: new Set<string>(),
selectedVersion: 1
});
const packageRow = findElement(component, (element) => element.props["data-download-row-id"] === row.package.id);
const title = findElement(component, (element) => element.type === "strong" && String(element.props.className || "").includes("downloads-copyable"));
packageRow.props.onClick({ ctrlKey: false, detail: 1, metaKey: false, shiftKey: false });
packageRow.props.onClick({ ctrlKey: false, detail: 2, metaKey: false, shiftKey: false });
expect(packageRow.props.onDoubleClick).toBeTypeOf("function");
packageRow.props.onDoubleClick({
ctrlKey: false,
currentTarget: {},
metaKey: false,
preventDefault: () => calls.push("prevent"),
shiftKey: false,
target: { closest: () => ({}) }
});
title.props.onDoubleClick({ stopPropagation: () => calls.push("stop") });
expect(calls).toEqual(["select", "stop", "rename"]);
});
it("keeps an already selected package selected during a row double click", () => {
const calls: string[] = [];
const model = withRuntime(createInput());
const row = model.packageRows[0];
const component = PackageCardContent({
actions: createActions({
onTogglePackageCollapse: () => calls.push("collapse"),
onToggleSelection: () => calls.push("select")
}),
columnOrder: model.columnOrder,
editing: false,
editingName: "",
gridTemplate: model.gridTemplate,
packageSpeedBps: 0,
row,
selectedIds: new Set([row.package.id]),
selectedVersion: 1
});
const packageRow = findElement(component, (element) => element.props["data-download-row-id"] === row.package.id);
packageRow.props.onClick({ ctrlKey: false, detail: 1, metaKey: false, shiftKey: false });
packageRow.props.onClick({ ctrlKey: false, detail: 2, metaKey: false, shiftKey: false });
packageRow.props.onDoubleClick({
ctrlKey: false,
metaKey: false,
preventDefault: () => calls.push("prevent"),
shiftKey: false,
target: { closest: () => null }
});
expect(calls).toEqual(["prevent", "collapse"]);
});
it("keeps the complete package status and audio details in the tooltip", () => {
const audioPackage = {
...pkg("audio-package", "Audio package", ["audio-item"]),
+1 -1
View File
@@ -46,7 +46,7 @@ describe("responsive shell mode", () => {
expect(html).toContain("md-shell is-full");
expect(css).toContain(".md-shell.is-compact");
expect(css).toContain(".md-shell.is-minimum");
expect(css).toMatch(/grid-template-columns:\s*56px minmax\(0,\s*1fr\)/);
expect(css).toMatch(/\.md-shell\.has-collapsed-sidebar \.md-shell-workspace\s*\{[^}]*grid-template-columns:\s*0 minmax\(0,\s*1fr\);/s);
});
it("keeps responsive rail content hidden behind a visible expand control", () => {