You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by gi...@apache.org on 2026/10/10 09:06:22 UTC

(texera) branch gh-readonly-queue/main/pr-8942-3fa608d04c124ec6c646e194f26f2cd391f9c6a9 created (now 8bfe034af1)

This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a change to branch gh-readonly-queue/main/pr-8942-3fa608d04c124ec6c646e194f26f2cd391f9c6a9
in repository https://gitbox.apache.org/repos/asf/texera.git


      at 8bfe034af1 refactor(frontend): extract shared VersionsFilesBrowser component (#8942)

This branch includes the following new commits:

     new 8bfe034af1 refactor(frontend): extract shared VersionsFilesBrowser component (#8942)

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(texera) 01/01: refactor(frontend): extract shared VersionsFilesBrowser component (#8942)

Posted by gi...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-8942-3fa608d04c124ec6c646e194f26f2cd391f9c6a9
in repository https://gitbox.apache.org/repos/asf/texera.git

commit 8bfe034af17f88e8396b4961714658a48f79cff2
Author: Matthew B. <mg...@uci.edu>
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 &amp; 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);
+  }
+}