diff --git a/apps/sim/app/api/__test-utils__/utils.ts b/apps/sim/app/api/__test-utils__/utils.ts index a93d240dea..85f8bbcc06 100644 --- a/apps/sim/app/api/__test-utils__/utils.ts +++ b/apps/sim/app/api/__test-utils__/utils.ts @@ -764,6 +764,20 @@ export function createStorageProviderMocks(options: StorageProviderMockOptions = bucket: 'test-s3-bucket', region: 'us-east-1', }, + S3_KB_CONFIG: { + bucket: 'test-s3-kb-bucket', + region: 'us-east-1', + }, + BLOB_CONFIG: { + accountName: 'testaccount', + accountKey: 'testkey', + containerName: 'test-container', + }, + BLOB_KB_CONFIG: { + accountName: 'testaccount', + accountKey: 'testkey', + containerName: 'test-kb-container', + }, })) vi.doMock('@aws-sdk/client-s3', () => ({ @@ -806,6 +820,11 @@ export function createStorageProviderMocks(options: StorageProviderMockOptions = accountKey: 'testkey', containerName: 'test-container', }, + BLOB_KB_CONFIG: { + accountName: 'testaccount', + accountKey: 'testkey', + containerName: 'test-kb-container', + }, })) vi.doMock('@azure/storage-blob', () => ({ diff --git a/apps/sim/app/api/files/presigned/route.test.ts b/apps/sim/app/api/files/presigned/route.test.ts index 443d260b65..4456863b07 100644 --- a/apps/sim/app/api/files/presigned/route.test.ts +++ b/apps/sim/app/api/files/presigned/route.test.ts @@ -39,8 +39,9 @@ describe('/api/files/presigned', () => { const response = await POST(request) const data = await response.json() - expect(response.status).toBe(400) + expect(response.status).toBe(500) // Changed from 400 to 500 (StorageConfigError) expect(data.error).toBe('Direct uploads are only available when cloud storage is enabled') + expect(data.code).toBe('STORAGE_CONFIG_ERROR') expect(data.directUploadSupported).toBe(false) }) @@ -64,7 +65,8 @@ describe('/api/files/presigned', () => { const data = await response.json() expect(response.status).toBe(400) - expect(data.error).toBe('Missing fileName or contentType') + expect(data.error).toBe('fileName is required and cannot be empty') + expect(data.code).toBe('VALIDATION_ERROR') }) it('should return error when contentType is missing', async () => { @@ -87,7 +89,59 @@ describe('/api/files/presigned', () => { const data = await response.json() expect(response.status).toBe(400) - expect(data.error).toBe('Missing fileName or contentType') + expect(data.error).toBe('contentType is required and cannot be empty') + expect(data.code).toBe('VALIDATION_ERROR') + }) + + it('should return error when fileSize is invalid', async () => { + setupFileApiMocks({ + cloudEnabled: true, + storageProvider: 's3', + }) + + const { POST } = await import('./route') + + const request = new NextRequest('http://localhost:3000/api/files/presigned', { + method: 'POST', + body: JSON.stringify({ + fileName: 'test.txt', + contentType: 'text/plain', + fileSize: 0, + }), + }) + + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error).toBe('fileSize must be a positive number') + expect(data.code).toBe('VALIDATION_ERROR') + }) + + it('should return error when file size exceeds limit', async () => { + setupFileApiMocks({ + cloudEnabled: true, + storageProvider: 's3', + }) + + const { POST } = await import('./route') + + const largeFileSize = 150 * 1024 * 1024 // 150MB (exceeds 100MB limit) + const request = new NextRequest('http://localhost:3000/api/files/presigned', { + method: 'POST', + body: JSON.stringify({ + fileName: 'large-file.txt', + contentType: 'text/plain', + fileSize: largeFileSize, + }), + }) + + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error).toContain('exceeds maximum allowed size') + expect(data.code).toBe('VALIDATION_ERROR') }) it('should generate S3 presigned URL successfully', async () => { @@ -122,6 +176,34 @@ describe('/api/files/presigned', () => { expect(data.directUploadSupported).toBe(true) }) + it('should generate knowledge-base S3 presigned URL with kb prefix', async () => { + setupFileApiMocks({ + cloudEnabled: true, + storageProvider: 's3', + }) + + const { POST } = await import('./route') + + const request = new NextRequest( + 'http://localhost:3000/api/files/presigned?type=knowledge-base', + { + method: 'POST', + body: JSON.stringify({ + fileName: 'knowledge-doc.pdf', + contentType: 'application/pdf', + fileSize: 2048, + }), + } + ) + + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.fileInfo.key).toMatch(/^kb\/.*knowledge-doc\.pdf$/) + expect(data.directUploadSupported).toBe(true) + }) + it('should generate Azure Blob presigned URL successfully', async () => { setupFileApiMocks({ cloudEnabled: true, @@ -182,8 +264,9 @@ describe('/api/files/presigned', () => { const response = await POST(request) const data = await response.json() - expect(response.status).toBe(400) - expect(data.error).toBe('Unknown storage provider') + expect(response.status).toBe(500) // Changed from 400 to 500 (StorageConfigError) + expect(data.error).toBe('Unknown storage provider: unknown') // Updated error message + expect(data.code).toBe('STORAGE_CONFIG_ERROR') expect(data.directUploadSupported).toBe(false) }) @@ -225,8 +308,10 @@ describe('/api/files/presigned', () => { const data = await response.json() expect(response.status).toBe(500) - expect(data.error).toBe('Error') - expect(data.message).toBe('S3 service unavailable') + expect(data.error).toBe( + 'Failed to generate S3 presigned URL - check AWS credentials and permissions' + ) // Updated error message + expect(data.code).toBe('STORAGE_CONFIG_ERROR') }) it('should handle Azure Blob errors gracefully', async () => { @@ -269,8 +354,8 @@ describe('/api/files/presigned', () => { const data = await response.json() expect(response.status).toBe(500) - expect(data.error).toBe('Error') - expect(data.message).toBe('Azure service unavailable') + expect(data.error).toBe('Failed to generate Azure Blob presigned URL') // Updated error message + expect(data.code).toBe('STORAGE_CONFIG_ERROR') }) it('should handle malformed JSON gracefully', async () => { @@ -289,9 +374,9 @@ describe('/api/files/presigned', () => { const response = await POST(request) const data = await response.json() - expect(response.status).toBe(500) - expect(data.error).toBe('SyntaxError') - expect(data.message).toContain('Unexpected token') + expect(response.status).toBe(400) // Changed from 500 to 400 (ValidationError) + expect(data.error).toBe('Invalid JSON in request body') // Updated error message + expect(data.code).toBe('VALIDATION_ERROR') }) }) diff --git a/apps/sim/app/api/files/presigned/route.ts b/apps/sim/app/api/files/presigned/route.ts index 020d896ca2..6abc79409e 100644 --- a/apps/sim/app/api/files/presigned/route.ts +++ b/apps/sim/app/api/files/presigned/route.ts @@ -6,7 +6,7 @@ import { createLogger } from '@/lib/logs/console-logger' import { getStorageProvider, isUsingCloudStorage } from '@/lib/uploads' import { getBlobServiceClient } from '@/lib/uploads/blob/blob-client' import { getS3Client, sanitizeFilenameForMetadata } from '@/lib/uploads/s3/s3-client' -import { BLOB_CONFIG, S3_CONFIG } from '@/lib/uploads/setup' +import { BLOB_CONFIG, BLOB_KB_CONFIG, S3_CONFIG, S3_KB_CONFIG } from '@/lib/uploads/setup' import { createErrorResponse, createOptionsResponse } from '../utils' const logger = createLogger('PresignedUploadAPI') @@ -17,124 +17,234 @@ interface PresignedUrlRequest { fileSize: number } +type UploadType = 'general' | 'knowledge-base' + +class PresignedUrlError extends Error { + constructor( + message: string, + public code: string, + public statusCode = 400 + ) { + super(message) + this.name = 'PresignedUrlError' + } +} + +class StorageConfigError extends PresignedUrlError { + constructor(message: string) { + super(message, 'STORAGE_CONFIG_ERROR', 500) + } +} + +class ValidationError extends PresignedUrlError { + constructor(message: string) { + super(message, 'VALIDATION_ERROR', 400) + } +} + export async function POST(request: NextRequest) { try { - // Parse the request body - const data: PresignedUrlRequest = await request.json() + let data: PresignedUrlRequest + try { + data = await request.json() + } catch { + throw new ValidationError('Invalid JSON in request body') + } + const { fileName, contentType, fileSize } = data - if (!fileName || !contentType) { - return NextResponse.json({ error: 'Missing fileName or contentType' }, { status: 400 }) + if (!fileName?.trim()) { + throw new ValidationError('fileName is required and cannot be empty') + } + if (!contentType?.trim()) { + throw new ValidationError('contentType is required and cannot be empty') + } + if (!fileSize || fileSize <= 0) { + throw new ValidationError('fileSize must be a positive number') } - // Only proceed if cloud storage is enabled + const MAX_FILE_SIZE = 100 * 1024 * 1024 + if (fileSize > MAX_FILE_SIZE) { + throw new ValidationError( + `File size (${fileSize} bytes) exceeds maximum allowed size (${MAX_FILE_SIZE} bytes)` + ) + } + + const uploadTypeParam = request.nextUrl.searchParams.get('type') + const uploadType: UploadType = + uploadTypeParam === 'knowledge-base' ? 'knowledge-base' : 'general' + if (!isUsingCloudStorage()) { - return NextResponse.json( - { - error: 'Direct uploads are only available when cloud storage is enabled', - directUploadSupported: false, - }, - { status: 400 } + throw new StorageConfigError( + 'Direct uploads are only available when cloud storage is enabled' ) } const storageProvider = getStorageProvider() + logger.info(`Generating ${uploadType} presigned URL for ${fileName} using ${storageProvider}`) switch (storageProvider) { case 's3': - return await handleS3PresignedUrl(fileName, contentType, fileSize) + return await handleS3PresignedUrl(fileName, contentType, fileSize, uploadType) case 'blob': - return await handleBlobPresignedUrl(fileName, contentType, fileSize) + return await handleBlobPresignedUrl(fileName, contentType, fileSize, uploadType) default: - return NextResponse.json( - { - error: 'Unknown storage provider', - directUploadSupported: false, - }, - { status: 400 } - ) + throw new StorageConfigError(`Unknown storage provider: ${storageProvider}`) } } catch (error) { logger.error('Error generating presigned URL:', error) + + if (error instanceof PresignedUrlError) { + return NextResponse.json( + { + error: error.message, + code: error.code, + directUploadSupported: false, + }, + { status: error.statusCode } + ) + } + return createErrorResponse( error instanceof Error ? error : new Error('Failed to generate presigned URL') ) } } -async function handleS3PresignedUrl(fileName: string, contentType: string, fileSize: number) { - // Create a unique key for the file - const safeFileName = fileName.replace(/\s+/g, '-') - const uniqueKey = `${Date.now()}-${uuidv4()}-${safeFileName}` +async function handleS3PresignedUrl( + fileName: string, + contentType: string, + fileSize: number, + uploadType: UploadType +) { + try { + const config = uploadType === 'knowledge-base' ? S3_KB_CONFIG : S3_CONFIG + + if (!config.bucket || !config.region) { + throw new StorageConfigError(`S3 configuration missing for ${uploadType} uploads`) + } - // Sanitize the original filename for S3 metadata to prevent header errors - const sanitizedOriginalName = sanitizeFilenameForMetadata(fileName) + const safeFileName = fileName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_') + const prefix = uploadType === 'knowledge-base' ? 'kb/' : '' + const uniqueKey = `${prefix}${Date.now()}-${uuidv4()}-${safeFileName}` - // Create the S3 command - const command = new PutObjectCommand({ - Bucket: S3_CONFIG.bucket, - Key: uniqueKey, - ContentType: contentType, - Metadata: { + const sanitizedOriginalName = sanitizeFilenameForMetadata(fileName) + + const metadata: Record = { originalName: sanitizedOriginalName, uploadedAt: new Date().toISOString(), - }, - }) - - // Generate the presigned URL - const presignedUrl = await getSignedUrl(getS3Client(), command, { expiresIn: 3600 }) - - // Create a path for API to serve the file - const servePath = `/api/files/serve/s3/${encodeURIComponent(uniqueKey)}` - - logger.info(`Generated presigned URL for ${fileName} (${uniqueKey})`) - - return NextResponse.json({ - presignedUrl, - fileInfo: { - path: servePath, - key: uniqueKey, - name: fileName, - size: fileSize, - type: contentType, - }, - directUploadSupported: true, - }) -} + } + + if (uploadType === 'knowledge-base') { + metadata.purpose = 'knowledge-base' + } + + const command = new PutObjectCommand({ + Bucket: config.bucket, + Key: uniqueKey, + ContentType: contentType, + Metadata: metadata, + }) + + let presignedUrl: string + try { + presignedUrl = await getSignedUrl(getS3Client(), command, { expiresIn: 3600 }) + } catch (s3Error) { + logger.error('Failed to generate S3 presigned URL:', s3Error) + throw new StorageConfigError( + 'Failed to generate S3 presigned URL - check AWS credentials and permissions' + ) + } + + const servePath = `/api/files/serve/s3/${encodeURIComponent(uniqueKey)}` -async function handleBlobPresignedUrl(fileName: string, contentType: string, fileSize: number) { - // Create a unique key for the file - const safeFileName = fileName.replace(/\s+/g, '-') - const uniqueKey = `${Date.now()}-${uuidv4()}-${safeFileName}` + logger.info(`Generated ${uploadType} S3 presigned URL for ${fileName} (${uniqueKey})`) + return NextResponse.json({ + presignedUrl, + fileInfo: { + path: servePath, + key: uniqueKey, + name: fileName, + size: fileSize, + type: contentType, + }, + directUploadSupported: true, + }) + } catch (error) { + if (error instanceof PresignedUrlError) { + throw error + } + logger.error('Error in S3 presigned URL generation:', error) + throw new StorageConfigError('Failed to generate S3 presigned URL') + } +} + +async function handleBlobPresignedUrl( + fileName: string, + contentType: string, + fileSize: number, + uploadType: UploadType +) { try { + const config = uploadType === 'knowledge-base' ? BLOB_KB_CONFIG : BLOB_CONFIG + + if ( + !config.accountName || + !config.containerName || + (!config.accountKey && !config.connectionString) + ) { + throw new StorageConfigError(`Azure Blob configuration missing for ${uploadType} uploads`) + } + + const safeFileName = fileName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_') + const prefix = uploadType === 'knowledge-base' ? 'kb/' : '' + const uniqueKey = `${prefix}${Date.now()}-${uuidv4()}-${safeFileName}` + const blobServiceClient = getBlobServiceClient() - const containerClient = blobServiceClient.getContainerClient(BLOB_CONFIG.containerName) + const containerClient = blobServiceClient.getContainerClient(config.containerName) const blockBlobClient = containerClient.getBlockBlobClient(uniqueKey) - // Generate SAS token for upload (write permission) const { BlobSASPermissions, generateBlobSASQueryParameters, StorageSharedKeyCredential } = await import('@azure/storage-blob') const sasOptions = { - containerName: BLOB_CONFIG.containerName, + containerName: config.containerName, blobName: uniqueKey, permissions: BlobSASPermissions.parse('w'), // Write permission for upload startsOn: new Date(), expiresOn: new Date(Date.now() + 3600 * 1000), // 1 hour expiration } - const sasToken = generateBlobSASQueryParameters( - sasOptions, - new StorageSharedKeyCredential(BLOB_CONFIG.accountName, BLOB_CONFIG.accountKey || '') - ).toString() + let sasToken: string + try { + sasToken = generateBlobSASQueryParameters( + sasOptions, + new StorageSharedKeyCredential(config.accountName, config.accountKey || '') + ).toString() + } catch (blobError) { + logger.error('Failed to generate Azure Blob SAS token:', blobError) + throw new StorageConfigError( + 'Failed to generate Azure Blob SAS token - check Azure credentials and permissions' + ) + } const presignedUrl = `${blockBlobClient.url}?${sasToken}` - // Create a path for API to serve the file const servePath = `/api/files/serve/blob/${encodeURIComponent(uniqueKey)}` - logger.info(`Generated presigned URL for ${fileName} (${uniqueKey})`) + logger.info(`Generated ${uploadType} Azure Blob presigned URL for ${fileName} (${uniqueKey})`) + + const uploadHeaders: Record = { + 'x-ms-blob-type': 'BlockBlob', + 'x-ms-blob-content-type': contentType, + 'x-ms-meta-originalname': encodeURIComponent(fileName), + 'x-ms-meta-uploadedat': new Date().toISOString(), + } + + if (uploadType === 'knowledge-base') { + uploadHeaders['x-ms-meta-purpose'] = 'knowledge-base' + } return NextResponse.json({ presignedUrl, @@ -146,22 +256,17 @@ async function handleBlobPresignedUrl(fileName: string, contentType: string, fil type: contentType, }, directUploadSupported: true, - uploadHeaders: { - 'x-ms-blob-type': 'BlockBlob', - 'x-ms-blob-content-type': contentType, - 'x-ms-meta-originalname': encodeURIComponent(fileName), - 'x-ms-meta-uploadedat': new Date().toISOString(), - }, + uploadHeaders, }) } catch (error) { - logger.error('Error generating Blob presigned URL:', error) - return createErrorResponse( - error instanceof Error ? error : new Error('Failed to generate Blob presigned URL') - ) + if (error instanceof PresignedUrlError) { + throw error + } + logger.error('Error in Azure Blob presigned URL generation:', error) + throw new StorageConfigError('Failed to generate Azure Blob presigned URL') } } -// Handle preflight requests export async function OPTIONS() { return createOptionsResponse() } diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index e73a006724..3ddff4d038 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -1,7 +1,8 @@ import { readFile } from 'fs/promises' import type { NextRequest, NextResponse } from 'next/server' import { createLogger } from '@/lib/logs/console-logger' -import { downloadFile, isUsingCloudStorage } from '@/lib/uploads' +import { downloadFile, getStorageProvider, isUsingCloudStorage } from '@/lib/uploads' +import { BLOB_KB_CONFIG, S3_KB_CONFIG } from '@/lib/uploads/setup' import '@/lib/uploads/setup.server' import { @@ -16,6 +17,19 @@ export const dynamic = 'force-dynamic' const logger = createLogger('FilesServeAPI') +async function streamToBuffer(readableStream: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + readableStream.on('data', (data) => { + chunks.push(data instanceof Buffer ? data : Buffer.from(data)) + }) + readableStream.on('end', () => { + resolve(Buffer.concat(chunks)) + }) + readableStream.on('error', reject) + }) +} + /** * Main API route handler for serving files */ @@ -85,12 +99,65 @@ async function handleLocalFile(filename: string): Promise { } } +async function downloadKBFile(cloudKey: string): Promise { + const storageProvider = getStorageProvider() + + if (storageProvider === 'blob') { + logger.info(`Downloading KB file from Azure Blob Storage: ${cloudKey}`) + // Use KB-specific blob configuration + const { getBlobServiceClient } = await import('@/lib/uploads/blob/blob-client') + const blobServiceClient = getBlobServiceClient() + const containerClient = blobServiceClient.getContainerClient(BLOB_KB_CONFIG.containerName) + const blockBlobClient = containerClient.getBlockBlobClient(cloudKey) + + const downloadBlockBlobResponse = await blockBlobClient.download() + if (!downloadBlockBlobResponse.readableStreamBody) { + throw new Error('Failed to get readable stream from blob download') + } + + // Convert stream to buffer + return await streamToBuffer(downloadBlockBlobResponse.readableStreamBody) + } + + if (storageProvider === 's3') { + logger.info(`Downloading KB file from S3: ${cloudKey}`) + // Use KB-specific S3 configuration + const { getS3Client } = await import('@/lib/uploads/s3/s3-client') + const { GetObjectCommand } = await import('@aws-sdk/client-s3') + + const s3Client = getS3Client() + const command = new GetObjectCommand({ + Bucket: S3_KB_CONFIG.bucket, + Key: cloudKey, + }) + + const response = await s3Client.send(command) + if (!response.Body) { + throw new Error('No body in S3 response') + } + + // Convert stream to buffer using the same method as the regular S3 client + const stream = response.Body as any + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + stream.on('data', (chunk: Buffer) => chunks.push(chunk)) + stream.on('end', () => resolve(Buffer.concat(chunks))) + stream.on('error', reject) + }) + } + + throw new Error(`Unsupported storage provider for KB files: ${storageProvider}`) +} + /** * Proxy cloud file through our server */ async function handleCloudProxy(cloudKey: string): Promise { try { - const fileBuffer = await downloadFile(cloudKey) + // Check if this is a KB file (starts with 'kb/') + const isKBFile = cloudKey.startsWith('kb/') + + const fileBuffer = isKBFile ? await downloadKBFile(cloudKey) : await downloadFile(cloudKey) // Extract the original filename from the key (last part after last /) const originalFilename = cloudKey.split('/').pop() || 'download' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/edit-chunk-modal/edit-chunk-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/edit-chunk-modal/edit-chunk-modal.tsx index 1fb7a87415..f1a8d88441 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/edit-chunk-modal/edit-chunk-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/edit-chunk-modal/edit-chunk-modal.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect, useState } from 'react' -import { AlertCircle, Loader2, X } from 'lucide-react' +import { AlertCircle, ChevronDown, ChevronUp, Loader2, X } from 'lucide-react' import { AlertDialog, AlertDialogAction, @@ -16,6 +16,7 @@ import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { createLogger } from '@/lib/logs/console-logger' import type { ChunkData, DocumentData } from '@/stores/knowledge/store' @@ -28,6 +29,12 @@ interface EditChunkModalProps { isOpen: boolean onClose: () => void onChunkUpdate?: (updatedChunk: ChunkData) => void + // New props for navigation + allChunks?: ChunkData[] + currentPage?: number + totalPages?: number + onNavigateToChunk?: (chunk: ChunkData) => void + onNavigateToPage?: (page: number, selectChunk: 'first' | 'last') => Promise } export function EditChunkModal({ @@ -37,11 +44,18 @@ export function EditChunkModal({ isOpen, onClose, onChunkUpdate, + allChunks = [], + currentPage = 1, + totalPages = 1, + onNavigateToChunk, + onNavigateToPage, }: EditChunkModalProps) { const [editedContent, setEditedContent] = useState(chunk?.content || '') const [isSaving, setIsSaving] = useState(false) + const [isNavigating, setIsNavigating] = useState(false) const [error, setError] = useState(null) const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false) + const [pendingNavigation, setPendingNavigation] = useState<(() => void) | null>(null) // Check if there are unsaved changes const hasUnsavedChanges = editedContent !== (chunk?.content || '') @@ -53,6 +67,13 @@ export function EditChunkModal({ } }, [chunk?.id, chunk?.content]) + // Find current chunk index in the current page + const currentChunkIndex = chunk ? allChunks.findIndex((c) => c.id === chunk.id) : -1 + + // Calculate navigation availability + const canNavigatePrev = currentChunkIndex > 0 || currentPage > 1 + const canNavigateNext = currentChunkIndex < allChunks.length - 1 || currentPage < totalPages + const handleSaveContent = async () => { if (!chunk || !document) return @@ -82,7 +103,6 @@ export function EditChunkModal({ if (result.success && onChunkUpdate) { onChunkUpdate(result.data) - onClose() } } catch (err) { logger.error('Error updating chunk:', err) @@ -92,8 +112,51 @@ export function EditChunkModal({ } } + const navigateToChunk = async (direction: 'prev' | 'next') => { + if (!chunk || isNavigating) return + + try { + setIsNavigating(true) + + if (direction === 'prev') { + if (currentChunkIndex > 0) { + // Navigate to previous chunk in current page + const prevChunk = allChunks[currentChunkIndex - 1] + onNavigateToChunk?.(prevChunk) + } else if (currentPage > 1) { + // Load previous page and navigate to last chunk + await onNavigateToPage?.(currentPage - 1, 'last') + } + } else { + if (currentChunkIndex < allChunks.length - 1) { + // Navigate to next chunk in current page + const nextChunk = allChunks[currentChunkIndex + 1] + onNavigateToChunk?.(nextChunk) + } else if (currentPage < totalPages) { + // Load next page and navigate to first chunk + await onNavigateToPage?.(currentPage + 1, 'first') + } + } + } catch (err) { + logger.error(`Error navigating ${direction}:`, err) + setError(`Failed to navigate to ${direction === 'prev' ? 'previous' : 'next'} chunk`) + } finally { + setIsNavigating(false) + } + } + + const handleNavigate = (direction: 'prev' | 'next') => { + if (hasUnsavedChanges) { + setPendingNavigation(() => () => navigateToChunk(direction)) + setShowUnsavedChangesAlert(true) + } else { + void navigateToChunk(direction) + } + } + const handleCloseAttempt = () => { if (hasUnsavedChanges && !isSaving) { + setPendingNavigation(null) setShowUnsavedChangesAlert(true) } else { onClose() @@ -102,7 +165,12 @@ export function EditChunkModal({ const handleConfirmDiscard = () => { setShowUnsavedChangesAlert(false) - onClose() + if (pendingNavigation) { + void pendingNavigation() + setPendingNavigation(null) + } else { + onClose() + } } const isFormValid = editedContent.trim().length > 0 && editedContent.trim().length <= 10000 @@ -118,7 +186,59 @@ export function EditChunkModal({ >
- Edit Chunk +
+ Edit Chunk + + {/* Navigation Controls */} +
+ + e.preventDefault()} + onBlur={(e) => e.preventDefault()} + > + + + + Previous chunk{' '} + {currentPage > 1 && currentChunkIndex === 0 ? '(previous page)' : ''} + + + + + e.preventDefault()} + onBlur={(e) => e.preventDefault()} + > + + + + Next chunk{' '} + {currentPage < totalPages && currentChunkIndex === allChunks.length - 1 + ? '(next page)' + : ''} + + +
+
+
@@ -167,7 +287,7 @@ export function EditChunkModal({ onChange={(e) => setEditedContent(e.target.value)} placeholder='Enter chunk content...' className='flex-1 resize-none' - disabled={isSaving} + disabled={isSaving || isNavigating} /> @@ -176,12 +296,16 @@ export function EditChunkModal({ {/* Footer */}
-