This is an automated email from the ASF dual-hosted git repository.
xuang7 pushed a commit to branch release/v1.2
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/release/v1.2 by this push:
new f7c67e8744 fix(frontend, v1.2): speed up dataset bulk upload with many
files (#6313)
f7c67e8744 is described below
commit f7c67e8744a558d0726c9d2c9f9ac8cd7427cd25
Author: Kunwoo (Chris) <[email protected]>
AuthorDate: Fri Jul 10 03:12:31 2026 -0400
fix(frontend, v1.2): speed up dataset bulk upload with many files (#6313)
### What changes were proposed in this PR?
Backport of #6306 to `release/v1.2` (cherry-pick of the four PR commits
`3fd0068`, `b47c5a6`, `ade89a2`, `8e28e13`; one conflict in
`frontend/src/jsdom-svg-polyfill.ts` — this branch carries an older
revision of that file — resolved by re-adding the `ResizeObserver` stub
in the branch's style).
This fixes bulk uploads of many files to a dataset being extremely slow
(~1,100 files took ~12 minutes) because the Pending and Finished lists
fully re-rendered on every change-detection pass on the same thread
driving the uploads. The backport carries the complete fix from #6306: a
Map-backed pending queue with a memoized `queuedFileNames` snapshot,
`cdk-virtual-scroll` + `trackBy` on the Pending and staged-changes
lists, an `auditTime`-coalesced staged-objects refetch (at most one
dataset-diff request per second instead of one per finished file) with
optimistic Finished-count updates so both headers move in real time,
viewport re-measure on collapse-panel expand, and two upload-slot-leak
fixes (`totalTime === 0` finishes and same-name re-uploads within the 5s
hide window). See #6306 for the review discussion and a screen recording
of a 1,100-file upload.
### Any related issues, documentation, discussions?
Backport of #6306, which fixes #5586 on `main`.
### How was this PR tested?
The two component spec files shipped with the fix were run on this
branch (`ng test --include
'**/user-dataset-explorer/dataset-detail.component.spec.ts' --include
'**/user-dataset-staged-objects-list.component.spec.ts'`): 26/26 passed.
`yarn format:ci` passes. On #6306, Codecov reports all modified lines
covered.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code, Claude Fable 5
---
.../dataset-detail.component.html | 30 +-
.../dataset-detail.component.scss | 22 +-
.../dataset-detail.component.spec.ts | 336 +++++++++++++++++++++
.../dataset-detail.component.ts | 113 +++++--
...user-dataset-staged-objects-list.component.html | 78 +++--
...user-dataset-staged-objects-list.component.scss | 17 +-
...r-dataset-staged-objects-list.component.spec.ts | 159 ++++++++++
.../user-dataset-staged-objects-list.component.ts | 42 ++-
frontend/src/jsdom-svg-polyfill.ts | 12 +
9 files changed, 735 insertions(+), 74 deletions(-)
diff --git
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html
index 75dbbaa545..023cd5e736 100644
---
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html
+++
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html
@@ -348,10 +348,26 @@
class="upload-status-panels">
<nz-collapse-panel
*ngIf="queuedCount > 0"
- [nzHeader]="'Pending: ' + queuedCount + ' file(s)'">
- <div class="upload-progress-wrapper-pending">
- <div *ngFor="let fileName of queuedFileNames">
- <span>{{ fileName }}</span>
+ [nzHeader]="'Pending: ' + queuedCount + ' file(s)'"
+ (nzActiveChange)="onPendingPanelActiveChange($event)">
+ <!-- Virtualized so only visible rows are in the DOM (#5586).
The viewport
+ measures height 0 while the panel is collapsed, so it is
re-measured
+ on expand. -->
+ <cdk-virtual-scroll-viewport
+ class="upload-progress-wrapper-pending"
+ [itemSize]="PENDING_ROW_HEIGHT_PX"
+ [minBufferPx]="PENDING_LIST_MAX_HEIGHT_PX"
+ [maxBufferPx]="2 * PENDING_LIST_MAX_HEIGHT_PX"
+ [style.height.px]="pendingListHeightPx">
+ <div
+ class="pending-file-row"
+ *cdkVirtualFor="let fileName of queuedFileNames; trackBy:
trackByPendingFile">
+ <span
+ class="pending-file-name"
+ nz-tooltip
+ [nzTooltipTitle]="fileName"
+ >{{ fileName }}</span
+ >
<button
nz-button
nzType="text"
@@ -365,7 +381,7 @@
nzTheme="outline"></span>
</button>
</div>
- </div>
+ </cdk-virtual-scroll-viewport>
</nz-collapse-panel>
<nz-divider
@@ -424,8 +440,10 @@
<nz-collapse-panel
*ngIf="hasAnyActivity"
- [nzHeader]="'Finished: ' + pendingChangesCount + ' file(s)'">
+ [nzHeader]="'Finished: ' + pendingChangesCount + ' file(s)'"
+ (nzActiveChange)="$event &&
stagedObjectsList.remeasureViewport()">
<texera-dataset-staged-objects-list
+ #stagedObjectsList
[uploadTimeMap]="uploadTimeMap"
[did]="did"
[userMakeChangesEvent]="userMakeChanges"
diff --git
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.scss
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.scss
index ac8b60b245..d5ef708f88 100644
---
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.scss
+++
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.scss
@@ -143,17 +143,29 @@ nz-select {
margin-top: 15%;
}
-.upload-progress-wrapper,
-.upload-progress-wrapper-pending {
+.upload-progress-wrapper {
max-height: 25vh;
overflow-y: auto;
padding-right: 4px;
}
+// Rows must stay exactly PENDING_ROW_HEIGHT_PX tall for the fixed-size scroll
+// strategy. The gutter lives on the rows: padding on the viewport never
+// reaches its absolutely positioned content wrapper.
.upload-progress-wrapper-pending {
- display: flex;
- flex-direction: column;
- max-height: 15vh;
+ .pending-file-row {
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding-right: 4px;
+
+ .pending-file-name {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+ }
}
.version-creator {
diff --git
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts
new file mode 100644
index 0000000000..a634473194
--- /dev/null
+++
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts
@@ -0,0 +1,336 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { ActivatedRoute } from "@angular/router";
+import { of, Subject } from "rxjs";
+import { NzModalService } from "ng-zorro-antd/modal";
+import { MarkdownService } from "ngx-markdown";
+import { DatasetDetailComponent } from "./dataset-detail.component";
+import { DatasetService, MultipartUploadProgress } from
"../../../../service/user/dataset/dataset.service";
+import { NotificationService } from
"../../../../../common/service/notification/notification.service";
+import { DownloadService } from
"../../../../service/user/download/download.service";
+import { UserService } from "../../../../../common/service/user/user.service";
+import { MOCK_USER, StubUserService } from
"../../../../../common/service/user/stub-user.service";
+import { HubService } from "../../../../../hub/service/hub.service";
+import { AdminSettingsService } from
"../../../../service/admin/settings/admin-settings.service";
+import { FileUploadItem } from "../../../../type/dashboard-file.interface";
+import { DatasetFileNode } from
"../../../../../common/type/datasetVersionFileTree";
+import { DatasetStagedObject } from
"../../../../../common/type/dataset-staged-object";
+import { commonTestImports, commonTestProviders } from
"../../../../../common/testing/test-utils";
+
+describe("DatasetDetailComponent upload queue", () => {
+ let fixture: ComponentFixture<DatasetDetailComponent>;
+ let component: DatasetDetailComponent;
+ let uploadSubjects: Subject<MultipartUploadProgress>[];
+ let uploadedPaths: string[];
+ let multipartUploadSpy: ReturnType<typeof vi.fn>;
+
+ const makeFileItem = (name: string): FileUploadItem => ({
+ file: new File(["x"], name),
+ name,
+ description: "",
+ uploadProgress: 0,
+ isUploadingFlag: false,
+ restart: false,
+ });
+
+ const dropFiles = (...names: string[]) =>
component.onNewUploadFilesChanged(names.map(makeFileItem));
+
+ const finishUpload = (index: number, filePath: string, totalTime = 1) =>
+ uploadSubjects[index].next({ filePath, percentage: 100, status:
"finished", totalTime });
+
+ beforeEach(() => {
+ uploadSubjects = [];
+ uploadedPaths = [];
+ multipartUploadSpy = vi.fn((_ownerEmail: string, _datasetName: string,
filePath: string) => {
+ const progress = new Subject<MultipartUploadProgress>();
+ uploadSubjects.push(progress);
+ uploadedPaths.push(filePath);
+ return progress.asObservable();
+ });
+
+ TestBed.configureTestingModule({
+ imports: [DatasetDetailComponent, ...commonTestImports],
+ providers: [
+ { provide: ActivatedRoute, useValue: { params: of({ did: 1 }), data:
of({}) } },
+ { provide: NzModalService, useValue: {} },
+ {
+ provide: DatasetService,
+ useValue: {
+ multipartUpload: multipartUploadSpy,
+ finalizeMultipartUpload: vi.fn(() => of({})),
+ getDataset: vi.fn(() =>
+ of({
+ dataset: { name: "test-dataset", description: "", isPublic:
false, isDownloadable: true },
+ accessPrivilege: "WRITE",
+ ownerEmail: "[email protected]",
+ isOwner: true,
+ })
+ ),
+ retrieveDatasetVersionList: vi.fn(() => of([])),
+ getDatasetDiff: vi.fn(() => of([])),
+ createDatasetVersion: vi.fn(() => of({})),
+ deleteDatasetFile: vi.fn(() => of({})),
+ },
+ },
+ { provide: NotificationService, useValue: { success: vi.fn(), error:
vi.fn(), info: vi.fn() } },
+ { provide: DownloadService, useValue: {} },
+ { provide: UserService, useClass: StubUserService },
+ {
+ provide: HubService,
+ useValue: {
+ getCounts: vi.fn(() => of([{ counts: { like: 0 } }])),
+ postView: vi.fn(() => of(0)),
+ isLiked: vi.fn(() => of([{ isLiked: false }])),
+ },
+ },
+ { provide: AdminSettingsService, useValue: { getSetting: vi.fn(() =>
of("3")) } },
+ { provide: MarkdownService, useValue: { parse: vi.fn(() => "") } },
+ ...commonTestProviders,
+ ],
+ });
+
+ fixture = TestBed.createComponent(DatasetDetailComponent);
+ component = fixture.componentInstance;
+ // Log in so ngOnInit reaches loadUploadSettings (maxConcurrentFiles = 3).
+ (TestBed.inject(UserService) as unknown as
StubUserService).userChangeSubject.next(MOCK_USER);
+ fixture.detectChanges();
+ });
+
+ it("starts at most maxConcurrentFiles uploads immediately and queues the
rest", () => {
+ dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt", "f5.txt");
+
+ expect(multipartUploadSpy).toHaveBeenCalledTimes(3);
+ expect(uploadedPaths).toEqual(["f1.txt", "f2.txt", "f3.txt"]);
+ expect(component.activeCount).toBe(3);
+ expect(component.queuedCount).toBe(2);
+ expect(component.queuedFileNames).toEqual(["f4.txt", "f5.txt"]);
+ });
+
+ it("does nothing when an empty file list is dropped", () => {
+ dropFiles();
+
+ expect(multipartUploadSpy).not.toHaveBeenCalled();
+ expect(component.activeCount).toBe(0);
+ expect(component.queuedCount).toBe(0);
+ expect(component.queuedFileNames).toEqual([]);
+ });
+
+ it("starts the next queued upload when an active upload finishes", () => {
+ dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt", "f5.txt");
+
+ finishUpload(0, "f1.txt");
+
+ expect(multipartUploadSpy).toHaveBeenCalledTimes(4);
+ expect(uploadedPaths[3]).toBe("f4.txt");
+ expect(component.activeCount).toBe(3);
+ expect(component.queuedCount).toBe(1);
+ expect(component.queuedFileNames).toEqual(["f5.txt"]);
+ });
+
+ it("removes a cancelled file from the pending queue without starting it", ()
=> {
+ dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt", "f5.txt");
+
+ component.cancelExistingUpload("f4.txt");
+
+ expect(multipartUploadSpy).toHaveBeenCalledTimes(3);
+ expect(component.queuedCount).toBe(1);
+ expect(component.queuedFileNames).toEqual(["f5.txt"]);
+ });
+
+ it("ignores cancellation of a file that is neither active nor queued", () =>
{
+ dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt");
+
+ component.cancelExistingUpload("missing.txt");
+
+ expect(component.activeCount).toBe(3);
+ expect(component.queuedCount).toBe(1);
+ expect(component.queuedFileNames).toEqual(["f4.txt"]);
+ });
+
+ // #5586: the template reads queuedFileNames on every change-detection pass,
+ // so it must not allocate a new array unless the queue changed.
+ it("keeps the same queuedFileNames array reference while the queue is
unchanged", () => {
+ dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt", "f5.txt");
+
+ const firstRead = component.queuedFileNames;
+
+ expect(component.queuedFileNames).toBe(firstRead);
+ });
+
+ it("exposes a new queuedFileNames array after the queue changes", () => {
+ dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt", "f5.txt");
+ const beforeCancel = component.queuedFileNames;
+
+ component.cancelExistingUpload("f4.txt");
+
+ expect(component.queuedFileNames).not.toBe(beforeCancel);
+ expect(component.queuedFileNames).toEqual(["f5.txt"]);
+ });
+
+ it("identifies pending queue entries by file name in trackByPendingFile", ()
=> {
+ expect(component.trackByPendingFile(0, "dir/a.txt")).toBe("dir/a.txt");
+ });
+
+ // A resumed upload with no missing parts finishes with totalTime exactly 0;
+ // the slot must still be released.
+ it("releases the concurrency slot when a finished upload reports totalTime
0", () => {
+ dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt");
+
+ finishUpload(0, "f1.txt", 0);
+
+ expect(multipartUploadSpy).toHaveBeenCalledTimes(4);
+ expect(uploadedPaths[3]).toBe("f4.txt");
+ expect(component.activeCount).toBe(3);
+ expect(component.queuedCount).toBe(0);
+ });
+
+ // The Pending header updates per file, so the Finished header must too — it
+ // cannot wait for the throttled staged-objects refetch.
+ it("updates the Finished count immediately when uploads finish", () => {
+ dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt");
+ expect(component.pendingChangesCount).toBe(0);
+
+ finishUpload(0, "f1.txt");
+ expect(component.pendingChangesCount).toBe(1);
+
+ finishUpload(1, "f2.txt");
+ expect(component.pendingChangesCount).toBe(2);
+ });
+
+ it("reconciles the optimistic Finished count with a diff response", () => {
+ dropFiles("f1.txt", "f2.txt", "f3.txt");
+ finishUpload(0, "f1.txt");
+ finishUpload(1, "f2.txt");
+
+ const diff: DatasetStagedObject[] = [{ path: "f1.txt", pathType: "file",
diffType: "added", sizeBytes: 1 }];
+ component.onStagedObjectsUpdated(diff);
+
+ // f1 is confirmed by the response; f2 stays counted until a response
includes it.
+ expect(component.pendingChangesCount).toBe(2);
+
+ component.onStagedObjectsUpdated([...diff, { path: "f2.txt", pathType:
"file", diffType: "added", sizeBytes: 1 }]);
+ expect(component.pendingChangesCount).toBe(2);
+ });
+
+ it("keeps an in-progress upload's slot while progress events stream in", ()
=> {
+ dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt");
+
+ uploadSubjects[0].next({ filePath: "f1.txt", percentage: 50, status:
"uploading" });
+
+ expect(component.uploadTasks.find(t => t.filePath ===
"f1.txt")?.percentage).toBe(50);
+ expect(component.activeCount).toBe(3);
+ expect(component.queuedCount).toBe(1);
+ });
+
+ it("does not double-count a finished upload already confirmed by a diff
response", () => {
+ dropFiles("f1.txt");
+ finishUpload(0, "f1.txt");
+ component.onStagedObjectsUpdated([{ path: "f1.txt", pathType: "file",
diffType: "added", sizeBytes: 1 }]);
+ expect(component.pendingChangesCount).toBe(1);
+
+ dropFiles("f1.txt"); // re-upload the already-staged file
+ finishUpload(1, "f1.txt");
+
+ expect(component.pendingChangesCount).toBe(1);
+ });
+
+ it("does not start queued uploads beyond a lowered concurrency limit", () =>
{
+ dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt");
+ component.maxConcurrentFiles = 1;
+
+ finishUpload(0, "f1.txt");
+
+ expect(component.activeCount).toBe(2);
+ expect(component.queuedCount).toBe(1);
+ expect(multipartUploadSpy).toHaveBeenCalledTimes(3);
+ });
+
+ it("clears the Finished count when a version is created", () => {
+ dropFiles("f1.txt");
+ finishUpload(0, "f1.txt");
+ expect(component.pendingChangesCount).toBe(1);
+
+ component.versionName = "v1";
+ component.onClickOpenVersionCreator();
+
+ expect(component.pendingChangesCount).toBe(0);
+ });
+
+ it("does not remove a re-uploaded file's active task when hiding its
finished predecessor", () => {
+ vi.useFakeTimers();
+ try {
+ dropFiles("a.txt");
+ finishUpload(0, "a.txt"); // schedules the finished row to hide in 5s
+
+ dropFiles("a.txt"); // re-upload the same name within the 5s window
+ vi.advanceTimersByTime(5000);
+
+ expect(component.uploadTasks).toHaveLength(1);
+ expect(component.uploadTasks[0].status).not.toBe("finished");
+ expect(component.activeCount).toBe(1);
+
+ finishUpload(1, "a.txt");
+ expect(component.activeCount).toBe(0);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("renders the virtualized pending list and re-measures viewports on panel
expand", async () => {
+ dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt", "f5.txt");
+ fixture.detectChanges();
+ // Flush the viewport's init microtask, then render the rows.
+ await Promise.resolve();
+ fixture.detectChanges();
+
+ expect(component.pendingListHeightPx).toBe(2 *
component.PENDING_ROW_HEIGHT_PX);
+ const rows = fixture.nativeElement.querySelectorAll(".pending-file-row");
+ expect(rows.length).toBe(2);
+
+ // Expand the Pending / Uploading / Finished panels.
+ const headers: NodeListOf<HTMLElement> =
fixture.nativeElement.querySelectorAll(
+ ".upload-status-panels .ant-collapse-header"
+ );
+ expect(headers.length).toBe(3);
+ headers.forEach(header => header.click());
+ fixture.detectChanges();
+ // Flush the checkViewportSize timers.
+ await new Promise(resolve => setTimeout(resolve));
+
+ // Collapsing again must be a no-op for the re-measure handler.
+ headers.forEach(header => header.click());
+ fixture.detectChanges();
+
+ // Cancel a queued file from its row.
+ const cancelButton =
fixture.nativeElement.querySelector(".pending-file-row button") as
HTMLButtonElement;
+ cancelButton.click();
+ expect(component.queuedCount).toBe(1);
+ expect(component.queuedFileNames).toEqual(["f5.txt"]);
+ });
+
+ it("counts a staged file deletion immediately", () => {
+ const node: DatasetFileNode = { name: "a.txt", type: "file", parentDir:
"/[email protected]/test-dataset/v1" };
+
+ component.onPreviouslyUploadedFileDeleted(node);
+
+ expect(component.pendingChangesCount).toBe(1);
+ });
+});
diff --git
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts
index 2287050ed5..c3b5c9a80f 100644
---
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts
+++
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts
@@ -17,7 +17,7 @@
* under the License.
*/
-import { Component, EventEmitter, OnInit, Output } from "@angular/core";
+import { Component, EventEmitter, OnInit, Output, ViewChild } from
"@angular/core";
import { ActivatedRoute } from "@angular/router";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
import { DatasetService, MultipartUploadProgress } from
"../../../../service/user/dataset/dataset.service";
@@ -67,6 +67,7 @@ import { FilesUploaderComponent } from
"../../files-uploader/files-uploader.comp
import { NzProgressComponent } from "ng-zorro-antd/progress";
import { UserDatasetStagedObjectsListComponent } from
"./user-dataset-staged-objects-list/user-dataset-staged-objects-list.component";
import { NzInputDirective } from "ng-zorro-antd/input";
+import { CdkFixedSizeVirtualScroll, CdkVirtualForOf, CdkVirtualScrollViewport
} from "@angular/cdk/scrolling";
export const THROTTLE_TIME_MS = 1000;
export const ABORT_RETRY_MAX_ATTEMPTS = 10;
@@ -110,6 +111,9 @@ export const ABORT_RETRY_BACKOFF_BASE_MS = 100;
NzProgressComponent,
UserDatasetStagedObjectsListComponent,
NzInputDirective,
+ CdkVirtualScrollViewport,
+ CdkFixedSizeVirtualScroll,
+ CdkVirtualForOf,
],
})
export class DatasetDetailComponent implements OnInit {
@@ -148,6 +152,11 @@ export class DatasetDetailComponent implements OnInit {
userHasPendingChanges: boolean = false;
pendingChangesCount: number = 0;
+ // Staged paths from the last diff response, plus locally staged paths not
yet
+ // in one: counted together so the Finished header keeps pace with the
+ // real-time Pending header between throttled refetches.
+ private confirmedStagedPaths = new Set<string>();
+ private unconfirmedStagedPaths = new Set<string>();
// Uploading setting
chunkSizeMiB: number = 50;
@@ -158,7 +167,16 @@ export class DatasetDetailComponent implements OnInit {
// Cap number of concurrent files uploads
maxConcurrentFiles: number = 3;
private activeUploads: number = 0;
- private pendingQueue: Array<{ fileName: string; startUpload: () => void }> =
[];
+ // FIFO queue of uploads waiting for a concurrency slot, keyed by file name.
+ private pendingQueue = new Map<string, () => void>();
+ private pendingQueueDirty = false;
+ private queuedFileNamesSnapshot: string[] = [];
+
+ // Row height must match .pending-file-row in the SCSS.
+ readonly PENDING_ROW_HEIGHT_PX = 32;
+ readonly PENDING_LIST_MAX_HEIGHT_PX = 160;
+
+ @ViewChild(CdkVirtualScrollViewport) private pendingViewport?:
CdkVirtualScrollViewport;
versionName: string = "";
isCreatingVersion: boolean = false;
@@ -263,6 +281,10 @@ export class DatasetDetailComponent implements OnInit {
this.notificationService.success("Version Created");
this.isCreatingVersion = false;
this.versionName = "";
+ // A new version consumes all staged changes.
+ this.confirmedStagedPaths.clear();
+ this.unconfirmedStagedPaths.clear();
+ this.refreshPendingChanges();
this.retrieveDatasetVersionList();
this.userMakeChanges.emit();
},
@@ -411,8 +433,25 @@ export class DatasetDetailComponent implements OnInit {
}
onStagedObjectsUpdated(stagedObjects: DatasetStagedObject[]) {
- this.userHasPendingChanges = stagedObjects.length > 0;
- this.pendingChangesCount = stagedObjects.length;
+ this.confirmedStagedPaths = new Set(stagedObjects.map(obj => obj.path));
+ for (const path of this.confirmedStagedPaths) {
+ this.unconfirmedStagedPaths.delete(path);
+ }
+ this.refreshPendingChanges();
+ }
+
+ // Reflects a locally staged change (finished upload or file deletion) in the
+ // Finished header immediately, ahead of the next diff response.
+ private markPathStaged(path: string): void {
+ if (!this.confirmedStagedPaths.has(path)) {
+ this.unconfirmedStagedPaths.add(path);
+ }
+ this.refreshPendingChanges();
+ }
+
+ private refreshPendingChanges(): void {
+ this.pendingChangesCount = this.confirmedStagedPaths.size +
this.unconfirmedStagedPaths.size;
+ this.userHasPendingChanges = this.pendingChangesCount > 0;
}
onVersionSelected(version: DatasetVersion): void {
@@ -460,6 +499,10 @@ export class DatasetDetailComponent implements OnInit {
return task.filePath;
}
+ trackByPendingFile(_: number, fileName: string): string {
+ return fileName;
+ }
+
private loadUploadSettings(): void {
this.adminSettingsService
.getSetting("multipart_upload_chunk_size_mib")
@@ -484,7 +527,7 @@ export class DatasetDetailComponent implements OnInit {
const continueWithUpload = () => {
// Create upload function
const startUpload = () => {
- this.pendingQueue = this.pendingQueue.filter(item => item.fileName
!== file.name);
+ this.removeFromPendingQueue(file.name);
// Add an initializing task placeholder to uploadTasks
this.uploadTasks.unshift({
@@ -517,10 +560,12 @@ export class DatasetDetailComponent implements OnInit {
percentage: progress.percentage ??
this.uploadTasks[taskIndex].percentage ?? 0,
};
- // Auto-hide when upload is truly finished
- if (progress.status === "finished" && progress.totalTime) {
+ // totalTime may be exactly 0 (resumed upload with no
missing
+ // parts); a truthiness check would leak the concurrency
slot.
+ if (progress.status === "finished" && progress.totalTime
!== undefined) {
const filename = file.name.split("/").pop() || file.name;
this.uploadTimeMap.set(filename, progress.totalTime);
+ this.markPathStaged(file.name);
this.userMakeChanges.emit();
this.scheduleHide(taskIndex);
this.onUploadComplete();
@@ -554,6 +599,7 @@ export class DatasetDetailComponent implements OnInit {
const taskIndex = this.uploadTasks.findIndex(t => t.filePath
=== file.name);
if (taskIndex !== -1 && this.uploadTasks[taskIndex].status
!== "finished") {
this.uploadTasks[taskIndex].status = "finished";
+ this.markPathStaged(file.name);
this.userMakeChanges.emit();
this.scheduleHide(taskIndex);
this.onUploadComplete();
@@ -569,7 +615,8 @@ export class DatasetDetailComponent implements OnInit {
this.activeUploads++;
startUpload();
} else {
- this.pendingQueue.push({ fileName: file.name, startUpload });
+ this.pendingQueue.set(file.name, startUpload);
+ this.pendingQueueDirty = true;
}
};
@@ -588,18 +635,21 @@ export class DatasetDetailComponent implements OnInit {
}
}
// Remove from pending queue if present
- this.pendingQueue = this.pendingQueue.filter(item => item.fileName !==
fileName);
+ this.removeFromPendingQueue(fileName);
if (onCanceled) {
onCanceled();
}
}
private processNextQueuedUpload(): void {
- if (this.pendingQueue.length > 0 && this.activeUploads <
this.maxConcurrentFiles) {
- const next = this.pendingQueue.shift();
- if (next) {
+ if (this.activeUploads < this.maxConcurrentFiles) {
+ const next = this.pendingQueue.entries().next();
+ if (!next.done) {
+ const [fileName, startUpload] = next.value;
+ this.pendingQueue.delete(fileName);
+ this.pendingQueueDirty = true;
this.activeUploads++;
- next.startUpload();
+ startUpload();
}
}
}
@@ -609,12 +659,36 @@ export class DatasetDetailComponent implements OnInit {
this.processNextQueuedUpload();
}
+ private removeFromPendingQueue(fileName: string): void {
+ if (this.pendingQueue.delete(fileName)) {
+ this.pendingQueueDirty = true;
+ }
+ }
+
+ // Stable array for the template: rebuilt at most once per queue change so
+ // change detection does not allocate a new array per pass (#5586).
get queuedFileNames(): string[] {
- return this.pendingQueue.map(item => item.fileName);
+ if (this.pendingQueueDirty) {
+ this.queuedFileNamesSnapshot = Array.from(this.pendingQueue.keys());
+ this.pendingQueueDirty = false;
+ }
+ return this.queuedFileNamesSnapshot;
}
get queuedCount(): number {
- return this.pendingQueue.length;
+ return this.pendingQueue.size;
+ }
+
+ get pendingListHeightPx(): number {
+ return Math.min(this.queuedCount * this.PENDING_ROW_HEIGHT_PX,
this.PENDING_LIST_MAX_HEIGHT_PX);
+ }
+
+ // The viewport initializes inside the collapsed (display: none) panel and
+ // measures height 0; the CDK only re-measures on window resize.
+ onPendingPanelActiveChange(active: boolean): void {
+ if (active) {
+ setTimeout(() => this.pendingViewport?.checkViewportSize());
+ }
}
get activeCount(): number {
@@ -630,10 +704,12 @@ export class DatasetDetailComponent implements OnInit {
if (idx === -1) {
return;
}
- const key = this.uploadTasks[idx].filePath;
- this.uploadSubscriptions.delete(key);
+ const task = this.uploadTasks[idx];
+ this.uploadSubscriptions.delete(task.filePath);
+ // Remove by identity, not filePath: a same-named re-upload within the
+ // window has its own row, which must survive this timer.
setTimeout(() => {
- this.uploadTasks = this.uploadTasks.filter(t => t.filePath !== key);
+ this.uploadTasks = this.uploadTasks.filter(t => t !== task);
}, 5000);
}
@@ -720,6 +796,7 @@ export class DatasetDetailComponent implements OnInit {
this.notificationService.success(
`File ${node.name} is successfully deleted. You may finalize it
or revert it at the "Create Version" panel`
);
+ this.markPathStaged(getRelativePathFromDatasetFileNode(node));
this.userMakeChanges.emit();
},
error: (err: unknown) => {
diff --git
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.html
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.html
index 01d21074ea..a6785abd50 100644
---
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.html
+++
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.html
@@ -17,40 +17,52 @@
under the License.
-->
-<div class="staged-object-list-container">
- <nz-list
- nzSize="small"
+<div
+ class="staged-object-list-container"
+ [class.has-list]="datasetStagedObjects.length > 0">
+ <!-- Virtualized so only visible rows are in the DOM (#5586). The viewport
can
+ initialize hidden and measure height 0; the host re-measures it on panel
+ expand (remeasureViewport). -->
+ <cdk-virtual-scroll-viewport
*ngIf="datasetStagedObjects.length > 0"
- class="custom-border-list">
- <nz-list-item *ngFor="let obj of datasetStagedObjects">
- <span>
- <nz-tag [nzColor]="obj.diffType === 'added' ? 'green' : 'red'"> {{
obj.diffType }} </nz-tag>
- </span>
- <span
- class="truncate-file-path"
- nz-tooltip
- [nzTooltipTitle]="fileTooltipTpl">
- {{ obj.path }}
- </span>
- <ng-template #fileTooltipTpl>
- <div>{{ obj.path }}</div>
- <div *ngIf="getFileUploadTime(obj.path) as uploadTime">Upload time: {{
formatTime(uploadTime) }}</div>
- </ng-template>
- <!-- Small delete button with tooltip -->
- <button
- nz-button
- nzType="link"
- class="delete-button"
- nz-tooltip
- [nzTooltipTitle]="'Revert the change'"
- (click)="onObjectReverted(obj)">
- <i
- nz-icon
- nzType="delete"
- nzTheme="outline"></i>
- </button>
- </nz-list-item>
- </nz-list>
+ class="staged-object-viewport"
+ [itemSize]="STAGED_ROW_HEIGHT_PX"
+ [minBufferPx]="STAGED_LIST_MAX_HEIGHT_PX"
+ [maxBufferPx]="2 * STAGED_LIST_MAX_HEIGHT_PX"
+ [style.height.px]="stagedListHeightPx">
+ <nz-list nzSize="small">
+ <nz-list-item
+ class="staged-object-row"
+ *cdkVirtualFor="let obj of datasetStagedObjects; trackBy:
trackByStagedObject">
+ <span>
+ <nz-tag [nzColor]="obj.diffType === 'added' ? 'green' : 'red'"> {{
obj.diffType }} </nz-tag>
+ </span>
+ <span
+ class="truncate-file-path"
+ nz-tooltip
+ [nzTooltipTitle]="fileTooltipTpl">
+ {{ obj.path }}
+ </span>
+ <ng-template #fileTooltipTpl>
+ <div>{{ obj.path }}</div>
+ <div *ngIf="getFileUploadTime(obj.path) as uploadTime">Upload time:
{{ formatTime(uploadTime) }}</div>
+ </ng-template>
+ <!-- Small delete button with tooltip -->
+ <button
+ nz-button
+ nzType="link"
+ class="delete-button"
+ nz-tooltip
+ [nzTooltipTitle]="'Revert the change'"
+ (click)="onObjectReverted(obj)">
+ <i
+ nz-icon
+ nzType="delete"
+ nzTheme="outline"></i>
+ </button>
+ </nz-list-item>
+ </nz-list>
+ </cdk-virtual-scroll-viewport>
<nz-empty
*ngIf="datasetStagedObjects.length === 0"
diff --git
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.scss
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.scss
index 493f1abaa3..fd7336b0e9 100644
---
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.scss
+++
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.scss
@@ -17,15 +17,20 @@
* under the License.
*/
-/* Styles for the file tree container */
+/* The bottom border sits outside the viewport: inside it would ride the
+ scrolled content and overflow the exactly row-sized viewport by 1px. */
.staged-object-list-container {
- max-height: 200px; /* Adjust the max-height as needed */
- overflow-y: auto; /* Enables vertical scrolling when content exceeds
max-height */
- overflow-x: auto; /* Prevents horizontal scrolling */
+ overflow: hidden;
+
+ &.has-list {
+ border-bottom: 1px solid #f0f0f0;
+ }
}
-.custom-border-list {
- border-bottom: 1px solid #f0f0f0;
+/* Must stay exactly STAGED_ROW_HEIGHT_PX tall for the fixed-size scroll
+ strategy. */
+.staged-object-row {
+ height: 40px;
}
.truncate-file-path {
diff --git
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.spec.ts
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.spec.ts
new file mode 100644
index 0000000000..d00ca78a71
--- /dev/null
+++
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.spec.ts
@@ -0,0 +1,159 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { EventEmitter } from "@angular/core";
+import { By } from "@angular/platform-browser";
+import { CdkVirtualScrollViewport } from "@angular/cdk/scrolling";
+import { NzTooltipDirective } from "ng-zorro-antd/tooltip";
+import { of } from "rxjs";
+import { UserDatasetStagedObjectsListComponent } from
"./user-dataset-staged-objects-list.component";
+import { DatasetService } from
"../../../../../service/user/dataset/dataset.service";
+import { NotificationService } from
"../../../../../../common/service/notification/notification.service";
+import { DatasetStagedObject } from
"../../../../../../common/type/dataset-staged-object";
+import { commonTestImports, commonTestProviders } from
"../../../../../../common/testing/test-utils";
+
+describe("UserDatasetStagedObjectsListComponent", () => {
+ let fixture: ComponentFixture<UserDatasetStagedObjectsListComponent>;
+ let component: UserDatasetStagedObjectsListComponent;
+ let getDatasetDiffSpy: ReturnType<typeof vi.fn>;
+ let resetDatasetFileDiffSpy: ReturnType<typeof vi.fn>;
+
+ const renderList = async () => {
+ component.did = 1;
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+ };
+
+ const stagedObjects: DatasetStagedObject[] = [
+ { path: "dir/a.txt", pathType: "file", diffType: "added", sizeBytes: 1 },
+ { path: "dir/b.txt", pathType: "file", diffType: "removed" },
+ ];
+
+ beforeEach(() => {
+ getDatasetDiffSpy = vi.fn(() => of(stagedObjects));
+ resetDatasetFileDiffSpy = vi.fn(() => of({}));
+
+ TestBed.configureTestingModule({
+ imports: [UserDatasetStagedObjectsListComponent, ...commonTestImports],
+ providers: [
+ {
+ provide: DatasetService,
+ useValue: { getDatasetDiff: getDatasetDiffSpy, resetDatasetFileDiff:
resetDatasetFileDiffSpy },
+ },
+ { provide: NotificationService, useValue: { success: vi.fn(), error:
vi.fn() } },
+ ...commonTestProviders,
+ ],
+ });
+
+ fixture = TestBed.createComponent(UserDatasetStagedObjectsListComponent);
+ component = fixture.componentInstance;
+ });
+
+ it("fetches staged objects on init and emits them", () => {
+ component.did = 1;
+ const emitted: DatasetStagedObject[][] = [];
+ component.stagedObjectsChanged.subscribe((objects: DatasetStagedObject[])
=> emitted.push(objects));
+
+ component.ngOnInit();
+
+ expect(getDatasetDiffSpy).toHaveBeenCalledWith(1);
+ expect(component.datasetStagedObjects).toEqual(stagedObjects);
+ expect(emitted).toEqual([stagedObjects]);
+ });
+
+ it("does not fetch staged objects when did is undefined", () => {
+ component.did = undefined;
+
+ component.ngOnInit();
+
+ expect(getDatasetDiffSpy).not.toHaveBeenCalled();
+ expect(component.datasetStagedObjects).toEqual([]);
+ });
+
+ // #5586: one change event per finished file must not mean one dataset-diff
+ // request per file.
+ it("coalesces bursts of change events into one refetch per audit window", ()
=> {
+ vi.useFakeTimers();
+ try {
+ component.did = 1;
+ const changes = new EventEmitter<void>();
+ component.userMakeChangesEvent = changes;
+
+ for (let i = 0; i < 50; i++) {
+ changes.emit();
+ }
+ expect(getDatasetDiffSpy).not.toHaveBeenCalled();
+
+
vi.advanceTimersByTime(UserDatasetStagedObjectsListComponent.REFRESH_AUDIT_TIME_MS);
+ expect(getDatasetDiffSpy).toHaveBeenCalledTimes(1);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("identifies staged objects by diff type and path in trackByStagedObject",
() => {
+ expect(component.trackByStagedObject(0,
stagedObjects[0])).toBe("added:dir/a.txt");
+ expect(component.trackByStagedObject(1,
stagedObjects[1])).toBe("removed:dir/b.txt");
+ });
+
+ it("renders staged object rows inside the virtual scroll viewport", async ()
=> {
+ await renderList();
+
+ const viewport =
fixture.nativeElement.querySelector("cdk-virtual-scroll-viewport");
+ expect(viewport).not.toBeNull();
+ const rows =
fixture.nativeElement.querySelectorAll("nz-list-item.staged-object-row");
+ expect(rows.length).toBe(stagedObjects.length);
+ expect(rows[0].textContent).toContain("dir/a.txt");
+ expect(rows[1].textContent).toContain("dir/b.txt");
+ });
+
+ it("reverts a staged object from its row's delete button", async () => {
+ await renderList();
+
+ (fixture.nativeElement.querySelector(".delete-button") as
HTMLButtonElement).click();
+
+ expect(resetDatasetFileDiffSpy).toHaveBeenCalledWith(1, "dir/a.txt");
+ expect(getDatasetDiffSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it("shows the full path and upload time in the row tooltip", async () => {
+ component.uploadTimeMap = new Map([["a.txt", 5]]);
+ await renderList();
+
+
fixture.debugElement.query(By.css(".truncate-file-path")).injector.get(NzTooltipDirective).show();
+ fixture.detectChanges();
+
+ const overlayText =
document.querySelector(".cdk-overlay-container")?.textContent ?? "";
+ expect(overlayText).toContain("dir/a.txt");
+ expect(overlayText).toContain("Upload time");
+ });
+
+ it("re-measures the viewport on request", async () => {
+ await renderList();
+ const viewport =
fixture.debugElement.query(By.directive(CdkVirtualScrollViewport)).componentInstance;
+ const checkViewportSizeSpy = vi.spyOn(viewport, "checkViewportSize");
+
+ component.remeasureViewport();
+ await new Promise(resolve => setTimeout(resolve));
+
+ expect(checkViewportSizeSpy).toHaveBeenCalled();
+ });
+});
diff --git
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.ts
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.ts
index 79a632f9c4..f92ba316f0 100644
---
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.ts
+++
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.ts
@@ -17,13 +17,14 @@
* under the License.
*/
-import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
+import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from
"@angular/core";
+import { auditTime } from "rxjs/operators";
import { DatasetStagedObject } from
"../../../../../../common/type/dataset-staged-object";
import { DatasetService } from
"../../../../../service/user/dataset/dataset.service";
import { NotificationService } from
"../../../../../../common/service/notification/notification.service";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
import { formatTime } from "src/app/common/util/format.util";
-import { NgIf, NgFor } from "@angular/common";
+import { NgIf } from "@angular/common";
import { NzListComponent, NzListItemComponent } from "ng-zorro-antd/list";
import { NzTagComponent } from "ng-zorro-antd/tag";
import { NzTooltipDirective } from "ng-zorro-antd/tooltip";
@@ -32,6 +33,7 @@ import { NzButtonComponent } from "ng-zorro-antd/button";
import { ɵNzTransitionPatchDirective } from
"ng-zorro-antd/core/transition-patch";
import { NzIconDirective } from "ng-zorro-antd/icon";
import { NzEmptyComponent } from "ng-zorro-antd/empty";
+import { CdkFixedSizeVirtualScroll, CdkVirtualForOf, CdkVirtualScrollViewport
} from "@angular/cdk/scrolling";
@UntilDestroy()
@Component({
@@ -41,8 +43,10 @@ import { NzEmptyComponent } from "ng-zorro-antd/empty";
imports: [
NgIf,
NzListComponent,
- NgFor,
NzListItemComponent,
+ CdkVirtualScrollViewport,
+ CdkFixedSizeVirtualScroll,
+ CdkVirtualForOf,
NzTagComponent,
NzTooltipDirective,
NzSpaceCompactItemDirective,
@@ -53,12 +57,18 @@ import { NzEmptyComponent } from "ng-zorro-antd/empty";
],
})
export class UserDatasetStagedObjectsListComponent implements OnInit {
+ // Coalesces change events so a bulk upload refetches the diff at most once
+ // per window, not once per finished file.
+ static readonly REFRESH_AUDIT_TIME_MS = 1000;
+
@Input() did?: number; // Dataset ID
@Input() set userMakeChangesEvent(event: EventEmitter<void>) {
if (event) {
- event.pipe(untilDestroyed(this)).subscribe(() => {
- this.fetchDatasetStagedObjects();
- });
+ event
+
.pipe(auditTime(UserDatasetStagedObjectsListComponent.REFRESH_AUDIT_TIME_MS),
untilDestroyed(this))
+ .subscribe(() => {
+ this.fetchDatasetStagedObjects();
+ });
}
}
@Input() uploadTimeMap?: Map<string, number>;
@@ -68,6 +78,22 @@ export class UserDatasetStagedObjectsListComponent
implements OnInit {
datasetStagedObjects: DatasetStagedObject[] = [];
formatTime = formatTime;
+ // Row height must match .staged-object-row in the SCSS.
+ readonly STAGED_ROW_HEIGHT_PX = 40;
+ readonly STAGED_LIST_MAX_HEIGHT_PX = 200;
+
+ @ViewChild(CdkVirtualScrollViewport) private viewport?:
CdkVirtualScrollViewport;
+
+ get stagedListHeightPx(): number {
+ return Math.min(this.datasetStagedObjects.length *
this.STAGED_ROW_HEIGHT_PX, this.STAGED_LIST_MAX_HEIGHT_PX);
+ }
+
+ // The viewport measures height 0 when created inside a hidden ancestor
+ // (e.g. a collapsed panel); hosts call this once the list is visible.
+ remeasureViewport(): void {
+ setTimeout(() => this.viewport?.checkViewportSize());
+ }
+
constructor(
private datasetService: DatasetService,
private notificationService: NotificationService
@@ -113,4 +139,8 @@ export class UserDatasetStagedObjectsListComponent
implements OnInit {
const filename = filePath.split("/").pop() || filePath;
return this.uploadTimeMap.get(filename) || null;
}
+
+ trackByStagedObject(_: number, obj: DatasetStagedObject): string {
+ return `${obj.diffType}:${obj.path}`;
+ }
}
diff --git a/frontend/src/jsdom-svg-polyfill.ts
b/frontend/src/jsdom-svg-polyfill.ts
index 34bccf59ce..92f1b86eee 100644
--- a/frontend/src/jsdom-svg-polyfill.ts
+++ b/frontend/src/jsdom-svg-polyfill.ts
@@ -124,6 +124,18 @@ if (typeof idleGlobal.cancelIdleCallback !== "function") {
idleGlobal.cancelIdleCallback = ((id: number) => clearTimeout(id)) as AnyFn;
}
+/**
+ * jsdom doesn't implement `ResizeObserver`; components that watch their own
+ * size (e.g. markdown-description) construct one on render. An inert stub is
+ * enough: jsdom has no layout, so there is never a resize to report.
+ */
+const observerGlobal = globalThis as unknown as { ResizeObserver?: unknown };
+observerGlobal.ResizeObserver ??= class {
+ observe(): void {}
+ unobserve(): void {}
+ disconnect(): void {}
+};
+
/**
* y-websocket schedules a reconnect timer the moment a service that uses
* collaborative editing is constructed. When that timer fires AFTER vitest