Skip to content
Merged
53 changes: 37 additions & 16 deletions frontend/src/components/ui/BrowsePage/FileViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import { formatFileSize, formatUnixTimestamp } from '@/utils';
import type { FileOrFolder } from '@/shared.types';
import {
useFileContentQuery,
useFileMetadataQuery
useFileMetadataQuery,
useFileBinaryPreviewQuery
} from '@/queries/fileContentQueries';
import HexDump from './HexDump';
import useDarkMode from '@/hooks/useDarkMode';

type FileViewerProps = {
Expand Down Expand Up @@ -84,12 +86,17 @@ export default function FileViewer({ file }: FileViewerProps) {
const isDarkMode = useDarkMode();
const [formatJson, setFormatJson] = useState<boolean>(true);

// First, fetch metadata to check if file is binary
const metadataQuery = useFileMetadataQuery(fspName, file.path);

// Only fetch content if metadata indicates it's not binary
const shouldFetchContent =
metadataQuery.isSuccess && !metadataQuery.data.isBinary;
const isBinary = metadataQuery.data?.isBinary === true;

const binaryPreviewQuery = useFileBinaryPreviewQuery(
fspName,
file.path,
isBinary
);

const shouldFetchContent = metadataQuery.isSuccess && !isBinary;
const contentQuery = useFileContentQuery(
shouldFetchContent ? fspName : undefined,
file.path
Expand All @@ -99,6 +106,30 @@ export default function FileViewer({ file }: FileViewerProps) {
const isJsonFile = language === 'json';

const renderViewer = () => {
// Binary file: show hex preview as soon as the first bytes arrive
if (isBinary) {
if (binaryPreviewQuery.isPending) {
return (
<Typography className="p-4 text-foreground">
Loading binary preview...
</Typography>
);
}
if (binaryPreviewQuery.error) {
return (
<Typography className="p-4 text-foreground/60">
Binary file — preview unavailable
</Typography>
);
}
return (
<HexDump
bytes={binaryPreviewQuery.data!}
totalFileSize={file.size ?? undefined}
/>
);
}

if (metadataQuery.isLoading) {
return (
<Typography className="p-4 text-foreground">
Expand All @@ -115,15 +146,6 @@ export default function FileViewer({ file }: FileViewerProps) {
);
}

// If file is binary, show a message instead of trying to load content
if (metadataQuery.data?.isBinary) {
return (
<Typography className="p-4 text-foreground">
Binary file - preview not available
</Typography>
);
}

if (contentQuery.isLoading) {
return (
<Typography className="p-4 text-foreground">
Expand Down Expand Up @@ -196,8 +218,7 @@ export default function FileViewer({ file }: FileViewerProps) {
};

// Determine if we should show JSON format toggle
const showJsonToggle =
isJsonFile && metadataQuery.isSuccess && !metadataQuery.data.isBinary;
const showJsonToggle = isJsonFile && !isBinary;

return (
<div className="flex flex-col h-full w-full overflow-hidden">
Expand Down
69 changes: 69 additions & 0 deletions frontend/src/components/ui/BrowsePage/HexDump.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Typography } from '@material-tailwind/react';

const BYTES_PER_ROW = 16;

/** Replace non-printable / non-ASCII bytes with a dot for the ASCII column. */
function toPrintable(byte: number): string {
return byte >= 0x20 && byte < 0x7f ? String.fromCharCode(byte) : '.';
}

type HexDumpProps = {
readonly bytes: Uint8Array;
readonly totalFileSize?: number;
};

/**
* Renders a Uint8Array in classic hexdump format:
*
* 0000: 50 4B 03 04 14 00 06 00 08 00 00 00 21 00 8C 27 PK..........!..'
* 0010: 4E 7B 01 00 00 00 FF FF FF FF 08 00 08 00 08 00 N{..............
*/
export default function HexDump({ bytes, totalFileSize }: HexDumpProps) {
const rows: string[] = [];

for (let offset = 0; offset < bytes.length; offset += BYTES_PER_ROW) {
const chunk = bytes.slice(offset, offset + BYTES_PER_ROW);

// Offset column
const offsetStr = offset.toString(16).padStart(4, '0').toUpperCase();

// Hex columns: first 8 bytes, gap, last 8 bytes
const hexParts: string[] = [];
for (let i = 0; i < BYTES_PER_ROW; i++) {
if (i === 8) {
hexParts.push(' ');
} // mid-row gap
hexParts.push(
i < chunk.length
? chunk[i].toString(16).padStart(2, '0').toUpperCase()
: ' '
);
}
const hexStr = hexParts.join(' ');

// ASCII column
const asciiStr = Array.from(chunk).map(toPrintable).join('');

rows.push(`${offsetStr}: ${hexStr} ${asciiStr}`);
}

const isTruncated =
totalFileSize !== undefined && totalFileSize > bytes.length;

return (
<div className="p-4">
{isTruncated ? (
<Typography className="text-xs text-foreground/60 mb-2">
Showing first {bytes.length} of {totalFileSize.toLocaleString()} bytes
</Typography>
) : (
<Typography className="text-xs text-foreground/60 mb-2">
{bytes.length} bytes
</Typography>
)}
<pre className="text-xs font-mono text-foreground leading-5 whitespace-pre overflow-x-auto">
{rows.join('\n')}
</pre>
</div>
);
}
39 changes: 38 additions & 1 deletion frontend/src/queries/fileContentQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,17 @@ import { buildUrl, sendFetchRequest } from '@/utils';
import { fetchFileContent } from './queryUtils';
import type { FetchRequestOptions } from '@/shared.types';

// Number of bytes to fetch for binary hex preview
const BINARY_PREVIEW_BYTES = 512;

// Query keys for file content and metadata
export const fileContentQueryKeys = {
detail: (fspName: string, filePath: string) =>
['fileContent', fspName, filePath] as const,
head: (fspName: string, filePath: string) =>
['fileContentHead', fspName, filePath] as const
['fileContentHead', fspName, filePath] as const,
binaryPreview: (fspName: string, filePath: string) =>
['fileBinaryPreview', fspName, filePath] as const
};

// Type for HEAD response metadata
Expand Down Expand Up @@ -99,3 +104,35 @@ export function useFileContentQuery(
}
});
}

/**
* Fetch the first BINARY_PREVIEW_BYTES bytes of a file using an HTTP Range
* request. Used to render a hex preview for binary files.
* Enabled only after HEAD confirms the file is binary.
*/
export function useFileBinaryPreviewQuery(
fspName: string | undefined,
filePath: string,
enabled: boolean = true
): UseQueryResult<Uint8Array, Error> {
return useQuery<Uint8Array, Error>({
queryKey: fileContentQueryKeys.binaryPreview(fspName || '', filePath),
queryFn: async ({ signal }: QueryFunctionContext) => {
const url = buildUrl('/api/content/', fspName!, { subpath: filePath });
const response = await sendFetchRequest(url, 'GET', undefined, {
signal,
headers: { Range: `bytes=0-${BINARY_PREVIEW_BYTES - 1}` }
});
// 206 Partial Content or 200 OK (if server ignores Range) are both fine
if (!response.ok) {
throw new Error(
`Failed to fetch binary preview: ${response.statusText}`
);
}
return new Uint8Array(await response.arrayBuffer());
},
enabled: !!fspName && !!filePath && enabled,
staleTime: 5 * 60 * 1000,
retry: false
});
}
1 change: 1 addition & 0 deletions frontend/src/shared.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ type Result<T> = Success<T> | Failure;

type FetchRequestOptions = {
signal?: AbortSignal;
headers?: Record<string, string>;
};

// --- App / Job types ---
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,8 @@ async function sendFetchRequest(
headers: {
...(method !== 'GET' &&
method !== 'HEAD' &&
method !== 'DELETE' && { 'Content-Type': 'application/json' })
method !== 'DELETE' && { 'Content-Type': 'application/json' }),
...options?.headers
},
...(method !== 'GET' &&
method !== 'HEAD' &&
Expand Down
Loading