Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/api/src/memory/memory.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ interface AuthenticatedRequest {
* (visibility-gated by the service). Writes are admin + developer; viewer
* is read-only.
*/
@Controller('memory')
@Controller('api/v1/memory')
export class MemoryController {
constructor(private readonly service: MemoryService) {}

Expand Down
30 changes: 26 additions & 4 deletions packages/api/src/workspace/__tests__/workspace.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ describe('WorkspaceController', () => {
send: vi.fn().mockResolvedValue(undefined),
} as any;

await controller.downloadFile(mockReq, '/index.ts', mockReply);
await controller.downloadFile(mockReq, '/index.ts', undefined, mockReply);

expect(mockService.downloadFile).toHaveBeenCalledWith('user-1', '/index.ts');
expect(mockReply.header).toHaveBeenCalledWith('Content-Type', 'text/plain');
Expand All @@ -273,15 +273,37 @@ describe('WorkspaceController', () => {
expect(mockReply.send).toHaveBeenCalledWith(mockStream);
});

it('should throw BadRequestException when path is missing', async () => {
it('should use inline Content-Disposition when inline=true', async () => {
const mockStream = { pipe: vi.fn() } as any;
mockService.downloadFile.mockResolvedValue({
stream: mockStream,
filename: 'photo.png',
contentType: 'image/png',
size: 100,
});

const mockReply = {
header: vi.fn().mockReturnThis(),
send: vi.fn().mockResolvedValue(undefined),
} as any;

await expect(controller.downloadFile(mockReq, undefined, mockReply)).rejects.toThrow(
BadRequestException,
await controller.downloadFile(mockReq, '/photo.png', 'true', mockReply);

expect(mockReply.header).toHaveBeenCalledWith(
'Content-Disposition',
'inline; filename="photo.png"',
);
});

it('should throw BadRequestException when path is missing', async () => {
const mockReply = {
header: vi.fn().mockReturnThis(),
send: vi.fn().mockResolvedValue(undefined),
} as any;

await expect(
controller.downloadFile(mockReq, undefined, undefined, mockReply),
).rejects.toThrow(BadRequestException);
});
});
});
4 changes: 3 additions & 1 deletion packages/api/src/workspace/workspace.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export class WorkspaceController {
async downloadFile(
@Req() req: { user: JwtPayload },
@Query('path') filePath?: string,
@Query('inline') inline?: string,
@Res() reply?: FastifyReply,
): Promise<void> {
if (!filePath) {
Expand All @@ -154,8 +155,9 @@ export class WorkspaceController {
req.user.sub,
filePath,
);
const disposition = inline === 'true' ? 'inline' : 'attachment';
reply!.header('Content-Type', contentType);
reply!.header('Content-Disposition', `attachment; filename="${filename}"`);
reply!.header('Content-Disposition', `${disposition}; filename="${filename}"`);
reply!.header('Content-Length', size);
await reply!.send(stream);
}
Expand Down
37 changes: 28 additions & 9 deletions packages/api/src/workspace/workspace.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,30 @@ const BINARY_TYPES: ReadonlySet<FileType> = new Set(['image', 'video', 'audio',

const EDITABLE_TYPES: ReadonlySet<FileType> = new Set(['text', 'code', 'markdown', 'json']);

const MIME_TYPE_MAP: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mov': 'video/quicktime',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.pdf': 'application/pdf',
'.zip': 'application/zip',
'.tar': 'application/x-tar',
'.gz': 'application/gzip',
'.rar': 'application/vnd.rar',
'.json': 'application/json',
'.md': 'text/markdown',
'.mdx': 'text/markdown',
};

@Injectable()
export class WorkspaceService {
constructor(private readonly userAgentRepo: UserAgentRepository) {}
Expand Down Expand Up @@ -385,22 +409,17 @@ export class WorkspaceService {
if (stat.isDirectory()) throw new BadRequestException('Cannot download a directory');
const resolved = sfs.resolve(filePath);
const filename = path.basename(resolved);
const ext = path.extname(filename).toLowerCase();
const type = WorkspaceService.detectFileType(filename);
const contentTypeMap: Partial<Record<string, string>> = {
image: 'image/*',
video: 'video/*',
audio: 'audio/*',
pdf: 'application/pdf',
archive: 'application/octet-stream',
json: 'application/json',
const fallbackByType: Partial<Record<string, string>> = {
code: 'text/plain',
text: 'text/plain',
markdown: 'text/markdown',
};
const contentType = MIME_TYPE_MAP[ext] ?? fallbackByType[type] ?? 'application/octet-stream';
return {
stream: sfs.createReadStream(filePath),
filename,
contentType: contentTypeMap[type] ?? 'application/octet-stream',
contentType,
size: stat.size,
};
}
Expand Down
23 changes: 14 additions & 9 deletions packages/web/src/app/(dashboard)/workspace/file-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { formatFileSize } from '@/lib/format';
import { ImagePreview } from './image-preview';
import type { FileContent } from '@clawix/shared';

interface FilePreviewProps {
Expand Down Expand Up @@ -47,7 +48,7 @@ export function FilePreview({ file, isLoading, onClose, onEdit, onFullPreview }:
</span>
</div>
<div className="flex items-center gap-1">
{onFullPreview && file.content !== null && (
{onFullPreview && (file.content !== null || file.type === 'image') && (
<Button
variant="ghost"
size="icon"
Expand Down Expand Up @@ -78,14 +79,18 @@ export function FilePreview({ file, isLoading, onClose, onEdit, onFullPreview }:
</CardHeader>
<CardContent className="flex-1 overflow-auto p-4">
{file.content === null ? (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center">
<FileWarning className="size-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">
{file.truncated
? 'File is too large to preview (> 1 MB)'
: 'Binary file — preview not available'}
</p>
</div>
file.type === 'image' && !file.truncated ? (
<ImagePreview path={file.path} alt={file.name} />
) : (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center">
<FileWarning className="size-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">
{file.truncated
? 'File is too large to preview (> 1 MB)'
: 'Binary file — preview not available'}
</p>
</div>
)
) : file.type === 'markdown' ? (
<div className="prose prose-sm dark:prose-invert max-w-none">
<Markdown remarkPlugins={[remarkGfm]}>{file.content}</Markdown>
Expand Down
75 changes: 75 additions & 0 deletions packages/web/src/app/(dashboard)/workspace/image-preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
'use client';

import { useEffect, useState } from 'react';
import { FileWarning, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { getAccessToken } from '@/lib/auth';

interface ImagePreviewProps {
readonly path: string;
readonly alt: string;
readonly className?: string;
}

export function ImagePreview({ path, alt, className }: ImagePreviewProps) {
const [url, setUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
let cancelled = false;
let objectUrl: string | null = null;

(async () => {
try {
const token = await getAccessToken();
if (!token) throw new Error('Not authenticated');
const apiBase = process.env['NEXT_PUBLIC_API_URL'] ?? 'http://localhost:3001';
const res = await fetch(
`${apiBase}/api/v1/workspace/files/download?path=${encodeURIComponent(path)}&inline=true`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!res.ok) throw new Error(`Failed to load image (HTTP ${res.status})`);
const blob = await res.blob();
objectUrl = URL.createObjectURL(blob);
if (!cancelled) {
setUrl(objectUrl);
setError(null);
} else {
URL.revokeObjectURL(objectUrl);
}
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load image');
}
})();

return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [path]);

if (error) {
return (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center">
<FileWarning className="size-8 text-muted-foreground/50" />
<p className="text-sm text-destructive">{error}</p>
</div>
);
}

if (!url) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}

return (
<img
src={url}
alt={alt}
className={cn('mx-auto block max-h-full max-w-full object-contain', className)}
/>
);
}
31 changes: 14 additions & 17 deletions packages/web/src/app/(dashboard)/workspace/workspace-dialogs.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';
import { AlertTriangle, ChevronRight, Folder, Pencil, X } from 'lucide-react';
import { AlertTriangle, ChevronRight, Folder, Pencil } from 'lucide-react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import mermaid from 'mermaid';
import { authFetch } from '@/lib/auth';
import { cn } from '@/lib/utils';
import { formatFileSize } from '@/lib/format';
import { Badge } from '@/components/ui/badge';
import { ImagePreview } from './image-preview';
import type { DirectoryListing, FileContent } from '@clawix/shared';
import {
Dialog,
Expand Down Expand Up @@ -545,7 +546,7 @@ export function FullPreviewDialog({ file, open, onOpenChange, onEdit }: FullPrev
Full preview of {file.name} ({file.type}, {formatFileSize(file.size)})
</DialogDescription>
{/* Header */}
<div className="flex items-center gap-2 border-b px-6 py-4">
<div className="flex items-center gap-2 border-b py-4 pl-6 pr-14">
<div className="flex min-w-0 flex-1 items-center gap-3">
<span className="truncate text-lg font-semibold">{file.name}</span>
<Badge variant="secondary" className="shrink-0">
Expand All @@ -570,27 +571,23 @@ export function FullPreviewDialog({ file, open, onOpenChange, onEdit }: FullPrev
<Pencil className="size-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={() => onOpenChange(false)}
>
<X className="size-4" />
</Button>
</div>
</div>

{/* Content */}
<div className="flex-1 overflow-auto p-6">
{file.content === null ? (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center">
<p className="text-sm text-muted-foreground">
{file.truncated
? 'File is too large to preview (> 1 MB)'
: 'Binary file — preview not available'}
</p>
</div>
file.type === 'image' && !file.truncated ? (
<ImagePreview path={file.path} alt={file.name} />
) : (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center">
<p className="text-sm text-muted-foreground">
{file.truncated
? 'File is too large to preview (> 1 MB)'
: 'Binary file — preview not available'}
</p>
</div>
)
) : isMarkdown ? (
<div className="prose prose-sm dark:prose-invert max-w-none">
<Markdown
Expand Down
10 changes: 5 additions & 5 deletions packages/web/src/lib/api/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,29 +14,29 @@ export interface MemoryItem {

export const memoryApi = {
list(scope: MemoryListScope): Promise<{ items: MemoryItem[] }> {
return authFetch(`/memory?scope=${encodeURIComponent(scope)}`);
return authFetch(`/api/v1/memory?scope=${encodeURIComponent(scope)}`);
},

read(id: string): Promise<MemoryItem> {
return authFetch(`/memory/${encodeURIComponent(id)}`);
return authFetch(`/api/v1/memory/${encodeURIComponent(id)}`);
},

create(input: CreateMemoryItemInput): Promise<MemoryItem> {
return authFetch('/memory', {
return authFetch('/api/v1/memory', {
method: 'POST',
body: JSON.stringify(input),
});
},

update(id: string, input: UpdateMemoryItemInput): Promise<MemoryItem> {
return authFetch(`/memory/${encodeURIComponent(id)}`, {
return authFetch(`/api/v1/memory/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: JSON.stringify(input),
});
},

delete(id: string): Promise<void> {
return authFetch(`/memory/${encodeURIComponent(id)}`, {
return authFetch(`/api/v1/memory/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
},
Expand Down
Loading