diff --git a/cli/src/modules/common/handlers/files.test.ts b/cli/src/modules/common/handlers/files.test.ts new file mode 100644 index 00000000..09354717 --- /dev/null +++ b/cli/src/modules/common/handlers/files.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdir, rm, stat, writeFile } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' +import { RpcHandlerManager } from '../../../api/rpc/RpcHandlerManager' +import { registerFileHandlers } from './files' + +async function createTempDir(prefix: string): Promise { + const path = join(tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`) + await mkdir(path, { recursive: true }) + return path +} + +describe('file RPC handlers', () => { + let rootDir: string + let rpc: RpcHandlerManager + + beforeEach(async () => { + rootDir = await createTempDir('hapi-file-handler') + rpc = new RpcHandlerManager({ scopePrefix: 'session-test' }) + registerFileHandlers(rpc, rootDir) + }) + + afterEach(async () => { + await rm(rootDir, { recursive: true, force: true }) + }) + + it('returns file metadata alongside content', async () => { + const filePath = join(rootDir, 'README.md') + await writeFile(filePath, '# test') + const expectedStats = await stat(filePath) + + const response = await rpc.handleRequest({ + method: 'session-test:readFile', + params: JSON.stringify({ path: 'README.md' }) + }) + const parsed = JSON.parse(response) as { + success: boolean + content?: string + size?: number + modified?: number + } + + expect(parsed.success).toBe(true) + expect(parsed.content).toBe(Buffer.from('# test').toString('base64')) + expect(parsed.size).toBe(expectedStats.size) + expect(parsed.modified).toBe(expectedStats.mtime.getTime()) + }) +}) diff --git a/cli/src/modules/common/handlers/files.ts b/cli/src/modules/common/handlers/files.ts index 0e3d28ba..282e8d7b 100644 --- a/cli/src/modules/common/handlers/files.ts +++ b/cli/src/modules/common/handlers/files.ts @@ -44,9 +44,15 @@ export function registerFileHandlers(rpcHandlerManager: RpcHandlerManager, worki try { const resolvedPath = resolve(workingDirectory, data.path) + const stats = await stat(resolvedPath) const buffer = await readFile(resolvedPath) const content = buffer.toString('base64') - return { success: true, content } + return { + success: true, + content, + size: stats.size, + modified: stats.mtime.getTime() + } } catch (error) { logger.debug('Failed to read file:', error) return rpcError(getErrorMessage(error, 'Failed to read file')) diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 5db9ee85..ca90e635 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -627,6 +627,8 @@ export type GitCommandResponse = CommandResponse export type FileReadResponse = { success: boolean content?: string + size?: number + modified?: number error?: string } diff --git a/web/src/routes/sessions/file.test.tsx b/web/src/routes/sessions/file.test.tsx index d484608d..a270a8d9 100644 --- a/web/src/routes/sessions/file.test.tsx +++ b/web/src/routes/sessions/file.test.tsx @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { I18nProvider } from '@/lib/i18n-context' +import { formatFileMetadata } from '@/lib/file-metadata' import { encodeBase64 } from '@/lib/utils' import FilePage from './file' @@ -12,6 +13,8 @@ const sampleMarkdown = '# Heading\n\n| Col A | Col B |\n| --- | --- |\n| one | t const filePath = 'docs/README.md' const encodedPath = encodeBase64(filePath) const encodedContent = encodeBase64(sampleMarkdown) +const fileSize = 1024 +const fileModified = 1_784_175_060_000 vi.mock('@tanstack/react-router', () => ({ useParams: () => ({ sessionId: 'session-1' }), @@ -28,6 +31,8 @@ vi.mock('@/lib/app-context', () => ({ readSessionFile: vi.fn(async () => ({ success: true, content: encodedContent, + size: fileSize, + modified: fileModified, })), }, }), @@ -82,6 +87,8 @@ describe('FilePage markdown preview', () => { await waitFor(() => { expect(screen.getByTestId('markdown-preview')).toHaveTextContent('# Heading') }) + expect(screen.getByText(formatFileMetadata(fileSize, fileModified, 'en')!)).toBeInTheDocument() + expect(screen.getAllByText(filePath)).toHaveLength(1) const previewCopyButton = screen.getByRole('button', { name: 'Copy file content' }) expect(previewCopyButton.closest('[data-hapi-file-content-header="true"]')).not.toBeNull() expect(previewCopyButton).not.toHaveClass('absolute') diff --git a/web/src/routes/sessions/file.tsx b/web/src/routes/sessions/file.tsx index 163855b0..24d99c83 100644 --- a/web/src/routes/sessions/file.tsx +++ b/web/src/routes/sessions/file.tsx @@ -14,6 +14,7 @@ import { useTranslation } from '@/lib/use-translation' import { decodeBase64 } from '@/lib/utils' import { ImagePreview } from '@/components/ImagePreview' import { MarkdownRenderer } from '@/components/MarkdownRenderer' +import { formatFileMetadata } from '@/lib/file-metadata' import { getInitialMarkdownPreviewMode, isMarkdownFile, @@ -199,7 +200,7 @@ function extractCommandError(result: GitCommandResponse | undefined): string | n export default function FilePage() { const { api } = useAppContext() - const { t } = useTranslation() + const { t, locale } = useTranslation() const { copied: pathCopied, copy: copyPath } = useCopyToClipboard() const { copied: contentCopied, copy: copyContent } = useCopyToClipboard() const goBack = useAppGoBack() @@ -298,6 +299,7 @@ export default function FilePage() { const missingPath = !filePath const diffErrorMessage = diffError ? formatDiffError(diffError, t) : null const fileErrorMessage = fileError ? formatReadFileError(fileError, t) : null + const fileMetadata = formatFileMetadata(fileContentResult?.size, fileContentResult?.modified, locale) return (
@@ -312,7 +314,7 @@ export default function FilePage() {
{fileName}
-
{filePath || t('file.page.unknownPath')}
+
{fileMetadata ?? (filePath || t('file.page.unknownPath'))}