+
+
+ { nodeIsFolder ? (
+
+ ) : (
+
+ ) }
+
+ { open && nodeIsFolder && (
+
+ { isLoading && (
+
+
+
+ ) }
+ { ! isLoading && ( children ?? [] ).length === 0 && (
+
+ { __( 'Empty', 'jetpack-backup-pkg' ) }
+
+ ) }
+ { ! isLoading &&
+ ( children ?? [] ).map( child => (
+
+ ) ) }
+
+ ) }
+
+ );
+}
diff --git a/projects/packages/backup/src/dashboard/components/file-browser/style.scss b/projects/packages/backup/src/dashboard/components/file-browser/style.scss
new file mode 100644
index 000000000000..45f093775bc9
--- /dev/null
+++ b/projects/packages/backup/src/dashboard/components/file-browser/style.scss
@@ -0,0 +1,46 @@
+.jpb-file-browser {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+
+ &__header {
+ padding: 4px 0;
+ }
+
+ &__layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 280px);
+ gap: 16px;
+ }
+
+ &__row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 0;
+ }
+
+ &__toggle,
+ &__file {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ background: transparent;
+ border: 0;
+ cursor: pointer;
+ font-family: inherit;
+ font-size: inherit;
+ padding: 0;
+ color: inherit;
+ }
+
+ &__empty {
+ font-style: italic;
+ color: var(--wp-components-color-foreground-muted, #757575);
+ padding: 4px 0;
+ }
+
+ &__loading {
+ padding: 4px 0;
+ }
+}
diff --git a/projects/packages/backup/src/dashboard/components/file-info-card/index.tsx b/projects/packages/backup/src/dashboard/components/file-info-card/index.tsx
new file mode 100644
index 000000000000..7a8060695887
--- /dev/null
+++ b/projects/packages/backup/src/dashboard/components/file-info-card/index.tsx
@@ -0,0 +1,99 @@
+import { __ } from '@wordpress/i18n';
+import { Icon, closeSmall } from '@wordpress/icons';
+import { Button, Card, Stack, Text } from '@wordpress/ui';
+import { useFileContents } from '../../hooks/use-file-contents';
+import { usePathInfo } from '../../hooks/use-path-info';
+import './style.scss';
+import type { FileNodeFile } from '../../types/file-tree';
+
+type Props = {
+ rewindId: string;
+ file: FileNodeFile;
+ onClose: () => void;
+};
+
+/**
+ * Returns true when the given mime type is renderable as plain text.
+ *
+ * @param mime - Mime type string.
+ * @return Whether the type is textual.
+ */
+function isTextual( mime: string ): boolean {
+ return (
+ mime.startsWith( 'text/' ) ||
+ mime === 'application/x-php' ||
+ mime === 'application/sql' ||
+ mime === 'application/json'
+ );
+}
+
+/**
+ * Formats a byte count as a short human-readable string.
+ *
+ * @param bytes - Size in bytes.
+ * @return Formatted size (e.g. `4.7 KB`).
+ */
+function humanSize( bytes: number ): string {
+ if ( bytes < 1024 ) {
+ return `${ bytes } B`;
+ }
+ if ( bytes < 1024 * 1024 ) {
+ return `${ ( bytes / 1024 ).toFixed( 1 ) } KB`;
+ }
+ return `${ ( bytes / 1024 / 1024 ).toFixed( 1 ) } MB`;
+}
+
+/**
+ * Side panel showing details for the currently-open file: name + path +
+ * size + mime, plus a monospace preview for recognized text mime types.
+ *
+ * @param props - Component props.
+ * @param props.rewindId - Backup rewind id, threaded into the file-contents fetcher.
+ * @param props.file - The file to render.
+ * @param props.onClose - Callback to close the card.
+ * @return The rendered info card.
+ */
+export default function FileInfoCard( { rewindId, file, onClose }: Props ) {
+ // The tree response only carries names + types — `mime_type` and `size`
+ // come from path-info, which we fetch once per opened file.
+ const { data: pathInfo, isLoading: pathInfoLoading } = usePathInfo( rewindId, file.path );
+ const mimeType = pathInfo?.mime_type ?? file.mimeType;
+ const sizeBytes = pathInfo?.size ?? file.sizeBytes;
+ const canPreview = ! pathInfoLoading && isTextual( mimeType );
+ const { content: contents } = useFileContents( rewindId, file.path, canPreview );
+
+ return (
+