This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new 8bfe034af1 refactor(frontend): extract shared VersionsFilesBrowser
component (#8942)
8bfe034af1 is described below
commit 8bfe034af17f88e8396b4961714658a48f79cff2
Author: Matthew B. <[email protected]>
AuthorDate: Sat Oct 10 08:50:21 2026 +0000
refactor(frontend): extract shared VersionsFilesBrowser component (#8942)
### What changes were proposed in this PR?
- Extracted the duplicated "Versions & Files" tab from `dataset-detail`
and `model-detail` into one shared presentational component,
`texera-versions-files-browser` (template, SCSS, toolbar view-state,
sider resize, and copy-path logic).
- Both detail pages now render `<texera-versions-files-browser>` with
inputs/outputs and keep their own data loading and version-selection
logic.
- Kept `isMaximized` and `isRightBarCollapsed` parent-owned as two-way
bindings, since `isMaximized` also gates the page header outside the
tab; the child reports toggles rather than owning the flags.
- Moved the view-state unit tests (sider resize, animation-frame
scheduling, copy-path, toggle emits) into the new component's spec;
parent specs keep their integration coverage by rendering the real
child.
### Any related issues, documentation, discussions?
Closes: #8941
### How was this PR tested?
- Run `cd frontend && npx ng test
--include='**/versions-files-browser.component.spec.ts'
--include='**/dataset-detail.component.spec.ts'
--include='**/model-detail.component.spec.ts'`, expect all 231 tests to
pass.
- Open a dataset detail page, switch to the "Versions & Files" tab,
confirm the version picker, file tree, file renderer, resize handle,
maximize/minimize, hide/show right bar, copy-path, and zip download all
behave as before.
- Repeat on a model detail page and confirm identical behavior, and
confirm the model Settings tab (which still uses nz-select) is
unaffected.
### Was this PR authored or co-authored using generative AI tooling?
Co-authored with Claude Opus 4.8 in compliance with ASF
π€ Generated with [Claude Code](https://claude.com/claude-code)
---
.../dataset-detail.component.html | 242 +++------------------
.../dataset-detail.component.spec.ts | 126 -----------
.../dataset-detail.component.ts | 64 +-----
.../model-detail.component.html | 242 +++------------------
.../model-detail.component.spec.ts | 63 +-----
.../user-model-explorer/model-detail.component.ts | 61 +-----
.../versions-files-browser.component.html | 231 ++++++++++++++++++++
.../versions-files-browser.component.scss | 136 ++++++++++++
.../versions-files-browser.component.spec.ts | 182 ++++++++++++++++
.../versions-files-browser.component.ts | 177 +++++++++++++++
10 files changed, 806 insertions(+), 718 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 acaf1ec5e8..7ef75951fa 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
@@ -251,218 +251,36 @@
</div>
</nz-tab>
<nz-tab nzTitle="Versions & Files">
- <div class="versions-tab-content">
- <nz-layout>
- <nz-content
- [ngClass]="{'grayed-out': false, 'disabled-click': false}"
- style="background-color: white">
- <nz-card>
- <div style="display: flex; justify-content: space-between;
align-items: center">
- <div class="file-info">
- <h3 class="file-title">
- <span class="file-title-main">
- <b>{{ currentDisplayedFileName }}</b>
-
- <button
- nz-button
- nzType="text"
- nzSize="small"
- class="copy-path-btn"
- nz-tooltip
- nzTooltipTitle="Copy file path"
- *ngIf="currentDisplayedFileName"
- (click)="copyCurrentFilePath()">
- <i
- nz-icon
- nzType="copy"
- nzTheme="twotone">
- </i>
- </button>
- </span>
-
- <span
- *ngIf="currentFileSize"
- class="file-size">
- <i
- nz-icon
- nzType="file"
- nzTheme="outline"
- class="icon-file"></i>
- {{ formatSize(currentFileSize) }}
- </span>
- </h3>
- </div>
- <div style="display: flex">
- <button
- nz-button
- *ngIf="selectedVersion"
- nz-tooltip="Download the file"
- [disabled]="!isLogin || !isDownloadAllowed()"
- (click)="onClickDownloadCurrentFile()">
- <i
- nz-icon
- nzTheme="outline"
- nzType="download">
- </i>
- </button>
- <button
- nz-button
- *ngIf="!isMaximized && selectedVersion"
- nz-tooltip="Maximize View"
- (click)="onClickScaleTheView()">
- <i
- nz-icon
- nzTheme="outline"
- nzType="expand">
- </i>
- </button>
- <button
- nz-button
- *ngIf="isMaximized && selectedVersion"
- nz-tooltip="Minimize View"
- (click)="onClickScaleTheView()">
- <i
- nz-icon
- nzTheme="outline"
- nzType="compress">
- </i>
- </button>
- <button
- *ngIf="!isRightBarCollapsed"
- nz-button
- nz-tooltip="Hide the right bar"
- (click)="onClickHideRightBar()">
- <i
- nz-icon
- nzTheme="outline"
- nzType="right">
- </i>
- </button>
- <button
- *ngIf="isRightBarCollapsed"
- nz-button
- nz-tooltip="Show Tree"
- (click)="onClickHideRightBar()">
- <i
- nz-icon
- nzTheme="outline"
- nzType="left">
- </i>
- </button>
- </div>
- </div>
- </nz-card>
- <nz-empty
- class="empty-version-indicator"
- *ngIf="!selectedVersion"
- nzNotFoundContent="No version is selected"></nz-empty>
-
- <texera-user-dataset-file-renderer
- *ngIf="selectedVersion"
- [isMaximized]="isMaximized"
- [resourceId]="did"
- [versionId]="selectedVersion.dvid"
- [filePath]="currentDisplayedFileName"
- [fileSize]="currentFileSize"
- [isLogin]="isLogin"
- class="file-renderer">
- </texera-user-dataset-file-renderer>
- </nz-content>
- <nz-sider
- *ngIf="!isRightBarCollapsed"
- nzTheme="light"
- [nzWidth]="siderWidth"
- nz-resizable
- [nzMinWidth]="MIN_SIDER_WIDTH"
- [nzMaxWidth]="MAX_SIDER_WIDTH"
- (nzResize)="onSideResize($event)">
- <nz-resize-handle nzDirection="left">
- <div class="sider-resize-line">
- <i
- class="sider-resize-handle"
- nz-icon
- nzType="more"
- nzTheme="outline"></i>
- </div>
- </nz-resize-handle>
- <div class="right-sider">
- <nz-collapse nzGhost>
- <nz-collapse-panel
- nzHeader="Current Versions"
- nzActive="true">
- <div class="version-storage">
- <h6 style="font-weight: lighter; font-size: 0.9em">Choose a
Version:</h6>
- <div class="select-and-button-container">
- <nz-select
- nzShowSearch
- nzPlaceHolder="Select a version"
- (ngModelChange)="onVersionSelected($event)"
- [(ngModel)]="selectedVersion">
- <nz-option
- *ngFor="let version of versions"
- [nzValue]="version"
- [nzLabel]="version.name"></nz-option>
- </nz-select>
- <button
- nz-button
- nz-tooltip="Download Dataset"
- (click)="onClickDownloadVersionAsZip()"
- *ngIf="selectedVersion"
- [disabled]="!isLogin || !isDownloadAllowed()"
- class="spaced-button">
- <i
- nz-icon
- nzType="download"
- nzTheme="outline"></i>
- </button>
- </div>
- <ng-container *ngIf="selectedVersion">
- <div class="version-size">
- <i
- nz-icon
- nzType="database"
- nzTheme="outline"
- class="icon-database"></i>
- Version Size: {{ formatSize(currentDatasetVersionSize) }}
- </div>
- <div
- *ngIf="selectedVersionCreationTime"
- class="version-date">
- <i
- nz-icon
- nzType="calendar"
- nzTheme="outline"
- class="icon-database"></i>
- Created at: {{ selectedVersionCreationTime }}
- </div>
- </ng-container>
- </div>
- <texera-user-dataset-version-filetree
- [fileTreeNodes]="fileTreeNodeList"
- [isTreeNodeDeletable]="userHasWriteAccess()"
- [isCoverSettable]="userHasWriteAccess()"
- (selectedTreeNode)="onVersionFileTreeNodeSelected($event)"
- (deletedTreeNode)="onPreviouslyUploadedFileDeleted($event)"
- (setCoverImage)="onSetCoverImage($event)">
- </texera-user-dataset-version-filetree>
- </nz-collapse-panel>
- </nz-collapse>
-
- <nz-divider *ngIf="userDatasetAccessLevel ===
'WRITE'"></nz-divider>
- <texera-version-uploader
- *ngIf="userDatasetAccessLevel === 'WRITE'"
- [resourceId]="did"
- [ownerEmail]="ownerEmail"
- [resourceName]="datasetName"
- [endpoint]="datasetEndpoint"
- [createVersion]="createDatasetVersion"
- (versionCreated)="onVersionCreated()"
- (uploadsInFlightChange)="uploadsInFlight = $event">
- </texera-version-uploader>
- </div>
- </nz-sider>
- </nz-layout>
- </div>
+ <texera-versions-files-browser
+ [(isMaximized)]="isMaximized"
+ [(isRightBarCollapsed)]="isRightBarCollapsed"
+ [currentDisplayedFileName]="currentDisplayedFileName"
+ [currentFileSize]="currentFileSize"
+ [currentVersionSize]="currentDatasetVersionSize"
+ [selectedVersionCreationTime]="selectedVersionCreationTime"
+ [versions]="versions"
+ [selectedVersion]="selectedVersion"
+ (versionSelected)="onVersionSelected($event)"
+ [fileTreeNodeList]="fileTreeNodeList"
+ (fileTreeNodeSelected)="onVersionFileTreeNodeSelected($event)"
+ (fileDeleted)="onPreviouslyUploadedFileDeleted($event)"
+ (setCoverImage)="onSetCoverImage($event)"
+ [resourceId]="did"
+ [versionId]="selectedVersion?.dvid"
+ [ownerEmail]="ownerEmail"
+ [resourceName]="datasetName"
+ [endpoint]="datasetEndpoint"
+ [createVersion]="createDatasetVersion"
+ (versionCreated)="onVersionCreated()"
+ (uploadsInFlightChange)="uploadsInFlight = $event"
+ [isLogin]="isLogin"
+ [writeAccess]="userHasWriteAccess()"
+ [downloadAllowed]="isDownloadAllowed()"
+ emptyMessage="No version is selected"
+ downloadZipTooltip="Download Dataset"
+ (downloadCurrentFile)="onClickDownloadCurrentFile()"
+ (downloadVersionAsZip)="onClickDownloadVersionAsZip()">
+ </texera-versions-files-browser>
</nz-tab>
<nz-tab
nzTitle="Settings"
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
index 0aa5c93707..5074cfad32 100644
---
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
@@ -24,7 +24,6 @@ import { NoopAnimationsModule } from
"@angular/platform-browser/animations";
import { ActivatedRoute, Router } from "@angular/router";
import { concat, of, Subject, throwError } from "rxjs";
import { NzModalService } from "ng-zorro-antd/modal";
-import { NzResizableDirective } from "ng-zorro-antd/resizable";
import { NzTooltipDirective } from "ng-zorro-antd/tooltip";
import { MarkdownService } from "ngx-markdown";
import { DatasetDetailComponent } from "./dataset-detail.component";
@@ -43,7 +42,6 @@ import { commonTestImports, commonTestProviders } from
"../../../../../common/te
import { Contributor, Dataset, DatasetVersion } from
"../../../../../common/type/dataset";
import { DashboardDataset } from
"../../../../type/dashboard-dataset.interface";
import { HttpErrorResponse, HttpStatusCode } from "@angular/common/http";
-import { NzResizeEvent } from "ng-zorro-antd/resizable";
import { format } from "date-fns";
import { USER_DATASET } from "../../../../../app-routing.constant";
@@ -232,15 +230,6 @@ describe("DatasetDetailComponent rendered explorer", () =>
{
expect((fixture.nativeElement as
HTMLElement).querySelector(".copy-path-btn")).not.toBeNull();
});
- it("copies the path of the file being shown", () => {
- const spy = vi.spyOn(component,
"copyCurrentFilePath").mockResolvedValue(undefined);
- const el = render(c => (c.currentDisplayedFileName = "a/b.csv"));
-
- el.querySelector<HTMLElement>(".copy-path-btn")!.click();
-
- expect(spy).toHaveBeenCalledTimes(1);
- });
-
it("shows the file size in human units, and nothing when it is unknown",
() => {
const el = render(c => {
c.currentDisplayedFileName = "a/b.csv";
@@ -1107,20 +1096,6 @@ describe("DatasetDetailComponent behavior", () => {
});
});
- describe("view flags", () => {
- beforeEach(() => createComponent());
-
- it("toggles the maximize and right-bar flags", () => {
- expect(component.isMaximized).toBe(false);
- component.onClickScaleTheView();
- expect(component.isMaximized).toBe(true);
-
- expect(component.isRightBarCollapsed).toBe(false);
- component.onClickHideRightBar();
- expect(component.isRightBarCollapsed).toBe(true);
- });
- });
-
describe("toggleLike", () => {
it("unlikes and decrements the like count when currently liked", () => {
hubServiceStub.postUnlike.mockReturnValue(of(true));
@@ -1371,54 +1346,6 @@ describe("DatasetDetailComponent behavior", () => {
});
});
- describe("copyCurrentFilePath", () => {
- let originalClipboardDescriptor: PropertyDescriptor | undefined;
- let writeText: ReturnType<typeof vi.fn>;
-
- beforeEach(() => {
- // Capture the original own-property descriptor (undefined if navigator
has no own
- // `clipboard`, e.g. under jsdom) so afterEach can restore the exact
shape.
- originalClipboardDescriptor = Object.getOwnPropertyDescriptor(navigator,
"clipboard");
- writeText = vi.fn().mockResolvedValue(undefined);
- Object.defineProperty(navigator, "clipboard", { value: { writeText },
configurable: true });
- createComponent();
- });
-
- afterEach(() => {
- if (originalClipboardDescriptor) {
- Object.defineProperty(navigator, "clipboard",
originalClipboardDescriptor);
- } else {
- delete (navigator as any).clipboard;
- }
- });
-
- it("writes the displayed path to the clipboard and toasts success", async
() => {
- component.currentDisplayedFileName = "/a/b/c.txt";
-
- await component.copyCurrentFilePath();
-
- expect(writeText).toHaveBeenCalledWith("/a/b/c.txt");
- expect(notificationServiceStub.success).toHaveBeenCalledWith("File path
copied to clipboard");
- });
-
- it("does nothing when no file is displayed", async () => {
- component.currentDisplayedFileName = "";
-
- await component.copyCurrentFilePath();
-
- expect(writeText).not.toHaveBeenCalled();
- });
-
- it("toasts an error when the clipboard write rejects", async () => {
- writeText.mockRejectedValue(new Error("denied"));
- component.currentDisplayedFileName = "/a/b/c.txt";
-
- await component.copyCurrentFilePath();
-
- expect(notificationServiceStub.error).toHaveBeenCalledWith("Failed to
copy file path");
- });
- });
-
describe("version-node selection", () => {
it("onVersionFileTreeNodeSelected loads the selected node's content", ()
=> {
const node = { name: "file.csv", type: "file" } as unknown as Parameters<
@@ -1971,38 +1898,6 @@ describe("DatasetDetailComponent behavior", () => {
expect(component.isMaximized).toBe(true);
});
-
- // βββ sider resize βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
- it("applies the dragged sider width on the next animation frame", async ()
=> {
- renderWith({ did: 5 });
-
- component.onSideResize({ width: 321 } as NzResizeEvent);
- // the handler defers to requestAnimationFrame; let that frame run
- await new Promise(resolve => requestAnimationFrame(() => resolve(null)));
-
- expect(component.siderWidth).toBe(321);
- });
-
- it("cancels the frame the previous resize scheduled", () => {
- renderWith({ did: 5 });
- // Hand out a known frame id so the assertion below pins down *which*
frame is
- // cancelled: the component starts with id = -1, so merely asserting that
- // cancelAnimationFrame was called would pass even if the id were never
tracked.
- const request = vi.spyOn(globalThis,
"requestAnimationFrame").mockReturnValue(100);
- const cancel = vi.spyOn(globalThis, "cancelAnimationFrame");
- try {
- component.onSideResize({ width: 100 } as NzResizeEvent);
- cancel.mockClear(); // drop the initial cancel(-1)
-
- component.onSideResize({ width: 200 } as NzResizeEvent);
-
- expect(cancel).toHaveBeenCalledWith(100);
- } finally {
- cancel.mockRestore();
- request.mockRestore();
- }
- });
});
});
@@ -2443,27 +2338,6 @@ describe("DatasetDetailComponent rendered template", ()
=> {
expect(el.querySelector(".dataset-header")).not.toBeNull();
expect(byTooltip("Minimize View")).toBeUndefined();
});
-
- it("applies a width the resize handle reports, between the bounds it
declares", async () => {
- const sider = fixture.debugElement.query(By.css("nz-sider"));
- expect(sider.nativeElement.style.width).toBe("400px");
-
- // The drag itself belongs to NzResizableDirective; what this component
owns
- // is the bounds it hands the directive and what it does with the
reported
- // width. Both have to be pinned, and in the right order β swapped bounds
- // would let the handle collapse the sider past its minimum.
- const resizable = sider.injector.get(NzResizableDirective);
- expect(resizable.nzMinWidth).toBe(component.MIN_SIDER_WIDTH);
- expect(resizable.nzMaxWidth).toBe(component.MAX_SIDER_WIDTH);
- expect(resizable.nzMinWidth).toBeLessThan(resizable.nzMaxWidth as
number);
-
- sider.triggerEventHandler("nzResize", { width: 520 });
- // The new width is applied on the next animation frame.
- await new Promise(resolve => requestAnimationFrame(() => resolve(null)));
- fixture.detectChanges();
-
- expect(sider.nativeElement.style.width).toBe("520px");
- });
});
describe("version picker", () => {
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 bb9521aa67..f9d8f70c16 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
@@ -23,7 +23,6 @@ import { USER_DATASET } from
"../../../../../app-routing.constant";
import { extractErrorMessage } from "../../../../../common/util/error";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
import { DatasetService, validateDatasetName } from
"../../../../service/user/dataset/dataset.service";
-import { NzResizeEvent, NzResizableDirective, NzResizeHandleComponent } from
"ng-zorro-antd/resizable";
import {
DatasetFileNode,
getFullPathFromDatasetFileNode,
@@ -58,18 +57,13 @@ import { NzPopconfirmDirective } from
"ng-zorro-antd/popconfirm";
import { NzSwitchComponent } from "ng-zorro-antd/switch";
import { FormsModule } from "@angular/forms";
import { MarkdownDescriptionComponent } from
"../../markdown-description/markdown-description.component";
-import { NzLayoutComponent, NzContentComponent, NzSiderComponent } from
"ng-zorro-antd/layout";
import { NzWaveDirective } from "ng-zorro-antd/core/wave";
-import { NzEmptyComponent } from "ng-zorro-antd/empty";
import { NzTabsComponent, NzTabComponent } from "ng-zorro-antd/tabs";
-import { UserDatasetFileRendererComponent } from
"./user-dataset-file-renderer/user-dataset-file-renderer.component";
import { NzCollapseComponent, NzCollapsePanelComponent } from
"ng-zorro-antd/collapse";
-import { NzSelectComponent, NzOptionComponent } from "ng-zorro-antd/select";
-import { UserDatasetVersionFiletreeComponent } from
"./user-dataset-version-filetree/user-dataset-version-filetree.component";
import { NzDividerComponent } from "ng-zorro-antd/divider";
-import { VersionUploaderComponent } from
"../../version-uploader/version-uploader.component";
import { DATASET_FILE_RESOURCE_ENDPOINT } from
"../../../../service/user/file-resource/file-resource-endpoint";
import { NzInputDirective } from "ng-zorro-antd/input";
+import { VersionsFilesBrowserComponent } from
"../../versions-files-browser/versions-files-browser.component";
export const THROTTLE_TIME_MS = 1000;
@@ -92,29 +86,19 @@ export const THROTTLE_TIME_MS = 1000;
NzSwitchComponent,
FormsModule,
MarkdownDescriptionComponent,
- NzLayoutComponent,
- NzContentComponent,
NzWaveDirective,
- NzEmptyComponent,
NzTabsComponent,
NzTabComponent,
- UserDatasetFileRendererComponent,
- NzSiderComponent,
- NzResizableDirective,
- NzResizeHandleComponent,
NzCollapseComponent,
NzCollapsePanelComponent,
- NzSelectComponent,
- NgFor,
- NzOptionComponent,
- UserDatasetVersionFiletreeComponent,
NzDividerComponent,
- VersionUploaderComponent,
+ NgFor,
NzInputDirective,
NzDropdownDirective,
NzDropdownMenuComponent,
NzMenuDirective,
NzMenuItemComponent,
+ VersionsFilesBrowserComponent,
],
})
export class DatasetDetailComponent implements OnInit {
@@ -136,8 +120,10 @@ export class DatasetDetailComponent implements OnInit {
public currentFileSize: number | undefined;
public currentDatasetVersionSize: number | undefined;
- public isRightBarCollapsed = false;
+ // The versions/files browser is presentational; its two toolbar flags live
here (maximizing
+ // the file view also hides the page header) and are shared through two-way
bindings.
public isMaximized = false;
+ public isRightBarCollapsed = false;
public versions: ReadonlyArray<DatasetVersion> = [];
public selectedVersion: DatasetVersion | undefined;
@@ -162,7 +148,8 @@ export class DatasetDetailComponent implements OnInit {
readonly datasetEndpoint = DATASET_FILE_RESOURCE_ENDPOINT;
- @ViewChild(VersionUploaderComponent) private versionUploader?:
VersionUploaderComponent;
+ @ViewChild(VersionsFilesBrowserComponent)
+ private versionsFilesBrowser?: VersionsFilesBrowserComponent<DatasetVersion>;
constructor(
private route: ActivatedRoute,
@@ -183,18 +170,6 @@ export class DatasetDetailComponent implements OnInit {
});
}
- // item for control the resizeable sider
- MAX_SIDER_WIDTH = 600;
- MIN_SIDER_WIDTH = 150;
- siderWidth = 400;
- id = -1;
- onSideResize({ width }: NzResizeEvent): void {
- cancelAnimationFrame(this.id);
- this.id = requestAnimationFrame(() => {
- this.siderWidth = width!;
- });
- }
-
ngOnInit(): void {
this.route.params
.pipe(
@@ -415,14 +390,6 @@ export class DatasetDetailComponent implements OnInit {
.subscribe();
};
- onClickScaleTheView() {
- this.isMaximized = !this.isMaximized;
- }
-
- onClickHideRightBar() {
- this.isRightBarCollapsed = !this.isRightBarCollapsed;
- }
-
onVersionSelected(version: DatasetVersion | undefined): void {
this.selectedVersion = version;
if (this.did && version?.dvid)
@@ -483,7 +450,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.versionUploader?.notePathStaged(relativePath);
+ this.versionsFilesBrowser?.notePathStaged(relativePath);
},
error: (err: unknown) => {
this.notificationService.error("Failed to delete the file");
@@ -644,19 +611,6 @@ export class DatasetDetailComponent implements OnInit {
});
}
- async copyCurrentFilePath(): Promise<void> {
- if (!this.currentDisplayedFileName) {
- return;
- }
-
- try {
- await navigator.clipboard.writeText(this.currentDisplayedFileName);
- this.notificationService.success("File path copied to clipboard");
- } catch (error) {
- this.notificationService.error("Failed to copy file path");
- }
- }
-
onAddContributor(): void {
this.openContributorEditor("Add Contributor", null, newContributor => [
...this.datasetContributors,
diff --git
a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.html
b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.html
index ca772155c4..cd40389898 100644
---
a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.html
+++
b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.html
@@ -159,217 +159,37 @@
</div>
</nz-tab>
<nz-tab nzTitle="Versions & Files">
- <div class="versions-tab-content">
- <nz-layout>
- <nz-content style="background-color: white">
- <nz-card>
- <div style="display: flex; justify-content: space-between;
align-items: center">
- <div class="file-info">
- <h3 class="file-title">
- <span class="file-title-main">
- <b>{{ currentDisplayedFileName }}</b>
-
- <button
- nz-button
- nzType="text"
- nzSize="small"
- class="copy-path-btn"
- nz-tooltip
- nzTooltipTitle="Copy file path"
- *ngIf="currentDisplayedFileName"
- (click)="copyCurrentFilePath()">
- <i
- nz-icon
- nzType="copy"
- nzTheme="twotone">
- </i>
- </button>
- </span>
-
- <span
- *ngIf="currentFileSize"
- class="file-size">
- <i
- nz-icon
- nzType="file"
- nzTheme="outline"
- class="icon-file"></i>
- {{ formatSize(currentFileSize) }}
- </span>
- </h3>
- </div>
- <div style="display: flex">
- <button
- nz-button
- *ngIf="selectedVersion"
- nz-tooltip="Download the file"
- [disabled]="!isLogin || !isDownloadAllowed()"
- (click)="onClickDownloadCurrentFile()">
- <i
- nz-icon
- nzTheme="outline"
- nzType="download">
- </i>
- </button>
- <button
- nz-button
- *ngIf="!isMaximized && selectedVersion"
- nz-tooltip="Maximize View"
- (click)="onClickScaleTheView()">
- <i
- nz-icon
- nzTheme="outline"
- nzType="expand">
- </i>
- </button>
- <button
- nz-button
- *ngIf="isMaximized && selectedVersion"
- nz-tooltip="Minimize View"
- (click)="onClickScaleTheView()">
- <i
- nz-icon
- nzTheme="outline"
- nzType="compress">
- </i>
- </button>
- <button
- *ngIf="!isRightBarCollapsed"
- nz-button
- nz-tooltip="Hide the right bar"
- (click)="onClickHideRightBar()">
- <i
- nz-icon
- nzTheme="outline"
- nzType="right">
- </i>
- </button>
- <button
- *ngIf="isRightBarCollapsed"
- nz-button
- nz-tooltip="Show Tree"
- (click)="onClickHideRightBar()">
- <i
- nz-icon
- nzTheme="outline"
- nzType="left">
- </i>
- </button>
- </div>
- </div>
- </nz-card>
- <nz-empty
- class="empty-version-indicator"
- *ngIf="!selectedVersion"
- nzNotFoundContent="This model has no version yet"></nz-empty>
-
- <texera-user-dataset-file-renderer
- *ngIf="selectedVersion"
- [isMaximized]="isMaximized"
- [resourceType]="modelEntityType"
- [resourceId]="mid"
- [versionId]="selectedVersion.mvid"
- [filePath]="currentDisplayedFileName"
- [fileSize]="currentFileSize"
- [isLogin]="isLogin"
- class="file-renderer">
- </texera-user-dataset-file-renderer>
- </nz-content>
- <nz-sider
- *ngIf="!isRightBarCollapsed"
- nzTheme="light"
- [nzWidth]="siderWidth"
- nz-resizable
- [nzMinWidth]="MIN_SIDER_WIDTH"
- [nzMaxWidth]="MAX_SIDER_WIDTH"
- (nzResize)="onSideResize($event)">
- <nz-resize-handle nzDirection="left">
- <div class="sider-resize-line">
- <i
- class="sider-resize-handle"
- nz-icon
- nzType="more"
- nzTheme="outline"></i>
- </div>
- </nz-resize-handle>
- <div class="right-sider">
- <nz-collapse nzGhost>
- <nz-collapse-panel
- nzHeader="Current Versions"
- nzActive="true">
- <div class="version-storage">
- <h6 style="font-weight: lighter; font-size: 0.9em">Choose a
Version:</h6>
- <div class="select-and-button-container">
- <nz-select
- nzShowSearch
- nzPlaceHolder="Select a version"
- (ngModelChange)="onVersionSelected($event)"
- [(ngModel)]="selectedVersion">
- <nz-option
- *ngFor="let version of versions"
- [nzValue]="version"
- [nzLabel]="version.name"></nz-option>
- </nz-select>
- <button
- nz-button
- nz-tooltip="Download this version as ZIP"
- (click)="onClickDownloadVersionAsZip()"
- *ngIf="selectedVersion"
- [disabled]="!isLogin || !isDownloadAllowed()"
- class="spaced-button">
- <i
- nz-icon
- nzType="download"
- nzTheme="outline"></i>
- </button>
- </div>
- <ng-container *ngIf="selectedVersion">
- <div class="version-size">
- <i
- nz-icon
- nzType="database"
- nzTheme="outline"
- class="icon-database"></i>
- Version Size: {{ formatSize(currentModelVersionSize) }}
- </div>
- <div
- *ngIf="selectedVersionCreationTime"
- class="version-date">
- <i
- nz-icon
- nzType="calendar"
- nzTheme="outline"
- class="icon-database"></i>
- Created at: {{ selectedVersionCreationTime }}
- </div>
- </ng-container>
- </div>
- <texera-user-dataset-version-filetree
- [fileTreeNodes]="fileTreeNodeList"
- [isTreeNodeDeletable]="userHasWriteAccess()"
- [isCoverSettable]="userHasWriteAccess()"
- (selectedTreeNode)="onVersionFileTreeNodeSelected($event)"
- (deletedTreeNode)="onPreviouslyUploadedFileDeleted($event)"
- (setCoverImage)="onSetCoverImage($event)">
- </texera-user-dataset-version-filetree>
- </nz-collapse-panel>
- </nz-collapse>
-
- <nz-divider *ngIf="userHasWriteAccess()"></nz-divider>
- <texera-version-uploader
- *ngIf="userHasWriteAccess()"
- [resourceId]="mid"
- [ownerEmail]="ownerEmail"
- [resourceName]="modelName"
- [endpoint]="modelEndpoint"
- [createVersion]="createModelVersion"
- (versionCreated)="onVersionCreated()"
- (uploadsInFlightChange)="uploadsInFlight = $event">
- </texera-version-uploader>
- </div>
- </nz-sider>
- </nz-layout>
- </div>
+ <texera-versions-files-browser
+ [(isMaximized)]="isMaximized"
+ [(isRightBarCollapsed)]="isRightBarCollapsed"
+ [currentDisplayedFileName]="currentDisplayedFileName"
+ [currentFileSize]="currentFileSize"
+ [currentVersionSize]="currentModelVersionSize"
+ [selectedVersionCreationTime]="selectedVersionCreationTime"
+ [versions]="versions"
+ [selectedVersion]="selectedVersion"
+ (versionSelected)="onVersionSelected($event)"
+ [fileTreeNodeList]="fileTreeNodeList"
+ (fileTreeNodeSelected)="onVersionFileTreeNodeSelected($event)"
+ (fileDeleted)="onPreviouslyUploadedFileDeleted($event)"
+ (setCoverImage)="onSetCoverImage($event)"
+ [resourceId]="mid"
+ [resourceType]="modelEntityType"
+ [versionId]="selectedVersion?.mvid"
+ [ownerEmail]="ownerEmail"
+ [resourceName]="modelName"
+ [endpoint]="modelEndpoint"
+ [createVersion]="createModelVersion"
+ (versionCreated)="onVersionCreated()"
+ (uploadsInFlightChange)="uploadsInFlight = $event"
+ [isLogin]="isLogin"
+ [writeAccess]="userHasWriteAccess()"
+ [downloadAllowed]="isDownloadAllowed()"
+ emptyMessage="This model has no version yet"
+ downloadZipTooltip="Download this version as ZIP"
+ (downloadCurrentFile)="onClickDownloadCurrentFile()"
+ (downloadVersionAsZip)="onClickDownloadVersionAsZip()">
+ </texera-versions-files-browser>
</nz-tab>
<nz-tab
nzTitle="Settings"
diff --git
a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts
b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts
index bb9d0e2900..970bd352f3 100644
---
a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts
+++
b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts
@@ -24,7 +24,6 @@ import { of, Subject, throwError } from "rxjs";
import { MarkdownService } from "ngx-markdown";
import { NzModalService } from "ng-zorro-antd/modal";
import { NgModel } from "@angular/forms";
-import { NzResizableDirective } from "ng-zorro-antd/resizable";
import { By } from "@angular/platform-browser";
import { commonTestImports, commonTestProviders } from
"../../../../../common/testing/test-utils";
import { NotificationService } from
"../../../../../common/service/notification/notification.service";
@@ -195,9 +194,6 @@ describe("ModelDetailComponent", () => {
return el as unknown as E;
};
- // The sider coalesces resize events into one write per animation frame.
- const nextFrame = (): Promise<void> => new Promise<void>(resolve =>
requestAnimationFrame(() => resolve()));
-
// βββ loading the model
ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
it("reads the mid off the route as a number and loads the model", () => {
@@ -459,29 +455,6 @@ describe("ModelDetailComponent", () => {
expect(component.isDownloadAllowed()).toBe(true);
});
- // βββ the file path clipboard
ββββββββββββββββββββββββββββββββββββββββββββββββ
-
- it("copies the open file's path and reports failure", async () => {
- create();
- const writeText = vi.fn(() => Promise.resolve());
- Object.defineProperty(navigator, "clipboard", { value: { writeText },
configurable: true });
-
- render({ currentDisplayedFileName: "/model/a/b/v1/model.pt" });
- await component.copyCurrentFilePath();
- expect(writeText).toHaveBeenCalledWith("/model/a/b/v1/model.pt");
- expect(notificationService["success"]).toHaveBeenCalled();
-
- writeText.mockImplementationOnce(() => Promise.reject(new
Error("denied")));
- await component.copyCurrentFilePath();
- expect(notificationService["error"]).toHaveBeenCalled();
-
- // Nothing open: no clipboard call at all.
- writeText.mockClear();
- render({ currentDisplayedFileName: "" });
- await component.copyCurrentFilePath();
- expect(writeText).not.toHaveBeenCalled();
- });
-
// βββ template
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
it("renders the model's name, tags and stats", () => {
@@ -1021,13 +994,13 @@ describe("ModelDetailComponent", () => {
const root = openTab("Versions & Files");
expect(root.querySelector("nz-sider")).not.toBeNull();
- component.onClickHideRightBar();
+ // The browser owns the toolbar controls; the page keeps the two flags and
feeds them back in.
+ component.isRightBarCollapsed = true;
fixture.detectChanges();
expect(fixture.nativeElement.querySelector("nz-sider")).toBeNull();
- component.onClickScaleTheView();
+ component.isMaximized = true;
fixture.detectChanges();
- expect(component.isMaximized).toBe(true);
expect(fixture.nativeElement.querySelector(".model-header")).toBeNull();
});
@@ -1054,23 +1027,6 @@ describe("ModelDetailComponent", () => {
expect(component.currentUid).toBe(42);
});
- // βββ the resizable sider
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
- it("stores the dragged sider width on the next animation frame", async () =>
{
- create();
- expect(component.siderWidth).toBe(400);
-
- // A drag emits continuously; the component coalesces to one write per
frame.
- component.onSideResize({ width: 250, height: 999 });
-
- // The write is deferred, not immediate β that deferral is the whole point
of the
- // requestAnimationFrame hop, and a synchronous assignment would land here.
- expect(component.siderWidth).toBe(400);
- await nextFrame();
-
- expect(component.siderWidth).toBe(250);
- });
-
// βββ guards against an absent model id
ββββββββββββββββββββββββββββββββββββββ
it("fetches nothing without a model id", () => {
@@ -1369,19 +1325,6 @@ describe("ModelDetailComponent", () => {
expect(q<HTMLButtonElement>(fixture.nativeElement,
".spaced-button").disabled).toBe(true);
});
- it("wires the sider's resize handle to the width the page keeps", async ()
=> {
- oneVersionWithOneFile();
- create();
- openTab("Versions & Files");
-
- const sider =
fixture.debugElement.query(By.directive(NzResizableDirective));
- expect(sider).not.toBeNull();
- sider.injector.get(NzResizableDirective).nzResize.emit({ width: 275,
height: 0 });
- await nextFrame();
-
- expect(component.siderWidth).toBe(275);
- });
-
it("wires the version picker to both the selection and the file tree", () =>
{
const versions = [aVersion(2, "v2"), aVersion(1, "v1")];
modelService["retrieveModelVersionList"] = vi.fn(() => of(versions));
diff --git
a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.ts
b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.ts
index 37bab19df4..2b11761d93 100644
---
a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.ts
+++
b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.ts
@@ -25,7 +25,6 @@ import { Observable, of } from "rxjs";
import { format } from "date-fns";
import { NgIf, NgClass, NgFor } from "@angular/common";
import { FormsModule } from "@angular/forms";
-import { NzResizeEvent, NzResizableDirective, NzResizeHandleComponent } from
"ng-zorro-antd/resizable";
import { NzCardComponent, NzCardMetaComponent } from "ng-zorro-antd/card";
import { NzTooltipDirective } from "ng-zorro-antd/tooltip";
import { NzTagComponent } from "ng-zorro-antd/tag";
@@ -33,9 +32,6 @@ import { Ι΅NzTransitionPatchDirective } from
"ng-zorro-antd/core/transition-patc
import { NzIconDirective } from "ng-zorro-antd/icon";
import { NzButtonComponent } from "ng-zorro-antd/button";
import { NzWaveDirective } from "ng-zorro-antd/core/wave";
-import { NzLayoutComponent, NzContentComponent, NzSiderComponent } from
"ng-zorro-antd/layout";
-import { NzEmptyComponent } from "ng-zorro-antd/empty";
-import { NzCollapseComponent, NzCollapsePanelComponent } from
"ng-zorro-antd/collapse";
import { NzSelectComponent, NzOptionComponent } from "ng-zorro-antd/select";
import { NzTabsComponent, NzTabComponent } from "ng-zorro-antd/tabs";
import { NzDividerComponent } from "ng-zorro-antd/divider";
@@ -64,9 +60,7 @@ import {
getRelativePathFromDatasetFileNode,
} from "../../../../../common/type/datasetVersionFileTree";
import { MarkdownDescriptionComponent } from
"../../markdown-description/markdown-description.component";
-import { VersionUploaderComponent } from
"../../version-uploader/version-uploader.component";
-import { UserDatasetFileRendererComponent } from
"../../user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component";
-import { UserDatasetVersionFiletreeComponent } from
"../../user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component";
+import { VersionsFilesBrowserComponent } from
"../../versions-files-browser/versions-files-browser.component";
@UntilDestroy()
@Component({
@@ -85,14 +79,6 @@ import { UserDatasetVersionFiletreeComponent } from
"../../user-dataset/user-dat
NzIconDirective,
NzButtonComponent,
NzWaveDirective,
- NzLayoutComponent,
- NzContentComponent,
- NzSiderComponent,
- NzResizableDirective,
- NzResizeHandleComponent,
- NzEmptyComponent,
- NzCollapseComponent,
- NzCollapsePanelComponent,
NzSelectComponent,
NzOptionComponent,
NzTabsComponent,
@@ -101,9 +87,7 @@ import { UserDatasetVersionFiletreeComponent } from
"../../user-dataset/user-dat
NzInputDirective,
NzSwitchComponent,
MarkdownDescriptionComponent,
- VersionUploaderComponent,
- UserDatasetFileRendererComponent,
- UserDatasetVersionFiletreeComponent,
+ VersionsFilesBrowserComponent,
],
})
export class ModelDetailComponent implements OnInit {
@@ -142,8 +126,10 @@ export class ModelDetailComponent implements OnInit {
public likeCount: number = 0;
public isLiked: boolean = false;
- public isRightBarCollapsed = false;
+ // The versions/files browser is presentational; its two toolbar flags live
here (maximizing
+ // the file view also hides the page header) and are shared through two-way
bindings.
public isMaximized = false;
+ public isRightBarCollapsed = false;
public isLogin: boolean = this.userService.isLogin();
public currentUid: number | undefined =
this.userService.getCurrentUser()?.uid;
@@ -153,7 +139,7 @@ export class ModelDetailComponent implements OnInit {
public readonly frameworks = MODEL_FRAMEWORKS;
public readonly formats = MODEL_FORMATS;
- @ViewChild(VersionUploaderComponent) private versionUploader?:
VersionUploaderComponent;
+ @ViewChild(VersionsFilesBrowserComponent) private versionsFilesBrowser?:
VersionsFilesBrowserComponent<ModelVersion>;
// Renaming mid-upload strands the in-flight parts under the old name, so
the Settings tab
// blocks it until the panel is idle.
@@ -180,19 +166,6 @@ export class ModelDetailComponent implements OnInit {
});
}
- // Resizable sider holding the version picker and the file tree.
- MAX_SIDER_WIDTH = 600;
- MIN_SIDER_WIDTH = 150;
- siderWidth = 400;
- id = -1;
-
- onSideResize({ width }: NzResizeEvent): void {
- cancelAnimationFrame(this.id);
- this.id = requestAnimationFrame(() => {
- this.siderWidth = width!;
- });
- }
-
ngOnInit(): void {
this.route.params
.pipe(
@@ -448,26 +421,6 @@ export class ModelDetailComponent implements OnInit {
.subscribe();
}
- async copyCurrentFilePath(): Promise<void> {
- if (!this.currentDisplayedFileName) {
- return;
- }
- try {
- await navigator.clipboard.writeText(this.currentDisplayedFileName);
- this.notificationService.success("File path copied to clipboard");
- } catch {
- this.notificationService.error("Failed to copy file path");
- }
- }
-
- onClickScaleTheView(): void {
- this.isMaximized = !this.isMaximized;
- }
-
- onClickHideRightBar(): void {
- this.isRightBarCollapsed = !this.isRightBarCollapsed;
- }
-
isDownloadAllowed(): boolean {
if (this.isOwner) {
return true;
@@ -494,7 +447,7 @@ export class ModelDetailComponent implements OnInit {
);
// Undefined only when the panel is not rendered, which is the same
write-access
// condition that gates the tree's delete control.
- this.versionUploader?.notePathStaged(relativePath);
+ this.versionsFilesBrowser?.notePathStaged(relativePath);
},
error: () => this.notificationService.error("Failed to delete the
file"),
});
diff --git
a/frontend/src/app/dashboard/component/user/versions-files-browser/versions-files-browser.component.html
b/frontend/src/app/dashboard/component/user/versions-files-browser/versions-files-browser.component.html
new file mode 100644
index 0000000000..9926d0b394
--- /dev/null
+++
b/frontend/src/app/dashboard/component/user/versions-files-browser/versions-files-browser.component.html
@@ -0,0 +1,231 @@
+<!--
+ ~ 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.
+ -->
+
+<div class="versions-tab-content">
+ <nz-layout>
+ <nz-content style="background-color: white">
+ <nz-card>
+ <div style="display: flex; justify-content: space-between;
align-items: center">
+ <div class="file-info">
+ <h3 class="file-title">
+ <span class="file-title-main">
+ <b>{{ currentDisplayedFileName }}</b>
+
+ <button
+ nz-button
+ nzType="text"
+ nzSize="small"
+ class="copy-path-btn"
+ nz-tooltip
+ nzTooltipTitle="Copy file path"
+ *ngIf="currentDisplayedFileName"
+ (click)="copyCurrentFilePath()">
+ <i
+ nz-icon
+ nzType="copy"
+ nzTheme="twotone">
+ </i>
+ </button>
+ </span>
+
+ <span
+ *ngIf="currentFileSize"
+ class="file-size">
+ <i
+ nz-icon
+ nzType="file"
+ nzTheme="outline"
+ class="icon-file"></i>
+ {{ formatSize(currentFileSize) }}
+ </span>
+ </h3>
+ </div>
+ <div style="display: flex">
+ <button
+ nz-button
+ *ngIf="selectedVersion"
+ nz-tooltip="Download the file"
+ [disabled]="!isLogin || !downloadAllowed"
+ (click)="downloadCurrentFile.emit()">
+ <i
+ nz-icon
+ nzTheme="outline"
+ nzType="download">
+ </i>
+ </button>
+ <button
+ nz-button
+ *ngIf="!isMaximized && selectedVersion"
+ nz-tooltip="Maximize View"
+ (click)="onClickScaleTheView()">
+ <i
+ nz-icon
+ nzTheme="outline"
+ nzType="expand">
+ </i>
+ </button>
+ <button
+ nz-button
+ *ngIf="isMaximized && selectedVersion"
+ nz-tooltip="Minimize View"
+ (click)="onClickScaleTheView()">
+ <i
+ nz-icon
+ nzTheme="outline"
+ nzType="compress">
+ </i>
+ </button>
+ <button
+ *ngIf="!isRightBarCollapsed"
+ nz-button
+ nz-tooltip="Hide the right bar"
+ (click)="onClickHideRightBar()">
+ <i
+ nz-icon
+ nzTheme="outline"
+ nzType="right">
+ </i>
+ </button>
+ <button
+ *ngIf="isRightBarCollapsed"
+ nz-button
+ nz-tooltip="Show Tree"
+ (click)="onClickHideRightBar()">
+ <i
+ nz-icon
+ nzTheme="outline"
+ nzType="left">
+ </i>
+ </button>
+ </div>
+ </div>
+ </nz-card>
+ <nz-empty
+ class="empty-version-indicator"
+ *ngIf="!selectedVersion"
+ [nzNotFoundContent]="emptyMessage"></nz-empty>
+
+ <texera-user-dataset-file-renderer
+ *ngIf="selectedVersion"
+ [isMaximized]="isMaximized"
+ [resourceType]="resourceType"
+ [resourceId]="resourceId"
+ [versionId]="versionId"
+ [filePath]="currentDisplayedFileName"
+ [fileSize]="currentFileSize"
+ [isLogin]="isLogin"
+ class="file-renderer">
+ </texera-user-dataset-file-renderer>
+ </nz-content>
+ <nz-sider
+ *ngIf="!isRightBarCollapsed"
+ nzTheme="light"
+ [nzWidth]="siderWidth"
+ nz-resizable
+ [nzMinWidth]="MIN_SIDER_WIDTH"
+ [nzMaxWidth]="MAX_SIDER_WIDTH"
+ (nzResize)="onSideResize($event)">
+ <nz-resize-handle nzDirection="left">
+ <div class="sider-resize-line">
+ <i
+ class="sider-resize-handle"
+ nz-icon
+ nzType="more"
+ nzTheme="outline"></i>
+ </div>
+ </nz-resize-handle>
+ <div class="right-sider">
+ <nz-collapse nzGhost>
+ <nz-collapse-panel
+ nzHeader="Current Versions"
+ nzActive="true">
+ <div class="version-storage">
+ <h6 style="font-weight: lighter; font-size: 0.9em">Choose a
Version:</h6>
+ <div class="select-and-button-container">
+ <nz-select
+ nzShowSearch
+ nzPlaceHolder="Select a version"
+ [ngModel]="selectedVersion"
+ (ngModelChange)="versionSelected.emit($event)">
+ <nz-option
+ *ngFor="let version of versions"
+ [nzValue]="version"
+ [nzLabel]="version.name"></nz-option>
+ </nz-select>
+ <button
+ nz-button
+ [nz-tooltip]="downloadZipTooltip"
+ [attr.nz-tooltip]="downloadZipTooltip"
+ (click)="downloadVersionAsZip.emit()"
+ *ngIf="selectedVersion"
+ [disabled]="!isLogin || !downloadAllowed"
+ class="spaced-button">
+ <i
+ nz-icon
+ nzType="download"
+ nzTheme="outline"></i>
+ </button>
+ </div>
+ <ng-container *ngIf="selectedVersion">
+ <div class="version-size">
+ <i
+ nz-icon
+ nzType="database"
+ nzTheme="outline"
+ class="icon-database"></i>
+ Version Size: {{ formatSize(currentVersionSize) }}
+ </div>
+ <div
+ *ngIf="selectedVersionCreationTime"
+ class="version-date">
+ <i
+ nz-icon
+ nzType="calendar"
+ nzTheme="outline"
+ class="icon-database"></i>
+ Created at: {{ selectedVersionCreationTime }}
+ </div>
+ </ng-container>
+ </div>
+ <texera-user-dataset-version-filetree
+ [fileTreeNodes]="fileTreeNodeList"
+ [isTreeNodeDeletable]="writeAccess"
+ [isCoverSettable]="writeAccess"
+ (selectedTreeNode)="fileTreeNodeSelected.emit($event)"
+ (deletedTreeNode)="fileDeleted.emit($event)"
+ (setCoverImage)="setCoverImage.emit($event)">
+ </texera-user-dataset-version-filetree>
+ </nz-collapse-panel>
+ </nz-collapse>
+
+ <nz-divider *ngIf="writeAccess"></nz-divider>
+ <texera-version-uploader
+ *ngIf="writeAccess"
+ [resourceId]="resourceId"
+ [ownerEmail]="ownerEmail"
+ [resourceName]="resourceName"
+ [endpoint]="endpoint"
+ [createVersion]="createVersion"
+ (versionCreated)="versionCreated.emit()"
+ (uploadsInFlightChange)="uploadsInFlightChange.emit($event)">
+ </texera-version-uploader>
+ </div>
+ </nz-sider>
+ </nz-layout>
+</div>
diff --git
a/frontend/src/app/dashboard/component/user/versions-files-browser/versions-files-browser.component.scss
b/frontend/src/app/dashboard/component/user/versions-files-browser/versions-files-browser.component.scss
new file mode 100644
index 0000000000..b1e2c96b26
--- /dev/null
+++
b/frontend/src/app/dashboard/component/user/versions-files-browser/versions-files-browser.component.scss
@@ -0,0 +1,136 @@
+/**
+ * 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.
+ */
+
+.versions-tab-content {
+ height: 100%;
+ overflow-y: auto;
+ padding-bottom: 24px;
+}
+
+nz-layout {
+ height: 100%;
+}
+
+.version-storage {
+ padding: 0 15px;
+ margin-bottom: 25px;
+}
+
+.version-storage nz-select {
+ width: 100%;
+ margin: 0;
+}
+
+nz-select {
+ flex-grow: 1;
+}
+
+.right-sider {
+ height: 100%;
+ overflow-y: auto;
+ position: relative;
+ z-index: 0;
+}
+
+.sider-resize-line {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ width: 5px;
+ border-right: 1px solid #e8e8e8;
+}
+
+.sider-resize-handle {
+ background: #fff;
+ border: 1px solid #ddd;
+ text-align: center;
+ font-size: 12px;
+ height: 20px;
+ line-height: 20px;
+}
+
+.file-renderer {
+ width: 95%;
+ height: 80%;
+ margin: auto;
+}
+
+.select-and-button-container {
+ display: flex;
+ align-items: stretch;
+ gap: 10px;
+}
+
+.spaced-button {
+ flex-shrink: 0;
+ width: 30px;
+ height: 24px;
+}
+
+.file-title {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 0;
+}
+
+.file-title-main {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+}
+
+.copy-path-btn {
+ padding: 0 4px;
+ height: auto;
+ line-height: 1;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.file-size,
+.version-size,
+.version-date {
+ font-size: 12px;
+ color: #8c8c8c;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.version-date {
+ display: flex;
+}
+
+.version-size {
+ margin-top: 8px;
+}
+
+.icon-database,
+.icon-file {
+ font-size: 14px;
+}
+
+.empty-version-indicator {
+ margin-top: 15%;
+}
diff --git
a/frontend/src/app/dashboard/component/user/versions-files-browser/versions-files-browser.component.spec.ts
b/frontend/src/app/dashboard/component/user/versions-files-browser/versions-files-browser.component.spec.ts
new file mode 100644
index 0000000000..7b558dede6
--- /dev/null
+++
b/frontend/src/app/dashboard/component/user/versions-files-browser/versions-files-browser.component.spec.ts
@@ -0,0 +1,182 @@
+/**
+ * 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 { By } from "@angular/platform-browser";
+import { NoopAnimationsModule } from "@angular/platform-browser/animations";
+import { NzResizeEvent } from "ng-zorro-antd/resizable";
+import { NzSelectComponent } from "ng-zorro-antd/select";
+import { VersionsFilesBrowserComponent } from
"./versions-files-browser.component";
+import { NotificationService } from
"../../../../common/service/notification/notification.service";
+import { commonTestImports, commonTestProviders } from
"../../../../common/testing/test-utils";
+
+describe("VersionsFilesBrowserComponent", () => {
+ let fixture: ComponentFixture<VersionsFilesBrowserComponent>;
+ let component: VersionsFilesBrowserComponent;
+ let notification: {
+ success: ReturnType<typeof vi.fn>;
+ error: ReturnType<typeof vi.fn>;
+ info: ReturnType<typeof vi.fn>;
+ };
+
+ beforeEach(() => {
+ notification = { success: vi.fn(), error: vi.fn(), info: vi.fn() };
+ TestBed.configureTestingModule({
+ imports: [VersionsFilesBrowserComponent, NoopAnimationsModule,
...commonTestImports],
+ providers: [{ provide: NotificationService, useValue: notification },
...commonTestProviders],
+ });
+ fixture = TestBed.createComponent(VersionsFilesBrowserComponent);
+ component = fixture.componentInstance;
+ });
+
+ it("starts with the default sider bounds and width", () => {
+ expect(component.siderWidth).toBe(400);
+ expect(component.MIN_SIDER_WIDTH).toBe(150);
+ expect(component.MAX_SIDER_WIDTH).toBe(600);
+ expect(component.MIN_SIDER_WIDTH).toBeLessThan(component.MAX_SIDER_WIDTH);
+ });
+
+ // The parent owns both toolbar flags: the browser reports a toggle and
never mutates its input.
+ it("reports a maximize toggle instead of owning the flag", () => {
+ const emitted: boolean[] = [];
+ component.isMaximizedChange.subscribe(v => emitted.push(v));
+
+ component.isMaximized = false;
+ component.onClickScaleTheView();
+ component.isMaximized = true;
+ component.onClickScaleTheView();
+
+ expect(emitted).toEqual([true, false]);
+ expect(component.isMaximized).toBe(true); // input left untouched
+ });
+
+ it("reports a right-bar toggle instead of owning the flag", () => {
+ const emitted: boolean[] = [];
+ component.isRightBarCollapsedChange.subscribe(v => emitted.push(v));
+
+ component.isRightBarCollapsed = false;
+ component.onClickHideRightBar();
+
+ expect(emitted).toEqual([true]);
+ expect(component.isRightBarCollapsed).toBe(false);
+ });
+
+ it("applies the dragged sider width on the next animation frame", async ()
=> {
+ component.onSideResize({ width: 321 } as NzResizeEvent);
+ // the handler defers to requestAnimationFrame; a synchronous write would
land here
+ expect(component.siderWidth).toBe(400);
+ await new Promise(resolve => requestAnimationFrame(() => resolve(null)));
+
+ expect(component.siderWidth).toBe(321);
+ });
+
+ it("cancels the frame the previous resize scheduled", () => {
+ const request = vi.spyOn(globalThis,
"requestAnimationFrame").mockReturnValue(100);
+ const cancel = vi.spyOn(globalThis, "cancelAnimationFrame");
+ try {
+ component.onSideResize({ width: 100 } as NzResizeEvent);
+ cancel.mockClear(); // drop the initial cancel of the sentinel id
+
+ component.onSideResize({ width: 200 } as NzResizeEvent);
+
+ expect(cancel).toHaveBeenCalledWith(100);
+ } finally {
+ cancel.mockRestore();
+ request.mockRestore();
+ }
+ });
+
+ describe("copyCurrentFilePath", () => {
+ let originalClipboardDescriptor: PropertyDescriptor | undefined;
+ let writeText: ReturnType<typeof vi.fn>;
+
+ beforeEach(() => {
+ originalClipboardDescriptor = Object.getOwnPropertyDescriptor(navigator,
"clipboard");
+ writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, "clipboard", { value: { writeText },
configurable: true });
+ });
+
+ afterEach(() => {
+ if (originalClipboardDescriptor) {
+ Object.defineProperty(navigator, "clipboard",
originalClipboardDescriptor);
+ } else {
+ delete (navigator as any).clipboard;
+ }
+ });
+
+ it("writes the displayed path to the clipboard and toasts success", async
() => {
+ component.currentDisplayedFileName = "/a/b/c.txt";
+
+ await component.copyCurrentFilePath();
+
+ expect(writeText).toHaveBeenCalledWith("/a/b/c.txt");
+ expect(notification.success).toHaveBeenCalledWith("File path copied to
clipboard");
+ });
+
+ it("does nothing when no file is displayed", async () => {
+ component.currentDisplayedFileName = "";
+
+ await component.copyCurrentFilePath();
+
+ expect(writeText).not.toHaveBeenCalled();
+ });
+
+ it("toasts an error when the clipboard write rejects", async () => {
+ writeText.mockRejectedValue(new Error("denied"));
+ component.currentDisplayedFileName = "/a/b/c.txt";
+
+ await component.copyCurrentFilePath();
+
+ expect(notification.error).toHaveBeenCalledWith("Failed to copy file
path");
+ });
+ });
+
+ it("delegates notePathStaged to the embedded uploader", () => {
+ const uploader = { notePathStaged: vi.fn() };
+ (component as any).versionUploader = uploader;
+
+ component.notePathStaged("owner/ds/v1/a.txt");
+
+ expect(uploader.notePathStaged).toHaveBeenCalledWith("owner/ds/v1/a.txt");
+ });
+
+ it("emits the version chosen in the picker", () => {
+ const v1 = { name: "v1" };
+ const v2 = { name: "v2" };
+ const picked: Array<{ name: string } | undefined> = [];
+ component.versions = [v1, v2];
+ component.versionSelected.subscribe(v => picked.push(v));
+ fixture.detectChanges();
+
+ const select = fixture.debugElement.query(By.directive(NzSelectComponent));
+ select.triggerEventHandler("ngModelChange", v2);
+
+ expect(picked).toEqual([v2]);
+ });
+
+ it("shows the empty-state message until a version is selected", () => {
+ component.emptyMessage = "No version is selected";
+ component.selectedVersion = undefined;
+ fixture.detectChanges();
+
+ const empty = (fixture.nativeElement as
HTMLElement).querySelector(".empty-version-indicator");
+ expect(empty).not.toBeNull();
+ expect((fixture.nativeElement as HTMLElement).textContent ??
"").toContain("No version is selected");
+ });
+});
diff --git
a/frontend/src/app/dashboard/component/user/versions-files-browser/versions-files-browser.component.ts
b/frontend/src/app/dashboard/component/user/versions-files-browser/versions-files-browser.component.ts
new file mode 100644
index 0000000000..7acc71532e
--- /dev/null
+++
b/frontend/src/app/dashboard/component/user/versions-files-browser/versions-files-browser.component.ts
@@ -0,0 +1,177 @@
+/**
+ * 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 { Component, EventEmitter, Input, Output, ViewChild } from
"@angular/core";
+import { Observable } from "rxjs";
+import { NgIf, NgFor } from "@angular/common";
+import { FormsModule } from "@angular/forms";
+import { NzResizeEvent, NzResizableDirective, NzResizeHandleComponent } from
"ng-zorro-antd/resizable";
+import { NzCardComponent } from "ng-zorro-antd/card";
+import { NzTooltipDirective } from "ng-zorro-antd/tooltip";
+import { NzIconDirective } from "ng-zorro-antd/icon";
+import { NzButtonComponent } from "ng-zorro-antd/button";
+import { NzLayoutComponent, NzContentComponent, NzSiderComponent } from
"ng-zorro-antd/layout";
+import { NzEmptyComponent } from "ng-zorro-antd/empty";
+import { NzCollapseComponent, NzCollapsePanelComponent } from
"ng-zorro-antd/collapse";
+import { NzSelectComponent, NzOptionComponent } from "ng-zorro-antd/select";
+import { NzDividerComponent } from "ng-zorro-antd/divider";
+import { formatSize } from "src/app/common/util/size-formatter.util";
+import { NotificationService } from
"../../../../common/service/notification/notification.service";
+import { EntityType } from "../../../../hub/service/hub.service";
+import { DatasetFileNode } from
"../../../../common/type/datasetVersionFileTree";
+import {
+ FileResourceEndpoint,
+ DATASET_FILE_RESOURCE_ENDPOINT,
+} from "../../../service/user/file-resource/file-resource-endpoint";
+import { UserDatasetFileRendererComponent } from
"../user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component";
+import { UserDatasetVersionFiletreeComponent } from
"../user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component";
+import { VersionUploaderComponent } from
"../version-uploader/version-uploader.component";
+
+/** The only field the version picker needs from a dataset/model version. */
+export interface VersionListEntry {
+ readonly name: string;
+}
+
+/**
+ * Presentational "Versions & Files" tab shared by the dataset and model
detail pages: a file
+ * header/toolbar, the file renderer, and a resizable sider holding the
version picker, file tree,
+ * and version uploader. It owns only its local toolbar view state; every
server call and the
+ * version-selection flow stay in the parent, reached through the inputs and
outputs below.
+ */
+@Component({
+ selector: "texera-versions-files-browser",
+ templateUrl: "./versions-files-browser.component.html",
+ styleUrls: ["./versions-files-browser.component.scss"],
+ imports: [
+ NgIf,
+ NgFor,
+ FormsModule,
+ NzCardComponent,
+ NzTooltipDirective,
+ NzIconDirective,
+ NzButtonComponent,
+ NzLayoutComponent,
+ NzContentComponent,
+ NzSiderComponent,
+ NzResizableDirective,
+ NzResizeHandleComponent,
+ NzEmptyComponent,
+ NzCollapseComponent,
+ NzCollapsePanelComponent,
+ NzSelectComponent,
+ NzOptionComponent,
+ NzDividerComponent,
+ UserDatasetFileRendererComponent,
+ UserDatasetVersionFiletreeComponent,
+ VersionUploaderComponent,
+ ],
+})
+export class VersionsFilesBrowserComponent<V extends VersionListEntry =
VersionListEntry> {
+ // File currently shown in the renderer (resolved by the parent).
+ @Input() currentDisplayedFileName: string = "";
+ @Input() currentFileSize: number | undefined;
+ @Input() currentVersionSize: number | undefined;
+ @Input() selectedVersionCreationTime: string = "";
+
+ // Version picker.
+ @Input() versions: ReadonlyArray<V> = [];
+ @Input() selectedVersion: V | undefined;
+ @Output() versionSelected = new EventEmitter<V | undefined>();
+
+ // File tree.
+ @Input() fileTreeNodeList: DatasetFileNode[] = [];
+ @Output() fileTreeNodeSelected = new EventEmitter<DatasetFileNode>();
+ @Output() fileDeleted = new EventEmitter<DatasetFileNode>();
+ @Output() setCoverImage = new EventEmitter<string>();
+
+ // File renderer. resourceType defaults to Dataset to match the renderer's
own default.
+ @Input() resourceId: number | undefined;
+ @Input() resourceType: EntityType = EntityType.Dataset;
+ @Input() versionId: number | undefined;
+
+ // Version uploader.
+ @Input() ownerEmail: string = "";
+ @Input() resourceName: string = "";
+ @Input() endpoint: FileResourceEndpoint = DATASET_FILE_RESOURCE_ENDPOINT;
+ @Input() createVersion!: (versionName: string) => Observable<unknown>;
+ @Output() versionCreated = new EventEmitter<void>();
+ @Output() uploadsInFlightChange = new EventEmitter<boolean>();
+
+ // Flags and per-resource labels.
+ @Input() isLogin: boolean = false;
+ @Input() writeAccess: boolean = false;
+ @Input() downloadAllowed: boolean = false;
+ @Input() emptyMessage: string = "";
+ @Input() downloadZipTooltip: string = "";
+
+ // Toolbar actions handled by the parent (per-resource download endpoints).
+ @Output() downloadCurrentFile = new EventEmitter<void>();
+ @Output() downloadVersionAsZip = new EventEmitter<void>();
+
+ // The parent owns the two toolbar view flags (maximizing also hides the
parent page
+ // header); the browser reads them and reports toggles back through the
two-way bindings.
+ @Input() isMaximized = false;
+ @Output() isMaximizedChange = new EventEmitter<boolean>();
+ @Input() isRightBarCollapsed = false;
+ @Output() isRightBarCollapsedChange = new EventEmitter<boolean>();
+
+ // Local sider width state.
+ readonly MAX_SIDER_WIDTH = 600;
+ readonly MIN_SIDER_WIDTH = 150;
+ siderWidth = 400;
+ private rafId = -1;
+
+ formatSize = formatSize;
+
+ @ViewChild(VersionUploaderComponent) private versionUploader?:
VersionUploaderComponent;
+
+ constructor(private notificationService: NotificationService) {}
+
+ onSideResize({ width }: NzResizeEvent): void {
+ cancelAnimationFrame(this.rafId);
+ this.rafId = requestAnimationFrame(() => {
+ this.siderWidth = width!;
+ });
+ }
+
+ onClickScaleTheView(): void {
+ this.isMaximizedChange.emit(!this.isMaximized);
+ }
+
+ onClickHideRightBar(): void {
+ this.isRightBarCollapsedChange.emit(!this.isRightBarCollapsed);
+ }
+
+ async copyCurrentFilePath(): Promise<void> {
+ if (!this.currentDisplayedFileName) {
+ return;
+ }
+ try {
+ await navigator.clipboard.writeText(this.currentDisplayedFileName);
+ this.notificationService.success("File path copied to clipboard");
+ } catch {
+ this.notificationService.error("Failed to copy file path");
+ }
+ }
+
+ /** Lets the parent stage a path on the embedded uploader after it deletes
the file. */
+ notePathStaged(relativePath: string): void {
+ this.versionUploader?.notePathStaged(relativePath);
+ }
+}