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

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

commit b59c7406d0a7d549a50c1f86888e85563f2d3446
Author: Tanishq Gandhi <[email protected]>
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 = "[email protected]";
+
+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/[email protected]/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. */

Reply via email to