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/08/28 22:55:46 UTC

(texera) branch gh-readonly-queue/main/pr-8072-782c37f905c484d7cff272111b45dabb2aee7ecd created (now b59c7406d0)

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-8072-782c37f905c484d7cff272111b45dabb2aee7ecd
in repository https://gitbox.apache.org/repos/asf/texera.git


      at b59c7406d0 feat(frontend): add the model detail page (#8072)

This branch includes the following new commits:

     new b59c7406d0 feat(frontend): add the model detail page (#8072)

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: feat(frontend): add the model detail page (#8072)

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-8072-782c37f905c484d7cff272111b45dabb2aee7ecd
in repository https://gitbox.apache.org/repos/asf/texera.git

commit b59c7406d0a7d549a50c1f86888e85563f2d3446
Author: Tanishq Gandhi <56...@users.noreply.github.com>
AuthorDate: Fri Aug 28 22:55:31 2026 +0000

    feat(frontend): add the model detail page (#8072)
    
    ### What changes were proposed in this PR?
    
    **Refactor.** `user-dataset-file-renderer`'s `did`/`dvid` inputs become
    `resourceId`/`versionId`, so a model no longer has to smuggle its ids
    through dataset-shaped names. `download` moves onto the resource
    descriptor: `card-item` and `list-item` both carried a
    `workflow`/`dataset` branch, and both now ask `descriptor.download`.
    
    One deliberate behavior change: the branch read
    `entry.workflow.workflow.name`, which a rename leaves stale, so
    downloading a just-renamed workflow produced a zip named after the old
    name. It now reads `entry.name`. Covered by a test.
    
    **The model detail page.** A read-only page at `/user/model/:mid`: name,
    created-at, public/downloadable/framework/format tags, placeholder view
    and like counters, cover image; a Model Card tab; and a Versions & Files
    tab with the version picker, file tree, file preview, and single-file
    and version-ZIP downloads. `ModelService` gains the read endpoints,
    `DownloadService` the three model downloads, and
    `ModelResourceDescriptor` a `privateRoute` — so the cards added in #8068
    now open something.
    
    The file renderer takes a `resourceType` input, but resolves its fetch
    through a new `retrieveSingleFile` descriptor slot rather than growing a
    `type === "model"` arm, which is the point of the registry.
    
    **No Settings tab yet, on purpose.** Metadata editing (name,
    description, framework, format) lands with upload in the next PR;
    visibility, sharing and covers in the one after.
    
    **View and like counters read a constant `0`, deliberately.** The hub
    backend has no model entity — `hub/EntityType.scala` is `Seq(Workflow,
    Dataset)` with a throwing `fromString`, `EntityTables.forType` matches
    only those two, and there are no `model_user_likes` / `model_view_count`
    tables, so every hub call from this page would 400. The tags are there
    so the header matches the dataset page; they get wired up in the hub PR,
    alongside #7930.
    
    <img width="807" height="647" alt="Screenshot 2026-08-28 at 11 19 16 AM"
    src="https://github.com/user-attachments/assets/a22ec4e3-eccc-4a5d-bcb5-3fc5147921f6"
    />
    <img width="1210" height="602" alt="Screenshot 2026-08-28 at 11 18
    45 AM"
    src="https://github.com/user-attachments/assets/eaabec50-7c93-4d36-aa67-11650e4500d7"
    />
    <img width="1228" height="687" alt="Screenshot 2026-08-28 at 11 18
    33 AM"
    src="https://github.com/user-attachments/assets/8974de85-9633-4439-8a4d-4dec0d03e129"
    />
    
    
    
    
    ### Any related issues, documentation, discussions?
    
    Part of #6499. Depends on #8068.
    
    ### How was this PR tested?
    
    New: 26 specs for the detail page, 7 for the new `ModelService` methods,
    3 for the model downloads, 2 for the renderer's model branch, plus
    registry coverage for `download` and `retrieveSingleFile`.
    
    The refactor's proof is that all 353 existing `user-dataset` specs pass
    unmodified. Four specs elsewhere needed scaffolding changes, none of
    them changed expectations: three `card-item` template tests set `entry`
    without running the input pipeline and now call `initializeEntry()` (a
    pattern already used in that file), and one `list-item` test moved from
    `(component as any).downloadService` to
    `TestBed.inject(DownloadService)`,since the component no longer injects
    it.
    
    Full dashboard and hub suite: 1880 tests, all passing. Verified manually
    against a local stack.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code
---
 frontend/src/app/app-routing.module.ts             |   5 +
 frontend/src/app/common/type/model.ts              |  12 +
 .../list-item/card-item/card-item.component.html   |   2 +-
 .../card-item/card-item.component.spec.ts          |   5 +-
 .../list-item/card-item/card-item.component.ts     |  17 +-
 .../user/list-item/list-item.component.html        |   2 +-
 .../user/list-item/list-item.component.spec.ts     |  26 +-
 .../user/list-item/list-item.component.ts          |  17 +-
 .../dataset-detail.component.html                  |   4 +-
 .../user-dataset-file-renderer.component.spec.ts   |  67 ++-
 .../user-dataset-file-renderer.component.ts        |  21 +-
 .../model-detail.component.html                    | 357 ++++++++++++++++
 .../model-detail.component.scss                    | 296 +++++++++++++
 .../model-detail.component.spec.ts                 | 473 +++++++++++++++++++++
 .../user-model-explorer/model-detail.component.ts  | 353 +++++++++++++++
 .../service/user/download/download.service.spec.ts |  45 ++
 .../service/user/download/download.service.ts      |  39 ++
 .../service/user/model/model.service.spec.ts       |  70 +++
 .../dashboard/service/user/model/model.service.ts  |  66 ++-
 .../dataset-resource.descriptor.ts                 |   9 +-
 .../resource-registry/model-resource.descriptor.ts |  14 +-
 .../resource-registry.service.spec.ts              |  42 +-
 .../workflow-resource.descriptor.ts                |   7 +-
 .../src/app/dashboard/type/resource-descriptor.ts  |   4 +
 24 files changed, 1882 insertions(+), 71 deletions(-)

diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts
index 6acbccebf1..cd936421d9 100644
--- a/frontend/src/app/app-routing.module.ts
+++ b/frontend/src/app/app-routing.module.ts
@@ -40,6 +40,7 @@ import { AdminGmailComponent } from "./dashboard/component/admin/gmail/admin-gma
 import { DatasetDetailComponent } from "./dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component";
 import { UserDatasetComponent } from "./dashboard/component/user/user-dataset/user-dataset.component";
 import { UserModelComponent } from "./dashboard/component/user/user-model/user-model.component";
+import { ModelDetailComponent } from "./dashboard/component/user/user-model/user-model-explorer/model-detail.component";
 import { HubWorkflowDetailComponent } from "./hub/component/workflow/detail/hub-workflow-detail.component";
 import { LandingPageComponent } from "./hub/component/landing-page/landing-page.component";
 import { USER_WORKFLOW } from "./app-routing.constant";
@@ -140,6 +141,10 @@ routes.push({
           path: "model",
           component: UserModelComponent,
         },
+        {
+          path: "model/:mid",
+          component: ModelDetailComponent,
+        },
         {
           path: "compute",
           component: UserComputingUnitComponent,
diff --git a/frontend/src/app/common/type/model.ts b/frontend/src/app/common/type/model.ts
index 05e979249d..2df770b624 100644
--- a/frontend/src/app/common/type/model.ts
+++ b/frontend/src/app/common/type/model.ts
@@ -17,6 +17,8 @@
  * under the License.
  */
 
+import { DatasetFileNode } from "./datasetVersionFileTree";
+
 export interface Model {
   mid: number | undefined;
   ownerUid: number | undefined;
@@ -30,3 +32,13 @@ export interface Model {
   framework: string | undefined;
   format: string | undefined;
 }
+
+export interface ModelVersion {
+  mvid: number | undefined;
+  mid: number;
+  creatorUid: number;
+  name: string;
+  versionHash: string | undefined;
+  creationTime: number | undefined;
+  fileNodes: DatasetFileNode[] | undefined;
+}
diff --git a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html
index c2e112601c..3846055a1f 100644
--- a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html
+++ b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html
@@ -232,7 +232,7 @@
           nzType="text"
           class="action-btn"
           title="Download"
-          *ngIf="entry.type === 'workflow' || entry.type === 'dataset'"
+          *ngIf="canDownload"
           (click)="onClickDownload(); $event.stopPropagation()">
           <i
             nz-icon
diff --git a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.spec.ts b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.spec.ts
index fbc393d345..ea0bc05524 100644
--- a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.spec.ts
+++ b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.spec.ts
@@ -689,7 +689,7 @@ describe("CardItemComponent", () => {
     it("onClickDownload downloads a workflow via the download service", () => {
       const downloadService = TestBed.inject(DownloadService);
       const downloadWorkflowSpy = vi.spyOn(downloadService, "downloadWorkflow").mockReturnValue(of({} as any));
-      component.entry = makeWorkflowEntry({ id: 7, workflow: { isOwner: true, workflow: { name: "myflow" } } } as any);
+      component.entry = makeWorkflowEntry({ id: 7, name: "myflow" });
 
       component.onClickDownload();
 
@@ -909,6 +909,7 @@ describe("CardItemComponent", () => {
       component.entry = makeWorkflowEntry();
       component.isPrivateSearch = true;
       component.currentUid = 1;
+      component.initializeEntry(); // the Download button reads a per-kind capability off the entry
       fixture.detectChanges();
 
       const de = fixture.debugElement;
@@ -927,6 +928,7 @@ describe("CardItemComponent", () => {
       component.entry = makeWorkflowEntry();
       component.isPrivateSearch = true;
       component.currentUid = 1;
+      component.initializeEntry();
       fixture.detectChanges();
 
       const detailSpy = vi.spyOn(component, "openDetailModal").mockImplementation(() => {});
@@ -1029,6 +1031,7 @@ describe("CardItemComponent", () => {
     it("shows Download but hides Detail/Copy/checkbox for a dataset in private mode", () => {
       component.entry = makeDatasetEntry();
       component.isPrivateSearch = true;
+      component.initializeEntry();
       fixture.detectChanges();
 
       const de = fixture.debugElement;
diff --git a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts
index 7061cd9050..5b3dfe16a5 100644
--- a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts
+++ b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts
@@ -46,7 +46,6 @@ import { WorkflowPersistService } from "src/app/common/service/workflow-persist/
 import { firstValueFrom } from "rxjs";
 import { HubWorkflowDetailComponent } from "../../../../../hub/component/workflow/detail/hub-workflow-detail.component";
 import { ActionType, HubService } from "../../../../../hub/service/hub.service";
-import { DownloadService } from "src/app/dashboard/service/user/download/download.service";
 import { formatSize } from "src/app/common/util/size-formatter.util";
 import { formatRelativeTime, formatCount } from "src/app/common/util/format.util";
 import { DatasetService } from "../../../../service/user/dataset/dataset.service";
@@ -78,6 +77,7 @@ export class CardItemComponent implements OnChanges {
   public originalName: string = "";
   public originalDescription: string | undefined = undefined;
   public disableDelete: boolean = false;
+  public canDownload: boolean = false;
   @Input() currentUid: number | undefined;
   @ViewChild("nameInput") nameInput!: ElementRef;
   @ViewChild("descriptionInput") descriptionInput!: ElementRef;
@@ -125,7 +125,6 @@ export class CardItemComponent implements OnChanges {
     private datasetService: DatasetService,
     private modal: NzModalService,
     private hubService: HubService,
-    private downloadService: DownloadService,
     private cdr: ChangeDetectorRef,
     private notificationService: NotificationService,
     private workflowCoverService: WorkflowCoverService,
@@ -191,6 +190,7 @@ export class CardItemComponent implements OnChanges {
     const descriptor = this.resourceRegistry.get(this.entry.type);
     this.iconType = descriptor.iconType;
     this.disableDelete = !descriptor.isOwner(this.entry);
+    this.canDownload = descriptor.download !== undefined;
     this.entryLink = this.resourceRegistry.entryLink(this.entry, this.currentUid);
     if (descriptor.hasSize && typeof this.entry.id === "number") {
       this.size = this.entry.size;
@@ -285,16 +285,9 @@ export class CardItemComponent implements OnChanges {
   }
 
   public onClickDownload = (): void => {
-    if (!this.entry.id) return;
-
-    if (this.entry.type === "workflow") {
-      this.downloadService
-        .downloadWorkflow(this.entry.id, this.entry.workflow.workflow.name)
-        .pipe(untilDestroyed(this))
-        .subscribe();
-    } else if (this.entry.type === "dataset") {
-      this.downloadService.downloadDataset(this.entry.id, this.entry.name).pipe(untilDestroyed(this)).subscribe();
-    }
+    const download = this.resourceRegistry.get(this.entry.type).download;
+    if (!this.entry.id || !download) return;
+    download(this.entry.id, this.entry.name).pipe(untilDestroyed(this)).subscribe();
   };
 
   onEditName(): void {
diff --git a/frontend/src/app/dashboard/component/user/list-item/list-item.component.html b/frontend/src/app/dashboard/component/user/list-item/list-item.component.html
index 7f75f16fe2..a223afe6e4 100644
--- a/frontend/src/app/dashboard/component/user/list-item/list-item.component.html
+++ b/frontend/src/app/dashboard/component/user/list-item/list-item.component.html
@@ -216,7 +216,7 @@
     <button
       nz-button
       nzType="text"
-      *ngIf="entry.type === 'workflow' || entry.type === 'dataset'"
+      *ngIf="canDownload"
       title="Download"
       (click)="onClickDownload()">
       <i
diff --git a/frontend/src/app/dashboard/component/user/list-item/list-item.component.spec.ts b/frontend/src/app/dashboard/component/user/list-item/list-item.component.spec.ts
index eb38647b7f..5128396243 100644
--- a/frontend/src/app/dashboard/component/user/list-item/list-item.component.spec.ts
+++ b/frontend/src/app/dashboard/component/user/list-item/list-item.component.spec.ts
@@ -18,6 +18,7 @@
  */
 
 import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { DownloadService } from "src/app/dashboard/service/user/download/download.service";
 import { By } from "@angular/platform-browser";
 import { ListItemComponent } from "./list-item.component";
 import {
@@ -566,10 +567,8 @@ describe("ListItemComponent", () => {
 
     describe("download", () => {
       it("downloads a workflow by id and name", () => {
-        const download = vi
-          .spyOn((component as any).downloadService, "downloadWorkflow")
-          .mockReturnValue(of(undefined));
-        feed(entryOf({ type: "workflow", workflow: { isOwner: true, workflow: { name: "flow" } } }));
+        const download = vi.spyOn(TestBed.inject(DownloadService), "downloadWorkflow").mockReturnValue(of({} as any));
+        feed(entryOf({ type: "workflow", name: "flow", workflow: { isOwner: true } }));
 
         component.onClickDownload();
 
@@ -577,7 +576,7 @@ describe("ListItemComponent", () => {
       });
 
       it("downloads a dataset by id and name", () => {
-        const download = vi.spyOn((component as any).downloadService, "downloadDataset").mockReturnValue(of(undefined));
+        const download = vi.spyOn(TestBed.inject(DownloadService), "downloadDataset").mockReturnValue(of(new Blob()));
         feed(entryOf({ type: "dataset", dataset: { isOwner: true }, name: "set" }));
 
         component.onClickDownload();
@@ -585,8 +584,23 @@ describe("ListItemComponent", () => {
         expect(download).toHaveBeenCalledWith(7, "set");
       });
 
+      it("downloads a renamed workflow under its new name", () => {
+        // The rename writes entry.name and leaves entry.workflow.workflow.name stale, so
+        // reading the payload here used to name the zip after the pre-rename workflow.
+        (workflowPersistService as any).updateWorkflowName.mockReturnValue(of({} as Response));
+        const download = vi.spyOn(TestBed.inject(DownloadService), "downloadWorkflow").mockReturnValue(of({} as any));
+        feed(
+          entryOf({ type: "workflow", name: "old-name", workflow: { isOwner: true, workflow: { name: "old-name" } } })
+        );
+
+        component.confirmUpdateCustomName("new-name");
+        component.onClickDownload();
+
+        expect(download).toHaveBeenCalledWith(7, "new-name");
+      });
+
       it("downloads nothing for an entry that was never persisted", () => {
-        const workflow = vi.spyOn((component as any).downloadService, "downloadWorkflow");
+        const workflow = vi.spyOn(TestBed.inject(DownloadService), "downloadWorkflow");
         feed(entryOf({ type: "file", id: 0 }));
 
         component.onClickDownload();
diff --git a/frontend/src/app/dashboard/component/user/list-item/list-item.component.ts b/frontend/src/app/dashboard/component/user/list-item/list-item.component.ts
index d141911596..4e7227e6ea 100644
--- a/frontend/src/app/dashboard/component/user/list-item/list-item.component.ts
+++ b/frontend/src/app/dashboard/component/user/list-item/list-item.component.ts
@@ -37,7 +37,6 @@ import { WorkflowPersistService } from "src/app/common/service/workflow-persist/
 import { firstValueFrom } from "rxjs";
 import { HubWorkflowDetailComponent } from "../../../../hub/component/workflow/detail/hub-workflow-detail.component";
 import { ActionType, HubService } from "../../../../hub/service/hub.service";
-import { DownloadService } from "src/app/dashboard/service/user/download/download.service";
 import { formatSize } from "src/app/common/util/size-formatter.util";
 import { formatCount, formatRelativeTime } from "src/app/common/util/format.util";
 import { DatasetService } from "../../../service/user/dataset/dataset.service";
@@ -84,6 +83,7 @@ export class ListItemComponent implements OnChanges {
   public originalName: string = "";
   public originalDescription: string | undefined = undefined;
   public disableDelete: boolean = false;
+  public canDownload: boolean = false;
   @Input() currentUid: number | undefined;
   @ViewChild("nameInput") nameInput!: ElementRef;
   @ViewChild("descriptionInput") descriptionInput!: ElementRef;
@@ -125,7 +125,6 @@ export class ListItemComponent implements OnChanges {
     private datasetService: DatasetService,
     private modal: NzModalService,
     private hubService: HubService,
-    private downloadService: DownloadService,
     private cdr: ChangeDetectorRef,
     private notificationService: NotificationService,
     private resourceRegistry: ResourceRegistryService
@@ -135,6 +134,7 @@ export class ListItemComponent implements OnChanges {
     const descriptor = this.resourceRegistry.get(this.entry.type);
     this.iconType = descriptor.iconType;
     this.disableDelete = !descriptor.isOwner(this.entry);
+    this.canDownload = descriptor.download !== undefined;
     this.entryLink = this.resourceRegistry.entryLink(this.entry, this.currentUid);
     if (descriptor.hasSize && typeof this.entry.id === "number") {
       this.size = this.entry.size;
@@ -211,16 +211,9 @@ export class ListItemComponent implements OnChanges {
   }
 
   public onClickDownload = (): void => {
-    if (!this.entry.id) return;
-
-    if (this.entry.type === "workflow") {
-      this.downloadService
-        .downloadWorkflow(this.entry.id, this.entry.workflow.workflow.name)
-        .pipe(untilDestroyed(this))
-        .subscribe();
-    } else if (this.entry.type === "dataset") {
-      this.downloadService.downloadDataset(this.entry.id, this.entry.name).pipe(untilDestroyed(this)).subscribe();
-    }
+    const download = this.resourceRegistry.get(this.entry.type).download;
+    if (!this.entry.id || !download) return;
+    download(this.entry.id, this.entry.name).pipe(untilDestroyed(this)).subscribe();
   };
 
   onEditName(): void {
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 d0595642aa..f47b8d43f9 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
@@ -360,8 +360,8 @@
           <texera-user-dataset-file-renderer
             *ngIf="selectedVersion"
             [isMaximized]="isMaximized"
-            [did]="did"
-            [dvid]="selectedVersion.dvid"
+            [resourceId]="did"
+            [versionId]="selectedVersion.dvid"
             [filePath]="currentDisplayedFileName"
             [fileSize]="currentFileSize"
             [isLogin]="isLogin"
diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.spec.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.spec.ts
index 0aadb792dd..e5e2a6b2ad 100644
--- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.spec.ts
+++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.spec.ts
@@ -21,6 +21,8 @@ import { ComponentFixture, TestBed } from "@angular/core/testing";
 import { HttpClientTestingModule } from "@angular/common/http/testing";
 import { getMimeType, MIME_TYPES, UserDatasetFileRendererComponent } from "./user-dataset-file-renderer.component";
 import { DatasetService } from "../../../../../service/user/dataset/dataset.service";
+import { ModelService } from "../../../../../service/user/model/model.service";
+import { EntityType } from "../../../../../../hub/service/hub.service";
 import { NotificationService } from "../../../../../../common/service/notification/notification.service";
 import { DomSanitizer } from "@angular/platform-browser";
 import { commonTestProviders } from "../../../../../../common/testing/test-utils";
@@ -39,6 +41,7 @@ describe("UserDatasetFileRendererComponent", () => {
       imports: [UserDatasetFileRendererComponent, HttpClientTestingModule],
       providers: [
         DatasetService,
+        ModelService,
         NotificationService,
         { provide: DomSanitizer, useValue: { bypassSecurityTrustUrl: vi.fn() } },
         ...commonTestProviders,
@@ -64,9 +67,9 @@ describe("UserDatasetFileRendererComponent", () => {
   describe("reloadFileContent", () => {
     it("flags an unsupported file type and does not hit the backend", () => {
       const spy = vi.spyOn(TestBed.inject(DatasetService), "retrieveDatasetVersionSingleFile");
-      // did/dvid are set so the early-return is what stops the request, not the missing ids.
-      component.did = 1;
-      component.dvid = 2;
+      // The ids are set so the early-return is what stops the request, not the missing ids.
+      component.resourceId = 1;
+      component.versionId = 2;
       component.filePath = "archive.bin"; // -> OCTET_STREAM -> unsupported
 
       component.reloadFileContent();
@@ -77,8 +80,8 @@ describe("UserDatasetFileRendererComponent", () => {
 
     it("flags an oversized file and does not hit the backend", () => {
       const spy = vi.spyOn(TestBed.inject(DatasetService), "retrieveDatasetVersionSingleFile");
-      component.did = 1;
-      component.dvid = 2;
+      component.resourceId = 1;
+      component.versionId = 2;
       component.filePath = "notes.txt"; // TXT limit is 1 MB
       component.fileSize = 5 * 1024 * 1024;
 
@@ -92,8 +95,8 @@ describe("UserDatasetFileRendererComponent", () => {
       const datasetService = TestBed.inject(DatasetService);
       const blob = new Blob(["hello"], { type: "text/plain" });
       const spy = vi.spyOn(datasetService, "retrieveDatasetVersionSingleFile").mockReturnValue(of(blob));
-      component.did = 1;
-      component.dvid = 2;
+      component.resourceId = 1;
+      component.versionId = 2;
       component.filePath = "notes.txt";
       component.isLogin = true;
       component.fileSize = 100;
@@ -104,6 +107,35 @@ describe("UserDatasetFileRendererComponent", () => {
       expect(component.displayPlainText).toBe(true);
       expect(component.isLoading).toBe(false);
     });
+
+    it("fetches from the model endpoint when the file belongs to a model", () => {
+      const datasetSpy = vi.spyOn(TestBed.inject(DatasetService), "retrieveDatasetVersionSingleFile");
+      const modelSpy = vi
+        .spyOn(TestBed.inject(ModelService), "retrieveModelVersionSingleFile")
+        .mockReturnValue(of(new Blob(["weights"], { type: "text/plain" })));
+      component.resourceType = EntityType.Model;
+      component.resourceId = 1;
+      component.versionId = 2;
+      component.filePath = "/model/a/m/v1/notes.txt";
+      component.isLogin = true;
+
+      component.reloadFileContent();
+
+      expect(modelSpy).toHaveBeenCalledWith("/model/a/m/v1/notes.txt", true);
+      expect(datasetSpy).not.toHaveBeenCalled();
+    });
+
+    it("hits nothing for a kind that has no file preview", () => {
+      const datasetSpy = vi.spyOn(TestBed.inject(DatasetService), "retrieveDatasetVersionSingleFile");
+      component.resourceType = EntityType.Workflow;
+      component.resourceId = 1;
+      component.versionId = 2;
+      component.filePath = "notes.txt";
+
+      component.reloadFileContent();
+
+      expect(datasetSpy).not.toHaveBeenCalled();
+    });
   });
 
   describe("error handlers", () => {
@@ -255,15 +287,15 @@ describe("UserDatasetFileRendererComponent", () => {
       expect(reloadSpy).toHaveBeenCalledTimes(1);
     });
 
-    it("reloads when both did and dvid change together", () => {
+    it("reloads when both resourceId and versionId change together", () => {
       const reloadSpy = vi.spyOn(component, "reloadFileContent").mockImplementation(() => {});
-      component.ngOnChanges({ did: chg(1, 2), dvid: chg(3, 4) } as SimpleChanges);
+      component.ngOnChanges({ resourceId: chg(1, 2), versionId: chg(3, 4) } as SimpleChanges);
       expect(reloadSpy).toHaveBeenCalledTimes(1);
     });
 
-    it("does not reload when only did changes without dvid", () => {
+    it("does not reload when only resourceId changes without versionId", () => {
       const reloadSpy = vi.spyOn(component, "reloadFileContent").mockImplementation(() => {});
-      component.ngOnChanges({ did: chg(1, 2) } as SimpleChanges);
+      component.ngOnChanges({ resourceId: chg(1, 2) } as SimpleChanges);
       expect(reloadSpy).not.toHaveBeenCalled();
     });
 
@@ -328,8 +360,8 @@ describe("UserDatasetFileRendererComponent", () => {
     function loadWith(filePath: string, blob: Blob) {
       const datasetService = TestBed.inject(DatasetService);
       vi.spyOn(datasetService, "retrieveDatasetVersionSingleFile").mockReturnValue(of(blob));
-      component.did = 1;
-      component.dvid = 2;
+      component.resourceId = 1;
+      component.versionId = 2;
       component.filePath = filePath;
       component.isLogin = false;
       component.fileSize = undefined;
@@ -422,8 +454,8 @@ describe("UserDatasetFileRendererComponent", () => {
       const spy = vi.spyOn(datasetService, "retrieveDatasetVersionSingleFile");
       // A supported, in-limit file, so the two pre-checks pass and the id guard is the
       // only thing left to stop the request.
-      component.did = undefined;
-      component.dvid = 2;
+      component.resourceId = undefined;
+      component.versionId = 2;
       component.filePath = "notes.txt";
       component.fileSize = 100;
 
@@ -568,8 +600,8 @@ describe("UserDatasetFileRendererComponent", () => {
       vi.spyOn(datasetService, "retrieveDatasetVersionSingleFile").mockReturnValue(
         of(new Blob(["ignored"], { type: MIME_TYPES.CSV }))
       );
-      component.did = 1;
-      component.dvid = 2;
+      component.resourceId = 1;
+      component.versionId = 2;
       component.filePath = "data.csv";
       component.fileSize = undefined;
       component.reloadFileContent();
@@ -616,6 +648,7 @@ describe("UserDatasetFileRendererComponent rendering", () => {
       imports: [UserDatasetFileRendererComponent, HttpClientTestingModule, MarkdownModule.forRoot()],
       providers: [
         DatasetService,
+        ModelService,
         NotificationService,
         {
           provide: DomSanitizer,
diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.ts
index 861479ca5a..7a0886ff4f 100644
--- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.ts
+++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.ts
@@ -18,7 +18,8 @@
  */
 
 import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from "@angular/core";
-import { DatasetService } from "../../../../../service/user/dataset/dataset.service";
+import { ResourceRegistryService } from "../../../../../service/user/resource-registry/resource-registry.service";
+import { EntityType } from "../../../../../../hub/service/hub.service";
 import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
 import * as Papa from "papaparse";
 import { ParseResult } from "papaparse";
@@ -145,11 +146,15 @@ export class UserDatasetFileRendererComponent implements OnInit, OnChanges, OnDe
   @Input()
   isMaximized: boolean = false;
 
+  // Which kind of resource the file belongs to; the registry turns it into the fetch.
   @Input()
-  did: number | undefined;
+  resourceType: EntityType = EntityType.Dataset;
 
   @Input()
-  dvid: number | undefined;
+  resourceId: number | undefined;
+
+  @Input()
+  versionId: number | undefined;
 
   @Input()
   filePath: string = "";
@@ -164,7 +169,7 @@ export class UserDatasetFileRendererComponent implements OnInit, OnChanges, OnDe
   loadFile = new EventEmitter<{ file: string; prefix: string }>();
 
   constructor(
-    private datasetService: DatasetService,
+    private resourceRegistry: ResourceRegistryService,
     private sanitizer: DomSanitizer,
     private notificationService: NotificationService
   ) {}
@@ -174,7 +179,7 @@ export class UserDatasetFileRendererComponent implements OnInit, OnChanges, OnDe
   }
 
   ngOnChanges(changes: SimpleChanges): void {
-    if ((changes.did && changes.dvid) || changes.filePath) {
+    if ((changes.resourceId && changes.versionId) || changes.filePath) {
       this.reloadFileContent();
     }
   }
@@ -208,9 +213,9 @@ export class UserDatasetFileRendererComponent implements OnInit, OnChanges, OnDe
 
     // Load file
     this.isLoading = true;
-    if (this.did && this.dvid && this.filePath != "") {
-      this.datasetService
-        .retrieveDatasetVersionSingleFile(this.filePath, this.isLogin)
+    const retrieveSingleFile = this.resourceRegistry.get(this.resourceType).retrieveSingleFile;
+    if (retrieveSingleFile && this.resourceId && this.versionId && this.filePath != "") {
+      retrieveSingleFile(this.filePath, this.isLogin)
         .pipe(untilDestroyed(this))
         .subscribe({
           next: blob => {
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
new file mode 100644
index 0000000000..e394517d29
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.html
@@ -0,0 +1,357 @@
+<!--
+ 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 *ngIf="!isMaximized">
+  <nz-card>
+    <div class="model-header">
+      <div class="model-header-meta">
+        <h2>Model: {{modelName}}</h2>
+        <nz-card-meta
+          style="padding-top: 5px"
+          [nzDescription]="descriptionTemplate">
+          <ng-template #descriptionTemplate>
+            Created at:
+            <span
+              nz-tooltip
+              [nzTooltipTitle]="modelCreationTimeTooltip">
+              {{modelCreationTime}}
+            </span>
+          </ng-template>
+        </nz-card-meta>
+
+        <div class="status-tag-row">
+          <nz-tag
+            class="status-tag"
+            [ngClass]="{ 'tag-public': modelIsPublic }">
+            <i
+              nz-icon
+              [nzType]="modelIsPublic ? 'global' : 'lock'"
+              nzTheme="outline"></i>
+            {{ modelIsPublic ? "Public" : "Private" }}
+          </nz-tag>
+
+          <nz-tag
+            class="status-tag"
+            [ngClass]="{ 'tag-downloadable': modelIsDownloadable }">
+            <i
+              nz-icon
+              [nzType]="modelIsDownloadable ? 'download' : 'stop'"
+              nzTheme="outline"></i>
+            {{ modelIsDownloadable ? "Downloadable" : "Download restricted" }}
+          </nz-tag>
+
+          <nz-tag
+            *ngIf="modelFramework"
+            class="status-tag tag-framework">
+            <i
+              nz-icon
+              nzType="code"
+              nzTheme="outline"></i>
+            {{ modelFramework }}
+          </nz-tag>
+
+          <nz-tag
+            *ngIf="modelFormat"
+            class="status-tag tag-format">
+            <i
+              nz-icon
+              nzType="file-text"
+              nzTheme="outline"></i>
+            {{ modelFormat }}
+          </nz-tag>
+
+          <nz-tag class="status-tag">
+            <i
+              nz-icon
+              nzType="eye"
+              nzTheme="outline"></i>
+            {{ formatCount(viewCount) }}
+          </nz-tag>
+
+          <nz-tag class="status-tag">
+            <i
+              nz-icon
+              nzType="like"
+              nzTheme="outline"></i>
+            {{ formatCount(likeCount) }}
+          </nz-tag>
+        </div>
+      </div>
+      <img
+        *ngIf="coverImageUrl"
+        class="model-cover-image"
+        [src]="coverImageUrl"
+        alt="Model cover" />
+    </div>
+  </nz-card>
+</div>
+<nz-tabs>
+  <nz-tab nzTitle="Model Card">
+    <div class="data-card-tab-content">
+      <div class="data-card-columns">
+        <nz-card class="data-card data-card-main">
+          <h3 class="data-card-heading">Description</h3>
+          <div class="data-card-description">
+            <texera-markdown-description
+              *ngIf="modelDescription; else noDescription"
+              [description]="modelDescription"
+              [editable]="false"
+              [enableViewMore]="true">
+            </texera-markdown-description>
+            <ng-template #noDescription>
+              <span class="empty-description">No description provided</span>
+            </ng-template>
+          </div>
+        </nz-card>
+
+        <nz-card class="data-card data-card-details">
+          <div class="data-card-stats">
+            <div class="stat-row">
+              <span class="stat-label">Created</span>
+              <span class="stat-value">{{ modelCreationTime }}</span>
+            </div>
+            <div class="stat-row">
+              <span class="stat-label">Last updated</span>
+              <span class="stat-value">{{ latestVersionCreationTime || "—" }}</span>
+            </div>
+            <div class="stat-row">
+              <span class="stat-label">Versions</span>
+              <span class="stat-value">{{ versions.length }}</span>
+            </div>
+            <div class="stat-row">
+              <span class="stat-label">Framework</span>
+              <span class="stat-value">{{ modelFramework || "—" }}</span>
+            </div>
+            <div class="stat-row">
+              <span class="stat-label">Format</span>
+              <span class="stat-value">{{ modelFormat || "—" }}</span>
+            </div>
+            <div class="stat-row">
+              <span class="stat-label">Latest version file</span>
+              <span class="stat-value">{{ latestVersionFileName || "—" }}</span>
+            </div>
+            <div class="stat-row">
+              <span class="stat-label">Latest version size</span>
+              <span class="stat-value"
+                >{{ latestVersionSize === undefined ? "—" : formatSize(latestVersionSize) }}</span
+              >
+            </div>
+          </div>
+        </nz-card>
+      </div>
+    </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"
+                  (selectedTreeNode)="onVersionFileTreeNodeSelected($event)">
+                </texera-user-dataset-version-filetree>
+              </nz-collapse-panel>
+            </nz-collapse>
+          </div>
+        </nz-sider>
+      </nz-layout>
+    </div>
+  </nz-tab>
+</nz-tabs>
diff --git a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.scss b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.scss
new file mode 100644
index 0000000000..ec85131c60
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.scss
@@ -0,0 +1,296 @@
+/**
+ * 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.
+ */
+
+// Mirrors dataset-detail.component.scss; the two detail pages are the same layout.
+
+.version-storage {
+  padding: 0 15px;
+  margin-bottom: 25px;
+}
+
+.version-storage nz-select {
+  width: 100%;
+  margin: 0;
+}
+
+nz-layout {
+  height: 100%;
+}
+
+.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;
+}
+
+nz-select {
+  flex-grow: 1;
+}
+
+.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%;
+}
+
+.status-tag-row {
+  margin-top: 16px;
+  display: flex;
+  align-items: center;
+  gap: 5px;
+}
+
+.status-tag {
+  padding: 3px 10px 3px 3px;
+  font-size: 13px;
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  background: #fff;
+  border-radius: 5px;
+
+  i {
+    width: 22px;
+    height: 22px;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    border-radius: 4px;
+    background: #f0f0f0;
+  }
+}
+
+.tag-public i {
+  color: #3b82f6;
+  background: #eff6ff;
+}
+
+.tag-downloadable i {
+  color: #22c55e;
+  background: #f0fdf4;
+}
+
+.tag-framework i {
+  color: #a855f7;
+  background: #faf5ff;
+}
+
+.tag-format i {
+  color: #f59e0b;
+  background: #fffbeb;
+}
+
+.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;
+}
+
+.model-header {
+  display: flex;
+  gap: 24px;
+}
+
+.model-header-meta {
+  flex: 1;
+}
+
+.model-cover-image {
+  width: 280px;
+  height: 120px;
+  object-fit: cover;
+  border-radius: 8px;
+  margin-right: 80px;
+}
+
+nz-tabs {
+  margin-top: 6px;
+}
+
+::ng-deep .ant-tabs-nav {
+  padding-left: 16px;
+}
+
+.data-card-tab-content,
+.versions-tab-content {
+  height: 100%;
+  overflow-y: auto;
+  padding-bottom: 24px;
+}
+
+// A wide description card beside a narrower stats card. The grow ratio with
+// basis 0 is the only thing setting their proportions.
+.data-card-columns {
+  display: flex;
+  align-items: flex-start;
+  gap: 20px;
+  margin-top: 24px;
+  width: 100%;
+  // Match the tab bar's left inset so the cards line up with the tab labels.
+  padding-left: 16px;
+}
+
+.data-card {
+  border-radius: 8px;
+  border: 1px solid #d9d9d9;
+  // Without this the default min-width:auto keeps the cards from shrinking to
+  // their flex share, skewing the ratio.
+  min-width: 0;
+}
+
+.data-card-main {
+  flex: 3 1 0;
+}
+
+.data-card-details {
+  flex: 1 1 0;
+}
+
+@media (max-width: 768px) {
+  .data-card-columns {
+    flex-direction: column;
+    align-items: stretch;
+  }
+}
+
+.data-card-heading {
+  font-size: 18px;
+  font-weight: 600;
+  color: rgba(0, 0, 0, 0.85);
+  margin: 0 0 20px;
+}
+
+.data-card-description {
+  font-size: 16px;
+  line-height: 1.6;
+  margin-bottom: 32px;
+}
+
+.empty-description {
+  color: rgba(0, 0, 0, 0.4);
+  font-style: italic;
+}
+
+.data-card-stats {
+  display: flex;
+  flex-direction: column;
+}
+
+.stat-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: baseline;
+  gap: 16px;
+  padding: 12px 0;
+  border-bottom: 1px solid #f0f0f0;
+
+  &:last-child {
+    border-bottom: none;
+  }
+}
+
+.stat-label {
+  font-size: 13px;
+  font-weight: 600;
+  color: rgba(0, 0, 0, 0.85);
+  flex-shrink: 0;
+}
+
+.stat-value {
+  font-weight: 400;
+  color: rgba(0, 0, 0, 0.45);
+  text-align: right;
+  word-break: break-word;
+}
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
new file mode 100644
index 0000000000..017e980b8d
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts
@@ -0,0 +1,473 @@
+/**
+ * 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 { NoopAnimationsModule } from "@angular/platform-browser/animations";
+import { ActivatedRoute } from "@angular/router";
+import { of, throwError } from "rxjs";
+import { MarkdownService } from "ngx-markdown";
+import { commonTestImports, commonTestProviders } from "../../../../../common/testing/test-utils";
+import { NotificationService } from "../../../../../common/service/notification/notification.service";
+import { UserService } from "../../../../../common/service/user/user.service";
+import { StubUserService } from "../../../../../common/service/user/stub-user.service";
+import { ModelService } from "../../../../service/user/model/model.service";
+import { DownloadService } from "../../../../service/user/download/download.service";
+import { DatasetFileNode } from "../../../../../common/type/datasetVersionFileTree";
+import { ModelVersion } from "../../../../../common/type/model";
+import { ModelDetailComponent } from "./model-detail.component";
+
+const MID = 5;
+const OWNER = "owner@texera.com";
+
+const aVersion = (mvid: number, name: string, creationTime = 1700000000000): ModelVersion => ({
+  mvid,
+  mid: MID,
+  creatorUid: 9,
+  name,
+  versionHash: `hash-${mvid}`,
+  creationTime,
+  fileNodes: undefined,
+});
+
+const aFile = (name: string, parentDir: string, size = 128): DatasetFileNode => ({
+  name,
+  type: "file",
+  parentDir,
+  size,
+});
+
+describe("ModelDetailComponent", () => {
+  let fixture: ComponentFixture<ModelDetailComponent>;
+  let component: ModelDetailComponent;
+  let modelService: Record<string, ReturnType<typeof vi.fn>>;
+  let downloadService: Record<string, ReturnType<typeof vi.fn>>;
+  let notificationService: Record<string, ReturnType<typeof vi.fn>>;
+
+  const dashboardModel = (overrides: Partial<Record<string, unknown>> = {}) => ({
+    isOwner: true,
+    ownerEmail: OWNER,
+    accessPrivilege: "WRITE",
+    size: 0,
+    ...overrides,
+    model: {
+      mid: MID,
+      ownerUid: 9,
+      name: "resnet-50",
+      repositoryName: "model-5",
+      isPublic: false,
+      isDownloadable: true,
+      description: "a description",
+      creationTime: 1699000000000,
+      coverImage: undefined,
+      framework: "pytorch",
+      format: "torchscript",
+      ...((overrides["model"] as object) ?? {}),
+    },
+  });
+
+  /** Rebuilds the fixture so per-test service stubs are in place before ngOnInit runs. */
+  const create = (): void => {
+    fixture = TestBed.createComponent(ModelDetailComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  };
+
+  beforeEach(() => {
+    TestBed.resetTestingModule();
+
+    modelService = {
+      getModel: vi.fn(() => of(dashboardModel())),
+      retrieveModelVersionList: vi.fn(() => of([])),
+      retrieveModelVersionFileTree: vi.fn(() => of({ fileNodes: [], size: 0 })),
+      // The real file renderer is rendered here, and it fetches whatever file is on screen.
+      retrieveModelVersionSingleFile: vi.fn(() => of(new Blob(["hi"], { type: "text/plain" }))),
+      getModelCoverUrl: vi.fn(() => of({ url: "http://cover" })),
+    };
+    downloadService = {
+      downloadModelSingleFile: vi.fn(() => of(new Blob())),
+      downloadModelVersion: vi.fn(() => of(new Blob())),
+    };
+    notificationService = { success: vi.fn(), error: vi.fn(), info: vi.fn() };
+
+    TestBed.configureTestingModule({
+      imports: [ModelDetailComponent, NoopAnimationsModule, ...commonTestImports],
+      providers: [
+        { provide: ActivatedRoute, useValue: { params: of({ mid: String(MID) }), data: of({}) } },
+        { provide: ModelService, useValue: modelService },
+        { provide: DownloadService, useValue: downloadService },
+        { provide: NotificationService, useValue: notificationService },
+        { provide: UserService, useClass: StubUserService },
+        { provide: MarkdownService, useValue: { parse: vi.fn(() => "") } },
+        ...commonTestProviders,
+      ],
+    });
+  });
+
+  afterEach(() => {
+    fixture?.destroy();
+  });
+
+  /** Applies state on top of what ngOnInit produced and renders it. */
+  const render = (state: Partial<ModelDetailComponent> = {}): HTMLElement => {
+    Object.assign(component, state);
+    fixture.detectChanges();
+    return fixture.nativeElement as HTMLElement;
+  };
+
+  // nz-tabs only instantiates the active tab, so a tab has to be opened before
+  // anything inside it exists to assert on.
+  const openTab = (title: string): HTMLElement => {
+    const tab = Array.from((fixture.nativeElement as HTMLElement).querySelectorAll<HTMLElement>(".ant-tabs-tab")).find(
+      el => (el.textContent ?? "").includes(title)
+    );
+    expect(tab, `expected a tab titled "${title}"`).toBeDefined();
+    tab!.click();
+    fixture.detectChanges();
+    return fixture.nativeElement as HTMLElement;
+  };
+
+  const q = <E extends Element>(root: ParentNode, selector: string): E => {
+    const el = root.querySelector(selector);
+    expect(el, `expected to find "${selector}"`).not.toBeNull();
+    return el as unknown as E;
+  };
+
+  // ─── loading the model ──────────────────────────────────────────────────────
+
+  it("reads the mid off the route as a number and loads the model", () => {
+    create();
+
+    expect(component.mid).toBe(MID);
+    expect(modelService["getModel"]).toHaveBeenCalledWith(MID, true);
+    expect(component.modelName).toBe("resnet-50");
+    expect(component.modelDescription).toBe("a description");
+    expect(component.modelFramework).toBe("pytorch");
+    expect(component.modelFormat).toBe("torchscript");
+    expect(component.ownerEmail).toBe(OWNER);
+    expect(component.userModelAccessLevel).toBe("WRITE");
+    expect(component.modelCreationTime).not.toBe("");
+  });
+
+  it("skips the cover fetch for a model that has none", () => {
+    create();
+
+    expect(modelService["getModelCoverUrl"]).not.toHaveBeenCalled();
+    expect(component.coverImageUrl).toBeNull();
+  });
+
+  it("resolves a presigned cover url only when the model carries a cover", () => {
+    modelService["getModel"] = vi.fn(() => of(dashboardModel({ model: { coverImage: "v1/cover.png" } })));
+    create();
+
+    expect(modelService["getModelCoverUrl"]).toHaveBeenCalledWith(MID);
+    expect(component.coverImageUrl).toBe("http://cover");
+  });
+
+  it("falls back to no cover when the presign call fails", () => {
+    modelService["getModel"] = vi.fn(() => of(dashboardModel({ model: { coverImage: "v1/cover.png" } })));
+    modelService["getModelCoverUrl"] = vi.fn(() => throwError(() => new Error("boom")));
+    create();
+
+    expect(component.coverImageUrl).toBeNull();
+  });
+
+  // ─── versions and the file tree ─────────────────────────────────────────────
+
+  it("selects the newest version and opens its first file", () => {
+    const versions = [aVersion(2, "v2", 1700000000000), aVersion(1, "v1", 1600000000000)];
+    modelService["retrieveModelVersionList"] = vi.fn(() => of(versions));
+    modelService["retrieveModelVersionFileTree"] = vi.fn(() =>
+      of({ fileNodes: [aFile("model.pt", `/model/${OWNER}/resnet-50/v2`, 512)], size: 512 })
+    );
+    create();
+
+    expect(component.selectedVersion).toBe(versions[0]);
+    expect(modelService["retrieveModelVersionFileTree"]).toHaveBeenCalledWith(MID, 2, true);
+    expect(component.currentDisplayedFileName).toBe(`/model/${OWNER}/resnet-50/v2/model.pt`);
+    expect(component.currentFileSize).toBe(512);
+    expect(component.currentModelVersionSize).toBe(512);
+    expect(component.latestVersionFileName).toBe(`/model/${OWNER}/resnet-50/v2/model.pt`);
+    expect(component.latestVersionSize).toBe(512);
+    expect(component.latestVersionCreationTime).not.toBe("");
+  });
+
+  it("descends into directories to find the file it opens first", () => {
+    modelService["retrieveModelVersionList"] = vi.fn(() => of([aVersion(1, "v1")]));
+    modelService["retrieveModelVersionFileTree"] = vi.fn(() =>
+      of({
+        fileNodes: [
+          {
+            name: "weights",
+            type: "directory",
+            parentDir: `/model/${OWNER}/resnet-50/v1`,
+            children: [aFile("model.pt", `/model/${OWNER}/resnet-50/v1/weights`)],
+          } as DatasetFileNode,
+        ],
+        size: 128,
+      })
+    );
+    create();
+
+    expect(component.currentDisplayedFileName).toBe(`/model/${OWNER}/resnet-50/v1/weights/model.pt`);
+  });
+
+  it("clears the open file when the selected version holds none", () => {
+    modelService["retrieveModelVersionList"] = vi.fn(() => of([aVersion(1, "v1")]));
+    create();
+
+    expect(component.currentDisplayedFileName).toBe("");
+    expect(component.currentFileSize).toBeUndefined();
+    expect(component.latestVersionFileName).toBe("");
+  });
+
+  it("leaves nothing selected for a model with no versions", () => {
+    create();
+
+    expect(component.versions).toEqual([]);
+    expect(component.selectedVersion).toBeUndefined();
+    expect(modelService["retrieveModelVersionFileTree"]).not.toHaveBeenCalled();
+  });
+
+  it("keeps the latest-version facts when an older version is selected", () => {
+    const versions = [aVersion(2, "v2"), aVersion(1, "v1")];
+    modelService["retrieveModelVersionList"] = vi.fn(() => of(versions));
+    modelService["retrieveModelVersionFileTree"] = vi.fn((_mid: number, mvid: number) =>
+      of({
+        fileNodes: [aFile(`v${mvid}.pt`, `/model/${OWNER}/resnet-50/v${mvid}`)],
+        size: mvid * 100,
+      })
+    );
+    create();
+    const latestFileName = component.latestVersionFileName;
+
+    component.onVersionSelected(versions[1]);
+
+    expect(component.currentDisplayedFileName).toBe(`/model/${OWNER}/resnet-50/v1/v1.pt`);
+    expect(component.currentModelVersionSize).toBe(100);
+    // The Model Card still describes the newest version, not the one being browsed.
+    expect(component.latestVersionFileName).toBe(latestFileName);
+    expect(component.latestVersionSize).toBe(200);
+  });
+
+  it("survives the version select being cleared", () => {
+    modelService["retrieveModelVersionList"] = vi.fn(() => of([aVersion(1, "v1")]));
+    create();
+
+    expect(() => component.onVersionSelected(undefined)).not.toThrow();
+    expect(component.selectedVersion).toBeUndefined();
+  });
+
+  it("reports a failure instead of rendering a blank page", () => {
+    // /model/list degrades an unreadable repository size to 0, while /model/{mid} throws,
+    // so a model that lists fine can still 500 here.
+    modelService["getModel"] = vi.fn(() => throwError(() => new Error("lakefs is down")));
+    modelService["retrieveModelVersionList"] = vi.fn(() => throwError(() => new Error("lakefs is down")));
+    create();
+
+    expect(notificationService["error"]).toHaveBeenCalledTimes(2);
+  });
+
+  it("refuses a route segment that is not a model id", () => {
+    TestBed.overrideProvider(ActivatedRoute, { useValue: { params: of({ mid: "abc" }), data: of({}) } });
+    create();
+
+    expect(component.mid).toBeUndefined();
+    expect(modelService["getModel"]).not.toHaveBeenCalled();
+    expect(notificationService["error"]).toHaveBeenCalled();
+  });
+
+  it("opens the file a tree node points at", () => {
+    create();
+
+    component.onVersionFileTreeNodeSelected(aFile("notes.txt", "/model/a/b/v1", 64));
+
+    expect(component.currentDisplayedFileName).toBe("/model/a/b/v1/notes.txt");
+    expect(component.currentFileSize).toBe(64);
+  });
+
+  // ─── downloads ──────────────────────────────────────────────────────────────
+
+  it("downloads the open file through the authenticated endpoint for an owner", () => {
+    modelService["retrieveModelVersionList"] = vi.fn(() => of([aVersion(1, "v1")]));
+    modelService["retrieveModelVersionFileTree"] = vi.fn(() =>
+      of({ fileNodes: [aFile("model.pt", `/model/${OWNER}/resnet-50/v1`)], size: 128 })
+    );
+    create();
+
+    component.onClickDownloadCurrentFile();
+
+    expect(downloadService["downloadModelSingleFile"]).toHaveBeenCalledWith(
+      `/model/${OWNER}/resnet-50/v1/model.pt`,
+      true
+    );
+  });
+
+  it("downloads a public model's file through the anonymous endpoint for a non-owner", () => {
+    modelService["getModel"] = vi.fn(() => of(dashboardModel({ isOwner: false, model: { isPublic: true } })));
+    modelService["retrieveModelVersionList"] = vi.fn(() => of([aVersion(1, "v1")]));
+    modelService["retrieveModelVersionFileTree"] = vi.fn(() =>
+      of({ fileNodes: [aFile("model.pt", `/model/${OWNER}/resnet-50/v1`)], size: 128 })
+    );
+    create();
+
+    component.onClickDownloadCurrentFile();
+
+    expect(downloadService["downloadModelSingleFile"]).toHaveBeenCalledWith(
+      `/model/${OWNER}/resnet-50/v1/model.pt`,
+      false
+    );
+  });
+
+  it("downloads the selected version as a zip", () => {
+    modelService["retrieveModelVersionList"] = vi.fn(() => of([aVersion(3, "v3")]));
+    create();
+
+    component.onClickDownloadVersionAsZip();
+
+    expect(downloadService["downloadModelVersion"]).toHaveBeenCalledWith(MID, 3, "resnet-50", "v3");
+  });
+
+  it("downloads nothing while no version is selected", () => {
+    create();
+
+    component.onClickDownloadCurrentFile();
+    component.onClickDownloadVersionAsZip();
+
+    expect(downloadService["downloadModelSingleFile"]).not.toHaveBeenCalled();
+    expect(downloadService["downloadModelVersion"]).not.toHaveBeenCalled();
+  });
+
+  it("allows a download for an owner, and for a grantee only while downloads are permitted", () => {
+    create();
+    expect(component.isDownloadAllowed()).toBe(true);
+
+    render({ isOwner: false, modelIsDownloadable: false, modelIsPublic: true });
+    expect(component.isDownloadAllowed()).toBe(false);
+
+    render({ isOwner: false, modelIsDownloadable: true, modelIsPublic: false, userModelAccessLevel: "NONE" });
+    expect(component.isDownloadAllowed()).toBe(false);
+
+    render({ isOwner: false, modelIsDownloadable: true, modelIsPublic: false, userModelAccessLevel: "READ" });
+    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", () => {
+    create();
+    const root = render();
+
+    expect(q(root, "h2").textContent).toContain("resnet-50");
+    expect(root.textContent).toContain("Private");
+    expect(root.textContent).toContain("Downloadable");
+    expect(root.textContent).toContain("pytorch");
+    expect(root.textContent).toContain("torchscript");
+  });
+
+  it("shows view and like counters as placeholder zeros", () => {
+    // The hub backend has no model entity type yet, so nothing populates these and
+    // nothing may call the hub from this page.
+    create();
+    const tags = q(render(), ".status-tag-row").textContent ?? "";
+
+    expect(tags.match(/\b0\b/g)?.length).toBe(2);
+    expect(component.viewCount).toBe(0);
+    expect(component.likeCount).toBe(0);
+  });
+
+  it("dashes out the latest-version facts for a model with no versions", () => {
+    create();
+    const stats = q(render(), ".data-card-stats").textContent ?? "";
+
+    // "0 B" would assert a zero-byte version that does not exist; the card already
+    // uses an em dash for an absent framework or format.
+    expect(stats).not.toContain("0 B");
+    expect(stats.match(/—/g)?.length).toBe(3);
+  });
+
+  it("shows the empty-version notice until a version exists", () => {
+    create();
+    const root = openTab("Versions & Files");
+
+    expect(q(root, "nz-empty")).toBeTruthy();
+    expect(root.querySelector("texera-user-dataset-file-renderer")).toBeNull();
+  });
+
+  it("hands the file renderer the model kind and the selected version", () => {
+    modelService["retrieveModelVersionList"] = vi.fn(() => of([aVersion(1, "v1")]));
+    modelService["retrieveModelVersionFileTree"] = vi.fn(() =>
+      of({ fileNodes: [aFile("notes.txt", `/model/${OWNER}/resnet-50/v1`)], size: 128 })
+    );
+    create();
+    const root = openTab("Versions & Files");
+
+    const renderer = q(root, "texera-user-dataset-file-renderer");
+    expect(renderer).toBeTruthy();
+    expect(modelService["retrieveModelVersionSingleFile"]).toHaveBeenCalledWith(
+      `/model/${OWNER}/resnet-50/v1/notes.txt`,
+      true
+    );
+  });
+
+  it("renders a cover image only once one resolves", () => {
+    create();
+    expect(fixture.nativeElement.querySelector(".model-cover-image")).toBeNull();
+
+    const root = render({ coverImageUrl: "http://cover" });
+    expect(q<HTMLImageElement>(root, ".model-cover-image").src).toContain("http://cover");
+  });
+
+  it("collapses and restores the right sider, and maximizes the preview", () => {
+    create();
+    const root = openTab("Versions & Files");
+
+    expect(root.querySelector("nz-sider")).not.toBeNull();
+    component.onClickHideRightBar();
+    fixture.detectChanges();
+    expect(fixture.nativeElement.querySelector("nz-sider")).toBeNull();
+
+    component.onClickScaleTheView();
+    fixture.detectChanges();
+    expect(component.isMaximized).toBe(true);
+    expect(fixture.nativeElement.querySelector(".model-header")).toBeNull();
+  });
+});
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
new file mode 100644
index 0000000000..d1f918ff6d
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.ts
@@ -0,0 +1,353 @@
+/**
+ * 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, OnInit } from "@angular/core";
+import { ActivatedRoute } from "@angular/router";
+import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
+import { switchMap } from "rxjs/operators";
+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";
+import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch";
+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 { ModelService } from "../../../../service/user/model/model.service";
+import { DownloadService } from "../../../../service/user/download/download.service";
+import { NotificationService } from "../../../../../common/service/notification/notification.service";
+import { UserService } from "../../../../../common/service/user/user.service";
+import { EntityType } from "../../../../../hub/service/hub.service";
+import { extractErrorMessage } from "../../../../../common/util/error";
+import { formatCount } from "src/app/common/util/format.util";
+import { formatSize } from "src/app/common/util/size-formatter.util";
+import { ModelVersion } from "../../../../../common/type/model";
+import { DatasetFileNode, getFullPathFromDatasetFileNode } from "../../../../../common/type/datasetVersionFileTree";
+import { MarkdownDescriptionComponent } from "../../markdown-description/markdown-description.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";
+
+@UntilDestroy()
+@Component({
+  templateUrl: "./model-detail.component.html",
+  styleUrls: ["./model-detail.component.scss"],
+  imports: [
+    NgIf,
+    NgFor,
+    NgClass,
+    FormsModule,
+    NzCardComponent,
+    NzCardMetaComponent,
+    NzTooltipDirective,
+    NzTagComponent,
+    ɵNzTransitionPatchDirective,
+    NzIconDirective,
+    NzButtonComponent,
+    NzWaveDirective,
+    NzLayoutComponent,
+    NzContentComponent,
+    NzSiderComponent,
+    NzResizableDirective,
+    NzResizeHandleComponent,
+    NzEmptyComponent,
+    NzCollapseComponent,
+    NzCollapsePanelComponent,
+    NzSelectComponent,
+    NzOptionComponent,
+    NzTabsComponent,
+    NzTabComponent,
+    MarkdownDescriptionComponent,
+    UserDatasetFileRendererComponent,
+    UserDatasetVersionFiletreeComponent,
+  ],
+})
+export class ModelDetailComponent implements OnInit {
+  public mid: number | undefined;
+  public modelName: string = "";
+  public modelDescription: string = "";
+  public modelCreationTime: string = "";
+  public modelCreationTimeTooltip: string = "";
+  public modelIsPublic: boolean = false;
+  public modelIsDownloadable: boolean = true;
+  public modelFramework: string | undefined;
+  public modelFormat: string | undefined;
+  public userModelAccessLevel: "READ" | "WRITE" | "NONE" = "NONE";
+  public ownerEmail: string = "";
+  public isOwner: boolean = false;
+  public coverImageUrl: string | null = null;
+
+  public versions: ReadonlyArray<ModelVersion> = [];
+  public selectedVersion: ModelVersion | undefined;
+  public selectedVersionCreationTime: string = "";
+  public fileTreeNodeList: DatasetFileNode[] = [];
+  public currentModelVersionSize: number | undefined;
+
+  // The Model Card's latest-version facts, read off the head of the version list.
+  public latestVersionCreationTime: string = "";
+  public latestVersionFileName: string = "";
+  public latestVersionSize: number | undefined;
+
+  public currentDisplayedFileName: string = "";
+  public currentFileSize: number | undefined;
+
+  // Placeholders until models reach the hub. The hub backend has no model entity type
+  // (`hub/EntityType.scala` is Workflow and Dataset only), so nothing can populate these yet.
+  public readonly viewCount: number = 0;
+  public readonly likeCount: number = 0;
+
+  public isRightBarCollapsed = false;
+  public isMaximized = false;
+
+  public isLogin: boolean = this.userService.isLogin();
+  public currentUid: number | undefined = this.userService.getCurrentUser()?.uid;
+
+  public readonly modelEntityType = EntityType.Model;
+
+  formatSize = formatSize;
+  formatCount = formatCount;
+
+  constructor(
+    private route: ActivatedRoute,
+    private modelService: ModelService,
+    private downloadService: DownloadService,
+    private notificationService: NotificationService,
+    private userService: UserService
+  ) {
+    this.userService
+      .userChanged()
+      .pipe(untilDestroyed(this))
+      .subscribe(() => {
+        this.isLogin = this.userService.isLogin();
+        this.currentUid = this.userService.getCurrentUser()?.uid;
+      });
+  }
+
+  // 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(
+        switchMap(params => {
+          // Route params are strings, and the segment is whatever the URL carried: reject
+          // anything that is not a positive integer once, here, rather than at each use.
+          const mid = Number(params["mid"]);
+          this.mid = Number.isInteger(mid) && mid > 0 ? mid : undefined;
+          if (this.mid === undefined) {
+            this.notificationService.error("This is not a valid model id");
+            return this.route.data;
+          }
+          this.retrieveModelInfo();
+          this.retrieveModelVersionList();
+          return this.route.data;
+        }),
+        untilDestroyed(this)
+      )
+      .subscribe();
+  }
+
+  retrieveModelInfo(): void {
+    if (!this.mid) {
+      return;
+    }
+    const mid = this.mid;
+    this.modelService
+      .getModel(mid, this.isLogin)
+      .pipe(untilDestroyed(this))
+      .subscribe({
+        next: dashboardModel => {
+          const model = dashboardModel.model;
+          this.modelName = model.name;
+          this.modelDescription = model.description;
+          this.modelIsPublic = model.isPublic;
+          this.modelIsDownloadable = model.isDownloadable;
+          this.modelFramework = model.framework;
+          this.modelFormat = model.format;
+          this.userModelAccessLevel = dashboardModel.accessPrivilege;
+          this.ownerEmail = dashboardModel.ownerEmail;
+          this.isOwner = dashboardModel.isOwner;
+          if (model.coverImage) {
+            this.loadCoverImageUrl(mid);
+          } else {
+            this.coverImageUrl = null;
+          }
+          if (typeof model.creationTime === "number") {
+            const date = new Date(model.creationTime);
+            this.modelCreationTime = format(date, "MM/dd/yyyy HH:mm:ss");
+            const timeZoneName =
+              new Intl.DateTimeFormat("en-US", { timeZoneName: "long" }).format(date).split(", ").pop() || "";
+            this.modelCreationTimeTooltip = `${format(date, "zzzz")} (${timeZoneName})`;
+          }
+        },
+        error: (err: unknown) => this.notificationService.error(extractErrorMessage(err)),
+      });
+  }
+
+  private loadCoverImageUrl(mid: number): void {
+    this.modelService
+      .getModelCoverUrl(mid)
+      .pipe(untilDestroyed(this))
+      .subscribe({
+        next: ({ url }) => (this.coverImageUrl = url),
+        error: () => (this.coverImageUrl = null),
+      });
+  }
+
+  retrieveModelVersionList(): void {
+    if (!this.mid) {
+      return;
+    }
+    this.modelService
+      .retrieveModelVersionList(this.mid, this.isLogin)
+      .pipe(untilDestroyed(this))
+      .subscribe({
+        next: versions => {
+          this.versions = versions;
+          if (versions.length === 0) {
+            return;
+          }
+          const latest = versions[0];
+          this.latestVersionCreationTime = this.formatCreationTime(latest);
+          this.onVersionSelected(latest);
+        },
+        error: (err: unknown) => this.notificationService.error(extractErrorMessage(err)),
+      });
+  }
+
+  onVersionSelected(version: ModelVersion | undefined): void {
+    this.selectedVersion = version;
+    if (!this.mid || !version?.mvid) {
+      return;
+    }
+    this.modelService
+      .retrieveModelVersionFileTree(this.mid, version.mvid, this.isLogin)
+      .pipe(untilDestroyed(this))
+      .subscribe({
+        next: data => {
+          this.fileTreeNodeList = data.fileNodes;
+          this.currentModelVersionSize = data.size;
+          this.selectedVersionCreationTime = this.formatCreationTime(version);
+
+          const firstFile = this.getFirstFileNode(this.fileTreeNodeList);
+          if (version === this.versions[0]) {
+            this.latestVersionFileName = firstFile ? getFullPathFromDatasetFileNode(firstFile) : "";
+            this.latestVersionSize = data.size;
+          }
+          if (!firstFile) {
+            this.currentDisplayedFileName = "";
+            this.currentFileSize = undefined;
+            return;
+          }
+          this.loadFileContent(firstFile);
+        },
+        error: (err: unknown) => this.notificationService.error(extractErrorMessage(err)),
+      });
+  }
+
+  onVersionFileTreeNodeSelected(node: DatasetFileNode): void {
+    this.loadFileContent(node);
+  }
+
+  loadFileContent(node: DatasetFileNode): void {
+    this.currentDisplayedFileName = getFullPathFromDatasetFileNode(node);
+    this.currentFileSize = node.size;
+  }
+
+  // Walk from the first node into directories until reaching a file.
+  private getFirstFileNode(nodes: DatasetFileNode[]): DatasetFileNode | undefined {
+    let currentNode: DatasetFileNode | undefined = nodes[0];
+    while (currentNode && currentNode.type === "directory" && currentNode.children) {
+      currentNode = currentNode.children[0];
+    }
+    return currentNode;
+  }
+
+  private formatCreationTime(version: ModelVersion): string {
+    return typeof version.creationTime === "number"
+      ? format(new Date(version.creationTime), "MM/dd/yyyy HH:mm:ss")
+      : "";
+  }
+
+  onClickDownloadCurrentFile = (): void => {
+    if (!this.mid || !this.selectedVersion?.mvid) {
+      return;
+    }
+    const shouldUsePublicEndpoint = this.modelIsPublic && !this.isOwner;
+    this.downloadService
+      .downloadModelSingleFile(this.currentDisplayedFileName, !shouldUsePublicEndpoint)
+      .pipe(untilDestroyed(this))
+      .subscribe();
+  };
+
+  onClickDownloadVersionAsZip(): void {
+    if (!this.mid || !this.selectedVersion?.mvid) {
+      return;
+    }
+    this.downloadService
+      .downloadModelVersion(this.mid, this.selectedVersion.mvid, this.modelName, this.selectedVersion.name)
+      .pipe(untilDestroyed(this))
+      .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;
+    }
+    return this.modelIsDownloadable && (this.modelIsPublic || this.userModelAccessLevel !== "NONE");
+  }
+}
diff --git a/frontend/src/app/dashboard/service/user/download/download.service.spec.ts b/frontend/src/app/dashboard/service/user/download/download.service.spec.ts
index 0cbdf9ee57..b295a16b6d 100644
--- a/frontend/src/app/dashboard/service/user/download/download.service.spec.ts
+++ b/frontend/src/app/dashboard/service/user/download/download.service.spec.ts
@@ -21,6 +21,7 @@ import { TestBed } from "@angular/core/testing";
 import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
 import { DownloadService, EXPORT_BASE_URL } from "./download.service";
 import { DatasetService } from "../dataset/dataset.service";
+import { ModelService } from "../model/model.service";
 import { FileSaverService } from "../file/file-saver.service";
 import { NotificationService } from "../../../../common/service/notification/notification.service";
 import { WorkflowPersistService } from "../../../../common/service/workflow-persist/workflow-persist.service";
@@ -39,6 +40,7 @@ const EXPORT_OPERATORS = [{ id: "op1", outputType: "csv" }];
 describe("DownloadService", () => {
   let downloadService: DownloadService;
   let datasetServiceSpy: Mocked<DatasetService>;
+  let modelServiceSpy: Mocked<ModelService>;
   let fileSaverServiceSpy: Mocked<FileSaverService>;
   let notificationServiceSpy: Mocked<NotificationService>;
   let workflowPersistServiceSpy: Mocked<WorkflowPersistService>;
@@ -46,6 +48,7 @@ describe("DownloadService", () => {
 
   beforeEach(() => {
     const datasetSpy = { retrieveDatasetVersionSingleFile: vi.fn(), retrieveDatasetVersionZip: vi.fn() };
+    const modelSpy = { retrieveModelVersionSingleFile: vi.fn(), retrieveModelVersionZip: vi.fn() };
     const fileSaverSpy = { saveAs: vi.fn() };
     const notificationSpy = { info: vi.fn(), success: vi.fn(), error: vi.fn() };
     const workflowPersistSpy = { retrieveWorkflow: vi.fn() };
@@ -55,6 +58,7 @@ describe("DownloadService", () => {
       providers: [
         DownloadService,
         { provide: DatasetService, useValue: datasetSpy },
+        { provide: ModelService, useValue: modelSpy },
         { provide: FileSaverService, useValue: fileSaverSpy },
         { provide: NotificationService, useValue: notificationSpy },
         { provide: WorkflowPersistService, useValue: workflowPersistSpy },
@@ -64,6 +68,7 @@ describe("DownloadService", () => {
 
     downloadService = TestBed.inject(DownloadService);
     datasetServiceSpy = TestBed.inject(DatasetService) as unknown as Mocked<DatasetService>;
+    modelServiceSpy = TestBed.inject(ModelService) as unknown as Mocked<ModelService>;
     fileSaverServiceSpy = TestBed.inject(FileSaverService) as unknown as Mocked<FileSaverService>;
     notificationServiceSpy = TestBed.inject(NotificationService) as unknown as Mocked<NotificationService>;
     workflowPersistServiceSpy = TestBed.inject(WorkflowPersistService) as unknown as Mocked<WorkflowPersistService>;
@@ -175,6 +180,46 @@ describe("DownloadService", () => {
     expect(notificationServiceSpy.error).toHaveBeenCalledWith("Error downloading version 'v1.0' as ZIP");
   });
 
+  // ─── model downloads ──────────────────────────────────────────────────────
+
+  it("downloads a model's latest version, a chosen version, and a single file", async () => {
+    const zip = new Blob(["model"], { type: "application/zip" });
+    const file = new Blob(["weights"]);
+    modelServiceSpy.retrieveModelVersionZip.mockReturnValue(of(zip));
+    modelServiceSpy.retrieveModelVersionSingleFile.mockReturnValue(of(file));
+
+    expect(await firstValueFrom(downloadService.downloadModel(4, "resnet-50"))).toBe(zip);
+    expect(modelServiceSpy.retrieveModelVersionZip).toHaveBeenCalledWith(4);
+    expect(fileSaverServiceSpy.saveAs).toHaveBeenCalledWith(zip, "resnet-50.zip");
+
+    expect(await firstValueFrom(downloadService.downloadModelVersion(4, 2, "resnet-50", "v2"))).toBe(zip);
+    expect(modelServiceSpy.retrieveModelVersionZip).toHaveBeenCalledWith(4, 2);
+    expect(fileSaverServiceSpy.saveAs).toHaveBeenCalledWith(zip, "resnet-50-v2.zip");
+
+    expect(await firstValueFrom(downloadService.downloadModelSingleFile("/model/a/m/v2/model.pt"))).toBe(file);
+    expect(modelServiceSpy.retrieveModelVersionSingleFile).toHaveBeenCalledWith("/model/a/m/v2/model.pt", true);
+    expect(fileSaverServiceSpy.saveAs).toHaveBeenCalledWith(file, "model.pt");
+  });
+
+  it("passes the logged-out flag through to the model file endpoint", async () => {
+    modelServiceSpy.retrieveModelVersionSingleFile.mockReturnValue(of(new Blob()));
+
+    await firstValueFrom(downloadService.downloadModelSingleFile("/model/a/m/v2/model.pt", false));
+
+    expect(modelServiceSpy.retrieveModelVersionSingleFile).toHaveBeenCalledWith("/model/a/m/v2/model.pt", false);
+  });
+
+  it("emits the model error notification and rethrows on retrieve failure", async () => {
+    modelServiceSpy.retrieveModelVersionZip.mockReturnValue(throwError(() => new Error("fail")));
+
+    await expect(firstValueFrom(downloadService.downloadModel(4, "resnet-50"))).rejects.toThrow("fail");
+
+    expect(fileSaverServiceSpy.saveAs).not.toHaveBeenCalled();
+    expect(notificationServiceSpy.error).toHaveBeenCalledWith(
+      "Error downloading the latest version of the model as ZIP"
+    );
+  });
+
   // ─── downloadWorkflow ─────────────────────────────────────────────────────
 
   it("downloads a workflow as a JSON blob named after the workflow", async () => {
diff --git a/frontend/src/app/dashboard/service/user/download/download.service.ts b/frontend/src/app/dashboard/service/user/download/download.service.ts
index fb5d67fb70..65430e5b54 100644
--- a/frontend/src/app/dashboard/service/user/download/download.service.ts
+++ b/frontend/src/app/dashboard/service/user/download/download.service.ts
@@ -23,6 +23,7 @@ import { catchError, map, switchMap, tap } from "rxjs/operators";
 import { FileSaverService } from "../file/file-saver.service";
 import { NotificationService } from "../../../../common/service/notification/notification.service";
 import { DatasetService } from "../dataset/dataset.service";
+import { ModelService } from "../model/model.service";
 import { WorkflowPersistService } from "src/app/common/service/workflow-persist/workflow-persist.service";
 import JSZip from "jszip";
 import { Workflow } from "../../../../common/type/workflow";
@@ -57,6 +58,7 @@ export class DownloadService {
     private fileSaverService: FileSaverService,
     private notificationService: NotificationService,
     private datasetService: DatasetService,
+    private modelService: ModelService,
     private workflowPersistService: WorkflowPersistService,
     private http: HttpClient
   ) {}
@@ -102,6 +104,43 @@ export class DownloadService {
     );
   }
 
+  downloadModel(id: number, name: string): Observable<Blob> {
+    return this.downloadWithNotification(
+      () => this.modelService.retrieveModelVersionZip(id),
+      `${name}.zip`,
+      "Starting to download the latest version of the model as ZIP",
+      "The latest version of the model has been downloaded as ZIP",
+      "Error downloading the latest version of the model as ZIP"
+    );
+  }
+
+  downloadModelVersion(
+    modelId: number,
+    modelVersionId: number,
+    modelName: string,
+    versionName: string
+  ): Observable<Blob> {
+    return this.downloadWithNotification(
+      () => this.modelService.retrieveModelVersionZip(modelId, modelVersionId),
+      `${modelName}-${versionName}.zip`,
+      `Starting to download version ${versionName} as ZIP`,
+      `Version ${versionName} has been downloaded as ZIP`,
+      `Error downloading version '${versionName}' as ZIP`
+    );
+  }
+
+  downloadModelSingleFile(filePath: string, isLogin: boolean = true): Observable<Blob> {
+    const DEFAULT_FILE_NAME = "download";
+    const fileName = filePath.split("/").pop() || DEFAULT_FILE_NAME;
+    return this.downloadWithNotification(
+      () => this.modelService.retrieveModelVersionSingleFile(filePath, isLogin),
+      fileName,
+      `Starting to download file ${filePath}`,
+      `File ${filePath} has been downloaded`,
+      `Error downloading file '${filePath}'`
+    );
+  }
+
   downloadWorkflowsAsZip(workflowEntries: Array<{ id: number; name: string }>): Observable<Blob> {
     return this.downloadWithNotification(
       () => this.createWorkflowsZip(workflowEntries),
diff --git a/frontend/src/app/dashboard/service/user/model/model.service.spec.ts b/frontend/src/app/dashboard/service/user/model/model.service.spec.ts
index f99a8ff4ed..903455a702 100644
--- a/frontend/src/app/dashboard/service/user/model/model.service.spec.ts
+++ b/frontend/src/app/dashboard/service/user/model/model.service.spec.ts
@@ -156,6 +156,76 @@ describe("ModelService", () => {
     describe.flush({});
   });
 
+  it("reads a model from the authenticated or the public endpoint", () => {
+    service.getModel(7).subscribe();
+    const authenticated = http.expectOne(`${API}/model/7`);
+    expect(authenticated.request.method).toBe("GET");
+    authenticated.flush({});
+
+    service.getModel(7, false).subscribe();
+    http.expectOne(`${API}/model/public/7`).flush({});
+  });
+
+  it("lists versions from the endpoint matching the caller's login state", () => {
+    service.retrieveModelVersionList(7).subscribe();
+    http.expectOne(`${API}/model/7/version/list`).flush([]);
+
+    service.retrieveModelVersionList(7, false).subscribe();
+    http.expectOne(`${API}/model/7/publicVersion/list`).flush([]);
+  });
+
+  it("reads a version's root file nodes with its size", async () => {
+    const tree = { fileNodes: [], size: 42 };
+    const pending = firstValueFrom(service.retrieveModelVersionFileTree(7, 3));
+    http.expectOne(`${API}/model/7/version/3/rootFileNodes`).flush(tree);
+    expect(await pending).toEqual(tree);
+
+    service.retrieveModelVersionFileTree(7, 3, false).subscribe();
+    http.expectOne(`${API}/model/7/publicVersion/3/rootFileNodes`).flush(tree);
+  });
+
+  it("asks for exactly one of mvid or latest on a version zip", () => {
+    // The backend answers a request carrying both, or neither, with a 400.
+    service.retrieveModelVersionZip(7, 3).subscribe();
+    const byId = http.expectOne(req => req.url === `${API}/model/7/versionZip`);
+    expect(byId.request.params.get("mvid")).toBe("3");
+    expect(byId.request.params.has("latest")).toBe(false);
+    byId.flush(new Blob());
+
+    service.retrieveModelVersionZip(7).subscribe();
+    const latest = http.expectOne(req => req.url === `${API}/model/7/versionZip`);
+    expect(latest.request.params.get("latest")).toBe("true");
+    expect(latest.request.params.has("mvid")).toBe(false);
+    latest.flush(new Blob());
+  });
+
+  it("fetches a single file by following the presigned url it is handed", async () => {
+    const blob = new Blob(["weights"]);
+    const pending = firstValueFrom(service.retrieveModelVersionSingleFile("/model/a/m/v1/model.pt"));
+
+    const presign = http.expectOne(
+      `${API}/model/presign-download?filePath=${encodeURIComponent("/model/a/m/v1/model.pt")}`
+    );
+    presign.flush({ presignedUrl: "http://minio/model.pt" });
+    http.expectOne("http://minio/model.pt").flush(blob);
+
+    expect(await pending).toEqual(blob);
+  });
+
+  it("uses the anonymous presign endpoint for a logged-out viewer", () => {
+    service.retrieveModelVersionSingleFile("/model/a/m/v1/model.pt", false).subscribe();
+    http
+      .expectOne(`${API}/model/public-presign-download?filePath=${encodeURIComponent("/model/a/m/v1/model.pt")}`)
+      .flush({ presignedUrl: "http://minio/model.pt" });
+    http.expectOne("http://minio/model.pt").flush(new Blob());
+  });
+
+  it("reads the presigned cover url, which is null for a model without one", async () => {
+    const pending = firstValueFrom(service.getModelCoverUrl(7));
+    http.expectOne(`${API}/model/7/cover-url`).flush({ url: null });
+    expect(await pending).toEqual({ url: null });
+  });
+
   it("surfaces a server error rather than swallowing it", async () => {
     const outcome = firstValueFrom(service.retrieveAccessibleModels()).catch((err: unknown) => err);
     http.expectOne(`${API}/model/list`).flush({ message: "nope" }, { status: 500, statusText: "Server Error" });
diff --git a/frontend/src/app/dashboard/service/user/model/model.service.ts b/frontend/src/app/dashboard/service/user/model/model.service.ts
index c83f136919..c5d26b5ecd 100644
--- a/frontend/src/app/dashboard/service/user/model/model.service.ts
+++ b/frontend/src/app/dashboard/service/user/model/model.service.ts
@@ -18,11 +18,13 @@
  */
 
 import { Injectable } from "@angular/core";
-import { HttpClient } from "@angular/common/http";
+import { HttpClient, HttpParams } from "@angular/common/http";
 import { Observable } from "rxjs";
+import { switchMap } from "rxjs/operators";
 import { AppSettings } from "../../../../common/app-setting";
-import { Model } from "../../../../common/type/model";
+import { Model, ModelVersion } from "../../../../common/type/model";
 import { DashboardModel } from "../../../type/dashboard-model.interface";
+import { DatasetFileNode } from "../../../../common/type/datasetVersionFileTree";
 
 export const MODEL_BASE_URL = "model";
 export const MODEL_CREATE_URL = MODEL_BASE_URL + "/create";
@@ -31,6 +33,11 @@ export const MODEL_UPDATE_NAME_URL = MODEL_UPDATE_BASE_URL + "/name";
 export const MODEL_UPDATE_DESCRIPTION_URL = MODEL_UPDATE_BASE_URL + "/description";
 export const MODEL_LIST_URL = MODEL_BASE_URL + "/list";
 
+export const MODEL_VERSION_BASE_URL = "version";
+export const MODEL_VERSION_RETRIEVE_LIST_URL = MODEL_VERSION_BASE_URL + "/list";
+export const MODEL_PUBLIC_VERSION_BASE_URL = "publicVersion";
+export const MODEL_PUBLIC_VERSION_RETRIEVE_LIST_URL = MODEL_PUBLIC_VERSION_BASE_URL + "/list";
+
 export const DEFAULT_MODEL_NAME = "Untitled-model";
 
 export const MODEL_NAME_MAX_LENGTH = 128;
@@ -73,6 +80,13 @@ export class ModelService {
     });
   }
 
+  public getModel(mid: number, isLogin: boolean = true): Observable<DashboardModel> {
+    const apiUrl = isLogin
+      ? `${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${mid}`
+      : `${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/public/${mid}`;
+    return this.http.get<DashboardModel>(apiUrl);
+  }
+
   public retrieveAccessibleModels(): Observable<DashboardModel[]> {
     return this.http.get<DashboardModel[]>(`${AppSettings.getApiEndpoint()}/${MODEL_LIST_URL}`);
   }
@@ -94,4 +108,52 @@ export class ModelService {
       description: description,
     });
   }
+
+  /** A model's versions, newest first; an anonymous caller gets the public-only endpoint. */
+  public retrieveModelVersionList(mid: number, isLogin: boolean = true): Observable<ModelVersion[]> {
+    const listUrl = isLogin ? MODEL_VERSION_RETRIEVE_LIST_URL : MODEL_PUBLIC_VERSION_RETRIEVE_LIST_URL;
+    return this.http.get<ModelVersion[]>(`${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${mid}/${listUrl}`);
+  }
+
+  public retrieveModelVersionFileTree(
+    mid: number,
+    mvid: number,
+    isLogin: boolean = true
+  ): Observable<{ fileNodes: DatasetFileNode[]; size: number }> {
+    const versionSegment = isLogin ? MODEL_VERSION_BASE_URL : MODEL_PUBLIC_VERSION_BASE_URL;
+    return this.http.get<{ fileNodes: DatasetFileNode[]; size: number }>(
+      `${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${mid}/${versionSegment}/${mvid}/rootFileNodes`
+    );
+  }
+
+  /** A model version as a zip. The backend requires exactly one of mvid or latest. */
+  public retrieveModelVersionZip(mid: number, mvid?: number): Observable<Blob> {
+    const params =
+      mvid !== undefined && mvid !== null
+        ? new HttpParams().set("mvid", mvid.toString())
+        : new HttpParams().set("latest", "true");
+
+    return this.http.get(`${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${mid}/versionZip`, {
+      params,
+      responseType: "blob",
+    });
+  }
+
+  /**
+   * A single file of a model version, fetched through a presigned URL.
+   *
+   * @param filePath Logical path of the file, e.g. "/model/bob@texera.com/resnet/v1/model.pt".
+   */
+  public retrieveModelVersionSingleFile(filePath: string, isLogin: boolean = true): Observable<Blob> {
+    const endpointSegment = isLogin ? "presign-download" : "public-presign-download";
+    const endpoint = `${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${endpointSegment}?filePath=${encodeURIComponent(filePath)}`;
+
+    return this.http
+      .get<{ presignedUrl: string }>(endpoint)
+      .pipe(switchMap(({ presignedUrl }) => this.http.get(presignedUrl, { responseType: "blob" })));
+  }
+
+  public getModelCoverUrl(mid: number): Observable<{ url: string | null }> {
+    return this.http.get<{ url: string | null }>(`${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${mid}/cover-url`);
+  }
 }
diff --git a/frontend/src/app/dashboard/service/user/resource-registry/dataset-resource.descriptor.ts b/frontend/src/app/dashboard/service/user/resource-registry/dataset-resource.descriptor.ts
index 727ff7c280..4c4078c1b2 100644
--- a/frontend/src/app/dashboard/service/user/resource-registry/dataset-resource.descriptor.ts
+++ b/frontend/src/app/dashboard/service/user/resource-registry/dataset-resource.descriptor.ts
@@ -23,6 +23,7 @@ import { ResourceDescriptor } from "../../../type/resource-descriptor";
 import { EntityType } from "../../../../hub/service/hub.service";
 import { DatasetService, DEFAULT_DATASET_NAME, validateDatasetName } from "../dataset/dataset.service";
 import { HUB_DATASET_RESULT_DETAIL, USER_DATASET } from "../../../../app-routing.constant";
+import { DownloadService } from "../download/download.service";
 
 @Injectable({
   providedIn: "root",
@@ -35,7 +36,10 @@ export class DatasetResourceDescriptor implements ResourceDescriptor {
   readonly hasSize = true;
   readonly defaultName = DEFAULT_DATASET_NAME;
 
-  constructor(private datasetService: DatasetService) {}
+  constructor(
+    private datasetService: DatasetService,
+    private downloadService: DownloadService
+  ) {}
 
   isOwner = (entry: DashboardEntry): boolean => entry.dataset.isOwner;
   validateName = validateDatasetName;
@@ -43,5 +47,8 @@ export class DatasetResourceDescriptor implements ResourceDescriptor {
   updateDescription = (id: number, description: string) =>
     this.datasetService.updateDatasetDescription(id, description);
   retrieveOwners = () => this.datasetService.retrieveOwners();
+  download = (id: number, name: string) => this.downloadService.downloadDataset(id, name);
+  retrieveSingleFile = (filePath: string, isLogin: boolean) =>
+    this.datasetService.retrieveDatasetVersionSingleFile(filePath, isLogin);
   // No dataset-id endpoint exists, so `retrieveIds` stays absent and the id filter hides itself.
 }
diff --git a/frontend/src/app/dashboard/service/user/resource-registry/model-resource.descriptor.ts b/frontend/src/app/dashboard/service/user/resource-registry/model-resource.descriptor.ts
index 25b36b03ab..90258baa1f 100644
--- a/frontend/src/app/dashboard/service/user/resource-registry/model-resource.descriptor.ts
+++ b/frontend/src/app/dashboard/service/user/resource-registry/model-resource.descriptor.ts
@@ -23,6 +23,8 @@ import { ResourceDescriptor } from "../../../type/resource-descriptor";
 import { EntityType } from "../../../../hub/service/hub.service";
 import { DEFAULT_MODEL_NAME, ModelService, validateModelName } from "../model/model.service";
 import { MODEL_ICON } from "../../../../common/icon/model-icon";
+import { USER_MODEL } from "../../../../app-routing.constant";
+import { DownloadService } from "../download/download.service";
 
 @Injectable({
   providedIn: "root",
@@ -30,16 +32,22 @@ import { MODEL_ICON } from "../../../../common/icon/model-icon";
 export class ModelResourceDescriptor implements ResourceDescriptor {
   readonly type = EntityType.Model;
   readonly iconType = MODEL_ICON;
-  // `privateRoute` is deliberately absent: /user/model/:mid has no component yet, so
-  // entryLink returns [] and a model card does not navigate.
+  readonly privateRoute = USER_MODEL;
+  // `hubRoute` is deliberately absent: models reach the hub with the rest of the hub UI.
   readonly hasSize = true;
   readonly defaultName = DEFAULT_MODEL_NAME;
 
-  constructor(private modelService: ModelService) {}
+  constructor(
+    private modelService: ModelService,
+    private downloadService: DownloadService
+  ) {}
 
   isOwner = (entry: DashboardEntry): boolean => entry.model.isOwner;
   validateName = validateModelName;
   rename = (id: number, name: string) => this.modelService.updateModelName(id, name);
   updateDescription = (id: number, description: string) => this.modelService.updateModelDescription(id, description);
+  download = (id: number, name: string) => this.downloadService.downloadModel(id, name);
+  retrieveSingleFile = (filePath: string, isLogin: boolean) =>
+    this.modelService.retrieveModelVersionSingleFile(filePath, isLogin);
   // `retrieveOwners` arrives with the share modal and the filters, which need it.
 }
diff --git a/frontend/src/app/dashboard/service/user/resource-registry/resource-registry.service.spec.ts b/frontend/src/app/dashboard/service/user/resource-registry/resource-registry.service.spec.ts
index 4701cf643e..ee91d5f4ab 100644
--- a/frontend/src/app/dashboard/service/user/resource-registry/resource-registry.service.spec.ts
+++ b/frontend/src/app/dashboard/service/user/resource-registry/resource-registry.service.spec.ts
@@ -26,10 +26,12 @@ import { EntityType } from "../../../../hub/service/hub.service";
 import { DatasetService } from "../dataset/dataset.service";
 import { ModelService } from "../model/model.service";
 import { WorkflowPersistService } from "../../../../common/service/workflow-persist/workflow-persist.service";
+import { DownloadService } from "../download/download.service";
 import {
   HUB_DATASET_RESULT_DETAIL,
   HUB_WORKFLOW_RESULT_DETAIL,
   USER_DATASET,
+  USER_MODEL,
   USER_PROJECT,
   USER_WORKSPACE,
 } from "../../../../app-routing.constant";
@@ -44,6 +46,7 @@ describe("ResourceRegistryService", () => {
   let workflowPersistService: { [k: string]: ReturnType<typeof vi.fn> };
   let datasetService: { [k: string]: ReturnType<typeof vi.fn> };
   let modelService: { [k: string]: ReturnType<typeof vi.fn> };
+  let downloadService: { [k: string]: ReturnType<typeof vi.fn> };
 
   beforeEach(() => {
     // Partial spies on purpose: the descriptors must not touch these until a caller asks.
@@ -57,16 +60,25 @@ describe("ResourceRegistryService", () => {
       updateDatasetName: vi.fn().mockReturnValue(of({})),
       updateDatasetDescription: vi.fn().mockReturnValue(of({})),
       retrieveOwners: vi.fn().mockReturnValue(of(["ds-owner"])),
+      retrieveDatasetVersionSingleFile: vi.fn().mockReturnValue(of(new Blob())),
     };
 
     modelService = {
       updateModelName: vi.fn().mockReturnValue(of({})),
       updateModelDescription: vi.fn().mockReturnValue(of({})),
+      retrieveModelVersionSingleFile: vi.fn().mockReturnValue(of(new Blob())),
+    };
+
+    downloadService = {
+      downloadWorkflow: vi.fn().mockReturnValue(of({})),
+      downloadDataset: vi.fn().mockReturnValue(of(new Blob())),
+      downloadModel: vi.fn().mockReturnValue(of(new Blob())),
     };
 
     TestBed.configureTestingModule({
       imports: [HttpClientTestingModule],
       providers: [
+        { provide: DownloadService, useValue: downloadService },
         { provide: WorkflowPersistService, useValue: workflowPersistService },
         { provide: DatasetService, useValue: datasetService },
         { provide: ModelService, useValue: modelService },
@@ -107,6 +119,19 @@ describe("ResourceRegistryService", () => {
     }
   });
 
+  it("offers a download and a file preview only for the kinds that hold files", () => {
+    for (const type of [EntityType.Workflow, EntityType.Dataset, EntityType.Model]) {
+      expect(registry.get(type).download).toBeDefined();
+    }
+    for (const type of [EntityType.Project, EntityType.File]) {
+      expect(registry.get(type).download).toBeUndefined();
+    }
+    // Workflows are one file, not a version tree, so nothing previews them.
+    expect(registry.get(EntityType.Workflow).retrieveSingleFile).toBeUndefined();
+    expect(registry.get(EntityType.Dataset).retrieveSingleFile).toBeDefined();
+    expect(registry.get(EntityType.Model).retrieveSingleFile).toBeDefined();
+  });
+
   it("offers an id filter only where the backend has an id endpoint", () => {
     expect(registry.get(EntityType.Workflow).retrieveIds).toBeDefined();
     expect(registry.get(EntityType.Dataset).retrieveIds).toBeUndefined();
@@ -130,6 +155,11 @@ describe("ResourceRegistryService", () => {
     registry.get(EntityType.Dataset).updateDescription!(2, "d");
     registry.get(EntityType.Model).rename!(3, "m");
     registry.get(EntityType.Model).updateDescription!(3, "d");
+    registry.get(EntityType.Workflow).download!(1, "wf");
+    registry.get(EntityType.Dataset).download!(2, "ds");
+    registry.get(EntityType.Model).download!(3, "m");
+    registry.get(EntityType.Dataset).retrieveSingleFile!("/dataset/a/ds/v1/f.csv", true);
+    registry.get(EntityType.Model).retrieveSingleFile!("/model/a/m/v1/f.pt", false);
 
     expect(workflowPersistService["updateWorkflowName"]).toHaveBeenCalledWith(1, "wf");
     expect(workflowPersistService["updateWorkflowDescription"]).toHaveBeenCalledWith(1, "d");
@@ -137,6 +167,11 @@ describe("ResourceRegistryService", () => {
     expect(datasetService["updateDatasetDescription"]).toHaveBeenCalledWith(2, "d");
     expect(modelService["updateModelName"]).toHaveBeenCalledWith(3, "m");
     expect(modelService["updateModelDescription"]).toHaveBeenCalledWith(3, "d");
+    expect(downloadService["downloadWorkflow"]).toHaveBeenCalledWith(1, "wf");
+    expect(downloadService["downloadDataset"]).toHaveBeenCalledWith(2, "ds");
+    expect(downloadService["downloadModel"]).toHaveBeenCalledWith(3, "m");
+    expect(datasetService["retrieveDatasetVersionSingleFile"]).toHaveBeenCalledWith("/dataset/a/ds/v1/f.csv", true);
+    expect(modelService["retrieveModelVersionSingleFile"]).toHaveBeenCalledWith("/model/a/m/v1/f.pt", false);
   });
 
   it("reads ownership off the kind's own payload", () => {
@@ -167,10 +202,9 @@ describe("ResourceRegistryService", () => {
     expect(registry.entryLink(entry({ type: EntityType.Project, id: 3 }), undefined)).toEqual([USER_PROJECT, "3"]);
   });
 
-  it("leaves models unrouted until they have a page to open", () => {
-    // /user/model/:mid has no component yet; a link would hit the ** wildcard and land on Workflows.
-    expect(registry.get(EntityType.Model).privateRoute).toBeUndefined();
-    expect(registry.entryLink(entry({ type: EntityType.Model, id: 9 }), 42)).toEqual([]);
+  it("routes a model to its detail page, which has no hub twin yet", () => {
+    expect(registry.get(EntityType.Model).hubRoute).toBeUndefined();
+    expect(registry.entryLink(entry({ type: EntityType.Model, id: 9 }), 42)).toEqual([USER_MODEL, "9"]);
   });
 
   it("leaves an unroutable or unsaved entry unlinked", () => {
diff --git a/frontend/src/app/dashboard/service/user/resource-registry/workflow-resource.descriptor.ts b/frontend/src/app/dashboard/service/user/resource-registry/workflow-resource.descriptor.ts
index 79e1e4bec1..07b8614796 100644
--- a/frontend/src/app/dashboard/service/user/resource-registry/workflow-resource.descriptor.ts
+++ b/frontend/src/app/dashboard/service/user/resource-registry/workflow-resource.descriptor.ts
@@ -26,6 +26,7 @@ import {
   WorkflowPersistService,
 } from "../../../../common/service/workflow-persist/workflow-persist.service";
 import { HUB_WORKFLOW_RESULT_DETAIL, USER_WORKSPACE } from "../../../../app-routing.constant";
+import { DownloadService } from "../download/download.service";
 
 @Injectable({
   providedIn: "root",
@@ -38,7 +39,10 @@ export class WorkflowResourceDescriptor implements ResourceDescriptor {
   readonly hasSize = true;
   readonly defaultName = DEFAULT_WORKFLOW_NAME;
 
-  constructor(private workflowPersistService: WorkflowPersistService) {}
+  constructor(
+    private workflowPersistService: WorkflowPersistService,
+    private downloadService: DownloadService
+  ) {}
 
   // Bound lazily, never in the constructor: specs hand these descriptors partial service spies,
   // and reading an absent method up front would fail the whole TestBed.
@@ -48,4 +52,5 @@ export class WorkflowResourceDescriptor implements ResourceDescriptor {
     this.workflowPersistService.updateWorkflowDescription(id, description);
   retrieveOwners = () => this.workflowPersistService.retrieveOwners();
   retrieveIds = () => this.workflowPersistService.retrieveWorkflowIDs();
+  download = (id: number, name: string) => this.downloadService.downloadWorkflow(id, name);
 }
diff --git a/frontend/src/app/dashboard/type/resource-descriptor.ts b/frontend/src/app/dashboard/type/resource-descriptor.ts
index bfba83a167..c2cdc39aa9 100644
--- a/frontend/src/app/dashboard/type/resource-descriptor.ts
+++ b/frontend/src/app/dashboard/type/resource-descriptor.ts
@@ -43,6 +43,10 @@ export interface ResourceDescriptor {
   validateName?(name: string): string | null;
   rename?(id: number, name: string): Observable<unknown>;
   updateDescription?(id: number, description: string): Observable<unknown>;
+  /** Downloads the whole resource; absent when the kind has nothing to download. */
+  download?(id: number, name: string): Observable<unknown>;
+  /** Fetches one file of a version for preview, by its logical path. */
+  retrieveSingleFile?(filePath: string, isLogin: boolean): Observable<Blob>;
   /** Owners of this kind, for the filter dropdown. */
   retrieveOwners?(): Observable<string[]>;
   /** Entry ids of this kind; absent when the backend exposes no such endpoint. */