diff --git a/apps/client/app/api-client/@tanstack/react-query.gen.ts b/apps/client/app/api-client/@tanstack/react-query.gen.ts index bc0df283..35e8bf03 100644 --- a/apps/client/app/api-client/@tanstack/react-query.gen.ts +++ b/apps/client/app/api-client/@tanstack/react-query.gen.ts @@ -17,6 +17,7 @@ import { mountVolume, unmountVolume, healthCheckVolume, + listFiles, } from "../sdk.gen"; import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query"; import type { @@ -45,6 +46,7 @@ import type { UnmountVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, + ListFilesData, } from "../types.gen"; import { client as _heyApiClient } from "../client.gen"; @@ -539,3 +541,23 @@ export const healthCheckVolumeMutation = ( }; return mutationOptions; }; + +export const listFilesQueryKey = (options: Options) => createQueryKey("listFiles", options); + +/** + * List files in a volume directory + */ +export const listFilesOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await listFiles({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: listFilesQueryKey(options), + }); +}; diff --git a/apps/client/app/api-client/sdk.gen.ts b/apps/client/app/api-client/sdk.gen.ts index 64432ddf..7a08e9be 100644 --- a/apps/client/app/api-client/sdk.gen.ts +++ b/apps/client/app/api-client/sdk.gen.ts @@ -41,6 +41,9 @@ import type { HealthCheckVolumeData, HealthCheckVolumeResponses, HealthCheckVolumeErrors, + ListFilesData, + ListFilesResponses, + ListFilesErrors, } from "./types.gen"; import { client as _heyApiClient } from "./client.gen"; @@ -248,3 +251,13 @@ export const healthCheckVolume = ( ...options, }); }; + +/** + * List files in a volume directory + */ +export const listFiles = (options: Options) => { + return (options.client ?? _heyApiClient).get({ + url: "/api/v1/volumes/{name}/files", + ...options, + }); +}; diff --git a/apps/client/app/api-client/types.gen.ts b/apps/client/app/api-client/types.gen.ts index 9085ca57..ad7f94d1 100644 --- a/apps/client/app/api-client/types.gen.ts +++ b/apps/client/app/api-client/types.gen.ts @@ -600,6 +600,40 @@ export type HealthCheckVolumeResponses = { export type HealthCheckVolumeResponse = HealthCheckVolumeResponses[keyof HealthCheckVolumeResponses]; +export type ListFilesData = { + body?: never; + path: { + name: string; + }; + query?: never; + url: "/api/v1/volumes/{name}/files"; +}; + +export type ListFilesErrors = { + /** + * Volume not found + */ + 404: unknown; +}; + +export type ListFilesResponses = { + /** + * List of files in the volume + */ + 200: { + files: Array<{ + name: string; + path: string; + type: "directory" | "file"; + modifiedAt?: number; + size?: number; + }>; + path: string; + }; +}; + +export type ListFilesResponse = ListFilesResponses[keyof ListFilesResponses]; + export type ClientOptions = { baseUrl: "http://localhost:4096" | (string & {}); }; diff --git a/apps/server/src/modules/volumes/volume.controller.ts b/apps/server/src/modules/volumes/volume.controller.ts index ea123efd..09ebba84 100644 --- a/apps/server/src/modules/volumes/volume.controller.ts +++ b/apps/server/src/modules/volumes/volume.controller.ts @@ -9,7 +9,9 @@ import { getVolumeDto, healthCheckDto, type ListContainersResponseDto, + type ListFilesResponseDto, type ListVolumesResponseDto, + listFilesDto, listVolumesDto, mountVolumeDto, testConnectionBody, @@ -118,4 +120,16 @@ export const volumeController = new Hono() const { error, status } = await volumeService.checkHealth(name); return c.json({ error, status }, 200); + }) + .get("/:name/files", listFilesDto, async (c) => { + const { name } = c.req.param(); + const subPath = c.req.query("path"); + const result = await volumeService.listFiles(name, subPath); + + const response = { + files: result.files, + path: result.path, + } satisfies ListFilesResponseDto; + + return c.json(response, 200); }); diff --git a/apps/server/src/modules/volumes/volume.dto.ts b/apps/server/src/modules/volumes/volume.dto.ts index cdcd9baa..85ae2f82 100644 --- a/apps/server/src/modules/volumes/volume.dto.ts +++ b/apps/server/src/modules/volumes/volume.dto.ts @@ -305,3 +305,39 @@ export const getContainersDto = describeRoute({ }, }, }); + +/** + * List files in a volume + */ +const fileEntrySchema = type({ + name: "string", + path: "string", + type: type.enumerated("file", "directory"), + size: "number?", + modifiedAt: "number?", +}); + +export const listFilesResponse = type({ + files: fileEntrySchema.array(), + path: "string", +}); +export type ListFilesResponseDto = typeof listFilesResponse.infer; + +export const listFilesDto = describeRoute({ + description: "List files in a volume directory", + operationId: "listFiles", + tags: ["Volumes"], + responses: { + 200: { + description: "List of files in the volume", + content: { + "application/json": { + schema: resolver(listFilesResponse), + }, + }, + }, + 404: { + description: "Volume not found", + }, + }, +}); diff --git a/apps/server/src/modules/volumes/volume.service.ts b/apps/server/src/modules/volumes/volume.service.ts index 043243ed..42decc00 100644 --- a/apps/server/src/modules/volumes/volume.service.ts +++ b/apps/server/src/modules/volumes/volume.service.ts @@ -253,6 +253,69 @@ const getContainersUsingVolume = async (name: string) => { return { containers: usingContainers }; }; +const listFiles = async (name: string, subPath?: string) => { + const volume = await db.query.volumesTable.findFirst({ + where: eq(volumesTable.name, name), + }); + + if (!volume) { + throw new NotFoundError("Volume not found"); + } + + if (volume.status !== "mounted") { + throw new InternalServerError("Volume is not mounted"); + } + + const requestedPath = subPath ? path.join(volume.path, subPath) : volume.path; + + const normalizedPath = path.normalize(requestedPath); + if (!normalizedPath.startsWith(volume.path)) { + throw new InternalServerError("Invalid path"); + } + + try { + const entries = await fs.readdir(normalizedPath, { withFileTypes: true }); + + const files = await Promise.all( + entries.map(async (entry) => { + const fullPath = path.join(normalizedPath, entry.name); + const relativePath = path.relative(volume.path, fullPath); + + try { + const stats = await fs.stat(fullPath); + return { + name: entry.name, + path: `/${relativePath}`, + type: entry.isDirectory() ? ("directory" as const) : ("file" as const), + size: entry.isFile() ? stats.size : undefined, + modifiedAt: stats.mtimeMs, + }; + } catch { + return { + name: entry.name, + path: `/${relativePath}`, + type: entry.isDirectory() ? ("directory" as const) : ("file" as const), + size: undefined, + modifiedAt: undefined, + }; + } + }), + ); + + return { + files: files.sort((a, b) => { + if (a.type !== b.type) { + return a.type === "directory" ? -1 : 1; + } + return a.name.localeCompare(b.name); + }), + path: subPath || "/", + }; + } catch (error) { + throw new InternalServerError(`Failed to list files: ${toMessage(error)}`); + } +}; + export const volumeService = { listVolumes, createVolume, @@ -264,4 +327,5 @@ export const volumeService = { unmountVolume, checkHealth, getContainersUsingVolume, + listFiles, };