feat: uncommitted changes UI

This commit is contained in:
zkldi
2022-09-21 21:33:38 +01:00
parent 76b59bf44d
commit c5c107a0b2
2 changed files with 203 additions and 48 deletions
@@ -4,14 +4,14 @@ import useSetSubheader from "components/layout/header/useSetSubheader";
import Loading from "components/util/Loading";
import useApiQuery from "components/util/query/useApiQuery";
import Select from "components/util/Select";
import React, { useEffect, useState } from "react";
import { Col, Row } from "react-bootstrap";
import { useParams } from "react-router-dom";
import React, { useEffect, useMemo, useState } from "react";
import { Button, Col, Modal, Row } from "react-bootstrap";
import Divider from "components/util/Divider";
import Icon from "components/util/Icon";
import { SetState } from "types/react";
import { TachiConfig } from "lib/config";
import ExternalLink from "components/util/ExternalLink";
import Card from "components/layout/page/Card";
export default function SeedsViewer() {
useSetSubheader(["Developer Utils", "Database Seeds Management"]);
@@ -58,54 +58,174 @@ export default function SeedsViewer() {
// return <BMSCourseLookupTable dataset={} />;
}
type Revision = { c: GitCommit; repo: string };
function InnerSeedsViewer({ hasLocalAPI }: { hasLocalAPI: boolean }) {
const { repo: initRepo } = useParams<{
repo: string | undefined;
}>();
// base, rev a-la traditional git comparisons. Head is expected to be 'after'
// the base, but no order is enforced.
const [baseRev, setBaseRev] = useState<Revision | null>(null);
const [headRev, setHeadRev] = useState<Revision | null>(null);
// a repo is one of the following:
// null - nothing has been selected yet
// "local" - we're referring to the files on the local development disk
// "gh:NAME/REPO" - we're referring to a repository on github, like gh:TNG-Dev/Tachi
const [repo, setRepo] = useState(initRepo ?? null);
// A revision is any valid pointer to a commit that Github understands.
const [rev, setRev] = useState<GitCommit | null>(null);
// if we have a base and a head, render the two separately.
if (baseRev && headRev) {
}
return (
<Row>
<Col xs={12} lg={6} className="offset-lg-3 text-center">
<div>
<span style={{ fontSize: "large" }}>Repository:</span>
<Select
style={{ display: "inline", width: "unset" }}
className="mx-2"
value={repo}
setValue={setRepo}
allowNull
>
{hasLocalAPI && <option value="local">Your Local Repo</option>}
</Select>
{rev && (
<>
<span>/</span>
{rev.sha}
</>
)}
</div>
</Col>
<Col xs={12} lg={10} className="offset-lg-1 text-center">
{repo && !rev && (
<>
<Divider />
<RevSelector repo={repo} setRev={setRev} />
</>
)}
<Col xs={12} lg={baseRev ? 6 : 12} className="text-center">
<SeedsPicker
hasLocalAPI={hasLocalAPI}
header="Base Commit"
setRev={setBaseRev}
rev={baseRev}
/>
</Col>
{baseRev && (
<Col xs={12} lg={6} className="text-center">
<SeedsPicker
hasLocalAPI={hasLocalAPI}
header="Compare Commit"
setRev={setHeadRev}
rev={headRev}
/>
</Col>
)}
</Row>
);
}
function SeedsPicker({
hasLocalAPI,
header,
rev,
setRev,
}: {
hasLocalAPI: boolean;
header: string;
rev: Revision | null;
setRev: SetState<Revision | null>;
}) {
// a repo is one of the following:
// null - nothing has been selected yet
// "local" - we're referring to the files on the local development disk
// "GitHub:NAME/REPO" - we're referring to a repository on github, like GitHub:TNG-Dev/Tachi
const [repo, setRepo] = useState<string | null>(null);
const [show, setShow] = useState(false);
const [subject, body] = useMemo(() => {
if (rev === null) {
return [null, null];
}
const [subject, _gap, ...maybeBodies] = rev.c.commit.message.split("\n");
return [subject, maybeBodies?.join("\n") ?? null];
}, [rev]);
const [showBody, setShowBody] = useState(false);
const authorNotCommitter = rev?.c.commit.author.email !== rev?.c.commit.committer.email;
return (
<>
<Card header={header}>
{rev ? (
<div className="mr-3" style={{ textAlign: "left" }}>
<span style={{ fontSize: "1.15rem" }}>
<code>
{rev.repo}/{rev.c.sha}
</code>
: {subject}
</span>
{body && (
<span
className="ml-4 badge badge-secondary"
onClick={() => setShowBody(!showBody)}
>
{showBody ? (
<Icon style={{ fontSize: "0.7rem" }} type="chevron-down" />
) : (
<Icon style={{ fontSize: "0.7rem" }} type="chevron-left" />
)}
</span>
)}
{showBody && <div className="ml-6 mt-1">{body}</div>}
<Divider />
<div style={{ width: "100%", display: "flex" }}>
<div style={{ flex: 1 }}>
{authorNotCommitter ? (
<>
Authored by <b>{rev.c.commit.author.name}</b>, Committed by{" "}
<b>{rev.c.commit.committer.name}</b>
</>
) : (
<>
Authored by <b>{rev.c.commit.author.name}</b>
</>
)}
</div>
<div className="text-right" style={{ flex: 1 }}>
{FormatTime(Date.parse(rev.c.commit.author.date))}
</div>
</div>
<Divider />
<div className="text-center">
<span
className="text-muted underline-on-hover"
onClick={() => setShow(true)}
>
Change Commit...
</span>
</div>
</div>
) : (
<Button variant="secondary" onClick={() => setShow(true)}>
Pick Commit...
</Button>
)}
</Card>
<Modal show={show} size="xl" onHide={() => setShow(false)}>
<Modal.Header closeButton>
<Modal.Title>Pick {header}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Row>
<Col xs={12} className="text-center">
<div>
<span style={{ fontSize: "large" }}>Repository:</span>
<Select
style={{ display: "inline", width: "unset" }}
className="mx-2"
value={repo}
setValue={setRepo}
allowNull
>
{hasLocalAPI && <option value="local">Your Local Repo</option>}
</Select>
</div>
</Col>
<Col xs={12} lg={10} className="offset-lg-1 text-center">
{repo && (
<>
<Divider />
<RevSelector
repo={repo}
onSelect={(commit) => {
setRev({ c: commit, repo });
setShow(false);
}}
/>
</>
)}
</Col>
</Row>
</Modal.Body>
</Modal>
</>
);
}
// stolen straight from server/src/utils/git.ts
interface GitCommit {
sha: string;
@@ -124,12 +244,12 @@ interface GitCommit {
};
}
function RevSelector({ repo, setRev }: { repo: string; setRev: SetState<GitCommit | null> }) {
function RevSelector({ repo, onSelect }: { repo: string; onSelect: (g: GitCommit) => void }) {
const [revs, setRevs] = useState<Array<GitCommit>>([]);
useEffect(() => {
(async () => {
if (repo.startsWith("gh:")) {
if (repo.startsWith("GitHub:")) {
throw new Error("Unsupported...");
} else {
// local
@@ -139,7 +259,32 @@ function RevSelector({ repo, setRev }: { repo: string; setRev: SetState<GitCommi
throw new Error(`Failed to fetch commits? ${res.description}`);
}
setRevs(res.body);
const hasUncommittedRes = await APIFetchV1<boolean>(
"/seeds/has-uncommitted-changes"
);
if (hasUncommittedRes.success && hasUncommittedRes.body) {
const LOCAL_COMMIT: GitCommit = {
sha: "WORKING_DIRECTORY",
commit: {
author: {
name: "Not Committed Yet",
date: "1970-01-01",
email: "null@example.com",
},
committer: {
name: "Not Committed Yet",
date: "1970-01-01",
email: "null@example.com",
},
message: "Uncommitted changes on your local disk.",
},
};
setRevs([LOCAL_COMMIT, ...res.body]);
} else {
setRevs(res.body);
}
}
})();
}, [repo]);
@@ -148,13 +293,19 @@ function RevSelector({ repo, setRev }: { repo: string; setRev: SetState<GitCommi
<div className="timeline timeline-2">
<div className="timeline-bar"></div>
{revs.map((r) => (
<Revision key={r.sha} rev={r} setRev={setRev} />
<Revision key={r.sha} rev={r} onSelect={onSelect} />
))}
</div>
);
}
function Revision({ rev, setRev }: { rev: GitCommit; setRev: SetState<GitCommit | null> }) {
function Revision({
rev,
onSelect: onSelect,
}: {
rev: GitCommit;
onSelect: (g: GitCommit) => void;
}) {
const authorNotCommitter = rev.commit.author.email !== rev.commit.committer.email;
// if there's a body then there's two newlines.
@@ -168,10 +319,10 @@ function Revision({ rev, setRev }: { rev: GitCommit; setRev: SetState<GitCommit
<div className="timeline-item">
<span className="timeline-badge bg-primary"></span>
<div className="timeline-content d-flex align-items-center justify-content-between">
<div className="mr-3" style={{ width: "50%", textAlign: "left" }}>
<div className="mr-3" style={{ width: "70%", textAlign: "left" }}>
<span style={{ fontSize: "1.15rem" }}>
<code>{rev.sha}</code>:{" "}
<a className="gentle-link" onClick={() => setRev(rev)}>
<a className="gentle-link" onClick={() => onSelect(rev)}>
{subject}
</a>
</span>
@@ -82,6 +82,10 @@ router.get("/has-uncommitted-changes", async (req, res) => {
// local changes.
const hasUncommittedChanges = stdout
.split("\n")
// note that doing this properly is frustrating. This has false positives for
// routes that partially contain this route. I've ameliorated this slightly with
// a leading space, but that is not a proper solution.
.some((row) => / database-seeds\/collections/u.exec(row));
return res.status(200).json({