├── .env.development
├── .env.production
├── .eslintrc
├── .gitignore
├── .prettierignore
├── README.md
├── package.json
├── public
├── index.html
└── manifest.json
├── server
├── index.js
└── migrations
│ ├── 001_add_diffs.sql
│ └── 002_add_users.sql
├── src
├── components
│ ├── App
│ │ └── App.tsx
│ ├── Change
│ │ ├── Change.css
│ │ └── Change.tsx
│ ├── Chunk
│ │ ├── Chunk.css
│ │ └── Chunk.tsx
│ ├── Diff
│ │ ├── Diff.css
│ │ └── Diff.tsx
│ ├── File
│ │ ├── File.css
│ │ └── File.tsx
│ ├── Home
│ │ ├── Home.css
│ │ └── Home.tsx
│ └── Nav
│ │ ├── Nav.css
│ │ └── Nav.tsx
├── index.css
├── index.tsx
├── move.svg
├── react-app-env.d.ts
└── registerServiceWorker.js
├── tsconfig.json
└── yarn.lock
/.env.development:
--------------------------------------------------------------------------------
1 | REACT_APP_SERVER_URL=http://localhost:3500
2 |
--------------------------------------------------------------------------------
/.env.production:
--------------------------------------------------------------------------------
1 | REACT_APP_SERVER_URL=https://api.narrated-diffs.thomasbroadley.com
2 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "@typescript-eslint/parser",
3 | "env": { "browser": true, "node": true },
4 | "plugins": ["@typescript-eslint"],
5 | "extends": [
6 | "eslint:recommended",
7 | "plugin:@typescript-eslint/recommended",
8 | "plugin:react/recommended",
9 | "plugin:import/errors",
10 | "plugin:import/warnings",
11 | "plugin:import/typescript",
12 | "prettier",
13 | "prettier/react"
14 | ],
15 | "rules": {
16 | "react/prop-types": "off",
17 | "import/order": ["error", { "newlines-between": "always" }],
18 | "import/no-default-export": "error",
19 | "@typescript-eslint/explicit-module-boundary-types": "off"
20 | },
21 | "settings": {
22 | "react": {
23 | "version": "detect"
24 | },
25 | "linkComponents": [{ "name": "Link", "linkAttribute": "to" }],
26 | "import/extensions": [".js", ".jsx", ".ts", ".tsx"]
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 |
12 | # misc
13 | .DS_Store
14 | .env.local
15 | .env.development.local
16 | .env.test.local
17 | .env.production.local
18 |
19 | npm-debug.log*
20 | yarn-debug.log*
21 | yarn-error.log*
22 |
23 | .env
24 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | build
2 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Narrated Diffs
2 |
3 | A tool to enable PR authors to tell a story with their changes.
4 |
5 | Paste a diff into the tool (you can easily get one by adding `.diff` to the end of a GitHub PR URL, e.g. https://github.com/tbroadley/spellchecker-cli/pull/59.diff). Then you can reorder chunks in the diff and add comments to them, to make it easier for PR reviewers to understand the purpose of your change.
6 |
7 | ## Future work
8 |
9 | ### MLP (minimum loveable product)
10 |
11 | - [x] Store narrated diffs in a database and make them accessible to others
12 | - Store the JSON of the App's `diff` state
13 | - Give diffs unique, unguessable identifiers on creation (on explicit save?)
14 | - [x] Allow pasting in a link to a GitHub PR and initialise a narrated diff for it
15 | - GitHub doesn't allow cross-origin requests, so we'll need to fetch the diff on the server
16 |
17 | ### Later
18 |
19 | - [x] Buttons to move a chunk to the top or bottom of the narrated diff, or up or down one spot
20 | - [x] Read-only mode for review purposes
21 | - [x] GitHub authentication
22 | - [x] Viewing diffs for PRs from private repos
23 | - File tree element on the left
24 | - Filter the chunk list by selecting a file in the tree
25 | - Filter out chunks from a specific file
26 | - Banned files are hidden from the chunk list by default but allow viewing them through the file tree
27 | - See a list of chunks in their original order under each file in the tree, navigate quickly by clicking on them
28 | - Show the position in the file, the context line?
29 | - Allow merging chunks together
30 | - Allow splitting chunks
31 | - Look at how `git add --patch` does it
32 | - Syntax highlighting
33 | - Highlight changed words
34 | - On PR creation, automatically add link to narrated diff in PR description, or in a comment
35 | - On PR merge, automatically add link to narrated diff to the merge commit and lock the narrated diff
36 | - Webhook from GitHub to Narrated Diffs backend that is sent on PR merge?
37 | - GitHub Actions?
38 | - Permissions system (authenticate with GitHub/GitLab?)
39 | - Create a browser extension that makes it easy to initialise narrated diffs from a GitHub PR page
40 | - Display comments from GitHub
41 | - Allow adding comments to GitHub from Narrated Diffs
42 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "literate-diffs",
3 | "version": "0.1.0",
4 | "private": true,
5 | "type": "module",
6 | "dependencies": {
7 | "@hapi/bell": "^12.1.1",
8 | "@hapi/cookie": "^11.0.2",
9 | "@hapi/hapi": "^20.0.1",
10 | "dotenv": "^8.2.0",
11 | "eslint-config-prettier": "^6.12.0",
12 | "lodash": "^4.17.20",
13 | "node-fetch": "^2.6.1",
14 | "parse-diff": "^0.4.0",
15 | "pg": "^8.3.3",
16 | "react": "^16.13.1",
17 | "react-dom": "^16.13.1",
18 | "react-quill": "^1.3.5",
19 | "react-router": "^5.2.0",
20 | "react-router-dom": "^5.2.0",
21 | "react-scripts": "^3.4.3",
22 | "react-sortable-hoc": "^0.7.4",
23 | "uuid": "^8.3.0"
24 | },
25 | "scripts": {
26 | "serve": "DOTENV_CONFIG_PATH=server/.env nodemon -r dotenv/config server",
27 | "start": "react-scripts start",
28 | "build": "react-scripts build",
29 | "test": "react-scripts test --env=jsdom",
30 | "lint": "eslint -c .eslintrc src/**/*.ts src/**/*.tsx",
31 | "lint:fix": "eslint --fix -c .eslintrc src/**/*.ts src/**/*.tsx",
32 | "format": "prettier --write .",
33 | "eject": "react-scripts eject"
34 | },
35 | "husky": {
36 | "hooks": {
37 | "pre-commit": "pretty-quick --staged && npm run lint:fix"
38 | }
39 | },
40 | "browserslist": {
41 | "production": [
42 | ">0.2%",
43 | "not dead",
44 | "not op_mini all"
45 | ],
46 | "development": [
47 | "last 1 chrome version",
48 | "last 1 firefox version",
49 | "last 1 safari version"
50 | ]
51 | },
52 | "homepage": "https://narrated-diffs.thomasbroadley.com",
53 | "devDependencies": {
54 | "@types/lodash": "^4.14.161",
55 | "@types/node": "^14.11.2",
56 | "@types/react": "^16.9.49",
57 | "@types/react-dom": "^16.9.8",
58 | "@types/react-router-dom": "^5.1.5",
59 | "@typescript-eslint/eslint-plugin": "4.0.1",
60 | "@typescript-eslint/parser": "4.0.1",
61 | "eslint-plugin-import": "^2.22.0",
62 | "eslint-plugin-react": "^7.21.2",
63 | "husky": "^4.3.0",
64 | "nodemon": "^2.0.4",
65 | "prettier": "^2.1.2",
66 | "pretty-quick": "^3.0.2",
67 | "typescript": "^4.0.3"
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
10 |
14 |
15 | Narrated Diffs
16 |
17 |
18 |
19 |
20 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Narrative Diffs",
3 | "name": "Narrative Diffs",
4 | "start_url": "./index.html",
5 | "display": "standalone",
6 | "theme_color": "#333333",
7 | "background_color": "#ffffff"
8 | }
9 |
--------------------------------------------------------------------------------
/server/index.js:
--------------------------------------------------------------------------------
1 | import Hapi from "@hapi/hapi";
2 | import Bell from "@hapi/bell";
3 | import Cookie from "@hapi/cookie";
4 | import fetch from "node-fetch";
5 | import pg from "pg";
6 | import { v4 as uuidv4 } from "uuid";
7 |
8 | const {
9 | NODE_ENV,
10 | SERVER_URL,
11 | PUBLIC_URL,
12 | COOKIE_PASSWORD,
13 | GITHUB_CLIENT_ID,
14 | GITHUB_CLIENT_SECRET,
15 | } = process.env;
16 |
17 | const pool = new pg.Pool();
18 |
19 | const init = async () => {
20 | const server = Hapi.server({
21 | port: 3500,
22 | host: "localhost",
23 | routes: {
24 | cors: {
25 | origin:
26 | NODE_ENV === "production"
27 | ? ["https://narrated-diffs.thomasbroadley.com"]
28 | : ["*"],
29 | credentials: true,
30 | },
31 | },
32 | });
33 |
34 | await server.register(Bell);
35 | await server.register(Cookie);
36 |
37 | server.auth.strategy("github", "bell", {
38 | provider: "github",
39 | password: COOKIE_PASSWORD,
40 | clientId: GITHUB_CLIENT_ID,
41 | clientSecret: GITHUB_CLIENT_SECRET,
42 | scope: ["user", "repo"],
43 | isSecure: false, // In production, apache2 rewrites Set-Cookie headers to create secure cookies
44 | location: SERVER_URL,
45 | });
46 |
47 | server.auth.strategy("session", "cookie", {
48 | cookie: {
49 | password: COOKIE_PASSWORD,
50 | isSecure: false, // In production, apache2 rewrites Set-Cookie headers to create secure cookies
51 | isSameSite: NODE_ENV === "production" && "Strict",
52 | path: "/",
53 | },
54 | validateFunc: async (_, session) => {
55 | const response = await pool.query("SELECT * FROM users WHERE id = $1", [
56 | session.id,
57 | ]);
58 |
59 | if (!response.rowCount === 0) {
60 | return { valid: false };
61 | }
62 |
63 | return { valid: true, credentials: response.rows[0] };
64 | },
65 | });
66 |
67 | server.route({
68 | method: ["GET", "POST"],
69 | path: "/users/login",
70 | options: {
71 | auth: {
72 | mode: "try",
73 | strategy: "github",
74 | },
75 | handler: async (request, h) => {
76 | try {
77 | const { auth } = request;
78 |
79 | if (!auth.isAuthenticated) {
80 | return h
81 | .response(`GitHub authentication failed: ${auth.error.message}`)
82 | .code(401);
83 | }
84 |
85 | // TODO handle refresh tokens
86 | const {
87 | token,
88 | profile: { id: githubId, username },
89 | } = auth.credentials;
90 |
91 | let response = await pool.query(
92 | "SELECT * FROM users WHERE github_id = $1",
93 | [githubId]
94 | );
95 |
96 | if (response.rowCount === 0) {
97 | await pool.query(
98 | "INSERT INTO users (github_id, github_username, github_token) values ($1, $2, $3)",
99 | [githubId, username, token]
100 | );
101 | response = await pool.query(
102 | "SELECT * FROM users WHERE github_id = $1",
103 | [githubId]
104 | );
105 | } else {
106 | await pool.query(
107 | "UPDATE users set github_username = $1, github_token = $2 where github_id = $3",
108 | [username, token, githubId]
109 | );
110 | response = await pool.query(
111 | "SELECT * FROM users WHERE github_id = $1",
112 | [githubId]
113 | );
114 | }
115 |
116 | request.cookieAuth.set({ id: response.rows[0].id });
117 |
118 | return h.redirect(PUBLIC_URL);
119 | } catch (e) {
120 | console.error(e);
121 | }
122 | },
123 | },
124 | });
125 |
126 | server.route({
127 | method: "GET",
128 | path: "/users/logout",
129 | options: {
130 | auth: "session",
131 | },
132 | handler: (request, h) => {
133 | request.cookieAuth.clear();
134 | return h.redirect(PUBLIC_URL);
135 | },
136 | });
137 |
138 | server.route({
139 | method: "GET",
140 | path: "/users/current",
141 | options: {
142 | auth: "session",
143 | },
144 | handler: async (request, h) => {
145 | const { credentials } = request.auth;
146 | if (!credentials) return h.response().code(401);
147 |
148 | return { githubUsername: credentials.github_username };
149 | },
150 | });
151 |
152 | server.route({
153 | method: "GET",
154 | path: "/github-diff",
155 | options: {
156 | auth: {
157 | strategy: "session",
158 | mode: "try",
159 | },
160 | },
161 | handler: async (request, h) => {
162 | const { credentials } = request.auth;
163 |
164 | let response;
165 |
166 | const { url, owner, repo, pull_number: pullNumber } = request.query;
167 | if (url) {
168 | // TODO add validation to only fetch diffs, e.g. it has to end with .diff
169 | response = await fetch(url);
170 | } else {
171 | response = await fetch(
172 | `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}`,
173 | {
174 | headers: {
175 | accept: "application/vnd.github.diff",
176 | Authorization:
177 | credentials?.github_token &&
178 | `token ${credentials.github_token}`,
179 | },
180 | }
181 | );
182 | }
183 | return h
184 | .response(await response.text())
185 | .code(response.status)
186 | .type("text/plain");
187 | },
188 | });
189 |
190 | server.route({
191 | method: "GET",
192 | path: "/diffs/{id}",
193 | handler: async (request, h) => {
194 | const { id } = request.params;
195 |
196 | const response = await pool.query("SELECT * FROM diffs WHERE id = $1", [
197 | id,
198 | ]);
199 | if (response.rowCount === 0) {
200 | return h.response().code(404);
201 | }
202 |
203 | const { diff } = response.rows[0];
204 | return { id, diff: JSON.parse(diff) };
205 | },
206 | });
207 |
208 | server.route({
209 | method: "POST",
210 | path: "/diffs",
211 | handler: async (request) => {
212 | const diff = request.payload.diff;
213 | const id = uuidv4();
214 |
215 | await pool.query("INSERT INTO diffs (id, diff) VALUES ($1, $2)", [
216 | id,
217 | JSON.stringify(diff),
218 | ]);
219 | return { id, diff };
220 | },
221 | });
222 |
223 | server.route({
224 | method: "PATCH",
225 | path: "/diffs/{id}",
226 | handler: async (request) => {
227 | const { id, diff } = request.payload;
228 |
229 | await pool.query("UPDATE diffs SET diff = $1 WHERE id = $2", [
230 | JSON.stringify(diff),
231 | id,
232 | ]);
233 | return { id, diff };
234 | },
235 | });
236 |
237 | await server.start();
238 | console.log("Server running on %s", server.info.uri);
239 | };
240 |
241 | process.on("unhandledRejection", (err) => {
242 | console.log(err);
243 | process.exit(1);
244 | });
245 |
246 | init();
247 |
--------------------------------------------------------------------------------
/server/migrations/001_add_diffs.sql:
--------------------------------------------------------------------------------
1 | create table diffs
2 | (
3 | id uuid primary key,
4 | diff text not null default '[]'
5 | )
6 |
--------------------------------------------------------------------------------
/server/migrations/002_add_users.sql:
--------------------------------------------------------------------------------
1 | create table users
2 | (
3 | id bigserial primary key,
4 | github_id bigint not null,
5 | github_username varchar(39) not null,
6 | github_token varchar(100) not null,
7 | constraint unq_github_id unique (github_id)
8 | )
9 |
--------------------------------------------------------------------------------
/src/components/App/App.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Route, BrowserRouter as Router, Switch } from "react-router-dom";
3 |
4 | import { Home } from "../Home/Home";
5 | import { Diff } from "../Diff/Diff";
6 | import { Nav } from "../Nav/Nav";
7 |
8 | const { REACT_APP_SERVER_URL } = process.env;
9 |
10 | export function App() {
11 | const [username, setUsername] = React.useState(undefined);
12 |
13 | React.useEffect(() => {
14 | (async () => {
15 | const response = await fetch(`${REACT_APP_SERVER_URL}/users/current`, {
16 | credentials: "include",
17 | });
18 | const { githubUsername } = await response.json();
19 | setUsername(githubUsername);
20 | })();
21 | }, []);
22 |
23 | return (
24 |
25 |
26 |
27 | {({ match }) => (
28 |
29 |
30 |
31 |
32 | )}
33 |
34 |
35 | {({ match }) => (
36 |
37 |
38 |
39 |
40 | )}
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/src/components/Change/Change.css:
--------------------------------------------------------------------------------
1 | .change {
2 | display: flex;
3 | padding: 0 0.25rem;
4 | line-height: 1.5;
5 | }
6 |
7 | .change--added {
8 | background-color: #d7ffd7;
9 | }
10 |
11 | .change--deleted {
12 | background-color: #ffd7d7;
13 | }
14 |
15 | .change__addition-or-deletion,
16 | .change__line-number,
17 | .change__content {
18 | font-family: monospace, monospace;
19 | }
20 |
21 | .change__addition-or-deletion {
22 | display: inline-block;
23 | flex-shrink: 0;
24 | width: 1rem;
25 | }
26 |
27 | .change__line-number {
28 | display: inline-block;
29 | flex-shrink: 0;
30 | width: 2rem;
31 | text-align: right;
32 | }
33 |
34 | .change__content {
35 | margin-left: 1rem;
36 | white-space: pre-wrap;
37 | flex-grow: 0;
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/Change/Change.tsx:
--------------------------------------------------------------------------------
1 | import parseDiff from "parse-diff";
2 | import React from "react";
3 | import "./Change.css";
4 |
5 | export function Change({ change }: { change: parseDiff.Change }) {
6 | const { type, content } = change;
7 | const lineNumber = change.type === "normal" ? change.ln2 : change.ln;
8 |
9 | return (
10 |
15 |
16 | {type === "add" ? "+" : ""}
17 | {type === "del" ? "-" : ""}
18 |
19 |
{lineNumber}
20 |
{content.slice(1)}
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/Chunk/Chunk.css:
--------------------------------------------------------------------------------
1 | .chunk {
2 | margin: 1rem 0 0;
3 | }
4 |
5 | .chunk__content {
6 | display: inline-block;
7 | padding-bottom: 0.25rem;
8 | margin: 0 0 1rem;
9 | font-family: monospace, monospace;
10 | border-bottom: 1px solid #333333;
11 | }
12 |
--------------------------------------------------------------------------------
/src/components/Chunk/Chunk.tsx:
--------------------------------------------------------------------------------
1 | import parseDiff from "parse-diff";
2 | import React from "react";
3 |
4 | import { Change } from "../Change/Change";
5 | import "./Chunk.css";
6 |
7 | export function Chunk({
8 | baseKey,
9 | content,
10 | changes,
11 | }: {
12 | baseKey: string;
13 | content: string;
14 | changes: parseDiff.Change[];
15 | }) {
16 | return (
17 |
18 |
{content}
19 | {changes.map((change) => {
20 | const line =
21 | change.type === "normal" ? `${change.ln1}-${change.ln2}` : change.ln;
22 | const key = `${baseKey}-${change.type}-${line}`;
23 | return
;
24 | })}
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/Diff/Diff.css:
--------------------------------------------------------------------------------
1 | .diff {
2 | margin: 0 1rem 1rem;
3 | }
4 |
--------------------------------------------------------------------------------
/src/components/Diff/Diff.tsx:
--------------------------------------------------------------------------------
1 | import { throttle } from "lodash";
2 | import flatMap from "lodash/flatMap";
3 | import parseDiff from "parse-diff";
4 | import React, { Component } from "react";
5 | import { SortableContainer, arrayMove } from "react-sortable-hoc";
6 |
7 | import { File } from "../File/File";
8 | import "./Diff.css";
9 |
10 | const { REACT_APP_SERVER_URL } = process.env;
11 |
12 | const HIDDEN_FILES = ["package-lock.json", "yarn.lock"];
13 |
14 | const DiffBase = SortableContainer(
15 | ({
16 | diff = [],
17 | readOnly,
18 | changeDescription,
19 | moveToTop,
20 | moveToBottom,
21 | }: {
22 | diff: DiffFile[];
23 | readOnly: boolean;
24 | changeDescription: (
25 | from: string,
26 | to: string,
27 | chunkIndex: number,
28 | description: string
29 | ) => void;
30 | moveToTop: (index: number) => void;
31 | moveToBottom: (index: number) => void;
32 | }) => (
33 |
34 | {diff.map(({ from, to, chunks, chunkIndex, description }, index) => {
35 | if (HIDDEN_FILES.includes(from) || HIDDEN_FILES.includes(to)) {
36 | return null;
37 | }
38 |
39 | return (
40 |
52 | );
53 | })}
54 |
55 | Icons made by{" "}
56 |
57 | Freepik
58 |
59 |
60 |
61 | )
62 | );
63 |
64 | type DiffFile = {
65 | from: string;
66 | to: string;
67 | chunkIndex: number;
68 | description: string;
69 | chunks: parseDiff.Chunk[];
70 | };
71 |
72 | export class Diff extends Component<{ readOnly?: boolean; id: string }> {
73 | state: { diff: DiffFile[]; loading: boolean } = { diff: [], loading: false };
74 |
75 | async componentDidMount() {
76 | this.setState({ loading: true });
77 |
78 | const { id } = this.props;
79 | const response = await fetch(`${REACT_APP_SERVER_URL}/diffs/${id}`);
80 | const { diff } = await response.json();
81 | this.setState({ id, diff, loading: false });
82 | }
83 |
84 | persistDiff = throttle(async () => {
85 | const { id } = this.props;
86 | const { diff } = this.state;
87 | return fetch(`${REACT_APP_SERVER_URL}/diffs/${id}`, {
88 | method: "PATCH",
89 | headers: { "content-type": "application/json" },
90 | body: JSON.stringify({ id, diff }),
91 | });
92 | }, 1000);
93 |
94 | onSortEnd = ({
95 | oldIndex,
96 | newIndex,
97 | }: {
98 | oldIndex: number;
99 | newIndex: number;
100 | }) => {
101 | console.log(oldIndex, newIndex);
102 | this.setState(
103 | {
104 | diff: arrayMove(this.state.diff, oldIndex, newIndex),
105 | },
106 | () => {
107 | this.persistDiff();
108 | }
109 | );
110 | };
111 |
112 | moveToTop = (oldIndex: number) => this.onSortEnd({ oldIndex, newIndex: 0 });
113 | moveToBottom = (oldIndex: number) =>
114 | this.onSortEnd({ oldIndex, newIndex: this.state.diff.length - 1 });
115 |
116 | setDiff = (rawDiff: string) => {
117 | const parsedDiff = parseDiff(rawDiff);
118 | const diff = flatMap(parsedDiff, ({ from, to, chunks }: parseDiff.File) => {
119 | return chunks.map((chunk, chunkIndex) => ({
120 | from,
121 | to,
122 | chunks: [chunk],
123 | chunkIndex,
124 | description: "",
125 | }));
126 | });
127 | this.setState({ diff }, () => {
128 | this.persistDiff();
129 | });
130 | };
131 |
132 | changeDescription = (
133 | from: string,
134 | to: string,
135 | chunkIndex: number,
136 | description: string
137 | ) => {
138 | const file = this.state.diff.find((f) => {
139 | return f.from === from && f.to === to && f.chunkIndex === chunkIndex;
140 | });
141 |
142 | if (!file) {
143 | throw new Error(
144 | `Couldn't find a file with from = ${from}, to = ${to}, and chunkIndex = ${chunkIndex}`
145 | );
146 | }
147 |
148 | file.description = description;
149 | this.setState({ diff: this.state.diff }, () => {
150 | this.persistDiff();
151 | });
152 | };
153 |
154 | render() {
155 | const { loading, diff } = this.state;
156 |
157 | return loading ? (
158 |
161 | ) : (
162 |
171 | );
172 | }
173 | }
174 |
--------------------------------------------------------------------------------
/src/components/File/File.css:
--------------------------------------------------------------------------------
1 | .file {
2 | margin: 1rem 0;
3 | padding: 1rem;
4 | border: 1px solid #333333;
5 | background-color: #ffffff;
6 | }
7 |
8 | .file__controls {
9 | display: flex;
10 | align-items: center;
11 | margin: 0 0 1rem;
12 | }
13 |
14 | .file__controls > *:not(:last-child) {
15 | margin-right: 0.5rem;
16 | }
17 |
18 | .file__user-text {
19 | margin: 0 0 1rem;
20 | }
21 |
22 | .file__drag-handle {
23 | width: 1.5rem;
24 | }
25 |
26 | .file__description {
27 | margin: 0 0 1rem;
28 | line-height: 1.5;
29 | }
30 |
31 | .file__name,
32 | .file__from-name,
33 | .file__to-name {
34 | display: inline-block;
35 | padding: 0 0.25rem;
36 | font-family: monospace, monospace;
37 | background-color: #d7d7d7;
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/File/File.tsx:
--------------------------------------------------------------------------------
1 | import parseDiff from "parse-diff";
2 | import React from "react";
3 | import ReactQuill from "react-quill";
4 | import "react-quill/dist/quill.snow.css";
5 | import { SortableElement, SortableHandle } from "react-sortable-hoc";
6 |
7 | import { Chunk } from "../Chunk/Chunk";
8 | import "./File.css";
9 | import move from "../../move.svg";
10 |
11 | const DragHandle = SortableHandle(() => (
12 |
{
17 | e.preventDefault();
18 | }}
19 | />
20 | ));
21 |
22 | type FileProps = {
23 | readOnly: boolean;
24 | eltIndex: number;
25 | from: string;
26 | to: string;
27 | chunkIndex: number;
28 | chunks: parseDiff.Chunk[];
29 | description: string;
30 | changeDescription: (
31 | from: string,
32 | to: string,
33 | chunkIndex: number,
34 | description: string
35 | ) => void;
36 | moveToTop: (index: number) => void;
37 | moveToBottom: (index: number) => void;
38 | };
39 |
40 | export const File = SortableElement(
41 | ({
42 | readOnly,
43 | eltIndex,
44 | from,
45 | to,
46 | chunkIndex,
47 | chunks,
48 | description,
49 | changeDescription,
50 | moveToTop,
51 | moveToBottom,
52 | }: FileProps) => {
53 | const DEV_NULL = "/dev/null";
54 |
55 | let fileDescription;
56 |
57 | if (from === to) {
58 | fileDescription = (
59 |
60 | {from}
61 |
62 | );
63 | } else if (from === DEV_NULL) {
64 | fileDescription = (
65 |
66 | File {to} created
67 |
68 | );
69 | } else if (to === DEV_NULL) {
70 | fileDescription = (
71 |
72 | File {from} deleted
73 |
74 | );
75 | } else {
76 | fileDescription = (
77 |
78 | File {from} renamed to{" "}
79 | {to}
80 |
81 | );
82 | }
83 |
84 | return (
85 |
86 | {readOnly ? (
87 |
88 | ) : (
89 | <>
90 |
91 |
92 |
93 |
96 |
97 |
98 | changeDescription(from, to, chunkIndex, d)}
101 | />
102 |
103 | >
104 | )}
105 |
106 | {fileDescription}
107 | {to === DEV_NULL
108 | ? null
109 | : chunks.map(({ oldStart, newStart, content, changes }) => {
110 | const key = `${from}-${to}-${oldStart}-${newStart}`;
111 | return (
112 |
118 | );
119 | })}
120 |
121 | );
122 | }
123 | );
124 |
--------------------------------------------------------------------------------
/src/components/Home/Home.css:
--------------------------------------------------------------------------------
1 | .home {
2 | height: calc(100% - 50px);
3 | }
4 |
5 | .paste-diff {
6 | height: 100%;
7 | width: 500px;
8 | max-width: 100%;
9 |
10 | margin: auto;
11 |
12 | display: flex;
13 | flex-direction: column;
14 | justify-content: center;
15 | }
16 |
17 | @media (max-width: 767px) {
18 | .paste-diff {
19 | justify-content: start;
20 | }
21 | }
22 |
23 | .paste-diff__tabs {
24 | width: 100%;
25 | display: flex;
26 | }
27 |
28 | .paste-diff__tab {
29 | height: 48px;
30 | width: 72px;
31 |
32 | font-size: 24px;
33 |
34 | display: flex;
35 | align-items: center;
36 | justify-content: center;
37 |
38 | border: 1px solid #aaaaaa;
39 | border-radius: 5px;
40 | border-bottom: none;
41 |
42 | cursor: pointer;
43 | }
44 |
45 | .paste-diff__tab--active {
46 | background-color: #f3f3f3;
47 | }
48 |
49 | .paste-diff__tab:not(:last-child) {
50 | border-right: none;
51 | }
52 |
53 | .paste-diff__tab-body {
54 | height: 500px;
55 | max-height: 100%;
56 |
57 | padding: 0 1rem;
58 |
59 | border: 1px solid #aaaaaa;
60 | border-radius: 5px;
61 | }
62 |
63 | .paste-diff__tab-body input,
64 | .paste-diff__tab-body textarea {
65 | width: 100%;
66 | }
67 |
68 | .paste-diff__tab-body textarea {
69 | height: 200px;
70 | box-sizing: border-box;
71 | resize: none;
72 | }
73 |
74 | .paste-diff__error {
75 | color: red;
76 | }
77 |
--------------------------------------------------------------------------------
/src/components/Home/Home.tsx:
--------------------------------------------------------------------------------
1 | import flatMap from "lodash/flatMap";
2 | import parseDiff from "parse-diff";
3 | import React, { Component } from "react";
4 | import { RouteComponentProps, withRouter } from "react-router-dom";
5 | import "./Home.css";
6 |
7 | const { REACT_APP_SERVER_URL } = process.env;
8 |
9 | enum Tab {
10 | PR,
11 | DIFF,
12 | }
13 |
14 | class HomeBase extends Component {
15 | state = { tab: Tab.PR, diff: "", url: "", loading: false, error: undefined };
16 |
17 | onChange = (
18 | event:
19 | | React.ChangeEvent
20 | | React.ChangeEvent
21 | ) => {
22 | this.setState({ [event.target.name]: event.target.value });
23 | };
24 |
25 | onClickTab = (tab: Tab) => () => {
26 | this.setState({ tab });
27 | };
28 |
29 | setError = (error: string) => {
30 | this.setState({
31 | loading: false,
32 | error,
33 | });
34 | };
35 |
36 | createDiff = async () => {
37 | this.setState({ loading: true, error: undefined });
38 |
39 | const rawDiff = this.state.diff;
40 |
41 | let parsedDiff;
42 | try {
43 | parsedDiff = parseDiff(rawDiff);
44 | } catch (e) {
45 | this.setError(`Couldn't parse that diff: ${e.message}`);
46 | return;
47 | }
48 |
49 | const diff = flatMap(parsedDiff, ({ from, to, chunks }) => {
50 | return chunks.map((chunk, chunkIndex) => ({
51 | from,
52 | to,
53 | chunks: [chunk],
54 | chunkIndex,
55 | description: "",
56 | }));
57 | });
58 |
59 | try {
60 | const response = await fetch(`${REACT_APP_SERVER_URL}/diffs`, {
61 | method: "POST",
62 | headers: { "content-type": "application/json" },
63 | body: JSON.stringify({ diff }),
64 | });
65 | if (!response.ok) {
66 | this.setError("Couldn't create a narrated diff");
67 | return;
68 | }
69 |
70 | const { id } = await response.json();
71 | this.props.history.push(`/${id}/edit`);
72 | } catch (e) {
73 | this.setError(`Couldn't create a narrated diff: ${e.message}`);
74 | }
75 | };
76 |
77 | fetchAndCreateDiff = async () => {
78 | this.setState({ loading: true, error: undefined });
79 |
80 | const match = this.state.url.match(
81 | /github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/
82 | );
83 | if (!match) {
84 | this.setError("Couldn't parse that GitHub PR URL");
85 | return;
86 | }
87 |
88 | const [, owner, repo, pullNumber] = match;
89 | if (!owner || !repo || !pullNumber) {
90 | this.setError("Couldn't parse that GitHub PR URL");
91 | return;
92 | }
93 |
94 | try {
95 | const response = await fetch(
96 | `${REACT_APP_SERVER_URL}/github-diff?owner=${owner}&repo=${repo}&pull_number=${pullNumber}`,
97 | {
98 | credentials: "include",
99 | }
100 | );
101 | if (!response.ok) {
102 | this.setError(
103 | `Couldn't fetch the diff for that GitHub PR: ${
104 | (await response.json()).message
105 | }`
106 | );
107 | return;
108 | }
109 |
110 | this.setState({ diff: await response.text() }, () => {
111 | this.createDiff();
112 | });
113 | } catch (e) {
114 | this.setError(`Couldn't fetch the diff for that GitHub PR: ${e.message}`);
115 | }
116 | };
117 |
118 | render() {
119 | return (
120 |
121 |
122 |
123 |
129 | PR
130 |
131 |
137 | Diff
138 |
139 |
140 |
141 | {this.state.tab === Tab.DIFF && (
142 |
143 |
Paste a Git diff:
144 |
145 |
150 |
151 |
152 | {this.state.loading &&
Loading...
}
153 |
154 | )}
155 |
156 | {this.state.tab === Tab.PR && (
157 |
158 |
Paste a GitHub PR URL:
159 |
160 |
165 |
166 |
169 | {this.state.loading &&
Loading...
}
170 | {this.state.error && (
171 |
{this.state.error}
172 | )}
173 |
174 | )}
175 |
176 |
177 | );
178 | }
179 | }
180 |
181 | export const Home = withRouter(HomeBase);
182 |
--------------------------------------------------------------------------------
/src/components/Nav/Nav.css:
--------------------------------------------------------------------------------
1 | .nav {
2 | width: 100%;
3 | height: 50px;
4 | position: sticky;
5 | top: 0;
6 |
7 | padding: 1rem;
8 |
9 | display: flex;
10 | align-items: center;
11 |
12 | background-color: white;
13 | border-bottom: 1px solid black;
14 |
15 | z-index: 100;
16 | }
17 |
18 | .nav > *:not(:last-child) {
19 | padding-right: 1rem;
20 | }
21 |
22 | .nav__horizontal-fill {
23 | flex-grow: 1;
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/Nav/Nav.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Link } from "react-router-dom";
3 |
4 | import "./Nav.css";
5 |
6 | const { REACT_APP_SERVER_URL } = process.env;
7 |
8 | export const Nav = ({
9 | username,
10 | id,
11 | readOnly,
12 | }: {
13 | username?: string;
14 | id?: string;
15 | readOnly?: boolean;
16 | }) => (
17 |
18 | {id ? (
19 | readOnly ? (
20 | <>
21 |
Home
22 |
Edit this diff
23 | >
24 | ) : (
25 | <>
26 |
Home
27 |
Link to this diff for reviewers
28 | >
29 | )
30 | ) : null}
31 |
32 | {username ? (
33 | <>
34 |
Hello, {username}
35 |
Logout
36 | >
37 | ) : (
38 |
Login with GitHub
39 | )}
40 |
41 | );
42 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | html {
2 | height: 100%;
3 | }
4 |
5 | body {
6 | margin: 0;
7 | padding: 0;
8 | height: 100%;
9 | font-family: sans-serif;
10 | font-size: 14px;
11 | color: #333333;
12 | }
13 |
14 | div#root {
15 | height: 100%;
16 | }
17 |
18 | * {
19 | box-sizing: border-box;
20 | }
21 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 |
4 | import "./index.css";
5 | import { App } from "./components/App/App";
6 | import registerServiceWorker from "./registerServiceWorker";
7 |
8 | ReactDOM.render(, document.getElementById("root"));
9 | registerServiceWorker();
10 |
--------------------------------------------------------------------------------
/src/move.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
50 |
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/registerServiceWorker.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 |
3 | // In production, we register a service worker to serve assets from local cache.
4 |
5 | // This lets the app load faster on subsequent visits in production, and gives
6 | // it offline capabilities. However, it also means that developers (and users)
7 | // will only see deployed updates on the "N+1" visit to a page, since previously
8 | // cached resources are updated in the background.
9 |
10 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
11 | // This link also includes instructions on opting out of this behavior.
12 |
13 | const isLocalhost = Boolean(
14 | window.location.hostname === "localhost" ||
15 | // [::1] is the IPv6 localhost address.
16 | window.location.hostname === "[::1]" ||
17 | // 127.0.0.1/8 is considered localhost for IPv4.
18 | window.location.hostname.match(
19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
20 | )
21 | );
22 |
23 | export default function register() {
24 | if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
25 | // The URL constructor is available in all browsers that support SW.
26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
27 | if (publicUrl.origin !== window.location.origin) {
28 | // Our service worker won't work if PUBLIC_URL is on a different origin
29 | // from what our page is served on. This might happen if a CDN is used to
30 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
31 | return;
32 | }
33 |
34 | window.addEventListener("load", () => {
35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
36 |
37 | if (isLocalhost) {
38 | // This is running on localhost. Lets check if a service worker still exists or not.
39 | checkValidServiceWorker(swUrl);
40 | } else {
41 | // Is not local host. Just register service worker
42 | registerValidSW(swUrl);
43 | }
44 | });
45 | }
46 | }
47 |
48 | function registerValidSW(swUrl) {
49 | navigator.serviceWorker
50 | .register(swUrl)
51 | .then((registration) => {
52 | registration.onupdatefound = () => {
53 | const installingWorker = registration.installing;
54 | installingWorker.onstatechange = () => {
55 | if (installingWorker.state === "installed") {
56 | if (navigator.serviceWorker.controller) {
57 | // At this point, the old content will have been purged and
58 | // the fresh content will have been added to the cache.
59 | // It's the perfect time to display a "New content is
60 | // available; please refresh." message in your web app.
61 | console.log("New content is available; please refresh.");
62 | } else {
63 | // At this point, everything has been precached.
64 | // It's the perfect time to display a
65 | // "Content is cached for offline use." message.
66 | console.log("Content is cached for offline use.");
67 | }
68 | }
69 | };
70 | };
71 | })
72 | .catch((error) => {
73 | console.error("Error during service worker registration:", error);
74 | });
75 | }
76 |
77 | function checkValidServiceWorker(swUrl) {
78 | // Check if the service worker can be found. If it can't reload the page.
79 | fetch(swUrl)
80 | .then((response) => {
81 | // Ensure service worker exists, and that we really are getting a JS file.
82 | if (
83 | response.status === 404 ||
84 | response.headers.get("content-type").indexOf("javascript") === -1
85 | ) {
86 | // No service worker found. Probably a different app. Reload the page.
87 | navigator.serviceWorker.ready.then((registration) => {
88 | registration.unregister().then(() => {
89 | window.location.reload();
90 | });
91 | });
92 | } else {
93 | // Service worker found. Proceed as normal.
94 | registerValidSW(swUrl);
95 | }
96 | })
97 | .catch(() => {
98 | console.log(
99 | "No internet connection found. App is running in offline mode."
100 | );
101 | });
102 | }
103 |
104 | export function unregister() {
105 | if ("serviceWorker" in navigator) {
106 | navigator.serviceWorker.ready.then((registration) => {
107 | registration.unregister();
108 | });
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "esModuleInterop": true,
8 | "allowSyntheticDefaultImports": true,
9 | "strict": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "noEmit": true,
16 | "jsx": "react"
17 | },
18 | "include": ["src"]
19 | }
20 |
--------------------------------------------------------------------------------