diff --git a/.eslintrc.yml b/.eslintrc.yml index a5526ee..8b1348e 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -37,7 +37,7 @@ rules: camelcase: off overrides: - - files: "**/*.ts" + - files: "**/*.{ts,tsx}" extends: eslint-config-dmitmel/presets/typescript-addon rules: node/no-missing-import: off @@ -47,7 +47,7 @@ overrides: no-void: off consistent-return: off - - files: "**/plugins/**/*.ts" + - files: "**/plugins/**/*.{ts,tsx}" extends: eslint-config-dmitmel/rules/typescript/with-type-checking rules: "@typescript-eslint/explicit-function-return-type": off @@ -57,6 +57,6 @@ overrides: rules: no-var: off - - files: "plugins/**/*.ts" + - files: "plugins/**/*.{ts,tsx}" parserOptions: project: tsconfig.json diff --git a/package.json b/package.json index 884268e..826e58b 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "devDependencies": { "@electron/asar": "^3.2.1", "@octokit/openapi-types": "^14.0.0", + "@octokit/types": "^8.1.1", "@types/node": "^18.11.2", "@types/react": "^18.0.26", "@typescript-eslint/eslint-plugin": "^5.40.1", @@ -35,7 +36,7 @@ "eslint-plugin-node": "^11.1.0", "eslint-plugin-react": "^7.31.10", "prettier": "^2.8.1", - "replugged": "4.0.0-beta0.21", + "replugged": "4.0.0-beta0.22", "tsx": "^3.10.3", "typescript": "^4.8.4" }, diff --git a/plugins/githubindiscord/Modal/Code.tsx b/plugins/githubindiscord/Modal/Code.tsx index 13a9d2a..62da447 100644 --- a/plugins/githubindiscord/Modal/Code.tsx +++ b/plugins/githubindiscord/Modal/Code.tsx @@ -4,26 +4,21 @@ import { TreeView } from "@primer/react/drafts"; import { FileDirectoryFillIcon, FileIcon } from "@primer/styled-octicons"; import { useCallback, useContext, useEffect, useState } from "react"; import { common, webpack } from "replugged"; -import { TabProps } from "."; import { SelectMenu } from "../components"; import { Context } from "../context"; import { TreeWithContent, getCommits, getFile, pluginSettings } from "../utils"; -import CommitsView from "./Commits/CommitsView"; +import CommitsView from "./Commits/CommitView"; const { parser } = common; const blober = webpack.getByProps("blob"); -export default (props: TabProps) => { - return pluginSettings.get("view", "standard") === "treeview" ? ( - - ) : ( - - ); +export default () => { + return pluginSettings.get("view", "standard") === "treeview" ? : ; }; -function StandardView({ branch, url, switchBranches }: TabProps) { - const { data } = useContext(Context)!; - const { tree, branches } = data!; +function StandardView() { + const { data, switchBranch } = useContext(Context)!; + const { tree, branches, currentBranch: branch, url } = data!; const [folder, setFolder] = useState<{ current: { latestCommit?: components["schemas"]["commit"]; tree: TreeWithContent[] }; prevs: Array<{ latestCommit?: components["schemas"]["commit"]; tree: TreeWithContent[] }>; @@ -55,7 +50,7 @@ function StandardView({ branch, url, switchBranches }: TabProps) { let ending = path.pop(); const latestCommit = file ? file.latestCommit : folder.current.latestCommit; - if (commit) return ; + if (commit) return setCommit(null)} />; return ( <> @@ -64,9 +59,7 @@ function StandardView({ branch, url, switchBranches }: TabProps) { className="Gbranches" value={branch.name} options={branches.map((branch) => ({ value: branch.name, label: branch.name }))} - onChange={(value: string) => { - switchBranches(value); - }} + onChange={switchBranch} /> )} {folder.prevs.length ? ( @@ -94,7 +87,22 @@ function StandardView({ branch, url, switchBranches }: TabProps) { ) : null} - + { + if (event.button !== 2 || event.detail !== 2 || !folder.prevs.length) return; + setFolder((prev) => { + const current = prev.prevs.pop(); + return { + current: current ?? { tree, latestCommit: branch.commit }, + prevs: prev.prevs, + }; + }); + setFile(null); + }}> {parser.defaultRules.codeBlock.react( { content: window.atob(file.content!).trimEnd(), lang: file.fileType }, - // @ts-ignore okay + // @ts-expect-error okay null, {}, )} @@ -187,20 +195,18 @@ function StandardView({ branch, url, switchBranches }: TabProps) { onClick={() => { if (c.type === "tree") { setFolder((prev) => ({ - current: c as any, + current: c as typeof prev["current"], prevs: [...prev.prevs, folder.current], })); - } else { - getBlob(c); - } + } else void getBlob(c); if (!c.latestCommit) - getCommits(url, { path: c.path, sha: branch.name }).then((o) => { + void getCommits(url, { path: c.path, sha: branch.name }).then((o) => { if (o[0]) { c.latestCommit = o[0]; if (c.type === "tree") setFolder((prev) => ({ - current: c as any, + current: c as typeof prev["current"], prevs: [...prev.prevs], })); else diff --git a/plugins/githubindiscord/Modal/Comment.tsx b/plugins/githubindiscord/Modal/Comment.tsx index ebe3d84..c93fbed 100644 --- a/plugins/githubindiscord/Modal/Comment.tsx +++ b/plugins/githubindiscord/Modal/Comment.tsx @@ -1,11 +1,14 @@ import { Avatar, Box, CaretProps, PointerBox, RelativeTime, Text, Timeline } from "@primer/react"; import { BetterSystemStyleObject } from "@primer/react/lib/sx"; +import { parseMarkdown } from "../parser"; export const TimelineComment = ({ comment, caret, sx, }: { + // ill type later + // eslint-disable-next-line @typescript-eslint/no-explicit-any comment: any; caret?: CaretProps["location"]; sx?: BetterSystemStyleObject; @@ -48,7 +51,16 @@ export const TimelineComment = ({ borderTop={0} borderBottomLeftRadius={2} borderBottomRightRadius={2} - dangerouslySetInnerHTML={{ __html: comment.body }}> + sx={{ + userSelect: "text", + code: { bg: "canvas.subtle" }, + lineHeight: "2rem", + img: { + maxWidth: "100%", + }, + }}> + {parseMarkdown(comment.body as string)} + diff --git a/plugins/githubindiscord/Modal/Commits.tsx b/plugins/githubindiscord/Modal/Commits.tsx new file mode 100644 index 0000000..40ac9b1 --- /dev/null +++ b/plugins/githubindiscord/Modal/Commits.tsx @@ -0,0 +1,5 @@ +import CommitHistory from "./Commits/CommitHistory"; + +export default () => { + return ; +}; diff --git a/plugins/githubindiscord/Modal/Commits/Commit.tsx b/plugins/githubindiscord/Modal/Commits/Commit.tsx new file mode 100644 index 0000000..a49b22e --- /dev/null +++ b/plugins/githubindiscord/Modal/Commits/Commit.tsx @@ -0,0 +1,54 @@ +import { operations } from "@octokit/openapi-types"; +import { common } from "replugged"; +import { Box, Link, Text } from "@primer/react"; +import { SxProp } from "@primer/react/lib-esm/sx"; +import { useState } from "react"; +import { ChevronDownIcon, ChevronRightIcon } from "@primer/styled-octicons"; +const { parser } = common; + +export default ({ + commit, + sx, +}: { + commit: operations["pulls/list-files"]["responses"]["200"]["content"]["application/json"][0]; + sx?: SxProp["sx"]; +}) => { + const [expanded, setExpanded] = useState(true); + + return ( + + + setExpanded(!expanded)}> + {expanded ? : } + + {commit.filename} + + {expanded && ( + + {parser.defaultRules.codeBlock.react( + { content: commit.patch?.trimEnd(), lang: "patch" }, + // @ts-expect-error better types need butttttttttttttttttt i wont pr :) + null, + {}, + )} + + )} + + ); +}; diff --git a/plugins/githubindiscord/Modal/Commits/CommitHistory.tsx b/plugins/githubindiscord/Modal/Commits/CommitHistory.tsx new file mode 100644 index 0000000..dd8ae24 --- /dev/null +++ b/plugins/githubindiscord/Modal/Commits/CommitHistory.tsx @@ -0,0 +1,96 @@ +import { + Avatar, + Box, + Link, + Pagination, + RelativeTime, + Text, + Timeline, + Truncate, +} from "@primer/react"; +import { CommitIcon } from "@primer/styled-octicons"; +import { useContext, useEffect, useState } from "react"; +import { Context } from "../../context"; +import { useCommits } from "../../paginate"; +import { Issue, TreeWithContent } from "../../utils"; +import Spinner from "../Spinner"; +import CommitsView from "./CommitView"; + +export default ({ pr }: { pr?: NonNullable }) => { + const { url } = useContext(Context)!.data!; + const commits = pr ? useCommits(url, { pr: pr.number }) : useContext(Context)?.data?.commits; + const [commit, setCommit] = useState | null>(null); + + useEffect(() => { + void commits?.fetch(); + }, []); + + if (!commits?.data?.page || !commits?.data?.info) return Fetching commits...; + + if (commit) return setCommit(null)} />; + + const page = commits.data.page[commits.data.info.currentPage - 1]; + + return ( + + {page.map((p) => ( + + + + + + + Commits + + + {p.map((c, i) => ( + + + + setCommit(c)}> + {c.commit.message.split("\n\n")[0]} + + + + + + + + {c.author?.login} + {" "} + committed + + + + ))} + + + + ))} + {commits.data.info.lastPage && ( + { + if (page > commits.data.info!.currentPage) void commits.nextPage(); + else void commits.previousPage(); + }} + /> + )} + + ); +}; diff --git a/plugins/githubindiscord/Modal/Commits/CommitView.tsx b/plugins/githubindiscord/Modal/Commits/CommitView.tsx index cef4fce..a6befb6 100644 --- a/plugins/githubindiscord/Modal/Commits/CommitView.tsx +++ b/plugins/githubindiscord/Modal/Commits/CommitView.tsx @@ -1,54 +1,63 @@ -import { operations } from "@octokit/openapi-types"; -import { common } from "replugged"; -import { Box, Link, Text } from "@primer/react"; -import { SxProp } from "@primer/react/lib-esm/sx"; -import { useState } from "react"; -import { ChevronDownIcon, ChevronRightIcon } from "@primer/styled-octicons"; -const { parser } = common; +import { Avatar, Box, Button, RelativeTime, Text } from "@primer/react"; +import { useContext, useEffect, useState } from "react"; +import { Context } from "../../context"; +import { TreeWithContent, getCommit } from "../../utils"; +import Spinner from "../Spinner"; +import CommitView from "./Commit"; export default ({ commit, - sx, + onClose, }: { - commit: operations["pulls/list-files"]["responses"]["200"]["content"]["application/json"][0]; - sx?: SxProp["sx"]; + commit: NonNullable; + onClose: () => void; }) => { - const [expanded, setExpanded] = useState(true); + const { url } = useContext(Context)!.data!; + const forceUpdate = useState({})[1]; + + useEffect(() => { + if (commit.files) return; + (async () => { + const ccommit = await getCommit(url, commit.sha); + commit.files = ccommit.files; + commit.stats = ccommit.stats; + forceUpdate({}); + })(); + }, []); + + if (!commit.files) return Fetching Commit; + + const message = commit.commit.message.split("\n\n"); return ( - - - setExpanded(!expanded)}> - {expanded ? : } - - {commit.filename} - - {expanded && ( - - {parser.defaultRules.codeBlock.react( - { content: commit.patch?.trimEnd(), lang: "patch" }, - // @ts-ignore - null, - {}, - )} + event.button === 2 && event.detail === 2 && onClose()}> + + + + {message[0]} + + + {message[1] &&
{message[1]}
}
- )} + + {" "} + {commit.author?.login} commited{" "} + + +
+ + Showing{" "} + + {commit.files.length} changed file{commit.files.length > 1 ? "s" : ""} + {" "} + with {commit.stats?.additions} additions{" "} + and {commit.stats?.deletions} deletions + + {commit.files.map((file) => ( + + ))}
); }; diff --git a/plugins/githubindiscord/Modal/Commits/CommitsView.tsx b/plugins/githubindiscord/Modal/Commits/CommitsView.tsx deleted file mode 100644 index 7feeae0..0000000 --- a/plugins/githubindiscord/Modal/Commits/CommitsView.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { Avatar, Box, RelativeTime, Text } from "@primer/react"; -import { useEffect, useState } from "react"; -import { TreeWithContent, getCommit } from "../../utils"; -import Spinner from "../Spinner"; -import CommitView from "./CommitView"; - -export default ({ - commit, - url, -}: { - commit: NonNullable; - url: string; -}) => { - const forceUpdate = useState({})[1]; - - useEffect(() => { - if (commit.files) return; - (async () => { - const ccommit = await getCommit(url, commit!.sha); - commit.files = ccommit.files; - commit.stats = ccommit.stats; - forceUpdate({}); - })(); - }, []); - - if (!commit.files) return Fetching Commit; - - const message = commit.commit.message.split("\n\n"); - console.log(commit); - - return ( - - - - - {message[0]} - - {message[1] && {message[1]}} - - - {" "} - {commit.author?.login} commited{" "} - - - - - Showing{" "} - - {commit.files.length} changed file{commit.files.length > 1 ? "s" : ""} - {" "} - with {commit.stats?.additions} additions{" "} - and {commit.stats?.deletions} deletions - - {commit.files!.map((file) => ( - - ))} - - ); -}; diff --git a/plugins/githubindiscord/Modal/ErrorBoundary.tsx b/plugins/githubindiscord/Modal/ErrorBoundary.tsx new file mode 100644 index 0000000..3fbf468 --- /dev/null +++ b/plugins/githubindiscord/Modal/ErrorBoundary.tsx @@ -0,0 +1,40 @@ +import { PureComponent, ReactNode } from "react"; +import { components } from "replugged"; +import { textClasses, wumpus } from "../components"; +import { ModalProps } from "../Modals"; + +const { ModalContent, ModalRoot } = components.Modal; + +export default class ErrorBoundary extends PureComponent< + { children: ReactNode; modalProps: ModalProps }, + { hasErr: boolean; err?: string } +> { + public constructor(props: { children: ReactNode; modalProps: ModalProps }) { + super(props); + this.state = { hasErr: false }; + } + + public static getDerivedStateFromError(error: Error) { + return { hasErr: true, err: error.message }; + } + + public render() { + if (!this.state.hasErr) return this.props.children; + + return ( + + +
+
+ + {this.state.err} + +
+ + + ); + } +} diff --git a/plugins/githubindiscord/Modal/Issues.tsx b/plugins/githubindiscord/Modal/Issues.tsx index 63e225e..a4f16d7 100644 --- a/plugins/githubindiscord/Modal/Issues.tsx +++ b/plugins/githubindiscord/Modal/Issues.tsx @@ -9,22 +9,25 @@ import { Timeline, } from "@primer/react"; import { CheckIcon, IssueOpenedIcon } from "@primer/styled-octicons"; -import { useContext, useEffect, useState } from "react"; -import { TabProps } from "."; +import { useContext, useState } from "react"; import { Context } from "../context"; -import { Issue, getMarkdown, getTimeline } from "../utils"; +import { useTimeline } from "../paginate"; +import { Issue } from "../utils"; import { TimelineComment } from "./Comment"; import IssueCard from "./IssueCard"; import Spinner from "./Spinner"; import TimelineItems from "./TimelineItems"; -export default ({ url }: TabProps) => { +export default () => { const { data } = useContext(Context)!; const { issues } = data!; const [selectedIssue, setIssue] = useState(null); - const page: Issue[] = issues.page[issues.state][issues.info.currentPage - 1]; - if (selectedIssue) return ; + const page: Issue[] = + issues.data![issues.data!.state][ + issues.info[issues.data!.state]!.data!.pageInfo.currentPage - 1 + ]; + if (selectedIssue) return setIssue(null)} />; return ( @@ -32,16 +35,16 @@ export default ({ url }: TabProps) => { {!page?.length ? ( @@ -49,14 +52,15 @@ export default ({ url }: TabProps) => { ) : ( page?.map((issue) => setIssue(issue)} />) )} - {issues.info.pages[issues.state] && ( + {issues.info[issues.data!.state].data?.pageInfo.lastPage && ( { - if (page > issues.info.currentPage) issues.nextPage(); + if (page > issues.info[issues.data!.state].data!.pageInfo.currentPage) + issues.nextPage(); else issues.previousPage(); }} /> @@ -66,27 +70,20 @@ export default ({ url }: TabProps) => { ); }; -function Issue({ issue, url }: { issue: Issue; url: string }) { - const forceUpdate = useState({})[1]; - useEffect(() => { - (async () => { - if (!issue.timeline) issue.timeline = await getTimeline(url, issue.number); - const markdown = issue.marked - ? issue.body - : await getMarkdown(issue.body ?? "*No description provided.*"); - issue.body = markdown; - issue.marked = true; - forceUpdate({}); - })(); - }, []); +function Issue({ issue, onClose }: { issue: Issue; onClose: () => void }) { + const { url } = useContext(Context)!.data!; + const timeline = useTimeline(url, issue.number); - if (!issue.timeline) return Fetching issue...; + if (!timeline.data.info) return Fetching issue...; return ( - + event.button === 2 && event.detail === 2 && onClose()}> - + {issue.title} #{issue.number} + - {issue.timeline?.map((t) => ( + {timeline.data.page?.map((t) => ( ))} + {timeline.data.info?.lastPage && + timeline.data.info?.currentPage !== timeline.data.info.lastPage && ( + + )} ); diff --git a/plugins/githubindiscord/Modal/Pulls.tsx b/plugins/githubindiscord/Modal/Pulls.tsx index 1219aac..8510b35 100644 --- a/plugins/githubindiscord/Modal/Pulls.tsx +++ b/plugins/githubindiscord/Modal/Pulls.tsx @@ -12,27 +12,30 @@ import { import { CheckIcon, CommentDiscussionIcon, + CommitIcon, FileDiffIcon, GitPullRequestIcon, } from "@primer/styled-octicons"; import { useContext, useEffect, useState } from "react"; -import { TabProps } from "."; import { Context } from "../context"; -import { Issue, TreeWithContent, getMarkdown, getPR, getPrFiles, getTimeline } from "../utils"; +import { useTimeline } from "../paginate"; +import { Issue, TreeWithContent, getPR, getPrFiles } from "../utils"; import { TimelineComment } from "./Comment"; -import CommitsView from "./Commits/CommitsView"; -import CommitView from "./Commits/CommitView"; +import CommitHistory from "./Commits/CommitHistory"; +import CommitsView from "./Commits/CommitView"; +import CommitView from "./Commits/Commit"; import IssueCard from "./IssueCard"; import Spinner from "./Spinner"; import TimelineItems from "./TimelineItems"; -export default ({ url }: TabProps) => { +export default () => { const { data } = useContext(Context)!; const { prs } = data!; const [selectedPr, setPr] = useState(null); - const page: Issue[] = prs.page[prs.state][prs.info.currentPage - 1]; - if (selectedPr) return ; + const page: Issue[] = + prs.data![prs.data!.state][prs.info[prs.data!.state]!.data!.pageInfo.currentPage - 1]; + if (selectedPr) return setPr(null)} />; return ( @@ -40,16 +43,16 @@ export default ({ url }: TabProps) => { {!page?.length ? ( @@ -57,14 +60,14 @@ export default ({ url }: TabProps) => { ) : ( page?.map((pr) => setPr(pr)} />) )} - {prs.info.pages[prs.state] && ( + {prs.info[prs.data!.state].data?.pageInfo.lastPage && ( { - if (page > prs.info.currentPage) prs.nextPage(); + if (page > prs.info[prs.data!.state].data!.pageInfo.currentPage) prs.nextPage(); else prs.previousPage(); }} /> @@ -80,34 +83,34 @@ const tabs = [ component: ConversationsTab, icon: CommentDiscussionIcon, }, + { title: "Commits", component: CommitsTab, icon: CommitIcon }, { title: "Files", component: FilesTab, icon: FileDiffIcon }, ]; -function PR({ pr, url }: { pr: Issue; url: string }) { - const forceUpdate = useState({})[1]; +function PR({ pr, onClose }: { pr: Issue; onClose: () => void }) { + const { url } = useContext(Context)!.data!; const [tab, setTab] = useState("Conversations"); + const timeline = useTimeline(url, pr.number); + const forceUpdate = useState({})[1]; useEffect(() => { (async () => { - if (!pr.timeline) pr.timeline = await getTimeline(url, pr.number); - if (!pr.pull) pr.pull = await getPR(url, pr.number); - const markdown = pr.marked - ? pr.body - : await getMarkdown(pr.body ?? "*No description provided.*"); - pr.body = markdown; - pr.marked = true; + pr.pull ??= await getPR(url, pr.number); forceUpdate({}); })(); }, []); - if (!pr.timeline) return Fetching pull request...; + if (!timeline.data.page) return Fetching Pull Request...; const Tab = tabs.find(({ title }) => title === tab); return ( - + event.button === 2 && event.detail === 2 && onClose()}> - + {pr.title} #{pr.number} + - {Tab && } + {Tab && } ); } -function ConversationsTab({ pr, url }: { pr: Issue; url: string }) { +function ConversationsTab({ + pr, + timeline, +}: { + pr: Issue; + timeline: ReturnType; +}) { const [commit, setCommit] = useState(null); - if (commit) return ; + if (commit) return setCommit(null)} />; return ( - {pr.timeline?.map((t) => ( - setCommit(commit) }} /> + {timeline.data?.page?.map((t) => ( + ))} + {timeline.data.info?.lastPage && + timeline.data.info?.currentPage !== timeline.data.info.lastPage && ( + + )} ); } -function FilesTab({ pr, url }: { pr: Issue; url: string }) { +function CommitsTab({ pr }: { pr: Issue }) { + return ; +} + +function FilesTab({ pr }: { pr: Issue }) { + const { url } = useContext(Context)!.data!; const forceUpdate = useState({})[1]; useEffect(() => { diff --git a/plugins/githubindiscord/Modal/SettingsModal.tsx b/plugins/githubindiscord/Modal/SettingsModal.tsx index 32877e1..6c05f46 100644 --- a/plugins/githubindiscord/Modal/SettingsModal.tsx +++ b/plugins/githubindiscord/Modal/SettingsModal.tsx @@ -1,29 +1,13 @@ -import { useEffect, useState } from "react"; import { common, components } from "replugged"; import { ModalProps } from "../Modals"; -import { pluginSettings } from "../utils"; -import { default as customTheme } from "../theme"; -import { SelectMenu } from "../components"; +import Settings from "../Settings"; const { ModalContent, ModalHeader, ModalRoot, ModalFooter, ModalCloseButton } = components.Modal; -const { FormItem, FormText, Input } = components; +const { FormItem, FormText } = components; let modalKey: string; - +// console.log(components); function SettingsModal(props: ModalProps) { - const [key, setKey] = useState(pluginSettings.get("key", "")); - const [darkTheme, setDarkTheme] = useState(pluginSettings.get("darkTheme", "dark_discord")); - const [lightTheme, setLightTheme] = useState(pluginSettings.get("lightTheme", "light_discord")); - - useEffect(() => { - pluginSettings.set("key", key); - pluginSettings.set("darkTheme", darkTheme); - pluginSettings.set("lightTheme", lightTheme); - }, [key, darkTheme, lightTheme]); - - const darkThemes = Object.keys(customTheme.colorSchemes).filter((t) => t.includes("dark")); - const lightThemes = Object.keys(customTheme.colorSchemes).filter((t) => t.includes("light")); - return ( @@ -32,26 +16,7 @@ function SettingsModal(props: ModalProps) { - - Github Token (reload for the token to take effect) - - - Dark Theme - {SelectMenu && ( - ({ label: t, value: t }))} - onChange={setDarkTheme} - /> - )} - Light Theme - {SelectMenu && ( - ({ label: t, value: t }))} - onChange={setLightTheme} - /> - )} + common.modal.closeModal(modalKey)}>Close diff --git a/plugins/githubindiscord/Modal/Tags.tsx b/plugins/githubindiscord/Modal/Tags.tsx index 810ee15..3357090 100644 --- a/plugins/githubindiscord/Modal/Tags.tsx +++ b/plugins/githubindiscord/Modal/Tags.tsx @@ -1,9 +1,9 @@ import { Box, SubNav, Text } from "@primer/react"; import { TagIcon } from "@primer/styled-octicons"; -import { useState } from "react"; -import { TabProps } from "."; +import { useContext, useState } from "react"; +import { Context } from "../context"; -export default (props: TabProps) => { +export default () => { const [tab, setTab] = useState("tags"); return ( @@ -18,12 +18,14 @@ export default (props: TabProps) => { - {tab === "tags" && } + {tab === "tags" && }
); }; -function TagsTab({ tags }: TabProps) { +function TagsTab() { + const { data } = useContext(Context)!; + const { tags } = data!; // const [tags, setTags] = useState(null); // useEffect(() => { diff --git a/plugins/githubindiscord/Modal/TimelineItems.tsx b/plugins/githubindiscord/Modal/TimelineItems.tsx index ab1bef7..b8cea72 100644 --- a/plugins/githubindiscord/Modal/TimelineItems.tsx +++ b/plugins/githubindiscord/Modal/TimelineItems.tsx @@ -3,6 +3,7 @@ import { Avatar, Label, Link, RelativeTime, Text, Timeline } from "@primer/react import { CommitIcon, EyeIcon, + FileDiffIcon, GitMergeIcon, IssueClosedIcon, PencilIcon, @@ -14,13 +15,13 @@ import { import { Issue } from "../utils"; import { TimelineComment } from "./Comment"; -type Props = { +interface Props { event: operations["issues/list-events-for-timeline"]["responses"]["200"]["content"]["application/json"][0] & { commit?: components["schemas"]["commit"]; issue: Issue; onCommitClick?: (commit: components["schemas"]["commit"]) => void; }; -}; +} const items = { labeled: Labeled, @@ -36,6 +37,7 @@ const items = { reviewed: { commented: ReviewComment, approved: ReviewComment, + changes_requested: ChangesRequested, }, }; @@ -168,6 +170,24 @@ function Closed({ event }: Props) { ); } +function ChangesRequested({ event }: Props) { + if (!event.body) return null; + return ( + <> + + + + + + {event.user?.login}{" "} + requested changes + + + + + ); +} + function ReviewComment({ event }: Props) { if (!event.body) return null; return ( @@ -191,7 +211,7 @@ function Committed({ event }: Props) { {" "} event.onCommitClick?.(event.commit!)}> - {event.message!} + {event.message?.split("\n\n")[0]} diff --git a/plugins/githubindiscord/Modal/index.tsx b/plugins/githubindiscord/Modal/index.tsx index e912fc7..369f8d7 100644 --- a/plugins/githubindiscord/Modal/index.tsx +++ b/plugins/githubindiscord/Modal/index.tsx @@ -11,6 +11,7 @@ import { import { UnderlineNav } from "@primer/react/drafts"; import { CodeIcon, + CommitIcon, GitPullRequestIcon, IssueOpenedIcon, LinkExternalIcon, @@ -19,43 +20,34 @@ import { RepoIcon, StarIcon, } from "@primer/styled-octicons"; -import { FC, useContext, useLayoutEffect, useState } from "react"; +import { FC, useContext, useState } from "react"; import { common, components, webpack } from "replugged"; import { ModalProps } from "../Modals"; -import { Branch, abbreviateNumber, pluginSettings } from "../utils"; +import { abbreviateNumber, pluginSettings } from "../utils"; import Code from "./Code"; import Issues from "./Issues"; import Pulls from "./Pulls"; import theme from "../theme"; import Spinner from "./Spinner"; -import { textClasses, wumpus } from "../components"; +import { textClasses } from "../components"; import { openSettingsModal } from "./SettingsModal"; import { Context, Provider } from "../context"; +import ErrorBoundary from "./ErrorBoundary"; +import Commits from "./Commits"; const { ModalContent, ModalHeader, ModalRoot, ModalFooter } = components.Modal; const tabs = [ { title: "Code", component: Code, icon: CodeIcon }, + { title: "Commits", component: Commits, icon: CommitIcon }, { title: "Issues", component: Issues, icon: IssueOpenedIcon }, { title: "Pull Requests", component: Pulls, icon: GitPullRequestIcon }, ]; -export interface TabProps { - branch: Branch; - url: string; - switchBranches: (branch: string) => void; -} -console.log(theme); -const GithubModal: FC = ({ url, tab, ...props }) => { - const [selectedBranch, setBranch] = useState(); - const { data: repo, error, refetch, status } = useContext(Context)!; +const GithubModal: FC = ({ tab, ...props }) => { + const { data: repo, refetch, status } = useContext(Context)!; const [currentTab, setTab] = useState(tab || "Code"); - useLayoutEffect(() => { - if (!repo?.branches || selectedBranch) return; - setBranch(repo.branches.find((b) => b.name === repo.repo.default_branch)); - }, [repo]); - const Tab = tabs.find(({ title }) => title === currentTab); return ( = ({ url, tab, { - setTab(""); - // setTab(title); - setTimeout(() => setTab(title)); - }} + onSelect={() => setTab(title)} counter={ title === "Issues" - ? abbreviateNumber(repo?.issues.page.totalOpen ?? 0) + ? abbreviateNumber(repo?.issues.data?.totalOpen ?? 0) : title === "Pull Requests" - ? abbreviateNumber(repo?.prs.page.totalOpen ?? 0) + ? abbreviateNumber(repo?.prs.data?.totalOpen ?? 0) : undefined }> {title} @@ -150,33 +138,8 @@ const GithubModal: FC = ({ url, tab, {status === "loading" ? ( Fetching Repository Contents... - ) : status === "err" ? ( -
-
-
- - {error} - -
-
) : ( - Tab && - repo && - repo.tree && - selectedBranch && ( - { - setBranch(repo.branches.find((b) => b.name === branch)); - refetch({ issues: { state: "open" }, prs: { state: "open" }, branch }); - }} - /> - ) + Tab && repo && repo.tree && )} @@ -187,7 +150,7 @@ const GithubModal: FC = ({ url, tab, { issues: { state: "open" }, prs: { state: "open" }, - branch: selectedBranch?.name, + branch: repo?.currentBranch.name, }, true, ) @@ -205,8 +168,10 @@ const GithubModal: FC = ({ url, tab, export function openGithubModal(url: string, tab: string) { common.modal.openModal((props) => ( - - - + + + + + )); } diff --git a/plugins/githubindiscord/Modals.tsx b/plugins/githubindiscord/Modals.tsx index 20a761f..0b643aa 100644 --- a/plugins/githubindiscord/Modals.tsx +++ b/plugins/githubindiscord/Modals.tsx @@ -13,6 +13,6 @@ export enum ModalSize { DYNAMIC = "dynamic", } -export type ModalProps = { +export type ModalProps = { [K in keyof T]: T[K]; } & { transitionState: ModalTransitionState; onClose(): Promise }; diff --git a/plugins/githubindiscord/Settings.tsx b/plugins/githubindiscord/Settings.tsx new file mode 100644 index 0000000..1279e50 --- /dev/null +++ b/plugins/githubindiscord/Settings.tsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from "react"; +import { components } from "replugged"; +import { pluginSettings } from "./utils"; +import { default as customTheme } from "./theme"; +import { SelectMenu } from "./components"; + +const { FormText, TextInput } = components; + +export default () => { + const [key, setKey] = useState(pluginSettings.get("key", "")); + const [darkTheme, setDarkTheme] = useState(pluginSettings.get("darkTheme", "dark_discord")); + const [lightTheme, setLightTheme] = useState(pluginSettings.get("lightTheme", "light_discord")); + + useEffect(() => { + pluginSettings.set("key", key); + pluginSettings.set("darkTheme", darkTheme); + pluginSettings.set("lightTheme", lightTheme); + }, [key, darkTheme, lightTheme]); + + const darkThemes = Object.keys(customTheme.colorSchemes).filter((t) => t.includes("dark")); + const lightThemes = Object.keys(customTheme.colorSchemes).filter((t) => t.includes("light")); + + return ( +
+ + Github Token (reload for the token to take effect) + + + Dark Theme + {SelectMenu && ( + ({ label: t, value: t }))} + onChange={setDarkTheme} + /> + )} + Light Theme + {SelectMenu && ( + ({ label: t, value: t }))} + onChange={setLightTheme} + /> + )} +
+ ); +}; diff --git a/plugins/githubindiscord/index.tsx b/plugins/githubindiscord/index.tsx index 0be234d..47ea218 100644 --- a/plugins/githubindiscord/index.tsx +++ b/plugins/githubindiscord/index.tsx @@ -3,9 +3,10 @@ import { Injector, components, webpack } from "replugged"; import { openGithubModal } from "./Modal"; import { MarkGithubIcon } from "@primer/styled-octicons"; import { Box } from "@primer/react"; -import { ModuleExports, ModuleExportsWithProps } from "replugged/dist/types"; +import type { ModuleExports, ModuleExportsWithProps } from "replugged/dist/types"; const { MenuItem, MenuGroup } = components.ContextMenu; const { Tooltip } = components; +export { default as Settings } from "./Settings"; const injector = new Injector(); @@ -24,18 +25,20 @@ export function getExportsForProto< const ghRegex = /https?:\/\/(?:www.)?github.com\/([\w-]+\/[\w-]+)(?:\/((?:tree|blob)\/([\w-]+)\/([\w/.?]+)|((issues|pulls)(?:\/([0-9]+))?)))?/g; -export function start() { - const e = webpack.getModule<{ ZP: Function }>((m) => +export async function start() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const module = await webpack.waitForModule((m) => Boolean(getExportsForProto(m.exports, ["renderTitle"])), ); - if (!e) return; - injector.after(e.ZP?.prototype, "renderProvider", (args, res) => { + + if (!module) return; + + injector.after(module.ZP?.prototype, "renderProvider", (_, res) => { if (!res) return res; - const link = res._owner?.stateNode?.props?.embed?.url; + const link = res._owner?.stateNode?.props?.embed?.url as string; if (!link) return res; - const msg = checkMessage(link); + const msg = checkMessage(link)?.[0]; if (!msg) return res; - return ( {res.props.children.props.children} @@ -55,26 +58,40 @@ export function stop(): void { injector.uninjectAll(); } +const tabs = { + issues: "Issues", + pulls: "Pull Request", +}; + function checkMessage(content: string) { - const match = [...content.matchAll(ghRegex)]?.[0]; + const match = [...content.matchAll(ghRegex)]; if (!match) return null; - const tab = match[2] === "issues" ? "Issues" : match[2] === "pulls" ? "Pull Requests" : ""; - return { - url: match[1], - tab, - }; + + return match.map((d) => ({ + url: d[1], + tab: d[2] && tabs[d[2] as keyof typeof tabs], + })); } export function menu(content: string, href?: string) { const msg = checkMessage(href || content); - if (!msg) return null; + if (!msg?.length) return null; + return ( openGithubModal(msg.url, msg.tab)} - /> + action={() => openGithubModal(msg[0].url, msg[0].tab)}> + {msg.length && + msg.map((m, i) => ( + openGithubModal(m.url, m.tab)} + /> + ))} + ); } diff --git a/plugins/githubindiscord/paginate.ts b/plugins/githubindiscord/paginate.ts index dd63587..b47dcc4 100644 --- a/plugins/githubindiscord/paginate.ts +++ b/plugins/githubindiscord/paginate.ts @@ -1,6 +1,7 @@ -import { Octokit, RestEndpointMethodTypes } from "@octokit/rest"; +import * as OctokitTypes from "@octokit/types"; +import { GetResponseDataTypeFromEndpointMethod } from "@octokit/types"; import { useEffect, useState } from "react"; -import { Issue } from "./utils"; +import { Issue, getCommit, octokit, sortCommits } from "./utils"; const regex = { prev: /page=([0-9]+)>;\s*rel="prev"/g, @@ -8,213 +9,344 @@ const regex = { last: /page=([0-9]+)>;\s*rel="last"/g, }; -export interface Paginate { - page: { - open: Issue[][]; - closed: Issue[][]; - all: Issue[][]; - totalOpen: number; - totalClosed: number; - }; - info: { +export interface Paginate { + pageInfo: { currentPage: number; - pages: { - open?: Page; - closed?: Page; - all?: Page; - }; + lastPage?: number; + nextPage?: number; + prevPage?: number; }; - state: "open" | "closed"; - nextPage: () => void; - previousPage: () => void; - viewClosed: () => void; - viewOpen: () => void; + pages: T[]; } -interface Page { - prev?: number; - next?: number; - last: number; -} - -const cache = new Map(); +const cache = new Map(); -export function usePaginate( - Octokit: Octokit, - params: RestEndpointMethodTypes["search"]["issuesAndPullRequests"]["parameters"], - { force, onError }: { force?: boolean; onError: (err: string) => void }, +export function usePaginate( + octokit: T, + params: Parameters[0], ) { - const [page, setPage] = useState({ - open: [], - closed: [], - all: [], - totalClosed: 0, - totalOpen: 0, + const [data, setData] = useState> | null>(null); + + const fetch = (force?: boolean, savePage?: boolean, currParams?: typeof params) => { + if (currParams) params = currParams; + + return new Promise((resolve: (value: NonNullable) => void) => + setData((prevData) => { + // if data already exist set current page to 1 if "savePage" isn't set + if (!force && prevData) { + const info: Paginate["pageInfo"] = { + currentPage: savePage ? prevData.pageInfo.currentPage : 1, + lastPage: prevData.pageInfo.lastPage, + nextPage: prevData.pageInfo.nextPage && 2, + }; + resolve({ ...prevData, pageInfo: info }); + return { ...prevData, pageInfo: info }; + // get from cache + } else if ( + !force && + cache.has( + JSON.stringify({ + ...(params as unknown as object), + url: octokit.endpoint.DEFAULTS.url, + }), + ) + ) { + resolve( + cache.get( + JSON.stringify({ + ...(params as unknown as object), + url: octokit.endpoint.DEFAULTS.url, + }), + ) as NonNullable, + ); + return cache.get( + JSON.stringify({ + ...(params as unknown as object), + url: octokit.endpoint.DEFAULTS.url, + }), + ) as NonNullable; + } + + // get data for the first + (async () => { + const res = await octokit(params); + const page = res.headers.link ? lastAndNextPage(res.headers.link) : undefined; + const epicData: typeof data = { + pageInfo: { + currentPage: 1, + lastPage: page?.last, + prevPage: page?.prev, + nextPage: page?.next, + }, + pages: [res.data], + }; + + resolve(epicData); + setData(epicData); + })(); + + return prevData; + }), + ); + }; + + const nextPage = () => { + return new Promise((resolve: (value: NonNullable) => void) => + setData((prevData) => { + if (!prevData?.pageInfo.nextPage) { + resolve(prevData!); + return prevData; + } + + const info = { + currentPage: prevData.pageInfo.currentPage + 1, + nextPage: + prevData.pageInfo.nextPage < prevData.pageInfo.lastPage! + ? prevData.pageInfo.nextPage + 1 + : undefined, + prevPage: prevData.pageInfo.currentPage, + lastPage: prevData.pageInfo.lastPage, + }; + // if the next page has already fetch return that + if (prevData.pages[prevData.pageInfo.nextPage - 1]) { + resolve({ + ...prevData, + pageInfo: info, + }); + return { + ...prevData, + pageInfo: info, + }; + } + + // fetch the next page for the first time + (async () => { + // @ts-expect-error die + const res = await octokit({ ...params, page: prevData.pageInfo.nextPage }); + prevData.pages.push(res.data); + resolve({ ...prevData, pageInfo: info }); + setData({ ...prevData, pageInfo: info }); + })(); + + return prevData; + }), + ); + }; + + const previousPage = () => { + return new Promise((resolve: (value: NonNullable) => void) => + setData((prevData) => { + if (!prevData?.pageInfo.prevPage) { + resolve(prevData!); + return prevData; + } + const info = { + currentPage: prevData.pageInfo.currentPage - 1, + nextPage: prevData.pageInfo.currentPage - 1 === 1 ? 2 : prevData.pageInfo.nextPage! - 1, + prevPage: prevData.pageInfo.prevPage === 1 ? undefined : prevData.pageInfo.prevPage - 1, + lastPage: prevData.pageInfo.lastPage, + }; + resolve({ ...prevData, pageInfo: info }); + return { ...prevData, pageInfo: info }; + }), + ); + }; + + useEffect(() => { + if (!data) return; + // save to cache when ever "data" changes + // console.log(params); + cache.set( + JSON.stringify({ ...(params as unknown as object), url: octokit.endpoint.DEFAULTS.url }), + data, + ); + }, [data]); + + return { data, fetch, nextPage, previousPage }; +} + +export function useIssues(repo: string, type: "issue" | "pr") { + const openedPaginate = usePaginate(octokit.search.issuesAndPullRequests, { + q: `repo:${repo} is:${type} is:open`, }); - const [pageInfo, setPageInfo] = useState({ - currentPage: 1, - pages: {}, + const closedPaginate = usePaginate(octokit.search.issuesAndPullRequests, { + q: `repo:${repo} is:${type} is:closed`, }); - const [state, setState] = useState<"open" | "closed">("open"); - useEffect(() => { - try { - (async () => { - if (!force && cache.has(params.q)) { - setPage(cache.get(params.q)!.page); - setPageInfo({ currentPage: 1, pages: cache.get(params.q)!.info }); - return; - } else cache.forEach((_, k) => k === params.q && cache.delete(k)); - const openedIssues = await Octokit.search.issuesAndPullRequests({ - ...params, - q: `${params.q} state:open`, - }); - setPage({ - all: [], - closed: [], - open: [[...openedIssues.data.items]], - totalOpen: openedIssues.data.total_count, - totalClosed: 0, - }); - - const info = openedIssues.headers.link - ? lastAndNextPage(openedIssues.headers.link) - : undefined; - setPageInfo({ currentPage: 1, pages: { open: info } }); - setState("open"); - cache.set(params.q, { - info: { - open: info, - }, - page: { - all: [], - closed: [], - open: [[...openedIssues.data.items]], - totalClosed: 0, - totalOpen: openedIssues.data.total_count, - }, - }); - })(); - } catch (err) { - // @ts-expect-error stfu - onError(err.message as string); - } - }, [force]); + const [data, setData] = useState<{ + open: Issue[][]; + closed: Issue[][]; + all: Issue[][]; + totalOpen: number; + totalClosed: number; + state: "open" | "closed"; + } | null>(null); - const nextPage = () => { - try { - // "page" won't be update to date so use this - setPage((prevPageState) => { - setPageInfo((prevInfoState) => { - const info = prevInfoState.pages[state]; - if (!info?.next) return prevInfoState; - // setting the next page - if (prevPageState[state].length >= info.next) { - info.prev = prevInfoState.currentPage; - if (info.last <= info.next) info.next = undefined; - else ++info.next; - ++prevInfoState.currentPage; - return { ...prevInfoState }; - } - (async () => { - if (!info?.next) return; - const request = await Octokit.search.issuesAndPullRequests({ - ...params, - q: `${params.q} state:${state}`, - page: info.next, + const fetch = (force?: boolean) => { + return new Promise((res) => + setData((prevData) => { + (async () => { + const openedRes = await openedPaginate.fetch(force); + const closedRes = await closedPaginate.fetch(force); + + if (!prevData || force) { + res(null); + setData({ + all: [], + closed: closedRes.pages.map((p) => p.items), + open: openedRes.pages.map((p) => p.items), + totalOpen: openedRes.pages[0].total_count, + totalClosed: closedRes.pages[0].total_count, + state: "open", }); - prevPageState[state].push([...request.data.items]); - setPage(prevPageState); - setPageInfo((p) => ({ - currentPage: ++prevInfoState.currentPage, - pages: { - ...p.pages, - [state]: { ...lastAndNextPage(request.headers.link!), last: p.pages[state]!.last }, - }, - })); - })(); - return prevInfoState; - }); - // return we only need the current state - return prevPageState; - }); - } catch (err) { - // @ts-expect-error stfu - onError(err.message); - } + } else res(null); + })(); + + return prevData; + }), + ); }; - const previousPage = () => { - setPage((prevPageState) => { - setPageInfo((prevInfoState) => { - const info = prevInfoState.pages[state]; - if (!info?.prev) return prevInfoState; - if (prevInfoState.currentPage === 2) info.prev = undefined; - else --info.prev; - --prevInfoState.currentPage; - const next = info.next! || info.last; - info.next = next - 1; - return { ...prevInfoState }; - }); - return prevPageState; + const nextPage = () => { + setData((prevData) => { + (async () => { + if (!prevData) return; + const res = + prevData.state === "open" + ? await openedPaginate.nextPage() + : await closedPaginate.nextPage(); + prevData[prevData.state] = res.pages.map((p) => p.items); + setData({ ...prevData }); + })(); + return prevData; }); }; - const viewClosed = () => { - setPage((prevPageState) => { - setPageInfo((prevInfoState) => { - if (prevInfoState.pages[state]) { - prevInfoState.pages[state]!.next = 2; - prevInfoState.pages[state]!.prev = undefined; - } - return { ...prevInfoState, currentPage: 1 }; - }); - if (prevPageState.closed.length) { - setState("closed"); - return { ...prevPageState }; - } else { - setPageInfo((prevInfoState) => { - (async () => { - const closedIssues = await Octokit.search.issuesAndPullRequests({ - ...params, - q: `${params.q} state:closed`, - }); - setPage((prev) => ({ - ...prev, - closed: [[...closedIssues.data.items]], - totalClosed: closedIssues.data.total_count, - })); - - const info = closedIssues.headers.link - ? lastAndNextPage(closedIssues.headers.link) - : undefined; - - setPageInfo((prev) => ({ ...prev, pages: { ...prev.pages, closed: info } })); - setState("closed"); - })(); - return prevInfoState; - }); - } - return prevPageState; + const previousPage = () => { + setData((prevData) => { + if (!prevData) return prevData; + if (prevData.state === "open") void openedPaginate.previousPage(); + else void closedPaginate.previousPage(); + return prevData; }); }; const viewOpen = () => { - setPageInfo((prevInfoState) => { - if (prevInfoState.pages[state]) { - prevInfoState.pages[state]!.next = 2; - prevInfoState.pages[state]!.prev = undefined; + setData((prev) => prev && { ...prev, state: "open" }); + }; + + const viewClosed = () => { + setData((prevData) => { + if (prevData?.closed.length) { + void closedPaginate.fetch(); // set page to 1 + return { ...prevData, state: "closed" }; } - return { ...prevInfoState, currentPage: 1 }; + (async () => { + if (!prevData) return; + const res = await closedPaginate.fetch(); + prevData.closed = res.pages.map((p) => p.items); + // prevData.currentIdx = 1; + prevData.state = "closed"; + prevData.totalClosed = res.pages[0].total_count; + setData({ ...prevData }); + })(); + return prevData; }); - setState("open"); }; + return { + data, + fetch, + nextPage, + previousPage, + viewClosed, + viewOpen, + info: { open: openedPaginate, closed: closedPaginate }, + }; +} + +export function useTimeline(url: string, issue: number) { + const paginate = usePaginate(octokit.issues.listEventsForTimeline, { + owner: url.split("/")[0], + repo: url.split("/")[1], + issue_number: issue, + }); + const [data, setData] = useState["pages"][0] | null>(null); + useEffect(() => { - cache.set(params.q, { info: pageInfo.pages, page }); - }, [pageInfo, page]); + void paginate.fetch(false, true); + }, []); + + useEffect(() => { + if (!paginate.data?.pages) return; + setData((prevData) => { + (async () => { + const data = await Promise.all( + paginate.data!.pages.map(async (t) => { + return await Promise.all( + t.map(async (t) => { + if (t.event === "committed") { + const commit = cache.get(`${url}${t.sha}`) || (await getCommit(url, t.sha!)); + cache.set(`${url}${t.sha}`, commit); + // @ts-expect-error now it does + t.commit = commit; + } + return t; + }), + ); + }), + ); + setData([...data.flat()]); + })(); + return prevData; + }); + }, [paginate.data?.pages.length]); + + return { ...paginate, data: { page: data, info: paginate.data?.pageInfo } }; +} + +export function useCommits(url: string, { pr, branch }: { pr?: number; branch?: string }) { + const paginate = usePaginate(pr ? octokit.pulls.listCommits : octokit.repos.listCommits, { + owner: url.split("/")[0], + repo: url.split("/")[1], + pull_number: pr, + sha: pr ? undefined : branch, + }); + const [data, setData] = useState["pages"]> | null>( + null, + ); + + const fetch = async (force?: boolean, currBranch?: string) => { + branch = currBranch; + const page = await paginate.fetch(force, false, { + owner: url.split("/")[0], + repo: url.split("/")[1], + pull_number: pr, + sha: pr ? undefined : branch, + }); + const pages = page.pages.map((p) => { + return p.map((c) => { + if (cache.has(`${url}${c.sha}`)) return cache.get(`${url}${c.sha}`) as typeof c; + return c; + }); + }); + const data = pages.map((p) => sortCommits(p)); + setData(data); + return data; + }; + + const nextPage = async () => { + const page = await paginate.nextPage(); + const data = page.pages.map((p) => { + return p.map((c) => { + if (cache.has(`${url}${c.sha}`)) return cache.get(`${url}${c.sha}`) as typeof c; + return c; + }); + }); + setData(data.map((p) => sortCommits(p))); + }; - return { info: pageInfo, page, nextPage, previousPage, state, viewClosed, viewOpen }; + return { ...paginate, nextPage, fetch, data: { page: data, info: paginate.data?.pageInfo } }; } function lastAndNextPage(link: string) { diff --git a/plugins/githubindiscord/parser.tsx b/plugins/githubindiscord/parser.tsx new file mode 100644 index 0000000..2c4d2c7 --- /dev/null +++ b/plugins/githubindiscord/parser.tsx @@ -0,0 +1,198 @@ +import { useTheme } from "@primer/react"; +import { ReactNode } from "react"; +import { common, webpack } from "replugged"; +import { Parser } from "replugged/dist/renderer/modules/webpack/common"; +import { AnyFunction } from "replugged/dist/types"; +import { textClasses } from "./components"; +import { classes } from "./utils"; + +const defaultParser = webpack.getByProps( + "sanitizeText", + "markdownToReact", + "defaultRules", + "sanitizeUrl", +); +const defaultRules = defaultParser?.defaultRules as typeof common.parser.defaultRules; + +// const regex = /^( *)(?:[*+-]) [\s\S]+?(?:\n{1,}(?! )(?!\1(?:[*+-]|\d+\.) )\n*|\s*\n*$)/ +const headings = { + h1: textClasses?.["heading-xxl/bold"], + h2: textClasses?.["heading-xl/bold"], + h3: textClasses?.["heading-lg/bold"], + h4: textClasses?.["heading-md/bold"], + h5: textClasses?.["heading-sm/bold"], + h6: textClasses?.["heading-sm/bold"], +}; + +const rules: Parser["defaultRules"] = { + ...defaultRules, + heading: { + ...defaultRules.heading, + // order: 30, + match: (source) => /^ *(#{1,6}) (.+)(?:\n)?/.exec(source), + // @ts-expect-error okay + react(props: { content: string; level: number }, t: AnyFunction, n: { key: string }) { + const { theme } = useTheme(); + const Element = `h${props.level}` as keyof JSX.IntrinsicElements; + return ( + + {t(props.content, n) as ReactNode} + + ); + }, + }, + list: { + ...defaultRules.list, + match(source, state) { + const prev = /(?:^|\n)( *)$/.exec(state.prevCapture ? state.prevCapture[0] : ""); + + if (prev) { + source = prev[1] + source; + return /^( *)((?:[*+-]|\d+\.)) [\s\S]+?(?:\n{1,}(?! )(?!\1(?:[*+-]|\d+\.) )\n*|\s*\n*$)/.exec( + source, + ); + } + return null; + }, + // @ts-expect-error okay + react( + props: { ordered: boolean; start: string; items: string[] }, + t: AnyFunction, + n: { key: string }, + ) { + const Element = props.ordered ? "ol" : ("ul" as keyof JSX.IntrinsicElements); + return ( + + {props.items.map((l, i) => ( +
  • {t(l, n) as ReactNode}
  • + ))} +
    + ); + }, + }, + blockQuote: { + ...defaultRules.blockQuote, + match: (source) => /^( *>[^\n]+(\n[^\n]+)*\n*)+/.exec(source), + // @ts-expect-error okay + react(props: { content: string }, t: AnyFunction, n: { key: string }) { + const { theme } = useTheme(); + return ( +
    + {t(props.content, n) as ReactNode} +
    + ); + }, + }, + codeBlock: common.parser.defaultRules.codeBlock, + image: { + ...defaultRules.image, + match: (source) => + /^|^!\[((?:\[[^\]]*\]|[^[\]]|\](?=[^[]*\]))*)\]\(\s*?(?:\s+['"]([\s\S]*?)['"])?\s*\)/.exec( + source, + ), + parse(match) { + return { + alt: /alt="(.*?)"/.exec(match[0])?.[1] || match[1], + width: /width="(.*?)"/.exec(match[0])?.[1], + height: /height="(.*?)"/.exec(match[0])?.[1], + src: /src="(.*?)"/.exec(match[0])?.[1] || match[2], + title: match[3], + }; + }, + // @ts-expect-error okay + react( + props: { alt: string; width?: string; height?: string; src: string; title?: string }, + _t: AnyFunction, + n: { key: string }, + ) { + return ( + {props.alt} + ); + }, + }, + url: { + ...defaultRules.url, + match: (source) => /^(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/.exec(source), + }, + link: { + ...defaultRules.link, + // @ts-expect-error okay + react(props, t: AnyFunction, n: { key: string }) { + return ( + string)(props.target)} + title={props.title} + target="_blank"> + {t(props.content, n) as ReactNode} + + ); + }, + }, + details: { + order: 5, + match: (source) => /^(.+)<\/summary>(.+)<\/details>/s.exec(source), + // @ts-expect-error okay + parse(match: string[], t: AnyFunction, n: { key: string }) { + return { + summary: match[1].trim(), + content: t(match[2].trim(), n), + }; + }, + // @ts-expect-error okay + react(props: { content: unknown[]; summary: string }, t: AnyFunction, n: { key: string }) { + return ( +
    + {props.summary} + {t(props.content, n) as ReactNode} +
    + ); + }, + }, + italic: { + order: 20, + match: (source) => /^\*((?:\\[\s\S]|[^\\])+?)\*/.exec(source), + // @ts-expect-error okay + parse(match: RegExpExecArray, t: AnyFunction, n: { key: string }) { + return { + content: t(match[1], n), + }; + }, + // @ts-expect-error okay + react(props: { content: string[] }, t: AnyFunction, n: { key: string }) { + return {t(props.content, n) as ReactNode}; + }, + }, +}; + +delete rules.Array; +delete rules.paragraph; + +const parse = ( + defaultParser?.parserFor as (rules: typeof defaultRules) => (source: string) => unknown +)(rules); +const reactOutput = ( + defaultParser?.outputFor as (rules: typeof defaultRules, outputFor: "react") => AnyFunction +)(rules, "react"); +console.log(rules); +export function parseMarkdown(markdown: string): ReactNode { + return reactOutput(parse(markdown)) as ReactNode; +} diff --git a/plugins/githubindiscord/style.scss b/plugins/githubindiscord/style.scss index 357c87f..fc2ae70 100644 --- a/plugins/githubindiscord/style.scss +++ b/plugins/githubindiscord/style.scss @@ -1,3 +1,15 @@ +.errorModal { + background-color: var(--modal-background) !important; +} + +.gid-heading { + padding-bottom: 0.3em; + border-style: solid; + border-bottom-width: 1px; + margin-top: 24px; + margin-bottom: 16px; +} + .githubModel { // color: var(--text-normal); width: fit-content; diff --git a/plugins/githubindiscord/utils.ts b/plugins/githubindiscord/utils.ts index 8602da8..d0bcd62 100644 --- a/plugins/githubindiscord/utils.ts +++ b/plugins/githubindiscord/utils.ts @@ -2,7 +2,7 @@ import { settings } from "replugged"; import { Octokit } from "@octokit/rest"; import { components, operations } from "@octokit/openapi-types"; import { useEffect, useState } from "react"; -import { usePaginate } from "./paginate"; +import { useCommits, useIssues } from "./paginate"; export type Branch = components["schemas"]["short-branch"] & { commit: components["schemas"]["commit"]; @@ -31,9 +31,18 @@ export type Issue = components["schemas"]["issue-search-result-item"] & { marked?: boolean; }; -export const pluginSettings = await settings.init("dev.eboi.githubindiscord"); - -const octokit = new Octokit({ auth: pluginSettings.get("key") }); +export const pluginSettings = await settings.init<{ + key: string; + darkTheme: string; + lightTheme: string; + view: "standard" | "treeview"; +}>("dev.eboi.githubindiscord", { + darkTheme: "dark_discord", + lightTheme: "light_discord", + view: "standard", +}); + +export const octokit = new Octokit({ auth: pluginSettings.get("key") }); const cache = new Map< string, { @@ -81,48 +90,35 @@ export function useRepo({ url, query }: { url: string; query: RepoQuery }) { tags: Array; tree: TreeWithContent[]; branches: Branch[]; + currentBranch: Branch; + url: string; }>(); - const [status, setStatus] = useState<"loading" | "err" | "complete">("loading"); - const [error, setError] = useState(); + const [status, setStatus] = useState<"loading" | "complete">("loading"); const [iQuery, setQuery] = useState(query); const [force, setForce] = useState(false); - const issues = usePaginate( - octokit, - { q: `repo:${url} is:issue` }, - { - force, - onError: (e) => { - setStatus("err"); - setError(e); - }, - }, - ); - const prs = usePaginate( - octokit, - { q: `repo:${url} is:pr` }, - { - force, - onError: (e) => { - setStatus("err"); - setError(e); - }, - }, - ); + const issues = useIssues(url, "issue"); + const prs = useIssues(url, "pr"); + const commits = useCommits(url, { branch: repo?.currentBranch.name }); useEffect(() => { setStatus("loading"); (async () => { - try { - const r = await getAll(url, iQuery, force); - setRepo(r); - setForce(false); - setStatus("complete"); - } catch (err) { - // @ts-expect-error stfu - setError(err.message as string); - setStatus("err"); - console.error(err); - } + const r = await getAll(url, iQuery, force); + await issues.fetch(force); + await prs.fetch(force); + + const currentBranch = iQuery.branch + ? r.branches.find((b) => b.name === iQuery.branch)! + : r.branches[0]; + await commits.fetch(force, currentBranch.name); + + setRepo({ + ...r, + url, + currentBranch, + }); + setForce(false); + setStatus("complete"); })(); }, [JSON.stringify(iQuery), url, force]); @@ -132,7 +128,17 @@ export function useRepo({ url, query }: { url: string; query: RepoQuery }) { setQuery(q); }; - return { data: (repo && { ...repo, issues, prs }) || null, status, error, refetch }; + const switchBranch = (branch: string) => { + if (branch === repo?.currentBranch.name) return; + refetch({ ...query, branch }); + }; + + return { + data: (repo && { ...repo, issues, prs, commits }) || null, + status, + refetch, + switchBranch, + }; } export async function getBranches( @@ -142,6 +148,7 @@ export async function getBranches( const branches = await octokit.repos.listBranches({ owner: url.split("/")[0]!, repo: url.split("/")[1], + per_page: 100, ...query, }); return await Promise.all( @@ -255,8 +262,8 @@ export async function getTimeline( }); return await Promise.all( timeline.data.map(async (t) => { - if (t.event === "commented" || t.event === "reviewed") - t.body = await getMarkdown(t.body ?? "*No description provided.*"); + // if (t.event === "commented" || t.event === "reviewed") + // t.body = await getMarkdown(t.body ?? "*No description provided.*"); // @ts-expect-error now it does if (t.event === "committed") t.commit = await getCommit(url, t.sha!); return t; @@ -333,6 +340,29 @@ function sortTree(tree: components["schemas"]["git-tree"]["tree"]) { return arr; } +export function sortCommits(commits: Array>) { + const arr: Array = [[commits[0]]]; + let currentIdx = 0; + + commits.sort((a, b) => { + const aDate = new Date(a.commit.author!.date!), + bDate = new Date(b.commit.author!.date!); + + if (aDate.getMonth() === bDate.getMonth() && aDate.getDate() === bDate.getDate()) + arr[currentIdx].push(a); + else { + ++currentIdx; + arr.push([a]); + } + + return 1; + }); + + return arr; +} + +export const classes = (...classes: unknown[]) => classes.filter(Boolean).join(" "); + export function abbreviateNumber(value: number): string { // eslint-disable-next-line new-cap return Intl.NumberFormat(undefined, { notation: "compact" }).format(value); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f908601..340d644 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,7 @@ specifiers: '@electron/asar': ^3.2.1 '@octokit/openapi-types': ^14.0.0 '@octokit/rest': ^19.0.5 + '@octokit/types': ^8.1.1 '@primer/react': ^35.16.0 '@primer/styled-octicons': ^17.10.0 '@types/node': ^18.11.2 @@ -20,7 +21,7 @@ specifiers: prettier: ^2.8.1 react: ^18.2.0 react-dom: ^18.2.0 - replugged: 4.0.0-beta0.21 + replugged: 4.0.0-beta0.22 styled-components: ^5.3.6 tsx: ^3.10.3 typescript: ^4.8.4 @@ -37,6 +38,7 @@ dependencies: devDependencies: '@electron/asar': 3.2.2 '@octokit/openapi-types': 14.0.0 + '@octokit/types': 8.1.1 '@types/node': 18.11.18 '@types/react': 18.0.26 '@typescript-eslint/eslint-plugin': 5.47.1_txmweb6yn7coi7nfrp22gpyqmy @@ -48,7 +50,7 @@ devDependencies: eslint-plugin-node: 11.1.0_eslint@8.30.0 eslint-plugin-react: 7.31.11_eslint@8.30.0 prettier: 2.8.1 - replugged: 4.0.0-beta0.21 + replugged: 4.0.0-beta0.22 tsx: 3.12.1 typescript: 4.9.4 @@ -2368,8 +2370,8 @@ packages: engines: {node: '>=8'} dev: true - /replugged/4.0.0-beta0.21: - resolution: {integrity: sha512-ImyGRnovKTVGNbz4x0nug1xh0kOHnwFSNZI55Lr0X5klzeDHDgsa55HXnL4mbkNQl10fBN3wiUgfN3MOOEuDMw==} + /replugged/4.0.0-beta0.22: + resolution: {integrity: sha512-zR61aYEQmOEBcSxJEzx0sfd0EsN348uVE06kzWXNtBpQeWzlp0c4sZCz4DNZ5hvs/aavRFvUNpoaMvtM9QJrbw==} engines: {node: '>=14.0.0'} dependencies: '@octokit/rest': 19.0.5 diff --git a/scripts/build.ts b/scripts/build.ts index 33cb5f9..f41d088 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -6,7 +6,7 @@ import { cp, mkdir, readdir, rm, writeFile } from "fs/promises"; import { PluginManifest } from "replugged/dist/types/addon"; import { pathToFileURL } from "url"; -const NODE_VERSION = "14"; +// const NODE_VERSION = "14"; const CHROME_VERSION = "91"; const globalModules: esbuild.Plugin = { @@ -95,12 +95,14 @@ const watch = process.argv.includes("--watch"); const common: esbuild.BuildOptions = { absWorkingDir: join(__dirname, ".."), bundle: true, - minify: true, - sourcemap: false, - format: "cjs" as esbuild.Format, + minify: !watch, + sourcemap: !watch, + format: "esm" as esbuild.Format, logLevel: "info", watch, plugins: [install, globalModules, sassPlugin()], + platform: "browser", + target: `chrome${CHROME_VERSION}`, }; async function buildPlugin(path: string): Promise { @@ -115,55 +117,19 @@ async function buildPlugin(path: string): Promise { esbuild.build({ ...common, entryPoints: [join(path, manifest.renderer)], - platform: "browser", - target: `chrome${CHROME_VERSION}`, outfile: `dist/${manifest.id}/renderer.js`, - format: "esm" as esbuild.Format, }), ); manifest.renderer = "renderer.js"; } - if ("preload" in manifest) { - targets.push( - esbuild.build({ - ...common, - entryPoints: [join(path, manifest.preload)], - platform: "node", - target: [`node${NODE_VERSION}`, `chrome${CHROME_VERSION}`], - outfile: `dist/${manifest.id}/preload.js`, - external: ["electron"], - }), - ); - - manifest.preload = "preload.js"; - } - - if ("main" in manifest) { - targets.push( - esbuild.build({ - ...common, - entryPoints: [join(path, manifest.main)], - platform: "node", - target: `node${NODE_VERSION}`, - outfile: `dist/${manifest.id}/main.js`, - external: ["electron"], - }), - ); - - manifest.main = "main.js"; - } - if ("plaintextPatches" in manifest) { targets.push( esbuild.build({ ...common, entryPoints: [join(path, manifest.plaintextPatches)], - platform: "browser", - target: `chrome${CHROME_VERSION}`, outfile: `dist/${manifest.id}/plaintextPatches.js`, - format: "esm" as esbuild.Format, }), ); diff --git a/tsconfig.json b/tsconfig.json index 8e58829..10ca0c3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -99,5 +99,6 @@ /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ - } + }, + "exclude": ["node_modules"] }