From e4bad96ceda08caeeb291ce454b70f3c25688cac Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Mon, 29 Dec 2025 16:16:48 -0300 Subject: [PATCH 1/3] Initial implementation for File Storage binding --- .gitignore | 4 +- apps/mesh/FILE_STORAGE_BINDING_PLAN.md | 1343 +++++++++++++++++ apps/mesh/src/core/context-factory.ts | 2 + apps/mesh/src/core/well-known-mcp.ts | 32 + apps/mesh/src/tools/index.ts | 5 + apps/mesh/src/web/components/chat/chat.tsx | 191 ++- .../collections/collections-list.tsx | 40 +- .../src/web/components/collections/types.ts | 6 + .../details/connection/collection-tab.tsx | 300 +++- .../src/web/components/details/file/index.tsx | 155 ++ .../src/web/components/file-drop-zone.tsx | 157 ++ .../src/web/components/files/file-browser.tsx | 220 +++ .../src/web/components/files/file-preview.tsx | 307 ++++ apps/mesh/src/web/components/files/index.ts | 9 + .../src/web/components/files/text-editor.tsx | 196 +++ apps/mesh/src/web/hooks/use-binding.ts | 2 + apps/mesh/src/web/hooks/use-file-storage.ts | 287 ++++ .../src/web/routes/orgs/collection-detail.tsx | 6 + package.json | 3 + packages/bindings/package.json | 3 +- .../bindings/src/well-known/file-storage.ts | 484 ++++++ 21 files changed, 3716 insertions(+), 36 deletions(-) create mode 100644 apps/mesh/FILE_STORAGE_BINDING_PLAN.md create mode 100644 apps/mesh/src/web/components/details/file/index.tsx create mode 100644 apps/mesh/src/web/components/file-drop-zone.tsx create mode 100644 apps/mesh/src/web/components/files/file-browser.tsx create mode 100644 apps/mesh/src/web/components/files/file-preview.tsx create mode 100644 apps/mesh/src/web/components/files/index.ts create mode 100644 apps/mesh/src/web/components/files/text-editor.tsx create mode 100644 apps/mesh/src/web/hooks/use-file-storage.ts create mode 100644 packages/bindings/src/well-known/file-storage.ts diff --git a/.gitignore b/.gitignore index 0991c68cc5..0fe8af8d54 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,6 @@ deploy/docs deploy/test* # Data Test files -apps/benchmark/data \ No newline at end of file +apps/benchmark/data +# Local storage +apps/mesh/storage diff --git a/apps/mesh/FILE_STORAGE_BINDING_PLAN.md b/apps/mesh/FILE_STORAGE_BINDING_PLAN.md new file mode 100644 index 0000000000..3099dab19e --- /dev/null +++ b/apps/mesh/FILE_STORAGE_BINDING_PLAN.md @@ -0,0 +1,1343 @@ +# File Storage Binding Plan + +> A comprehensive plan for implementing file storage capabilities in Deco Mesh, including the `FILE_STORAGE_BINDING`, `mcp-local-fs` default MCP, and drag-and-drop file upload UI. + +## ✅ Implementation Status + +| Phase | Component | Status | +|-------|-----------|--------| +| 1.1 | FILE_STORAGE_BINDING | ✅ Implemented | +| 1.2 | FileEntity & FolderEntity schemas | ✅ Implemented | +| 1.3 | Collection bindings (FILES, FOLDERS) | ✅ Implemented | +| 2.1 | LocalFileStorage class | ✅ Implemented | +| 2.2 | File tools (READ, WRITE, DELETE, MOVE, COPY, MKDIR) | ✅ Implemented | +| 2.3 | Collection tools (LIST, GET for files/folders) | ✅ Implemented | +| 3.1 | MeshContext.fileStorage | ✅ Implemented | +| 3.2 | /mcp/local-fs route | ✅ Implemented | +| 3.3 | /api/files route for file serving | ✅ Implemented | +| 3.4 | Well-known MCP definition (LOCAL_FS) | ✅ Implemented | +| 4.1 | useFileStorageConnections hook | ✅ Implemented | +| 4.2 | FileDropZone component | ✅ Implemented | +| 4.3 | FileBrowser component | ✅ Implemented | +| 4.4 | FilePreview component | ✅ Implemented | +| 4.5 | FileDetailsView / FolderDetailsView | ✅ Implemented | +| 4.6 | Well-known views registration | ✅ Implemented | +| 5 | Monaco editor integration | 🔜 Phase 2 | +| 5 | File editing via tools | 🔜 Phase 2 | + +### Files Created/Modified + +**New Files:** +- `packages/bindings/src/well-known/file-storage.ts` - FILE_STORAGE_BINDING definition +- `apps/mesh/src/file-storage/types.ts` - File storage types +- `apps/mesh/src/file-storage/local-fs.ts` - LocalFileStorage implementation +- `apps/mesh/src/file-storage/index.ts` - Module exports +- `apps/mesh/src/tools/files/*.ts` - File tools (read, write, delete, move, copy, mkdir, list) +- `apps/mesh/src/api/routes/local-fs.ts` - MCP route for local-fs +- `apps/mesh/src/api/routes/files.ts` - File serving route +- `apps/mesh/src/web/hooks/use-file-storage.ts` - React hooks +- `apps/mesh/src/web/components/file-drop-zone.tsx` - Drop zone component +- `apps/mesh/src/web/components/files/*.tsx` - File browser/preview +- `apps/mesh/src/web/components/details/file/index.tsx` - Detail views + +**Modified Files:** +- `packages/bindings/package.json` - Added file-storage export +- `apps/mesh/src/core/mesh-context.ts` - Added fileStorage property +- `apps/mesh/src/core/context-factory.ts` - File storage initialization +- `apps/mesh/src/core/well-known-mcp.ts` - Added LOCAL_FS definition +- `apps/mesh/src/api/app.ts` - Mounted new routes +- `apps/mesh/src/web/hooks/use-binding.ts` - Added FILE_STORAGE to bindings +- `apps/mesh/src/web/routes/orgs/collection-detail.tsx` - Registered file/folder views + +--- + +## Overview + +This plan introduces a first-class file storage system for Deco Mesh that allows: + +1. **Drag-and-drop file uploads** - Drop files anywhere in the Mesh UI when a storage provider is available +2. **File/Folder collections** - Browse files and folders as standard collections with custom UI +3. **mcp-local-fs** - A default MCP that stores files in a local `storage/` directory +4. **Extensibility** - Any MCP can implement the `FILE_STORAGE_BINDING` to provide storage (S3, GCS, R2, etc.) + +--- + +## Architecture + +### Binding-First Design + +Following the existing patterns in `@decocms/bindings`, we define: + +``` +packages/bindings/src/well-known/ +├── file-storage.ts # FILE_STORAGE_BINDING definition +└── index.ts # Export file-storage binding + +apps/mesh/src/ +├── file-storage/ # Core file storage logic +│ ├── index.ts # Storage manager +│ ├── local-fs.ts # Local filesystem implementation +│ └── types.ts # Shared types +├── tools/ +│ └── files/ # MCP tools for file operations +│ ├── index.ts +│ ├── schema.ts +│ ├── read.ts +│ ├── write.ts +│ ├── delete.ts +│ └── list.ts +└── web/ + ├── components/ + │ ├── file-drop-zone.tsx # Global drop zone overlay + │ ├── files/ + │ │ ├── file-browser.tsx # Folder view component + │ │ ├── file-editor.tsx # Monaco editor for files + │ │ └── file-preview.tsx # Preview component + │ └── details/ + │ └── file/ + │ └── index.tsx # Well-known file detail view + └── hooks/ + └── use-file-storage.ts # React hook for file operations +``` + +--- + +## Phase 1: FILE_STORAGE_BINDING + +### 1.1 Define the Binding (`packages/bindings/src/well-known/file-storage.ts`) + +```typescript +import { z } from "zod"; +import type { ToolBinder } from "../core/binder"; +import { bindingClient } from "../core/binder"; + +// ============================================================================ +// Entity Schemas +// ============================================================================ + +/** + * File metadata schema + */ +export const FileEntitySchema = z.object({ + /** Unique file path (serves as ID) */ + id: z.string().describe("Unique file path/identifier"), + + /** Display name */ + title: z.string().describe("File name"), + + /** Optional description */ + description: z.string().nullish(), + + /** File path relative to storage root */ + path: z.string().describe("File path relative to storage root"), + + /** Parent folder path (empty string for root) */ + parent: z.string().describe("Parent folder path"), + + /** MIME type */ + mimeType: z.string().describe("MIME type of the file"), + + /** File size in bytes */ + size: z.number().describe("File size in bytes"), + + /** Whether this is a directory */ + isDirectory: z.boolean().describe("Whether this is a directory"), + + /** Created timestamp */ + created_at: z.string().datetime(), + + /** Updated timestamp */ + updated_at: z.string().datetime(), + + /** Optional URL for direct access (pre-signed URL or public URL) */ + url: z.string().url().optional().describe("Direct access URL"), + + /** Optional thumbnail URL for images */ + thumbnailUrl: z.string().url().optional(), +}); + +export type FileEntity = z.infer; + +/** + * Folder entity schema (alias for directory) + */ +export const FolderEntitySchema = FileEntitySchema.extend({ + isDirectory: z.literal(true), + /** Number of items in folder */ + itemCount: z.number().optional().describe("Number of items in folder"), +}); + +export type FolderEntity = z.infer; + +// ============================================================================ +// Tool Schemas +// ============================================================================ + +/** + * FILE_READ Input - Read file content + */ +export const FileReadInputSchema = z.object({ + /** File path to read */ + path: z.string().describe("File path to read"), + + /** Encoding for text files (default: utf-8) */ + encoding: z.enum(["utf-8", "base64", "binary"]).optional().default("utf-8"), +}); + +export type FileReadInput = z.infer; + +export const FileReadOutputSchema = z.object({ + /** File content (text or base64 encoded) */ + content: z.string().describe("File content"), + + /** File metadata */ + metadata: FileEntitySchema, +}); + +export type FileReadOutput = z.infer; + +/** + * FILE_WRITE Input - Write/upload file + */ +export const FileWriteInputSchema = z.object({ + /** File path to write */ + path: z.string().describe("File path to write"), + + /** File content (text or base64 encoded) */ + content: z.string().describe("File content (text or base64)"), + + /** Content encoding */ + encoding: z.enum(["utf-8", "base64"]).optional().default("utf-8"), + + /** MIME type (auto-detected if not provided) */ + mimeType: z.string().optional(), + + /** Whether to create parent directories if they don't exist */ + createParents: z.boolean().optional().default(true), + + /** Whether to overwrite if file exists */ + overwrite: z.boolean().optional().default(true), +}); + +export type FileWriteInput = z.infer; + +export const FileWriteOutputSchema = z.object({ + /** Written file metadata */ + file: FileEntitySchema, +}); + +export type FileWriteOutput = z.infer; + +/** + * FILE_DELETE Input + */ +export const FileDeleteInputSchema = z.object({ + /** Path to delete */ + path: z.string().describe("Path to delete"), + + /** Whether to recursively delete directories */ + recursive: z.boolean().optional().default(false), +}); + +export type FileDeleteInput = z.infer; + +export const FileDeleteOutputSchema = z.object({ + success: z.boolean(), + path: z.string(), + deletedCount: z.number().optional(), +}); + +export type FileDeleteOutput = z.infer; + +/** + * FILE_MOVE Input - Move/rename file or folder + */ +export const FileMoveInputSchema = z.object({ + /** Source path */ + from: z.string().describe("Source path"), + + /** Destination path */ + to: z.string().describe("Destination path"), + + /** Whether to overwrite if destination exists */ + overwrite: z.boolean().optional().default(false), +}); + +export type FileMoveInput = z.infer; + +export const FileMoveOutputSchema = z.object({ + /** Moved file metadata */ + file: FileEntitySchema, +}); + +export type FileMoveOutput = z.infer; + +/** + * FILE_COPY Input + */ +export const FileCopyInputSchema = z.object({ + /** Source path */ + from: z.string().describe("Source path"), + + /** Destination path */ + to: z.string().describe("Destination path"), + + /** Whether to overwrite if destination exists */ + overwrite: z.boolean().optional().default(false), +}); + +export type FileCopyInput = z.infer; + +export const FileCopyOutputSchema = z.object({ + /** Copied file metadata */ + file: FileEntitySchema, +}); + +export type FileCopyOutput = z.infer; + +/** + * FILE_MKDIR Input - Create directory + */ +export const FileMkdirInputSchema = z.object({ + /** Directory path to create */ + path: z.string().describe("Directory path to create"), + + /** Whether to create parent directories */ + recursive: z.boolean().optional().default(true), +}); + +export type FileMkdirInput = z.infer; + +export const FileMkdirOutputSchema = z.object({ + /** Created directory metadata */ + folder: FolderEntitySchema, +}); + +export type FileMkdirOutput = z.infer; + +/** + * FILE_UPLOAD_URL Input - Get a pre-signed URL for direct upload + * (Optional - for backends that support direct upload) + */ +export const FileUploadUrlInputSchema = z.object({ + /** Target path for the upload */ + path: z.string().describe("Target path for upload"), + + /** MIME type of file to upload */ + mimeType: z.string().describe("MIME type"), + + /** File size in bytes (for validation) */ + size: z.number().optional(), + + /** URL expiration in seconds (default: 3600) */ + expiresIn: z.number().optional().default(3600), +}); + +export type FileUploadUrlInput = z.infer; + +export const FileUploadUrlOutputSchema = z.object({ + /** Pre-signed upload URL */ + uploadUrl: z.string().url(), + + /** HTTP method to use (PUT or POST) */ + method: z.enum(["PUT", "POST"]), + + /** Headers to include with the upload request */ + headers: z.record(z.string()).optional(), + + /** Form fields for multipart uploads */ + fields: z.record(z.string()).optional(), + + /** URL expiration timestamp */ + expiresAt: z.string().datetime(), + + /** Final path where file will be stored */ + path: z.string(), +}); + +export type FileUploadUrlOutput = z.infer; + +// ============================================================================ +// FILE_STORAGE_BINDING Definition +// ============================================================================ + +/** + * File Storage Binding + * + * Core tools for file operations. All storage providers must implement these. + * + * Required: + * - FILE_READ: Read file content + * - FILE_WRITE: Write/upload file content + * - FILE_DELETE: Delete file or directory + * + * Optional: + * - FILE_MOVE: Move/rename files + * - FILE_COPY: Copy files + * - FILE_MKDIR: Create directories + * - FILE_UPLOAD_URL: Get pre-signed upload URL (for direct uploads) + */ +export const FILE_STORAGE_BINDING = [ + { + name: "FILE_READ" as const, + inputSchema: FileReadInputSchema, + outputSchema: FileReadOutputSchema, + }, + { + name: "FILE_WRITE" as const, + inputSchema: FileWriteInputSchema, + outputSchema: FileWriteOutputSchema, + }, + { + name: "FILE_DELETE" as const, + inputSchema: FileDeleteInputSchema, + outputSchema: FileDeleteOutputSchema, + }, + { + name: "FILE_MOVE" as const, + inputSchema: FileMoveInputSchema, + outputSchema: FileMoveOutputSchema, + opt: true, + }, + { + name: "FILE_COPY" as const, + inputSchema: FileCopyInputSchema, + outputSchema: FileCopyOutputSchema, + opt: true, + }, + { + name: "FILE_MKDIR" as const, + inputSchema: FileMkdirInputSchema, + outputSchema: FileMkdirOutputSchema, + opt: true, + }, + { + name: "FILE_UPLOAD_URL" as const, + inputSchema: FileUploadUrlInputSchema, + outputSchema: FileUploadUrlOutputSchema, + opt: true, + }, +] satisfies ToolBinder[]; + +/** + * File Storage Binding Client + */ +export const FileStorageBinding = bindingClient(FILE_STORAGE_BINDING); + +export type FileStorageBindingClient = ReturnType< + typeof FileStorageBinding.forConnection +>; +``` + +### 1.2 Files Collection Binding + +Since files should also be browseable as a collection, we create collection bindings: + +```typescript +import { createCollectionBindings } from "./collections"; + +/** + * Files collection binding - for browsing files + * Uses the standard collection pattern for LIST and GET + */ +export const FILES_COLLECTION_BINDING = createCollectionBindings( + "files", + FileEntitySchema, + { readOnly: true } // Mutations go through FILE_WRITE, FILE_DELETE +); + +/** + * Folders collection binding - for browsing folders + */ +export const FOLDERS_COLLECTION_BINDING = createCollectionBindings( + "folders", + FolderEntitySchema, + { readOnly: true } +); +``` + +--- + +## Phase 2: mcp-local-fs (Default MCP) + +### 2.1 Add as Well-Known MCP + +Update `apps/mesh/src/core/well-known-mcp.ts`: + +```typescript +export const WellKnownMCPId = { + SELF: "self", + REGISTRY: "registry", + COMMUNITY_REGISTRY: "community-registry", + LOCAL_FS: "local-fs", // NEW +}; + +export const WellKnownOrgMCPId = { + // ... existing + LOCAL_FS: (org: string) => `${org}_${WellKnownMCPId.LOCAL_FS}`, +}; + +/** + * Get well-known connection definition for local file storage. + * Stores files in a local storage/ directory where mesh is running. + */ +export function getWellKnownLocalFsConnection( + baseUrl: string, + orgId: string, +): ConnectionCreateData { + return { + id: WellKnownOrgMCPId.LOCAL_FS(orgId), + title: "Local Files", + description: "File storage in the local storage/ directory", + connection_type: "HTTP", + connection_url: `${baseUrl}/mcp/local-fs`, + icon: "folder-open", // Use icon name for built-in icons + app_name: "@deco/local-fs", + connection_token: null, + connection_headers: null, + oauth_config: null, + configuration_state: null, + configuration_scopes: null, + metadata: { + isDefault: true, + type: "file-storage", + }, + }; +} +``` + +### 2.2 Local FS Implementation + +Create `apps/mesh/src/file-storage/local-fs.ts`: + +```typescript +import { mkdir, readFile, writeFile, unlink, stat, readdir, rename, copyFile } from "node:fs/promises"; +import { join, dirname, basename, extname } from "node:path"; +import { existsSync } from "node:fs"; +import type { FileEntity, FolderEntity } from "@decocms/bindings/file-storage"; +import mime from "mime-types"; + +export interface LocalFsConfig { + /** Root directory for storage (default: ./storage) */ + rootDir: string; + + /** Base URL for generating file URLs */ + baseUrl: string; +} + +export class LocalFileStorage { + private rootDir: string; + private baseUrl: string; + + constructor(config: LocalFsConfig) { + this.rootDir = config.rootDir; + this.baseUrl = config.baseUrl; + } + + private resolvePath(path: string): string { + // Sanitize path to prevent directory traversal + const normalized = path.replace(/\.\./g, "").replace(/^\/+/, ""); + return join(this.rootDir, normalized); + } + + private async ensureDir(dir: string): Promise { + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }); + } + } + + private async getMetadata(path: string): Promise { + const fullPath = this.resolvePath(path); + const stats = await stat(fullPath); + const name = basename(path); + const parent = dirname(path) === "." ? "" : dirname(path); + const mimeType = stats.isDirectory() + ? "inode/directory" + : mime.lookup(name) || "application/octet-stream"; + + return { + id: path, + title: name, + description: null, + path, + parent, + mimeType, + size: stats.size, + isDirectory: stats.isDirectory(), + created_at: stats.birthtime.toISOString(), + updated_at: stats.mtime.toISOString(), + url: stats.isDirectory() ? undefined : `${this.baseUrl}/files/${encodeURIComponent(path)}`, + }; + } + + async read(path: string, encoding: "utf-8" | "base64" | "binary" = "utf-8") { + const fullPath = this.resolvePath(path); + const content = await readFile(fullPath); + + return { + content: encoding === "base64" + ? content.toString("base64") + : content.toString("utf-8"), + metadata: await this.getMetadata(path), + }; + } + + async write( + path: string, + content: string, + options: { + encoding?: "utf-8" | "base64"; + createParents?: boolean; + overwrite?: boolean; + mimeType?: string; + } = {} + ) { + const fullPath = this.resolvePath(path); + + if (options.createParents !== false) { + await this.ensureDir(dirname(fullPath)); + } + + if (!options.overwrite && existsSync(fullPath)) { + throw new Error(`File already exists: ${path}`); + } + + const buffer = options.encoding === "base64" + ? Buffer.from(content, "base64") + : Buffer.from(content, "utf-8"); + + await writeFile(fullPath, buffer); + + return { + file: await this.getMetadata(path), + }; + } + + async delete(path: string, recursive = false) { + const fullPath = this.resolvePath(path); + const stats = await stat(fullPath); + + if (stats.isDirectory()) { + if (!recursive) { + throw new Error("Cannot delete directory without recursive flag"); + } + // Use fs.rm with recursive option + const { rm } = await import("node:fs/promises"); + await rm(fullPath, { recursive: true, force: true }); + } else { + await unlink(fullPath); + } + + return { success: true, path }; + } + + async list( + folder: string = "", + options: { limit?: number; offset?: number } = {} + ): Promise { + const fullPath = this.resolvePath(folder); + + if (!existsSync(fullPath)) { + return []; + } + + const entries = await readdir(fullPath, { withFileTypes: true }); + const files: FileEntity[] = []; + + for (const entry of entries) { + const entryPath = folder ? `${folder}/${entry.name}` : entry.name; + files.push(await this.getMetadata(entryPath)); + } + + // Apply pagination + const start = options.offset ?? 0; + const end = options.limit ? start + options.limit : undefined; + + return files.slice(start, end); + } + + async mkdir(path: string, recursive = true) { + const fullPath = this.resolvePath(path); + await mkdir(fullPath, { recursive }); + return { folder: await this.getMetadata(path) as FolderEntity }; + } + + async move(from: string, to: string, overwrite = false) { + const fromPath = this.resolvePath(from); + const toPath = this.resolvePath(to); + + if (!overwrite && existsSync(toPath)) { + throw new Error(`Destination already exists: ${to}`); + } + + await this.ensureDir(dirname(toPath)); + await rename(fromPath, toPath); + + return { file: await this.getMetadata(to) }; + } + + async copy(from: string, to: string, overwrite = false) { + const fromPath = this.resolvePath(from); + const toPath = this.resolvePath(to); + + if (!overwrite && existsSync(toPath)) { + throw new Error(`Destination already exists: ${to}`); + } + + await this.ensureDir(dirname(toPath)); + await copyFile(fromPath, toPath); + + return { file: await this.getMetadata(to) }; + } +} +``` + +### 2.3 MCP Tools + +Create tools in `apps/mesh/src/tools/files/`: + +```typescript +// apps/mesh/src/tools/files/index.ts +import { defineTool } from "@/core/define-tool"; +import { + FileReadInputSchema, + FileReadOutputSchema, + FileWriteInputSchema, + FileWriteOutputSchema, + FileDeleteInputSchema, + FileDeleteOutputSchema, + FileMoveInputSchema, + FileMoveOutputSchema, + FileCopyInputSchema, + FileCopyOutputSchema, + FileMkdirInputSchema, + FileMkdirOutputSchema, +} from "@decocms/bindings/file-storage"; +import type { MeshContext } from "@/core/mesh-context"; + +export function createFileTools(ctx: MeshContext) { + const storage = ctx.fileStorage; // Local FS instance + + return [ + defineTool({ + name: "FILE_READ", + description: "Read a file's content from storage", + inputSchema: FileReadInputSchema, + outputSchema: FileReadOutputSchema, + handler: async (input) => storage.read(input.path, input.encoding), + }), + + defineTool({ + name: "FILE_WRITE", + description: "Write content to a file in storage", + inputSchema: FileWriteInputSchema, + outputSchema: FileWriteOutputSchema, + handler: async (input) => storage.write(input.path, input.content, { + encoding: input.encoding, + createParents: input.createParents, + overwrite: input.overwrite, + mimeType: input.mimeType, + }), + }), + + defineTool({ + name: "FILE_DELETE", + description: "Delete a file or directory from storage", + inputSchema: FileDeleteInputSchema, + outputSchema: FileDeleteOutputSchema, + handler: async (input) => storage.delete(input.path, input.recursive), + }), + + defineTool({ + name: "FILE_MOVE", + description: "Move or rename a file", + inputSchema: FileMoveInputSchema, + outputSchema: FileMoveOutputSchema, + handler: async (input) => storage.move(input.from, input.to, input.overwrite), + }), + + defineTool({ + name: "FILE_COPY", + description: "Copy a file", + inputSchema: FileCopyInputSchema, + outputSchema: FileCopyOutputSchema, + handler: async (input) => storage.copy(input.from, input.to, input.overwrite), + }), + + defineTool({ + name: "FILE_MKDIR", + description: "Create a directory", + inputSchema: FileMkdirInputSchema, + outputSchema: FileMkdirOutputSchema, + handler: async (input) => storage.mkdir(input.path, input.recursive), + }), + + // Collection tools for browsing + defineTool({ + name: "COLLECTION_FILES_LIST", + description: "List files in a folder", + inputSchema: z.object({ + where: z.object({ + parent: z.string().optional(), + }).optional(), + limit: z.number().optional(), + offset: z.number().optional(), + }), + outputSchema: z.object({ + items: z.array(FileEntitySchema), + totalCount: z.number().optional(), + }), + handler: async (input) => { + const parent = input.where?.parent ?? ""; + const items = await storage.list(parent, { + limit: input.limit, + offset: input.offset, + }); + return { items }; + }, + }), + + defineTool({ + name: "COLLECTION_FILES_GET", + description: "Get a file by path", + inputSchema: z.object({ id: z.string() }), + outputSchema: z.object({ item: FileEntitySchema.nullable() }), + handler: async (input) => { + try { + const metadata = await storage.getMetadata(input.id); + return { item: metadata }; + } catch { + return { item: null }; + } + }, + }), + + defineTool({ + name: "COLLECTION_FOLDERS_LIST", + description: "List folders", + inputSchema: z.object({ + where: z.object({ + parent: z.string().optional(), + }).optional(), + limit: z.number().optional(), + offset: z.number().optional(), + }), + outputSchema: z.object({ + items: z.array(FolderEntitySchema), + totalCount: z.number().optional(), + }), + handler: async (input) => { + const parent = input.where?.parent ?? ""; + const all = await storage.list(parent, { + limit: input.limit, + offset: input.offset, + }); + const items = all.filter((f) => f.isDirectory); + return { items }; + }, + }), + + defineTool({ + name: "COLLECTION_FOLDERS_GET", + description: "Get a folder by path", + inputSchema: z.object({ id: z.string() }), + outputSchema: z.object({ item: FolderEntitySchema.nullable() }), + handler: async (input) => { + try { + const metadata = await storage.getMetadata(input.id); + if (!metadata.isDirectory) return { item: null }; + return { item: metadata as FolderEntity }; + } catch { + return { item: null }; + } + }, + }), + ]; +} +``` + +--- + +## Phase 3: UI Components + +### 3.1 File Drop Zone (Global Overlay) + +Create `apps/mesh/src/web/components/file-drop-zone.tsx`: + +```tsx +import { useFileStorageConnections } from "@/web/hooks/use-file-storage"; +import { useDropzone } from "react-dropzone"; +import { Upload01 } from "@untitledui/icons"; +import { cn } from "@deco/ui/lib/utils"; +import { useState } from "react"; +import { toast } from "sonner"; + +interface FileDropZoneProps { + children: React.ReactNode; +} + +export function FileDropZone({ children }: FileDropZoneProps) { + const { storageConnections, uploadFile, isUploading } = useFileStorageConnections(); + const [isDragOver, setIsDragOver] = useState(false); + + // Only show drop zone if we have at least one storage connection + const hasStorage = storageConnections.length > 0; + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + noClick: true, // Don't open file picker on click + noKeyboard: true, + onDragEnter: () => setIsDragOver(true), + onDragLeave: () => setIsDragOver(false), + onDrop: async (acceptedFiles) => { + setIsDragOver(false); + + if (!hasStorage) { + toast.error("No file storage configured"); + return; + } + + // Upload each file + for (const file of acceptedFiles) { + try { + await uploadFile(file); + toast.success(`Uploaded ${file.name}`); + } catch (error) { + toast.error(`Failed to upload ${file.name}`); + } + } + }, + }); + + // Only render drop zone if storage is available + if (!hasStorage) { + return <>{children}; + } + + return ( +
+ + + {/* Drop overlay */} + {isDragOver && ( +
+
+
+ +
+
+

Drop files to upload

+

+ Files will be stored in {storageConnections[0]?.title ?? "storage"} +

+
+
+
+ )} + + {children} +
+ ); +} +``` + +### 3.2 File Storage Hook + +Create `apps/mesh/src/web/hooks/use-file-storage.ts`: + +```typescript +import { useConnections } from "@/web/hooks/collections/use-connection"; +import { createToolCaller } from "@/tools/client"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { FILE_STORAGE_BINDING } from "@decocms/bindings/file-storage"; +import { createBindingChecker } from "@decocms/bindings"; + +const fileStorageChecker = createBindingChecker(FILE_STORAGE_BINDING); + +/** + * Hook to find connections that implement FILE_STORAGE_BINDING + */ +export function useFileStorageConnections() { + const { data: connections } = useConnections(); + const queryClient = useQueryClient(); + + // Filter connections that implement file storage binding + const storageConnections = (connections ?? []).filter((conn) => { + const tools = conn.tools?.map((t) => ({ name: t.name })) ?? []; + return fileStorageChecker.isImplementedBy(tools); + }); + + // Use the first available storage connection + const primaryStorage = storageConnections[0]; + + const uploadMutation = useMutation({ + mutationFn: async (file: File) => { + if (!primaryStorage) throw new Error("No storage configured"); + + const caller = createToolCaller(primaryStorage.id); + + // Read file as base64 + const content = await fileToBase64(file); + + // Upload via FILE_WRITE + const result = await caller("FILE_WRITE", { + path: file.name, + content, + encoding: "base64", + mimeType: file.type, + }); + + return result; + }, + onSuccess: () => { + // Invalidate file collections + queryClient.invalidateQueries({ queryKey: ["collection", "files"] }); + queryClient.invalidateQueries({ queryKey: ["collection", "folders"] }); + }, + }); + + return { + storageConnections, + primaryStorage, + uploadFile: uploadMutation.mutateAsync, + isUploading: uploadMutation.isPending, + }; +} + +async function fileToBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const base64 = (reader.result as string).split(",")[1]; + resolve(base64); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} +``` + +### 3.3 File Browser Component (for Folders Collection) + +Create `apps/mesh/src/web/components/files/file-browser.tsx`: + +```tsx +import { useCollectionData, useCollectionActions } from "@/web/hooks/use-collections"; +import { createToolCaller } from "@/tools/client"; +import { Button } from "@deco/ui/components/button"; +import { Folder01, File06, ArrowLeft } from "@untitledui/icons"; +import { useState } from "react"; +import { formatBytes } from "@/web/utils/format"; + +interface FileBrowserProps { + connectionId: string; + initialPath?: string; +} + +export function FileBrowser({ connectionId, initialPath = "" }: FileBrowserProps) { + const [currentPath, setCurrentPath] = useState(initialPath); + const toolCaller = createToolCaller(connectionId); + + const { data: files } = useCollectionData( + connectionId, + "files", + toolCaller, + { where: { parent: currentPath } } + ); + + const navigateUp = () => { + const parts = currentPath.split("/").filter(Boolean); + parts.pop(); + setCurrentPath(parts.join("/")); + }; + + return ( +
+ {/* Breadcrumb / navigation */} +
+ + + /{currentPath || ""} + +
+ + {/* File list */} +
+ {files?.items?.length === 0 ? ( +
+ + Empty folder +
+ ) : ( +
+ {files?.items?.map((file) => ( +
{ + if (file.isDirectory) { + setCurrentPath(file.path); + } + }} + > + {file.isDirectory ? ( + + ) : ( + + )} +
+
{file.title}
+ {!file.isDirectory && ( +
+ {formatBytes(file.size)} +
+ )} +
+
+ ))} +
+ )} +
+
+ ); +} +``` + +### 3.4 File Editor Component (Monaco) + +Create `apps/mesh/src/web/components/files/file-editor.tsx`: + +```tsx +import Editor from "@monaco-editor/react"; +import { useFileContent, useFileMutations } from "@/web/hooks/use-file-storage"; +import { useState } from "react"; +import { Button } from "@deco/ui/components/button"; +import { Save01 } from "@untitledui/icons"; + +interface FileEditorProps { + connectionId: string; + path: string; + readOnly?: boolean; +} + +export function FileEditor({ connectionId, path, readOnly = false }: FileEditorProps) { + const { data: file, isLoading } = useFileContent(connectionId, path); + const { save, isSaving } = useFileMutations(connectionId); + + const [localContent, setLocalContent] = useState(null); + const [hasChanges, setHasChanges] = useState(false); + + // Determine language from file extension + const getLanguage = (path: string) => { + const ext = path.split(".").pop()?.toLowerCase(); + const languageMap: Record = { + js: "javascript", + jsx: "javascript", + ts: "typescript", + tsx: "typescript", + json: "json", + md: "markdown", + html: "html", + css: "css", + py: "python", + yaml: "yaml", + yml: "yaml", + }; + return languageMap[ext ?? ""] ?? "plaintext"; + }; + + const handleSave = async () => { + if (localContent === null) return; + await save({ path, content: localContent }); + setHasChanges(false); + }; + + if (isLoading) { + return
Loading...
; + } + + return ( +
+ {/* Toolbar */} + {!readOnly && ( +
+ +
+ )} + + {/* Editor */} +
+ { + setLocalContent(value ?? ""); + setHasChanges(value !== file?.content); + }} + /> +
+
+ ); +} +``` + +### 3.5 Register Well-Known Views + +Update `apps/mesh/src/web/routes/orgs/collection-detail.tsx`: + +```typescript +import { AssistantDetailsView } from "@/web/components/details/assistant/index.tsx"; +import { FileDetailsView } from "@/web/components/details/file/index.tsx"; +import { FolderDetailsView } from "@/web/components/details/folder/index.tsx"; + +const WELL_KNOWN_VIEW_DETAILS: Record< + string, + ComponentType +> = { + assistant: AssistantDetailsView, + files: FileDetailsView, // NEW + folders: FolderDetailsView, // NEW +}; +``` + +--- + +## Phase 4: Integration + +### 4.1 Mount Drop Zone in Shell Layout + +Update `apps/mesh/src/web/layouts/shell.tsx`: + +```tsx +import { FileDropZone } from "@/web/components/file-drop-zone"; + +export function ShellLayout({ children }: { children: React.ReactNode }) { + return ( + +
+ +
+ {children} +
+
+
+ ); +} +``` + +### 4.2 Add MCP Route for Local FS + +Update `apps/mesh/src/api/routes/` to add the local-fs MCP endpoint. + +### 4.3 Initialize Local FS on Startup + +Update mesh startup to: +1. Create `storage/` directory if it doesn't exist +2. Register the `local-fs` connection for each organization + +--- + +## Implementation Order + +### Phase 1 (Foundation) - Week 1 +1. [ ] Create `FILE_STORAGE_BINDING` in `packages/bindings/src/well-known/file-storage.ts` +2. [ ] Add exports to `packages/bindings/package.json` +3. [ ] Create `LocalFileStorage` class in `apps/mesh/src/file-storage/local-fs.ts` +4. [ ] Create file tools in `apps/mesh/src/tools/files/` + +### Phase 2 (MCP) - Week 1-2 +5. [ ] Add `LOCAL_FS` to `well-known-mcp.ts` +6. [ ] Create MCP route at `/mcp/local-fs` +7. [ ] Auto-register local-fs connection on org creation +8. [ ] Add file serving route for downloads + +### Phase 3 (UI - Basic) - Week 2 +9. [ ] Create `useFileStorageConnections` hook +10. [ ] Create `FileDropZone` component +11. [ ] Mount drop zone in shell layout +12. [ ] Test file uploads + +### Phase 4 (UI - Enhanced) - Week 3 +13. [ ] Create `FileBrowser` component +14. [ ] Create `FileEditor` component (Monaco) +15. [ ] Create `FilePreview` component (images, PDFs) +16. [ ] Register `FileDetailsView` and `FolderDetailsView` in well-known views + +### Phase 5 (Polish) - Week 3-4 +17. [ ] Add upload progress indicators +18. [ ] Add file type icons +19. [ ] Add context menu (download, rename, delete) +20. [ ] Add keyboard shortcuts +21. [ ] Add search/filter for files + +--- + +## Future Extensions + +### Cloud Storage Adapters + +The `FILE_STORAGE_BINDING` can be implemented by other MCPs: + +- **mcp-s3** - Amazon S3 storage +- **mcp-gcs** - Google Cloud Storage +- **mcp-r2** - Cloudflare R2 +- **mcp-azure-blob** - Azure Blob Storage + +### Advanced Features + +- **Versioning** - Track file versions with history +- **Thumbnails** - Auto-generate image thumbnails +- **Search** - Full-text search in files +- **Sharing** - Public links with expiration +- **Sync** - Two-way sync with local filesystem + +--- + +## Testing + +### Unit Tests +- `LocalFileStorage` class methods +- Path sanitization and security +- MIME type detection + +### Integration Tests +- File upload via MCP tools +- Collection binding responses +- Drop zone functionality + +### E2E Tests +- Drag and drop file upload +- File browser navigation +- Monaco editor save + +--- + +## Security Considerations + +1. **Path Traversal** - Sanitize all paths to prevent `../` attacks +2. **File Size Limits** - Configure max upload size +3. **MIME Type Validation** - Validate content matches declared type +4. **Access Control** - Respect organization boundaries +5. **Rate Limiting** - Prevent upload abuse + diff --git a/apps/mesh/src/core/context-factory.ts b/apps/mesh/src/core/context-factory.ts index 70dd052f97..b678565a01 100644 --- a/apps/mesh/src/core/context-factory.ts +++ b/apps/mesh/src/core/context-factory.ts @@ -628,6 +628,8 @@ export function createMeshContextFactory( // Note: Token revocation handled by Better Auth (deleteApiKey) }; + // File storage is created per-request with the correct base URL + // Return factory function return async (req?: Request): Promise => { const connectionId = req?.headers.get("x-caller-id") ?? undefined; diff --git a/apps/mesh/src/core/well-known-mcp.ts b/apps/mesh/src/core/well-known-mcp.ts index 6046125424..9150457c1e 100644 --- a/apps/mesh/src/core/well-known-mcp.ts +++ b/apps/mesh/src/core/well-known-mcp.ts @@ -4,12 +4,14 @@ export const WellKnownMCPId = { SELF: "self", REGISTRY: "registry", COMMUNITY_REGISTRY: "community-registry", + LOCAL_FS: "local-fs", }; export const WellKnownOrgMCPId = { SELF: (org: string) => `${org}_${WellKnownMCPId.SELF}`, REGISTRY: (org: string) => `${org}_${WellKnownMCPId.REGISTRY}`, COMMUNITY_REGISTRY: (org: string) => `${org}_${WellKnownMCPId.COMMUNITY_REGISTRY}`, + LOCAL_FS: (org: string) => `${org}_${WellKnownMCPId.LOCAL_FS}`, }; /** @@ -99,3 +101,33 @@ export function getWellKnownSelfConnection( }, }; } + +/** + * Get well-known connection definition for Local File Storage. + * Stores files in a local storage/ directory where mesh is running. + * + * @param baseUrl - The base URL for the MCP server + * @returns ConnectionCreateData for Local File Storage + */ +export function getWellKnownLocalFsConnection( + baseUrl: string, +): ConnectionCreateData { + return { + id: WellKnownMCPId.LOCAL_FS, + title: "Local Files", + description: "File storage in the local storage/ directory", + connection_type: "HTTP", + connection_url: `${baseUrl}/mcp/local-fs`, + icon: "https://assets.decocache.com/mcp/folder-open.png", + app_name: "@deco/local-fs", + connection_token: null, + connection_headers: null, + oauth_config: null, + configuration_state: null, + configuration_scopes: null, + metadata: { + isDefault: true, + type: "file-storage", + }, + }; +} diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index 17f6940119..dbb4f3a52d 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -3,6 +3,10 @@ * * Central export for all MCP Mesh management tools * Types are inferred from ALL_TOOLS - this is the source of truth. + * + * Note: File Storage tools are NOT included here - they are provided by + * external MCPs like mcp-local-fs. The Mesh only provides the binding + * definitions and UI for file storage. */ import { mcpServer } from "@/api/utils/mcp"; @@ -51,6 +55,7 @@ export const ALL_TOOLS = [ // Monitoring tools MonitoringTools.MONITORING_LOGS_LIST, MonitoringTools.MONITORING_STATS, + // API Key tools ApiKeyTools.API_KEY_CREATE, ApiKeyTools.API_KEY_LIST, diff --git a/apps/mesh/src/web/components/chat/chat.tsx b/apps/mesh/src/web/components/chat/chat.tsx index f3b0c73599..c67f0f96a7 100644 --- a/apps/mesh/src/web/components/chat/chat.tsx +++ b/apps/mesh/src/web/components/chat/chat.tsx @@ -22,6 +22,8 @@ import type { } from "./model-selector.tsx"; import { ModelSelector } from "./model-selector.tsx"; import { UsageStats } from "./usage-stats.tsx"; +import { useFileDropUpload } from "@/web/components/file-drop-zone"; +import { File06, Folder, X, Loading01 } from "@untitledui/icons"; export { useGateways } from "./gateway-selector"; export type { GatewayInfo } from "./gateway-selector"; @@ -196,6 +198,17 @@ function ChatInputGatewaySelector(_props: { return null; } +/** Uploaded file reference for chat context */ +interface UploadedFileRef { + id: string; + name: string; + path: string; + connectionId: string; + connectionTitle: string; + status: "uploading" | "success" | "error"; + error?: string; +} + function ChatInput({ onSubmit, onStop, @@ -213,6 +226,11 @@ function ChatInput({ usageMessages?: ChatMessage[]; }>) { const [input, setInput] = useState(""); + const [uploadedFiles, setUploadedFiles] = useState([]); + const [isDragOver, setIsDragOver] = useState(false); + + // File storage upload hook + const { uploadFiles, hasStorage, storageConnection } = useFileDropUpload(); const modelSelector = findChild(children, ChatInputModelSelector); const gatewaySelector = findChild(children, ChatInputGatewaySelector); @@ -221,15 +239,36 @@ function ChatInput({ ChatInputGatewaySelector, ]); + // Build message with file references + const buildMessageWithFiles = (text: string): string => { + if (uploadedFiles.length === 0) { + return text; + } + + const successfulFiles = uploadedFiles.filter((f) => f.status === "success"); + if (successfulFiles.length === 0) { + return text; + } + + // Add file references at the beginning of the message + const fileRefs = successfulFiles + .map((f) => `@file:${f.connectionTitle}:${f.path}`) + .join(" "); + + return `${fileRefs}\n\n${text}`; + }; + const handleSubmit = async (e?: React.FormEvent) => { e?.preventDefault(); if (!input?.trim() || isStreaming) { return; } - const text = input.trim(); + const text = buildMessageWithFiles(input.trim()); try { await onSubmit(text); setInput(""); + // Clear uploaded files after successful send + setUploadedFiles([]); } catch (error) { console.error("Failed to send message:", error); const message = @@ -238,6 +277,83 @@ function ChatInput({ } }; + // Handle file drop + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + + if (!hasStorage) { + toast.error( + "No file storage configured. Add a File Storage binding first.", + ); + return; + } + + const files = Array.from(e.dataTransfer?.files ?? []); + if (files.length === 0) return; + + // Add files to state as uploading + const newFiles: UploadedFileRef[] = files.map((f) => ({ + id: crypto.randomUUID(), + name: f.name, + path: f.name, + connectionId: storageConnection?.id ?? "", + connectionTitle: storageConnection?.title ?? "File Storage", + status: "uploading" as const, + })); + setUploadedFiles((prev) => [...prev, ...newFiles]); + + // Upload each file + for (const [i, file] of files.entries()) { + const fileRef = newFiles[i]; + if (!fileRef) continue; + + try { + await uploadFiles([file]); + setUploadedFiles((prev) => + prev.map((f) => + f.id === fileRef.id ? { ...f, status: "success" as const } : f, + ), + ); + toast.success(`Uploaded ${file.name} to ${storageConnection?.title}`); + } catch (error) { + console.error("Upload failed:", error); + setUploadedFiles((prev) => + prev.map((f) => + f.id === fileRef.id + ? { + ...f, + status: "error" as const, + error: + error instanceof Error ? error.message : "Upload failed", + } + : f, + ), + ); + toast.error(`Failed to upload ${file.name}`); + } + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer?.types.includes("Files") && hasStorage) { + setIsDragOver(true); + } + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + }; + + const removeFile = (id: string) => { + setUploadedFiles((prev) => prev.filter((f) => f.id !== id)); + }; + const leftActions = (
{gatewaySelector ? ( @@ -279,17 +395,70 @@ function ChatInput({
); + // File context content showing uploaded files + const contextContent = + uploadedFiles.length > 0 ? ( +
+ {uploadedFiles.map((file) => ( +
+ {file.status === "uploading" ? ( + + ) : ( + + )} + {file.name} + + @{file.connectionTitle} + + +
+ ))} +
+ ) : null; + return ( - +
+ {/* Drop overlay */} + {isDragOver && ( +
+
+ + Drop to upload to {storageConnection?.title} +
+
+ )} + + +
); } diff --git a/apps/mesh/src/web/components/collections/collections-list.tsx b/apps/mesh/src/web/components/collections/collections-list.tsx index 4a3ff18d76..a8d49051a1 100644 --- a/apps/mesh/src/web/components/collections/collections-list.tsx +++ b/apps/mesh/src/web/components/collections/collections-list.tsx @@ -82,6 +82,7 @@ export function CollectionsList({ columns = undefined, hideToolbar = false, sortableFields = undefined, + simpleDeleteOnly = false, }: CollectionsListProps) { // Generate sort options from columns or schema const sortOptions = columns @@ -155,7 +156,13 @@ export function CollectionsList({ ) : ( ({ ); } +// Helper to generate simple delete-only column (just a trash icon) +function generateSimpleDeleteColumn( + onDelete: (item: T) => void | Promise, +): TableColumn { + return { + id: "actions", + header: "", + render: (row) => ( + + ), + cellClassName: "w-[50px]", + sortable: false, + }; +} + // Helper to generate actions column function generateActionsColumn( actions: Record void | Promise>, @@ -413,6 +445,7 @@ function getTableColumns( schema: JsonSchema, sortableFields: string[] | undefined, actions: Record void | Promise>, + simpleDeleteOnly: boolean, ): TableColumn[] { const baseColumns = columns || generateColumnsFromSchema(schema, sortableFields); @@ -424,6 +457,11 @@ function getTableColumns( return baseColumns; } + // For simpleDeleteOnly, show only a trash icon if delete action exists + if (simpleDeleteOnly && actions.delete) { + return [...baseColumns, generateSimpleDeleteColumn(actions.delete)]; + } + // Append actions column only if there are any actions available const hasActions = Object.keys(actions).length > 0; if (hasActions) { diff --git a/apps/mesh/src/web/components/collections/types.ts b/apps/mesh/src/web/components/collections/types.ts index 5106a0ad7c..ebd1d31f89 100644 --- a/apps/mesh/src/web/components/collections/types.ts +++ b/apps/mesh/src/web/components/collections/types.ts @@ -117,4 +117,10 @@ export interface CollectionsListProps { * @default [12, 24, 48, 96] */ itemsPerPageOptions?: number[]; + + /** + * If true, shows only a simple trash icon for delete action + * instead of the dropdown menu. Useful for FILES collection. + */ + simpleDeleteOnly?: boolean; } diff --git a/apps/mesh/src/web/components/details/connection/collection-tab.tsx b/apps/mesh/src/web/components/details/connection/collection-tab.tsx index f185083c93..369ba6b025 100644 --- a/apps/mesh/src/web/components/details/connection/collection-tab.tsx +++ b/apps/mesh/src/web/components/details/connection/collection-tab.tsx @@ -14,6 +14,7 @@ import { useCollectionActions, useCollectionList, } from "@/web/hooks/use-collections"; +import { useFileMutations, useFileUpload } from "@/web/hooks/use-file-storage"; import { useListState } from "@/web/hooks/use-list-state"; import { authClient } from "@/web/lib/auth-client"; import { BaseCollectionJsonSchema } from "@/web/utils/constants"; @@ -30,10 +31,16 @@ import { import { Button } from "@deco/ui/components/button.tsx"; import type { BaseCollectionEntity } from "@decocms/bindings/collections"; import { useNavigate } from "@tanstack/react-router"; -import { Plus } from "@untitledui/icons"; +import { Plus, Upload01, Loading01 } from "@untitledui/icons"; import { useState } from "react"; import { toast } from "sonner"; import { ViewActions } from "../layout"; +import { cn } from "@deco/ui/lib/utils.ts"; + +interface UploadingFile { + name: string; + status: "uploading" | "done" | "error"; +} interface CollectionTabProps { connectionId: string; @@ -92,6 +99,9 @@ export function CollectionTab({ // Collection is read-only if ALL mutation tools are missing const isReadOnly = !hasCreateTool && !hasUpdateTool && !hasDeleteTool; + // Check if this is the FILES collection for special handling + const isFilesCollection = collectionName.toUpperCase() === "FILES"; + // Create action handlers const handleEdit = (item: BaseCollectionEntity) => { navigate({ @@ -126,17 +136,32 @@ export function CollectionTab({ setItemToDelete(item); }; + // File mutations for FILES collection + const fileMutations = useFileMutations(connectionId); + // Build actions object with only available actions + // For FILES collection, always show delete (uses file deletion) const listItemActions: Record void> = - { - ...(hasUpdateTool && { edit: handleEdit }), - ...(hasCreateTool && { duplicate: handleDuplicate }), - ...(hasDeleteTool && { delete: handleDelete }), - }; + isFilesCollection + ? { + edit: handleEdit, + delete: handleDelete, + } + : { + ...(hasUpdateTool && { edit: handleEdit }), + ...(hasCreateTool && { duplicate: handleDuplicate }), + ...(hasDeleteTool && { delete: handleDelete }), + }; const confirmDelete = async () => { if (!itemToDelete) return; - await actions.delete.mutateAsync(itemToDelete.id); + + if (isFilesCollection) { + // Use file deletion mutation - item.id is the file path + await fileMutations.delete.mutateAsync({ path: itemToDelete.id }); + } else { + await actions.delete.mutateAsync(itemToDelete.id); + } setItemToDelete(null); }; @@ -183,6 +208,70 @@ export function CollectionTab({ const showCreateInToolbar = hasCreateTool && hasItems; const showCreateInEmptyState = hasCreateTool && !hasItems && !search; + // File upload mutation for FILES collection + const uploadMutation = useFileUpload(connectionId); + + // File drop zone state + const [isDragOver, setIsDragOver] = useState(false); + + // Track uploading files for progress display + const [uploadingFiles, setUploadingFiles] = useState([]); + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer?.types.includes("Files")) { + setIsDragOver(true); + } + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + }; + + const handleFileDrop = async (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + + const files = Array.from(e.dataTransfer?.files ?? []); + if (files.length === 0) return; + + // Add files to uploading state + const newUploads = files.map((f) => ({ + name: f.name, + status: "uploading" as const, + })); + setUploadingFiles((prev) => [...prev, ...newUploads]); + + for (const file of files) { + try { + await uploadMutation.mutateAsync({ file }); + setUploadingFiles((prev) => + prev.map((f) => + f.name === file.name ? { ...f, status: "done" as const } : f, + ), + ); + toast.success(`Uploaded ${file.name}`); + } catch (error) { + console.error("Upload failed:", error); + setUploadingFiles((prev) => + prev.map((f) => + f.name === file.name ? { ...f, status: "error" as const } : f, + ), + ); + toast.error(`Failed to upload ${file.name}`); + } + } + + // Clear completed uploads after a delay + setTimeout(() => { + setUploadingFiles((prev) => prev.filter((f) => f.status === "uploading")); + }, 2000); + }; + const createButton = hasCreateTool ? ( + { + handleFileInputChange(e.target.files); + e.target.value = ""; // Reset input + }} + /> + + ) : null; + return ( <> @@ -211,10 +372,33 @@ export function CollectionTab({ title={`${collectionName}s`} icon={connection?.icon ?? "grid_view"} /> + {uploadButton} {showCreateInToolbar && createButton} -
+
+ {/* Drop zone overlay for FILES collection */} + {isFilesCollection && isDragOver && ( +
+
+
+ +
+
+

Drop files to upload

+

+ Files will be stored in local storage +

+
+
+
+ )} + {/* Search */} handleEdit(item)} readOnly={isReadOnly} + simpleDeleteOnly={isFilesCollection} emptyState={ - + isFilesCollection ? ( +
+
+ +
+

No files yet

+

+ Drag and drop files here or click to upload +

+ {uploadButton} +
+ ) : ( + + ) } />
+ + {/* Upload progress indicator */} + {uploadingFiles.length > 0 && ( +
+
+ +
+
+ Uploading{" "} + { + uploadingFiles.filter((f) => f.status === "uploading") + .length + }{" "} + file(s) +
+
+ {uploadingFiles.map((f) => ( + + {f.status === "uploading" && ( + + )} + {f.status === "done" && "✓"} + {f.status === "error" && "✗"} + {f.name} + + ))} +
+
+
+
+ )}
{/* Delete Confirmation Dialog */} @@ -271,22 +509,40 @@ export function CollectionTab({ > - Delete item? + + {isFilesCollection ? "Delete file?" : "Delete item?"} + Are you sure you want to delete "{itemToDelete?.title}"? This action cannot be undone. - + Cancel - {actions.delete.isPending ? "Deleting..." : "Delete"} + {( + isFilesCollection + ? fileMutations.isDeleting + : actions.delete.isPending + ) + ? "Deleting..." + : "Delete"} diff --git a/apps/mesh/src/web/components/details/file/index.tsx b/apps/mesh/src/web/components/details/file/index.tsx new file mode 100644 index 0000000000..ef022d86f9 --- /dev/null +++ b/apps/mesh/src/web/components/details/file/index.tsx @@ -0,0 +1,155 @@ +/** + * File/Folder Details View + * + * Custom view components for files and folders collections. + */ + +import { useParams } from "@tanstack/react-router"; +import { Button } from "@deco/ui/components/button.tsx"; +import { + Download01, + Trash01, + File06, + Folder, + Loading01, +} from "@untitledui/icons"; +import { FileBrowser } from "@/web/components/files/file-browser"; +import { FilePreview } from "@/web/components/files/file-preview"; +import { useFileContent, useFileMutations } from "@/web/hooks/use-file-storage"; +import { ViewLayout, ViewActions, ViewTabs } from "../layout"; +import { toast } from "sonner"; +import type { FileEntity } from "@decocms/bindings/file-storage"; + +interface FileDetailsProps { + itemId: string; + onBack: () => void; + onUpdate: (updates: Record) => Promise; +} + +/** + * File Details View + * + * Shows file preview and allows basic operations. + * For text files, could integrate Monaco editor in the future. + */ +export function FileDetailsView({ itemId, onBack }: FileDetailsProps) { + const { connectionId } = useParams({ + from: "/shell/$org/mcps/$connectionId/$collectionName/$itemId", + }); + + const path = decodeURIComponent(itemId); + const { data, isLoading } = useFileContent(connectionId, path); + const { delete: deleteMutation } = useFileMutations(connectionId); + + const file = data?.metadata; + + const handleDelete = async () => { + if (!file) return; + + if (!confirm(`Delete ${file.title}?`)) return; + + try { + await deleteMutation.mutateAsync({ path: file.path }); + toast.success(`Deleted ${file.title}`); + onBack(); + } catch (error) { + toast.error("Failed to delete file"); + } + }; + + const handleDownload = () => { + if (file?.url) { + window.open(file.url, "_blank"); + } + }; + + if (isLoading || !file) { + return ( + +
+ +
+
+ ); + } + + return ( + + +
+ + {file.title} + ({file.path}) +
+
+ + + {file.url && ( + + )} + + + +
+ +
+
+ ); +} + +/** + * Folder Details View + * + * Shows folder contents with file browser. + */ +export function FolderDetailsView({ itemId, onBack }: FileDetailsProps) { + const { connectionId } = useParams({ + from: "/shell/$org/mcps/$connectionId/$collectionName/$itemId", + }); + + const path = decodeURIComponent(itemId); + const folderName = path.split("/").pop() || "Root"; + + const handleFileSelect = (file: FileEntity) => { + // Navigate to file detail view + // For now, just show a toast + if (!file.isDirectory) { + toast.info(`Selected: ${file.title}`); + } + }; + + return ( + + +
+ + {folderName} + (/{path}) +
+
+ +
+ +
+
+ ); +} diff --git a/apps/mesh/src/web/components/file-drop-zone.tsx b/apps/mesh/src/web/components/file-drop-zone.tsx new file mode 100644 index 0000000000..a72e6607c5 --- /dev/null +++ b/apps/mesh/src/web/components/file-drop-zone.tsx @@ -0,0 +1,157 @@ +/** + * FileDropZone Component + * + * A global drop zone overlay that appears when files are dragged over the app. + * Only active when at least one MCP implements FILE_STORAGE_BINDING. + */ + +import { useState, type ReactNode } from "react"; +import { Upload01 } from "@untitledui/icons"; +import { toast } from "sonner"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { + useFileStorageConnections, + useFileUpload, +} from "@/web/hooks/use-file-storage"; + +interface FileDropZoneProps { + children: ReactNode; + className?: string; +} + +/** + * Global file drop zone that wraps the app content. + * Shows a drop overlay when files are dragged and a storage provider is available. + */ +export function FileDropZone({ children, className }: FileDropZoneProps) { + const storageConnections = useFileStorageConnections(); + const hasStorage = storageConnections.length > 0; + const primaryStorage = storageConnections[0]; + + const [isDragOver, setIsDragOver] = useState(false); + // Track enter/leave count to handle nested elements + const [, setDragCounter] = useState(0); + + const uploadMutation = useFileUpload(primaryStorage?.id ?? ""); + + // Handle drag events + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragCounter((prev) => prev + 1); + if (e.dataTransfer?.types.includes("Files")) { + setIsDragOver(true); + } + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragCounter((prev) => { + const newCount = prev - 1; + if (newCount === 0) { + setIsDragOver(false); + } + return newCount; + }); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + setDragCounter(0); + + if (!hasStorage) { + toast.error("No file storage configured"); + return; + } + + const files = Array.from(e.dataTransfer?.files ?? []); + + if (files.length === 0) { + return; + } + + // Upload each file + for (const file of files) { + try { + await uploadMutation.mutateAsync({ file }); + toast.success(`Uploaded ${file.name}`); + } catch (error) { + console.error("Upload failed:", error); + toast.error(`Failed to upload ${file.name}`); + } + } + }; + + // Don't render drag overlay if no storage is available + if (!hasStorage) { + return <>{children}; + } + + return ( +
+ {/* Drop overlay */} + {isDragOver && ( +
+
+
+ +
+
+

Drop files to upload

+

+ Files will be stored in{" "} + {primaryStorage?.title ?? "local storage"} +

+
+
+
+ )} + + {children} +
+ ); +} + +/** + * Hook to manually trigger file upload + * Useful for buttons or programmatic uploads + */ +export function useFileDropUpload() { + const storageConnections = useFileStorageConnections(); + const primaryStorage = storageConnections[0]; + const uploadMutation = useFileUpload(primaryStorage?.id ?? ""); + + const uploadFiles = async (files: File[]) => { + if (!primaryStorage) { + throw new Error("No file storage configured"); + } + + const results = []; + for (const file of files) { + const result = await uploadMutation.mutateAsync({ file }); + results.push(result); + } + return results; + }; + + return { + uploadFiles, + isUploading: uploadMutation.isPending, + hasStorage: !!primaryStorage, + storageConnection: primaryStorage, + }; +} diff --git a/apps/mesh/src/web/components/files/file-browser.tsx b/apps/mesh/src/web/components/files/file-browser.tsx new file mode 100644 index 0000000000..5be7c8e490 --- /dev/null +++ b/apps/mesh/src/web/components/files/file-browser.tsx @@ -0,0 +1,220 @@ +/** + * FileBrowser Component + * + * A file browser component for navigating folders and files. + */ + +import { useState } from "react"; +import { Button } from "@deco/ui/components/button.tsx"; +import { EmptyState } from "@/web/components/empty-state"; +import { + ArrowLeft, + Folder, + File06, + FileCode01, + Image01, + FileCheck02, + Loading01, +} from "@untitledui/icons"; +import { useFileList } from "@/web/hooks/use-file-storage"; +import type { FileEntity } from "@decocms/bindings/file-storage"; +import { cn } from "@deco/ui/lib/utils.ts"; + +interface FileBrowserProps { + connectionId: string; + initialPath?: string; + onFileSelect?: (file: FileEntity) => void; +} + +/** + * Format bytes to human-readable size + */ +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; +} + +/** + * Get icon based on file type + */ +function getFileIcon(file: FileEntity) { + if (file.isDirectory) { + return ; + } + + const ext = file.path.split(".").pop()?.toLowerCase(); + const mimeType = file.mimeType; + + // Images + if (mimeType?.startsWith("image/")) { + return ; + } + + // Code files + if ( + [ + "js", + "jsx", + "ts", + "tsx", + "json", + "html", + "css", + "py", + "go", + "rs", + ].includes(ext ?? "") + ) { + return ; + } + + // Markdown + if (["md", "mdx", "markdown"].includes(ext ?? "")) { + return ; + } + + // Default file icon + return ; +} + +/** + * File browser for navigating files and folders + */ +export function FileBrowser({ + connectionId, + initialPath = "", + onFileSelect, +}: FileBrowserProps) { + const [currentPath, setCurrentPath] = useState(initialPath); + const { data, isLoading, error } = useFileList(connectionId, currentPath); + + const files = data?.items ?? []; + + // Navigate to parent folder + const navigateUp = () => { + const parts = currentPath.split("/").filter(Boolean); + parts.pop(); + setCurrentPath(parts.join("/")); + }; + + // Handle item click + const handleItemClick = (file: FileEntity) => { + if (file.isDirectory) { + setCurrentPath(file.path); + } else if (onFileSelect) { + onFileSelect(file); + } + }; + + // Build breadcrumb parts + const breadcrumbParts = currentPath.split("/").filter(Boolean); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Breadcrumb / navigation */} +
+ + +
+ + {breadcrumbParts.map((part, index) => ( + + / + + + ))} +
+
+ + {/* File list */} +
+ {files.length === 0 ? ( +
+ + Empty folder +

+ Drop files here to upload +

+
+ ) : ( +
+ {files.map((file) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/apps/mesh/src/web/components/files/file-preview.tsx b/apps/mesh/src/web/components/files/file-preview.tsx new file mode 100644 index 0000000000..d2f61ec238 --- /dev/null +++ b/apps/mesh/src/web/components/files/file-preview.tsx @@ -0,0 +1,307 @@ +/** + * FilePreview Component + * + * Preview different file types (images, text, code, etc.) + */ + +import { Suspense, lazy, useState } from "react"; +import { Loading01 } from "@untitledui/icons"; +import { useFileContent } from "@/web/hooks/use-file-storage"; +import type { FileEntity } from "@decocms/bindings/file-storage"; +import { cn } from "@deco/ui/lib/utils.ts"; + +// Lazy load the TextEditor to avoid loading Monaco on initial page load +const TextEditor = lazy(() => + import("./text-editor").then((mod) => ({ default: mod.TextEditor })), +); + +interface FilePreviewProps { + connectionId: string; + file: FileEntity; + className?: string; +} + +/** + * Check if a MIME type is previewable as text + */ +function isTextFile(mimeType: string): boolean { + return ( + mimeType.startsWith("text/") || + mimeType === "application/json" || + mimeType === "application/javascript" || + mimeType === "application/xml" + ); +} + +/** + * Check if a MIME type is an image + */ +function isImageFile(mimeType: string): boolean { + return mimeType.startsWith("image/"); +} + +/** + * Check if a MIME type is a PDF + */ +function isPdfFile(mimeType: string): boolean { + return mimeType === "application/pdf"; +} + +/** + * Check if a MIME type is a video + */ +function isVideoFile(mimeType: string): boolean { + return mimeType.startsWith("video/"); +} + +/** + * Check if a MIME type is audio + */ +function isAudioFile(mimeType: string): boolean { + return mimeType.startsWith("audio/"); +} + +/** + * Image with loading state + */ +function ImageWithLoading({ + src, + alt, + className, +}: { + src: string; + alt: string; + className?: string; +}) { + const [isLoaded, setIsLoaded] = useState(false); + const [hasError, setHasError] = useState(false); + + return ( +
+ {/* Loading spinner - shown while image loads */} + {!isLoaded && !hasError && ( +
+ +
+ )} + + {/* Error state */} + {hasError && ( +
+ Failed to load image +
+ )} + + {/* Image - hidden until loaded */} + {alt} setIsLoaded(true)} + onError={() => setHasError(true)} + /> +
+ ); +} + +/** + * PDF viewer using browser's native viewer + */ +function PdfViewer({ url, className }: { url: string; className?: string }) { + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); + + return ( +
+ {isLoading && !hasError && ( +
+ +
+ )} + {hasError ? ( +
+

Unable to preview PDF in browser

+ + Open in new tab + +
+ ) : ( +