Skip to content

Commit 580c73d

Browse files
Merge pull request #345 from mkitti/mkitti-binary-file-display
feat: Binary file hex preview in FileViewer
2 parents 1f98792 + f489ba4 commit 580c73d

5 files changed

Lines changed: 147 additions & 18 deletions

File tree

frontend/src/components/ui/BrowsePage/FileViewer.tsx

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ import { formatFileSize, formatUnixTimestamp } from '@/utils';
1212
import type { FileOrFolder } from '@/shared.types';
1313
import {
1414
useFileContentQuery,
15-
useFileMetadataQuery
15+
useFileMetadataQuery,
16+
useFileBinaryPreviewQuery
1617
} from '@/queries/fileContentQueries';
18+
import HexDump from './HexDump';
1719
import useDarkMode from '@/hooks/useDarkMode';
1820

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

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

90-
// Only fetch content if metadata indicates it's not binary
91-
const shouldFetchContent =
92-
metadataQuery.isSuccess && !metadataQuery.data.isBinary;
91+
const isBinary = metadataQuery.data?.isBinary === true;
92+
93+
const binaryPreviewQuery = useFileBinaryPreviewQuery(
94+
fspName,
95+
file.path,
96+
isBinary
97+
);
98+
99+
const shouldFetchContent = metadataQuery.isSuccess && !isBinary;
93100
const contentQuery = useFileContentQuery(
94101
shouldFetchContent ? fspName : undefined,
95102
file.path
@@ -99,6 +106,30 @@ export default function FileViewer({ file }: FileViewerProps) {
99106
const isJsonFile = language === 'json';
100107

101108
const renderViewer = () => {
109+
// Binary file: show hex preview as soon as the first bytes arrive
110+
if (isBinary) {
111+
if (binaryPreviewQuery.isPending) {
112+
return (
113+
<Typography className="p-4 text-foreground">
114+
Loading binary preview...
115+
</Typography>
116+
);
117+
}
118+
if (binaryPreviewQuery.error) {
119+
return (
120+
<Typography className="p-4 text-foreground/60">
121+
Binary file — preview unavailable
122+
</Typography>
123+
);
124+
}
125+
return (
126+
<HexDump
127+
bytes={binaryPreviewQuery.data!}
128+
totalFileSize={file.size ?? undefined}
129+
/>
130+
);
131+
}
132+
102133
if (metadataQuery.isLoading) {
103134
return (
104135
<Typography className="p-4 text-foreground">
@@ -115,15 +146,6 @@ export default function FileViewer({ file }: FileViewerProps) {
115146
);
116147
}
117148

118-
// If file is binary, show a message instead of trying to load content
119-
if (metadataQuery.data?.isBinary) {
120-
return (
121-
<Typography className="p-4 text-foreground">
122-
Binary file - preview not available
123-
</Typography>
124-
);
125-
}
126-
127149
if (contentQuery.isLoading) {
128150
return (
129151
<Typography className="p-4 text-foreground">
@@ -196,8 +218,7 @@ export default function FileViewer({ file }: FileViewerProps) {
196218
};
197219

198220
// Determine if we should show JSON format toggle
199-
const showJsonToggle =
200-
isJsonFile && metadataQuery.isSuccess && !metadataQuery.data.isBinary;
221+
const showJsonToggle = isJsonFile && !isBinary;
201222

202223
return (
203224
<div className="flex flex-col h-full w-full overflow-hidden">
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { Typography } from '@material-tailwind/react';
2+
3+
const BYTES_PER_ROW = 16;
4+
5+
/** Replace non-printable / non-ASCII bytes with a dot for the ASCII column. */
6+
function toPrintable(byte: number): string {
7+
return byte >= 0x20 && byte < 0x7f ? String.fromCharCode(byte) : '.';
8+
}
9+
10+
type HexDumpProps = {
11+
readonly bytes: Uint8Array;
12+
readonly totalFileSize?: number;
13+
};
14+
15+
/**
16+
* Renders a Uint8Array in classic hexdump format:
17+
*
18+
* 0000: 50 4B 03 04 14 00 06 00 08 00 00 00 21 00 8C 27 PK..........!..'
19+
* 0010: 4E 7B 01 00 00 00 FF FF FF FF 08 00 08 00 08 00 N{..............
20+
*/
21+
export default function HexDump({ bytes, totalFileSize }: HexDumpProps) {
22+
const rows: string[] = [];
23+
24+
for (let offset = 0; offset < bytes.length; offset += BYTES_PER_ROW) {
25+
const chunk = bytes.slice(offset, offset + BYTES_PER_ROW);
26+
27+
// Offset column
28+
const offsetStr = offset.toString(16).padStart(4, '0').toUpperCase();
29+
30+
// Hex columns: first 8 bytes, gap, last 8 bytes
31+
const hexParts: string[] = [];
32+
for (let i = 0; i < BYTES_PER_ROW; i++) {
33+
if (i === 8) {
34+
hexParts.push(' ');
35+
} // mid-row gap
36+
hexParts.push(
37+
i < chunk.length
38+
? chunk[i].toString(16).padStart(2, '0').toUpperCase()
39+
: ' '
40+
);
41+
}
42+
const hexStr = hexParts.join(' ');
43+
44+
// ASCII column
45+
const asciiStr = Array.from(chunk).map(toPrintable).join('');
46+
47+
rows.push(`${offsetStr}: ${hexStr} ${asciiStr}`);
48+
}
49+
50+
const isTruncated =
51+
totalFileSize !== undefined && totalFileSize > bytes.length;
52+
53+
return (
54+
<div className="p-4">
55+
{isTruncated ? (
56+
<Typography className="text-xs text-foreground/60 mb-2">
57+
Showing first {bytes.length} of {totalFileSize.toLocaleString()} bytes
58+
</Typography>
59+
) : (
60+
<Typography className="text-xs text-foreground/60 mb-2">
61+
{bytes.length} bytes
62+
</Typography>
63+
)}
64+
<pre className="text-xs font-mono text-foreground leading-5 whitespace-pre overflow-x-auto">
65+
{rows.join('\n')}
66+
</pre>
67+
</div>
68+
);
69+
}

frontend/src/queries/fileContentQueries.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,17 @@ import { buildUrl, sendFetchRequest } from '@/utils';
88
import { fetchFileContent } from './queryUtils';
99
import type { FetchRequestOptions } from '@/shared.types';
1010

11+
// Number of bytes to fetch for binary hex preview
12+
const BINARY_PREVIEW_BYTES = 512;
13+
1114
// Query keys for file content and metadata
1215
export const fileContentQueryKeys = {
1316
detail: (fspName: string, filePath: string) =>
1417
['fileContent', fspName, filePath] as const,
1518
head: (fspName: string, filePath: string) =>
16-
['fileContentHead', fspName, filePath] as const
19+
['fileContentHead', fspName, filePath] as const,
20+
binaryPreview: (fspName: string, filePath: string) =>
21+
['fileBinaryPreview', fspName, filePath] as const
1722
};
1823

1924
// Type for HEAD response metadata
@@ -99,3 +104,35 @@ export function useFileContentQuery(
99104
}
100105
});
101106
}
107+
108+
/**
109+
* Fetch the first BINARY_PREVIEW_BYTES bytes of a file using an HTTP Range
110+
* request. Used to render a hex preview for binary files.
111+
* Enabled only after HEAD confirms the file is binary.
112+
*/
113+
export function useFileBinaryPreviewQuery(
114+
fspName: string | undefined,
115+
filePath: string,
116+
enabled: boolean = true
117+
): UseQueryResult<Uint8Array, Error> {
118+
return useQuery<Uint8Array, Error>({
119+
queryKey: fileContentQueryKeys.binaryPreview(fspName || '', filePath),
120+
queryFn: async ({ signal }: QueryFunctionContext) => {
121+
const url = buildUrl('/api/content/', fspName!, { subpath: filePath });
122+
const response = await sendFetchRequest(url, 'GET', undefined, {
123+
signal,
124+
headers: { Range: `bytes=0-${BINARY_PREVIEW_BYTES - 1}` }
125+
});
126+
// 206 Partial Content or 200 OK (if server ignores Range) are both fine
127+
if (!response.ok) {
128+
throw new Error(
129+
`Failed to fetch binary preview: ${response.statusText}`
130+
);
131+
}
132+
return new Uint8Array(await response.arrayBuffer());
133+
},
134+
enabled: !!fspName && !!filePath && enabled,
135+
staleTime: 5 * 60 * 1000,
136+
retry: false
137+
});
138+
}

frontend/src/shared.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ type Result<T> = Success<T> | Failure;
5353

5454
type FetchRequestOptions = {
5555
signal?: AbortSignal;
56+
headers?: Record<string, string>;
5657
};
5758

5859
// --- App / Job types ---

frontend/src/utils/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,8 @@ async function sendFetchRequest(
154154
headers: {
155155
...(method !== 'GET' &&
156156
method !== 'HEAD' &&
157-
method !== 'DELETE' && { 'Content-Type': 'application/json' })
157+
method !== 'DELETE' && { 'Content-Type': 'application/json' }),
158+
...options?.headers
158159
},
159160
...(method !== 'GET' &&
160161
method !== 'HEAD' &&

0 commit comments

Comments
 (0)