github.com/treeverse/lakefs@v1.24.1-0.20240520134607-95648127bfb0/webui/src/lib/components/repository/tree.jsx (about)

     1  import React, { useCallback, useState } from "react";
     2  
     3  import dayjs from "dayjs";
     4  import {
     5    PasteIcon,
     6    DotIcon,
     7    DownloadIcon,
     8    FileDirectoryIcon,
     9    FileIcon,
    10    GearIcon,
    11    InfoIcon,
    12    LinkIcon,
    13    PencilIcon,
    14    PlusIcon,
    15    TrashIcon,
    16    LogIcon,
    17    BeakerIcon,
    18  } from "@primer/octicons-react";
    19  import Tooltip from "react-bootstrap/Tooltip";
    20  import Table from "react-bootstrap/Table";
    21  import Card from "react-bootstrap/Card";
    22  import OverlayTrigger from "react-bootstrap/OverlayTrigger";
    23  import Button from "react-bootstrap/Button";
    24  import Container from "react-bootstrap/Container";
    25  import Row from "react-bootstrap/Row";
    26  import Col from "react-bootstrap/Col";
    27  import Dropdown from "react-bootstrap/Dropdown";
    28  
    29  import { commits, linkToPath, objects } from "../../api";
    30  import { ConfirmationModal } from "../modals";
    31  import { Paginator } from "../pagination";
    32  import { Link } from "../nav";
    33  import { RefTypeBranch, RefTypeCommit } from "../../../constants";
    34  import {ClipboardButton, copyTextToClipboard, AlertError, Loading} from "../controls";
    35  import Modal from "react-bootstrap/Modal";
    36  import { useAPI } from "../../hooks/api";
    37  import noop from "lodash/noop";
    38  import {FaDownload} from "react-icons/fa";
    39  import {CommitInfoCard} from "./commits";
    40  
    41  export const humanSize = (bytes) => {
    42    if (!bytes) return "0.0 B";
    43    const e = Math.floor(Math.log(bytes) / Math.log(1024));
    44    return (
    45      (bytes / Math.pow(1024, e)).toFixed(1) + " " + " KMGTP".charAt(e) + "B"
    46    );
    47  };
    48  
    49  const Na = () => <span>&mdash;</span>;
    50  
    51  const EntryRowActions = ({ repo, reference, entry, onDelete, presign, presign_ui = false }) => {
    52    const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
    53    const handleCloseDeleteConfirmation = () => setShowDeleteConfirmation(false);
    54    const handleShowDeleteConfirmation = () => setShowDeleteConfirmation(true);
    55    const deleteConfirmMsg = `are you sure you wish to delete object "${entry.path}"?`;
    56    const onSubmitDeletion = () => {
    57      onDelete(entry);
    58      setShowDeleteConfirmation(false);
    59    };
    60  
    61    const [showObjectStat, setShowObjectStat] = useState(false);
    62    const [showObjectOrigin, setShowObjectOrigin] = useState(false);
    63    const [showPrefixSize, setShowPrefixSize] = useState(false);
    64  
    65    const handleShowObjectOrigin = useCallback(
    66      (e) => {
    67        e.preventDefault();
    68        setShowObjectOrigin(true);
    69      },
    70      [setShowObjectOrigin]
    71    );
    72  
    73    const handleShowPrefixSize = useCallback(e => {
    74      e.preventDefault();
    75      setShowPrefixSize(true)
    76    }, [setShowPrefixSize]);
    77  
    78    return (
    79      <>
    80        <Dropdown align="end">
    81          <Dropdown.Toggle variant="light" size="sm" className={"row-hover"}>
    82            <GearIcon />
    83          </Dropdown.Toggle>
    84  
    85          <Dropdown.Menu>
    86            {entry.path_type === "object" && presign && (
    87                 <Dropdown.Item
    88                  onClick={async e => {
    89                    try {
    90                      const resp = await objects.getStat(repo.id, reference.id, entry.path, true);
    91                      copyTextToClipboard(resp.physical_address);
    92                    } catch (err) {
    93                      alert(err);
    94                    }
    95                    e.preventDefault();
    96                  }}
    97                >
    98                  <LinkIcon /> Copy Presigned URL
    99                </Dropdown.Item>
   100            )}
   101            {entry.path_type === "object" && (
   102              <PathLink
   103                path={entry.path}
   104                reference={reference}
   105                repoId={repo.id}
   106                as={Dropdown.Item}
   107                presign={presign_ui}
   108              >
   109                <DownloadIcon /> Download
   110              </PathLink>
   111            )}
   112            {entry.path_type === "object" && (
   113              <Dropdown.Item
   114                onClick={(e) => {
   115                  e.preventDefault();
   116                  setShowObjectStat(true);
   117                }}
   118              >
   119                <InfoIcon /> Object Info
   120              </Dropdown.Item>
   121            )}
   122  
   123            <Dropdown.Item onClick={handleShowObjectOrigin}>
   124              <LogIcon /> Blame
   125            </Dropdown.Item>
   126  
   127            <Dropdown.Item
   128              onClick={(e) => {
   129                copyTextToClipboard(
   130                  `lakefs://${repo.id}/${reference.id}/${entry.path}`
   131                );
   132                e.preventDefault();
   133              }}
   134            >
   135              <PasteIcon /> Copy URI
   136            </Dropdown.Item>
   137  
   138            {entry.path_type === "object" && reference.type === RefTypeBranch && (
   139              <>
   140                <Dropdown.Divider />
   141                <Dropdown.Item
   142                  onClick={(e) => {
   143                    e.preventDefault();
   144                    handleShowDeleteConfirmation();
   145                  }}
   146                >
   147                  <TrashIcon /> Delete
   148                </Dropdown.Item>
   149              </>
   150            )}
   151  
   152            {entry.path_type === "common_prefix" && (
   153              <Dropdown.Item onClick={handleShowPrefixSize}>
   154                <BeakerIcon /> Calculate Size
   155              </Dropdown.Item>
   156            )}
   157  
   158          </Dropdown.Menu>
   159        </Dropdown>
   160  
   161        <ConfirmationModal
   162          show={showDeleteConfirmation}
   163          onHide={handleCloseDeleteConfirmation}
   164          msg={deleteConfirmMsg}
   165          onConfirm={onSubmitDeletion}
   166        />
   167  
   168        <StatModal
   169          entry={entry}
   170          show={showObjectStat}
   171          onHide={() => setShowObjectStat(false)}
   172        />
   173  
   174        <OriginModal
   175          entry={entry}
   176          repo={repo}
   177          reference={reference}
   178          show={showObjectOrigin}
   179          onHide={() => setShowObjectOrigin(false)}
   180        />
   181  
   182        <PrefixSizeModal
   183          entry={entry}
   184          repo={repo}
   185          reference={reference}
   186          show={showPrefixSize}
   187          onHide={() => setShowPrefixSize(false)}
   188        />
   189      </>
   190    );
   191  };
   192  
   193  const StatModal = ({ show, onHide, entry }) => {
   194    return (
   195      <Modal show={show} onHide={onHide} size={"xl"}>
   196        <Modal.Header closeButton>
   197          <Modal.Title>Object Information</Modal.Title>
   198        </Modal.Header>
   199        <Modal.Body>
   200          <Table responsive hover>
   201            <tbody>
   202              <tr>
   203                <td>
   204                  <strong>Path</strong>
   205                </td>
   206                <td>
   207                  <code>{entry.path}</code>
   208                </td>
   209              </tr>
   210              <tr>
   211                <td>
   212                  <strong>Physical Address</strong>
   213                </td>
   214                <td>
   215                  <code>{entry.physical_address}</code>
   216                </td>
   217              </tr>
   218              <tr>
   219                <td>
   220                  <strong>Size (Bytes)</strong>
   221                </td>
   222                <td>{`${entry.size_bytes}  (${humanSize(entry.size_bytes)})`}</td>
   223              </tr>
   224              <tr>
   225                <td>
   226                  <strong>Checksum</strong>
   227                </td>
   228                <td>
   229                  <code>{entry.checksum}</code>
   230                </td>
   231              </tr>
   232              <tr>
   233                <td>
   234                  <strong>Last Modified</strong>
   235                </td>
   236                <td>{`${dayjs.unix(entry.mtime).fromNow()} (${dayjs
   237                  .unix(entry.mtime)
   238                  .format("MM/DD/YYYY HH:mm:ss")})`}</td>
   239              </tr>
   240              {entry.content_type && (
   241                <tr>
   242                  <td>
   243                    <strong>Content-Type</strong>
   244                  </td>
   245                  <td>
   246                    <code>{entry.content_type}</code>
   247                  </td>
   248                </tr>
   249              )}
   250              {entry.metadata && (
   251                  <tr>
   252                    <td>
   253                      <strong>Metadata</strong>
   254                    </td>
   255                    <td>
   256                      <EntryMetadata metadata={entry.metadata}/>
   257                    </td>
   258                  </tr>
   259              )}
   260            </tbody>
   261          </Table>
   262        </Modal.Body>
   263      </Modal>
   264    );
   265  };
   266  
   267  const EntryMetadata = ({ metadata }) => {
   268      return (
   269          <Table hover striped>
   270            <thead>
   271            <tr>
   272              <th>Key</th>
   273              <th>Value</th>
   274            </tr>
   275            </thead>
   276            <tbody>
   277            {Object.getOwnPropertyNames(metadata).map(key =>
   278                <tr key={`metadata:${key}`}>
   279                  <td><code>{key}</code></td>
   280                  <td><code>{metadata[key]}</code></td>
   281                </tr>
   282            )}
   283            </tbody>
   284          </Table>
   285      )
   286  };
   287  
   288  export const PrefixSizeInfoCard = ({ entry, totalObjects }) => {
   289    const totalBytes = totalObjects.reduce((acc, obj) => acc + obj.size_bytes, 0)
   290    const table = (
   291      <Table>
   292        <Table bordered hover>
   293          <tbody>
   294          <tr>
   295            <td><strong>path</strong></td>
   296            <td><code>{entry.path}</code></td>
   297          </tr>
   298          <tr>
   299            <td><strong>Total Objects</strong></td>
   300            <td><code>{totalObjects.length.toLocaleString()}</code></td>
   301          </tr>
   302          <tr>
   303            <td><strong>Total Size</strong></td>
   304            <td><code>{totalBytes.toLocaleString()} Bytes ({humanSize(totalBytes)})</code></td>
   305          </tr>
   306          </tbody>
   307        </Table>
   308      </Table>
   309    )
   310    return table;
   311  }
   312  
   313  const PrefixSizeModal = ({show, onHide, entry, repo, reference }) => {
   314    const [progress, setProgress] = useState(0)
   315    const {
   316      response,
   317      error,
   318      loading,
   319    } = useAPI(async () => {
   320      if (show) {
   321        setProgress(0)
   322        let accumulator = []
   323        let finished = false
   324        const iterator = objects.listAll(repo.id, reference.id, entry.path)
   325        while (!finished) {
   326          let {page, done} = await iterator.next()
   327          accumulator = accumulator.concat(page)
   328          setProgress(accumulator.length)
   329          if (done) finished = true
   330        }
   331        return accumulator;
   332      }
   333      return null;
   334    }, [show, repo.id, reference.id, entry.path, setProgress]);
   335  
   336    let content = <Loading message={`Finding all objects (${progress.toLocaleString()} so far). This could take a while...`} />;
   337  
   338    if (error) {
   339      content = <AlertError error={error} />;
   340    }
   341    if (!loading && !error && response) {
   342      content = (
   343        <PrefixSizeInfoCard repo={repo} reference={reference} entry={entry} totalObjects={response}/>
   344      );
   345    }
   346  
   347    if (!loading && !error && !response) {
   348      content = (
   349        <>
   350          <h5>
   351            <small>
   352              No objects found
   353            </small>
   354          </h5>
   355        </>
   356      );
   357    }
   358  
   359    return (
   360      <Modal show={show} onHide={onHide} size={"lg"}>
   361        <Modal.Header closeButton>
   362          <Modal.Title>
   363            Total Objects in Path
   364          </Modal.Title>
   365        </Modal.Header>
   366        <Modal.Body>{content}</Modal.Body>
   367      </Modal>
   368    );
   369  };
   370  
   371  const OriginModal = ({ show, onHide, entry, repo, reference }) => {
   372    const {
   373      response: commit,
   374      error,
   375      loading,
   376    } = useAPI(async () => {
   377      if (show) {
   378        return await commits.blame(
   379          repo.id,
   380          reference.id,
   381          entry.path,
   382          entry.path_type
   383        );
   384      }
   385      return null;
   386    }, [show, repo.id, reference.id, entry.path]);
   387  
   388    const pathType = entry.path_type === "object" ? "object" : "prefix";
   389  
   390    let content = <Loading />;
   391  
   392    if (error) {
   393      content = <AlertError error={error} />;
   394    }
   395    if (!loading && !error && commit) {
   396      content = (
   397        <CommitInfoCard bare={true} repo={repo} commit={commit}/>
   398      );
   399    }
   400  
   401    if (!loading && !error && !commit) {
   402      content = (
   403        <>
   404          <h5>
   405            <small>
   406              No commit found, perhaps this is an{" "}
   407              <Link
   408                className="me-2"
   409                href={{
   410                  pathname: "/repositories/:repoId/changes",
   411                  params: { repoId: repo.id },
   412                  query: { ref: reference.id },
   413                }}
   414              >
   415                uncommitted change
   416              </Link>
   417              ?
   418            </small>
   419          </h5>
   420        </>
   421      );
   422    }
   423  
   424    return (
   425      <Modal show={show} onHide={onHide} size={"lg"}>
   426        <Modal.Header closeButton>
   427          <Modal.Title>
   428            Last commit to modify <>{pathType}</>
   429          </Modal.Title>
   430        </Modal.Header>
   431        <Modal.Body>{content}</Modal.Body>
   432      </Modal>
   433    );
   434  };
   435  
   436  const PathLink = ({ repoId, reference, path, children, presign = false, as = null }) => {
   437    const name = path.split("/").pop();
   438    const link = linkToPath(repoId, reference.id, path, presign);
   439    if (as === null)
   440      return (
   441        <a href={link} download={name}>
   442          {children}
   443        </a>
   444      );
   445    return React.createElement(as, { href: link, download: name }, children);
   446  };
   447  
   448  const EntryRow = ({ config, repo, reference, path, entry, onDelete, showActions }) => {
   449    let rowClass = "change-entry-row ";
   450    switch (entry.diff_type) {
   451      case "changed":
   452        rowClass += "diff-changed";
   453        break;
   454      case "added":
   455        rowClass += "diff-added";
   456        break;
   457      case "removed":
   458        rowClass += "diff-removed";
   459        break;
   460      default:
   461        break;
   462    }
   463  
   464    const subPath = path.lastIndexOf("/") !== -1 ? path.substr(0, path.lastIndexOf("/")) : "";
   465    const buttonText =
   466        subPath.length > 0 ? entry.path.substr(subPath.length + 1) : entry.path;
   467  
   468    const params = { repoId: repo.id };
   469    const query = { ref: reference.id, path: entry.path };
   470  
   471    let button;
   472    if (entry.path_type === "common_prefix") {
   473      button = (
   474        <Link href={{ pathname: "/repositories/:repoId/objects", query, params }}>
   475          {buttonText}
   476        </Link>
   477      );
   478    } else if (entry.diff_type === "removed") {
   479      button = <span>{buttonText}</span>;
   480    } else {
   481      const filePathQuery = {
   482        ref: query.ref,
   483        path: query.path,
   484      };
   485      button = (
   486        <Link
   487          href={{
   488            pathname: "/repositories/:repoId/object",
   489            query: filePathQuery,
   490            params: params,
   491          }}
   492        >
   493          {buttonText}
   494        </Link>
   495      );
   496    }
   497  
   498    let size;
   499    if (entry.diff_type === "removed" || entry.path_type === "common_prefix") {
   500      size = <Na />;
   501    } else {
   502      size = (
   503        <OverlayTrigger
   504          placement="bottom"
   505          overlay={<Tooltip>{entry.size_bytes} bytes</Tooltip>}
   506        >
   507          <span>{humanSize(entry.size_bytes)}</span>
   508        </OverlayTrigger>
   509      );
   510    }
   511  
   512    let modified;
   513    if (entry.diff_type === "removed" || entry.path_type === "common_prefix") {
   514      modified = <Na />;
   515    } else {
   516      modified = (
   517        <OverlayTrigger
   518          placement="bottom"
   519          overlay={
   520            <Tooltip>
   521              {dayjs.unix(entry.mtime).format("MM/DD/YYYY HH:mm:ss")}
   522            </Tooltip>
   523          }
   524        >
   525          <span>{dayjs.unix(entry.mtime).fromNow()}</span>
   526        </OverlayTrigger>
   527      );
   528    }
   529  
   530    let diffIndicator;
   531    switch (entry.diff_type) {
   532      case "removed":
   533        diffIndicator = (
   534          <OverlayTrigger
   535            placement="bottom"
   536            overlay={<Tooltip>removed in diff</Tooltip>}
   537          >
   538            <span>
   539              <TrashIcon />
   540            </span>
   541          </OverlayTrigger>
   542        );
   543        break;
   544      case "added":
   545        diffIndicator = (
   546          <OverlayTrigger
   547            placement="bottom"
   548            overlay={<Tooltip>added in diff</Tooltip>}
   549          >
   550            <span>
   551              <PlusIcon />
   552            </span>
   553          </OverlayTrigger>
   554        );
   555        break;
   556      case "changed":
   557        diffIndicator = (
   558          <OverlayTrigger
   559            placement="bottom"
   560            overlay={<Tooltip>changed in diff</Tooltip>}
   561          >
   562            <span>
   563              <PencilIcon />
   564            </span>
   565          </OverlayTrigger>
   566        );
   567        break;
   568      default:
   569        break;
   570    }
   571  
   572    let entryActions;
   573    if (showActions && entry.diff_type !== "removed") {
   574      entryActions = (
   575        <EntryRowActions
   576          repo={repo}
   577          reference={reference}
   578          entry={entry}
   579          onDelete={onDelete}
   580          presign={config.config.pre_sign_support}
   581          presign_ui={config.config.pre_sign_support_ui}
   582        />
   583      );
   584    }
   585  
   586    return (
   587      <>
   588        <tr className={rowClass}>
   589          <td className="diff-indicator">{diffIndicator}</td>
   590          <td className="tree-path">
   591            {entry.path_type === "common_prefix" ? (
   592              <FileDirectoryIcon />
   593            ) : (
   594              <FileIcon />
   595            )}{" "}
   596            {button}
   597          </td>
   598          <td className="tree-size">{size}</td>
   599          <td className="tree-modified">{modified}</td>
   600          <td className={"change-entry-row-actions"}>{entryActions}</td>
   601        </tr>
   602      </>
   603    );
   604  };
   605  
   606  function pathParts(path, isPathToFile) {
   607    let parts = path.split(/\//);
   608    let resolved = [];
   609    if (parts.length === 0) {
   610      return resolved;
   611    }
   612  
   613    if (parts[parts.length - 1] === "" || !isPathToFile) {
   614      parts = parts.slice(0, parts.length - 1);
   615    }
   616  
   617    // else
   618    for (let i = 0; i < parts.length; i++) {
   619      let currentPath = parts.slice(0, i + 1).join("/");
   620      if (currentPath.length > 0) {
   621        currentPath = `${currentPath}/`;
   622      }
   623      resolved.push({
   624        name: parts[i],
   625        path: currentPath,
   626      });
   627    }
   628  
   629    return resolved;
   630  }
   631  
   632  const buildPathURL = (params, query) => {
   633    return { pathname: "/repositories/:repoId/objects", params, query };
   634  };
   635  
   636  export const URINavigator = ({
   637    repo,
   638    reference,
   639    path,
   640    downloadUrl,
   641    relativeTo = "",
   642    pathURLBuilder = buildPathURL,
   643    isPathToFile = false,
   644    hasCopyButton = false
   645  }) => {
   646    const parts = pathParts(path, isPathToFile);
   647    const params = { repoId: repo.id };
   648  
   649    return (
   650      <div className="d-flex">
   651        <div className="lakefs-uri flex-grow-1">
   652          {relativeTo === "" ? (
   653            <>
   654              <strong>{"lakefs://"}</strong>
   655              <Link href={{ pathname: "/repositories/:repoId/objects", params }}>
   656                {repo.id}
   657              </Link>
   658              <strong>{"/"}</strong>
   659              <Link
   660                href={{
   661                  pathname: "/repositories/:repoId/objects",
   662                  params,
   663                  query: { ref: reference.id },
   664                }}
   665              >
   666                {reference.type === RefTypeCommit
   667                  ? reference.id.substr(0, 12)
   668                  : reference.id}
   669              </Link>
   670              <strong>{"/"}</strong>
   671            </>
   672          ) : (
   673            <>
   674              <Link href={pathURLBuilder(params, { path: "" })}>{relativeTo}</Link>
   675              <strong>{"/"}</strong>
   676            </>
   677          )}
   678  
   679          {parts.map((part, i) => {
   680            const path =
   681              parts
   682                .slice(0, i + 1)
   683                .map((p) => p.name)
   684                .join("/") + "/";
   685            const query = { path, ref: reference.id };
   686            const edgeElement =
   687              isPathToFile && i === parts.length - 1 ? (
   688                <span>{part.name}</span>
   689              ) : (
   690                <>
   691                  <Link href={pathURLBuilder(params, query)}>{part.name}</Link>
   692                  <strong>{"/"}</strong>
   693                </>
   694              );
   695            return <span key={part.name}>{edgeElement}</span>;
   696          })}
   697          </div>
   698        <div className="object-viewer-buttons">
   699          {hasCopyButton &&
   700          <ClipboardButton
   701              text={`lakefs://${repo.id}/${reference.id}/${path}`}
   702              variant="link"
   703              size="sm"
   704              onSuccess={noop}
   705              onError={noop}
   706              className={"me-1"}
   707              tooltip={"copy URI to clipboard"}/>}
   708          {(downloadUrl) && (
   709            <a
   710                href={downloadUrl}
   711                download={path.split('/').pop()}
   712                className="btn btn-link btn-sm download-button me-1">
   713              <FaDownload />
   714            </a>
   715          )}
   716        </div>
   717      </div>
   718    );
   719  };
   720  
   721  const GetStarted = ({ config, onUpload, onImport }) => {
   722    const importDisabled = !config.config.import_support;
   723    return (
   724      <Container className="m-4 mb-5">
   725        <h2 className="mt-2">To get started with this repository:</h2>
   726  
   727        <Row className="pt-2 ms-2">
   728          <Col>
   729            <DotIcon className="me-1 mt-3" />
   730            <Button
   731              variant="link"
   732              className="mb-1"
   733              disabled={importDisabled}
   734              onClick={onImport}
   735            >
   736              Import
   737            </Button>
   738            &nbsp;data from {config.config.blockstore_type}. Or, see the&nbsp;
   739            <a
   740              href="https://docs.lakefs.io/howto/import.html"
   741              target="_blank"
   742              rel="noopener noreferrer"
   743            >
   744              docs
   745            </a>
   746            &nbsp;for other ways to import data to your repository.
   747          </Col>
   748        </Row>
   749  
   750        <Row className="pt-2 ms-2">
   751          <Col>
   752            <DotIcon className="me-1 mt-1" />
   753            <Button variant="link" className="mb-1" onClick={onUpload}>
   754              Upload
   755            </Button>
   756            &nbsp;an object.
   757          </Col>
   758        </Row>
   759  
   760        <Row className="pt-2 ms-2">
   761          <Col>
   762            <DotIcon className="me-1 mt-1" />
   763            Use&nbsp;
   764            <a
   765              href="https://docs.lakefs.io/howto/copying.html#using-distcp"
   766              target="_blank"
   767              rel="noopener noreferrer"
   768            >
   769              DistCp
   770            </a>
   771            &nbsp;or&nbsp;
   772            <a
   773              href="https://docs.lakefs.io/howto/copying.html#using-rclone"
   774              target="_blank"
   775              rel="noopener noreferrer"
   776            >
   777              Rclone
   778            </a>
   779            &nbsp;to copy data into your repository.
   780          </Col>
   781        </Row>
   782      </Container>
   783    );
   784  };
   785  
   786  export const Tree = ({
   787    config,
   788    repo,
   789    reference,
   790    results,
   791    after,
   792    onPaginate,
   793    nextPage,
   794    onUpload,
   795    onImport,
   796    onDelete,
   797    showActions = false,
   798    path = "",
   799  }) => {
   800    let body;
   801    if (results.length === 0 && path === "" && reference.type === RefTypeBranch) {
   802      // empty state!
   803      body = (
   804        <GetStarted config={config} onUpload={onUpload} onImport={onImport} />
   805      );
   806    } else {
   807      body = (
   808        <>
   809          <Table borderless size="sm">
   810            <tbody>
   811              {results.map((entry) => (
   812                <EntryRow
   813                  config={config}
   814                  key={entry.path}
   815                  entry={entry}
   816                  path={path}
   817                  repo={repo}
   818                  reference={reference}
   819                  showActions={showActions}
   820                  onDelete={onDelete}
   821                />
   822              ))}
   823            </tbody>
   824          </Table>
   825        </>
   826      );
   827    }
   828  
   829    return (
   830      <div className="tree-container">
   831        <Card>
   832          <Card.Header>
   833            <URINavigator path={path} repo={repo} reference={reference} hasCopyButton={true}/>
   834          </Card.Header>
   835          <Card.Body>{body}</Card.Body>
   836        </Card>
   837  
   838        <Paginator onPaginate={onPaginate} nextPage={nextPage} after={after} />
   839      </div>
   840    );
   841  };